chore: 初始化项目结构与登录鉴权模块
后端 FastAPI 六层结构(api/services/repositories/models/schemas/core), 统一响应信封、全局异常、日志脱敏;6 张表经 Alembic 迁移建库,SQL 留存 db/migration;登录鉴权含图形验证码(Redis GETDEL 防重放)、JWT、登录限流、 登出黑名单(TTL=令牌剩余有效期)、登录日志。 前端 Vue3 + TS + Element Plus 骨架,含登录页、路由守卫、请求封装与 Vite 代理。 工程配置:uv 锁定 Python 3.12、ruff 检查与格式化、editorconfig、gitattributes。 测试 184 项(单元 155 / 集成 29)全绿,浏览器端到端 8 项通过。 Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
commit
273ebf4657
28
.editorconfig
Normal file
28
.editorconfig
Normal file
@ -0,0 +1,28 @@
|
||||
# 编辑器统一配置。
|
||||
#
|
||||
# 本项目同时含 Python 与 Vue/TS,编辑器的默认缩进、换行符、编码各不相同,
|
||||
# 不做统一的话,一次提交会混进大量「只有空白字符差异」的 diff,review 时
|
||||
# 真正的改动会被淹没。多数编辑器与 IDE 原生支持 .editorconfig,无需装插件。
|
||||
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.py]
|
||||
indent_size = 4
|
||||
|
||||
[*.{md,markdown}]
|
||||
# Markdown 行尾的两个空格是「硬换行」语法,不能自动删除
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[*.{json,yml,yaml}]
|
||||
indent_size = 2
|
||||
|
||||
[Makefile]
|
||||
indent_style = tab
|
||||
31
.gitattributes
vendored
Normal file
31
.gitattributes
vendored
Normal file
@ -0,0 +1,31 @@
|
||||
# Git 属性:统一换行符与文本/二进制判定。
|
||||
#
|
||||
# 现在加最省事 —— 仓库尚无提交,不会触发历史文件的重整化;
|
||||
# 等有了提交再加,就要面对一次全仓库的换行符变更。
|
||||
|
||||
# 默认:由 Git 自动判定文本文件,仓库内一律存 LF
|
||||
* text=auto eol=lf
|
||||
|
||||
# 明确按文本处理,避免被误判为二进制
|
||||
*.py text
|
||||
*.ts text
|
||||
*.vue text
|
||||
*.json text
|
||||
*.sql text
|
||||
*.yml text
|
||||
*.yaml text
|
||||
*.md text
|
||||
|
||||
# 明确按二进制处理,禁止任何换行符转换
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.ico binary
|
||||
*.docx binary
|
||||
*.xlsx binary
|
||||
*.woff binary
|
||||
*.woff2 binary
|
||||
*.ttf binary
|
||||
|
||||
# 锁文件不参与 diff 统计,避免 review 界面被噪声占满
|
||||
uv.lock -diff linguist-generated
|
||||
pnpm-lock.yaml -diff linguist-generated
|
||||
56
.gitignore
vendored
Normal file
56
.gitignore
vendored
Normal file
@ -0,0 +1,56 @@
|
||||
# AI辅助目录(整个目录忽略)
|
||||
ai-aux/
|
||||
|
||||
# 依赖
|
||||
node_modules/
|
||||
.venv/
|
||||
venv/
|
||||
env/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
vendor/
|
||||
target/
|
||||
bin/
|
||||
obj/
|
||||
*.egg-info/
|
||||
|
||||
# 构建产物
|
||||
dist/
|
||||
build/
|
||||
out/
|
||||
|
||||
# 环境变量
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# 日志
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
.vs/
|
||||
*.swp
|
||||
|
||||
# 测试与覆盖率
|
||||
coverage/
|
||||
.coverage
|
||||
htmlcov/
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
.tox/
|
||||
# 端到端测试产出的截图
|
||||
frontend/e2e/*.png
|
||||
|
||||
# 临时文件
|
||||
*.tmp
|
||||
*.bak
|
||||
*.cache
|
||||
# Office/WPS 打开文档时生成的锁文件(如 ~$xxx.docx)
|
||||
~$*
|
||||
|
||||
# 备份
|
||||
backup/
|
||||
4
.markdownlint.json
Normal file
4
.markdownlint.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/DavidAnson/markdownlint/main/schema/markdownlint-config-schema.json",
|
||||
"MD013": false
|
||||
}
|
||||
135
README.md
Normal file
135
README.md
Normal file
@ -0,0 +1,135 @@
|
||||
# 衍水集团营销人员工资管理系统
|
||||
|
||||
营销人员绩效工资核算系统。后端 Python/FastAPI,前端 Vue 3,数据存 MySQL,会话与缓存在 Redis。
|
||||
|
||||
> 当前进度:登录鉴权链路已跑通(验证码 / JWT / 限流 / 登出黑名单 / 登录日志)。业务模块(基础数据、业务数据、绩效核算、报表)待需求澄清后开发。
|
||||
|
||||
## 快速开始
|
||||
|
||||
### 0. 前置条件
|
||||
|
||||
| 工具 | 版本 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| Docker Desktop | — | 提供 MySQL 8.0 与 Redis 7,**必须先启动** |
|
||||
| Python | 3.12 | 由 uv 自动获取,无需手动安装 |
|
||||
| uv | ≥ 0.12 | Python 依赖与虚拟环境管理 |
|
||||
| Node.js / pnpm | 22 / 12 | 前端 |
|
||||
|
||||
> **国内网络建议先配镜像。** 直连 pypi.org 实测约 4 KB/s,一次 `uv sync` 要几分钟。
|
||||
> 配在 **uv 的用户级配置**(`%APPDATA%\uv\uv.toml`,Linux/macOS 为 `~/.config/uv/uv.toml`),
|
||||
> **不要写进 `pyproject.toml`** —— 那会被 `uv.lock` 记录为包来源,仓库就此绑死在该镜像上。
|
||||
>
|
||||
> ```toml
|
||||
> [[index]]
|
||||
> url = "https://pypi.tuna.tsinghua.edu.cn/simple"
|
||||
> default = true
|
||||
> ```
|
||||
>
|
||||
> 前端已默认走 npmmirror,无需额外配置。
|
||||
|
||||
### 1. 首次准备
|
||||
|
||||
```bash
|
||||
# 从模板创建配置文件,填入真实值(.env 不入库)
|
||||
cp docker/.env.example docker/.env
|
||||
cp backend/.env.example backend/.env
|
||||
|
||||
# 生成后端密钥并填入 backend/.env 的 APP_SECRET_KEY
|
||||
python -c "import secrets; print(secrets.token_urlsafe(48))"
|
||||
|
||||
# 两处 MYSQL_PASSWORD / REDIS_PASSWORD 需保持一致
|
||||
```
|
||||
|
||||
### 2. 启动(三步)
|
||||
|
||||
```bash
|
||||
# ① 数据库与缓存(首次会自动执行 db/init/init-dev.sql 建表)
|
||||
docker compose -f docker/docker-compose.dev.yml up -d
|
||||
|
||||
# ② 后端:装依赖 → 应用迁移 → 建管理员 → 起服务
|
||||
cd backend
|
||||
uv sync
|
||||
uv run alembic upgrade head
|
||||
uv run python -m app.cli create-admin --username admin # 密码交互输入
|
||||
uv run uvicorn app.main:app --reload --port 8000
|
||||
|
||||
# ③ 前端(另开终端)
|
||||
cd frontend
|
||||
pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
打开 <http://localhost:5173> 即可登录。接口文档在 <http://localhost:8000/api/docs>。
|
||||
|
||||
> **务必加 `--reload`**:不加的话改了后端代码进程不会重载,而前端仍能正常访问,
|
||||
> 会出现「改了没生效但看不出哪里错」的困惑。
|
||||
|
||||
## 日常开发
|
||||
|
||||
```bash
|
||||
# 后端测试与代码检查
|
||||
cd backend
|
||||
uv run pytest tests/unit -q # 纯单元,无需容器,秒级
|
||||
uv run pytest -q # 全部(集成部分需容器在跑)
|
||||
uv run ruff check . # 代码检查
|
||||
uv run ruff format . # 代码格式化
|
||||
|
||||
# 前端
|
||||
cd frontend
|
||||
pnpm dev # 开发服务
|
||||
pnpm type-check # 类型检查
|
||||
pnpm build # 类型检查 + 生产构建
|
||||
|
||||
# 浏览器端到端(需容器、后端、前端都在运行)
|
||||
python e2e/login_flow.py
|
||||
```
|
||||
|
||||
## 目录结构
|
||||
|
||||
```text
|
||||
.
|
||||
├── backend/ 后端 FastAPI(六层:api/core/models/repositories/schemas/services)
|
||||
│ ├── app/ 应用代码
|
||||
│ ├── alembic/ 数据库迁移
|
||||
│ ├── tests/ unit(无需容器)/ integration(需容器)
|
||||
│ └── README.md 模块说明
|
||||
├── frontend/ 前端 Vue 3 + TS
|
||||
│ ├── src/ 应用代码
|
||||
│ ├── e2e/ Playwright 端到端脚本
|
||||
│ └── README.md 模块说明(含视觉设计约定)
|
||||
├── db/
|
||||
│ ├── init/ 开发环境初始化(仅供参考,见下方说明)
|
||||
│ ├── migration/ 生产迁移脚本,按序编号
|
||||
│ └── queries/ 业务 SQL 留存
|
||||
├── docker/ 容器编排
|
||||
├── docs/ 需求文档
|
||||
└── ai-aux/ AI 辅助上下文(不纳入版本管理)
|
||||
```
|
||||
|
||||
## 几个容易踩的点
|
||||
|
||||
**`db/init/init-dev.sql` 改了不生效**
|
||||
它只在 MySQL **数据卷为空时的首次启动**执行。数据卷已存在时改动它没有任何提示。
|
||||
需要重置时:`docker compose -f docker/docker-compose.dev.yml down -v` 再 `up -d`(会清空数据)。
|
||||
**生产路径是 `db/migration/`,那个才必须保证正确。**
|
||||
|
||||
**环境变量分两处,重复项要同步改**
|
||||
`docker/.env` 管编排凭据(数据库 root 密码、业务账号密码、Redis 密码);
|
||||
`backend/.env` 管应用配置(连接串、密钥、JWT、验证码、T+ 凭据)。
|
||||
`MYSQL_PASSWORD` / `REDIS_PASSWORD` 两处都有,改一处要改两处。
|
||||
|
||||
**后端 401 与登录失败是两回事**
|
||||
`401` 只表示「登录态失效」;登录接口自身失败返回 `400`/`403`。
|
||||
前端拦截器把 401 一律当作「清 token + 跳登录页」,两者混用会导致「输错密码就被弹回登录页」。
|
||||
|
||||
**登录失败后必须刷新验证码**
|
||||
后端用 Redis `GETDEL` 消费验证码,任何一次提交后它都已失效——前端已自动处理,改动登录页时注意保留。
|
||||
|
||||
## 相关文档
|
||||
|
||||
| 文档 | 内容 |
|
||||
| --- | --- |
|
||||
| `backend/README.md` | 后端启动、分层约定、接口清单 |
|
||||
| `backend/tests/README.md` | 测试约定、用例清单、运行记录、已修复缺陷 |
|
||||
| `frontend/README.md` | 前端版本约束、视觉设计约定、端到端测试 |
|
||||
| `docs/` | 需求文档与工资算法方案 |
|
||||
31
backend/.env.example
Normal file
31
backend/.env.example
Normal file
@ -0,0 +1,31 @@
|
||||
# 后端应用配置模板——复制为 backend/.env 并填入真实值(.env 已 gitignore)
|
||||
# 注意:MYSQL_PASSWORD / REDIS_PASSWORD 与 docker/.env 保持一致(改一处要改两处)
|
||||
|
||||
# ===== MySQL =====
|
||||
MYSQL_HOST=127.0.0.1
|
||||
MYSQL_PORT=3306
|
||||
MYSQL_DATABASE=payroll
|
||||
MYSQL_USER=payroll
|
||||
MYSQL_PASSWORD=
|
||||
|
||||
# ===== Redis =====
|
||||
REDIS_HOST=127.0.0.1
|
||||
REDIS_PORT=6379
|
||||
REDIS_PASSWORD=
|
||||
REDIS_DB=0
|
||||
|
||||
# ===== 应用 =====
|
||||
APP_ENV=dev
|
||||
APP_SECRET_KEY=
|
||||
# JWT 有效期(分钟),规范要求 ≤120
|
||||
JWT_EXPIRE_MINUTES=120
|
||||
# 验证码有效期(秒),规范要求 300
|
||||
CAPTCHA_EXPIRE_SECONDS=300
|
||||
# 登录连续失败锁定:次数与锁定分钟
|
||||
LOGIN_FAIL_LIMIT=5
|
||||
LOGIN_LOCK_MINUTES=15
|
||||
|
||||
# ===== T+ 对接(待畅捷通确认后填写)=====
|
||||
T_PLUS_BASE_URL=
|
||||
T_PLUS_APP_KEY=
|
||||
T_PLUS_APP_SECRET=
|
||||
1
backend/.python-version
Normal file
1
backend/.python-version
Normal file
@ -0,0 +1 @@
|
||||
3.12
|
||||
129
backend/README.md
Normal file
129
backend/README.md
Normal file
@ -0,0 +1,129 @@
|
||||
# 后端
|
||||
|
||||
> AI摘要:衍水集团营销人员工资管理系统的后端服务。FastAPI + SQLAlchemy(同步)+ MySQL + Redis,Python 3.12 由 uv 锁定。六层结构 `api / services / repositories / models / schemas / core`。当前实现登录鉴权与健康检查,业务模块待需求澄清后开发。
|
||||
|
||||
## 职责
|
||||
|
||||
- 提供 REST 接口(统一前缀 `/api`)
|
||||
- 登录鉴权:图形验证码、JWT、登录限流、登出黑名单、登录日志
|
||||
- 后续承担:基础数据、业务数据、绩效核算、报表统计、T+ 数据同步
|
||||
|
||||
## 技术栈与版本
|
||||
|
||||
| 组件 | 版本 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| Python | 3.12 | uv 锁定。系统默认是 3.14,**不要用**——第三方库轮子覆盖不如 3.12 完备 |
|
||||
| FastAPI / Starlette | 0.143 / 1.7 | `starlette` 显式限定 `<2`:FastAPI 对它的约束无上界,升级时可能漂移 |
|
||||
| SQLAlchemy | 2.1 | **同步**引擎,路由写 `def` 由 FastAPI 放入线程池 |
|
||||
| PyMySQL | 1.2 | **必须带 `[rsa]`**:MySQL 8.0 默认 `caching_sha2_password`,缺 `cryptography` 时首次连接抛 `RuntimeError`,且报错在建连阶段、极易误读为「密码错误」 |
|
||||
| alembic | 1.20 | 迁移;运行期与迁移共用同一份 `mysql+pymysql` 连接串 |
|
||||
| redis | 8.1 | **同步**客户端 |
|
||||
| pwdlib | 0.3 | 密码哈希。**不用 passlib**(已停更且与 bcrypt ≥4.1 不兼容)。算法**显式指定** BcryptHasher,不用 `recommended()` |
|
||||
| captcha | 0.7 | **仅用其内置字体文件**,渲染已自绘(见 `core/captcha_image.py`) |
|
||||
|
||||
## 分层结构与红线
|
||||
|
||||
```text
|
||||
app/
|
||||
├── api/ HTTP 层:路由、依赖注入、参数校验
|
||||
│ ├── deps.py 公共依赖:get_db、get_current_user、get_raw_token
|
||||
│ └── v1/ 版本化业务路由
|
||||
├── services/ 业务编排 + **唯一事务边界**(commit/rollback 只在此层)
|
||||
├── repositories/ 数据访问,**只 flush 不 commit**
|
||||
├── models/ ORM 映射,不含业务逻辑
|
||||
├── schemas/ 请求/响应模型,禁止把 ORM 对象直接当 response_model
|
||||
└── core/ 横切基础设施:配置、安全、连接、异常、响应、日志
|
||||
```
|
||||
|
||||
| 层 | 可以做 | **不可做** |
|
||||
| --- | --- | --- |
|
||||
| `api/` | 参数绑定、调 service、返回统一响应 | 写业务判断、直接拼 SQL |
|
||||
| `services/` | 编排多个 repository、提交事务 | — |
|
||||
| `repositories/` | 读写数据、flush | `commit()`、抛业务异常 |
|
||||
| `models/` | 表映射 | 业务逻辑 |
|
||||
| `core/` | 基础设施 | 业务规则 |
|
||||
|
||||
## 核心接口
|
||||
|
||||
| 方法 | 路径 | 认证 | 说明 |
|
||||
| --- | --- | :---: | --- |
|
||||
| GET | `/api/health` | 否 | 探活,上报 app/db/redis 三项状态 |
|
||||
| GET | `/api/v1/auth/captcha` | 否 | 签发图形验证码,返回 id + 图片 base64 |
|
||||
| POST | `/api/v1/auth/login` | 否 | 账号 + 密码 + 验证码 → JWT |
|
||||
| POST | `/api/v1/auth/logout` | 是 | 令牌入黑名单,立即失效 |
|
||||
| GET | `/api/v1/auth/me` | 是 | 当前用户与角色 |
|
||||
|
||||
统一响应 `{code, message, data}`,分页为 `list/total/pageNum/pageSize`。
|
||||
状态码:400 参数错误 / 401 未登录 / 403 无权限 / 404 不存在 / 500 服务端异常。
|
||||
|
||||
**登录失败一律 400 或 403,绝不使用 401** —— 前端把 401 一律当作「清 token + 跳登录页」,
|
||||
若登录接口自身返回 401,用户输错一次密码就会触发「清空 + 跳转 + 报错」的风暴。
|
||||
|
||||
## 常用命令
|
||||
|
||||
```bash
|
||||
uv sync # 安装依赖
|
||||
uv run uvicorn app.main:app --reload --port 8000 # 起服务(开发务必加 --reload)
|
||||
uv run alembic upgrade head # 应用迁移
|
||||
uv run alembic revision --autogenerate -m "说明" # 生成迁移
|
||||
uv run python -m app.cli create-admin # 建管理员(幂等,已存在不覆盖)
|
||||
uv run pytest tests/unit -q # 纯单元测试,无需容器
|
||||
uv run pytest -q # 全部测试
|
||||
uv run ruff check . # 代码检查
|
||||
uv run ruff check --fix . # 代码检查并自动修复可修项
|
||||
uv run ruff format . # 格式化代码
|
||||
```
|
||||
|
||||
Python 版本由 `.python-version` 与 `pyproject.toml` 的 `requires-python` 双重锁定。
|
||||
|
||||
### 代码检查与格式化
|
||||
|
||||
配置在 `pyproject.toml` 的 `[tool.ruff]`。三处**刻意关闭**的规则,不要误以为是漏配:
|
||||
|
||||
| 规则 | 关闭原因 |
|
||||
| --- | --- |
|
||||
| `E501` 行宽 | 已由 formatter 控制,lint 重复报无意义;长中文注释极易触发 |
|
||||
| `B008` 参数默认值调用 | FastAPI 依赖注入要求在参数默认值处写 `Depends()`,是框架约定而非缺陷 |
|
||||
| `RUF001/002/003` 易混淆 Unicode | 该规则默认针对英文文本,会把中文的全角逗号、句号、括号全部误报(本项目实测 800+ 条)。中文里全角标点正是规范写法 |
|
||||
|
||||
另有 `known-first-party = ["app", "tests"]`:本项目 `[tool.uv] package = false`(不作为包安装),不显式声明的话 ruff 无法识别 `app` 是第一方包,会把第三方依赖排到它后面。
|
||||
|
||||
## 关键实现约定
|
||||
|
||||
### JWT 时间一律用 UTC
|
||||
|
||||
`exp` 是 UTC 时间戳,而库列存本地时间(容器 `TZ=Asia/Shanghai`)。混算会产生 8 小时偏差,
|
||||
表现为「刚登录就过期」或「过期了还能用」。库列用 `datetime.now()`,JWT 用 `datetime.now(timezone.utc)`。
|
||||
|
||||
### 「离职即失效」用 `users.token_version`,不依赖 Redis
|
||||
|
||||
禁用 / 改密 / 强制下线只需 `UPDATE users SET token_version = token_version + 1`。
|
||||
鉴权时比对 JWT 的 `ver` 声明与库中值,Redis 故障时依然生效。
|
||||
Redis 黑名单只负责「用户主动登出」。
|
||||
|
||||
### Redis 键 TTL 由业务语义决定,不要套用固定值
|
||||
|
||||
| 键 | TTL | 依据 |
|
||||
| --- | --- | --- |
|
||||
| `ys:captcha:{id}` | 300s | 规范要求 |
|
||||
| `ys:login:fail:{username}` | 900s | = 锁定时长,写死会导致锁定提前失效 |
|
||||
| `ys:token:blacklist:{jti}` | 令牌剩余有效期 | 写死会导致「登出 N 分钟后令牌又可用」 |
|
||||
|
||||
### 验证码用 `GETDEL` 原子取出即删
|
||||
|
||||
写成「先 GET 再 DEL」存在竞态窗口,并发请求可用同一验证码重复提交。
|
||||
|
||||
### 密码按字节数校验,不按字符数
|
||||
|
||||
bcrypt 5.0 起对超过 72 字节的密码**直接抛 ValueError**(此前是静默截断)。
|
||||
中文一个字符占 3 字节,24 个汉字即到顶——`max_length=20` 这类字符数校验拦不住。
|
||||
|
||||
## 常见问题
|
||||
|
||||
**起服务报 `APP_SECRET_KEY` 相关错误** —— `backend/.env` 未填或不足 32 字符。这是刻意的 fail-fast:空密钥签发 JWT 是安全问题,必须启动即失败。
|
||||
|
||||
**连数据库抛 `RuntimeError: 'cryptography' package is required`** —— 依赖被改回不带 `[rsa]` 的 `pymysql`。装回 `pymysql[rsa]`。
|
||||
|
||||
**`alembic upgrade head` 报表已存在** —— 开发库是 `init-dev.sql` 建的、`alembic_version` 未写入。手工补版本记录,或 `down -v` 重建。
|
||||
|
||||
**改了代码不生效** —— 启动时没加 `--reload`。
|
||||
149
backend/alembic.ini
Normal file
149
backend/alembic.ini
Normal file
@ -0,0 +1,149 @@
|
||||
# A generic, single database configuration.
|
||||
|
||||
[alembic]
|
||||
# path to migration scripts.
|
||||
# this is typically a path given in POSIX (e.g. forward slashes)
|
||||
# format, relative to the token %(here)s which refers to the location of this
|
||||
# ini file
|
||||
script_location = %(here)s/alembic
|
||||
|
||||
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
|
||||
# Uncomment the line below if you want the files to be prepended with date and time
|
||||
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
|
||||
# for all available tokens
|
||||
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
|
||||
# Or organize into date-based subdirectories (requires recursive_version_locations = true)
|
||||
# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s
|
||||
|
||||
# sys.path path, will be prepended to sys.path if present.
|
||||
# defaults to the current working directory. for multiple paths, the path separator
|
||||
# is defined by "path_separator" below.
|
||||
prepend_sys_path = .
|
||||
|
||||
|
||||
# timezone to use when rendering the date within the migration file
|
||||
# as well as the filename.
|
||||
# If specified, requires the tzdata library which can be installed by adding
|
||||
# `alembic[tz]` to the pip requirements.
|
||||
# string value is passed to ZoneInfo()
|
||||
# leave blank for localtime
|
||||
# timezone =
|
||||
|
||||
# max length of characters to apply to the "slug" field
|
||||
# truncate_slug_length = 40
|
||||
|
||||
# set to 'true' to run the environment during
|
||||
# the 'revision' command, regardless of autogenerate
|
||||
# revision_environment = false
|
||||
|
||||
# set to 'true' to allow .pyc and .pyo files without
|
||||
# a source .py file to be detected as revisions in the
|
||||
# versions/ directory
|
||||
# sourceless = false
|
||||
|
||||
# version location specification; This defaults
|
||||
# to <script_location>/versions. When using multiple version
|
||||
# directories, initial revisions must be specified with --version-path.
|
||||
# The path separator used here should be the separator specified by "path_separator"
|
||||
# below.
|
||||
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
|
||||
|
||||
# path_separator; This indicates what character is used to split lists of file
|
||||
# paths, including version_locations and prepend_sys_path within configparser
|
||||
# files such as alembic.ini.
|
||||
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
|
||||
# to provide os-dependent path splitting.
|
||||
#
|
||||
# Note that in order to support legacy alembic.ini files, this default does NOT
|
||||
# take place if path_separator is not present in alembic.ini. If this
|
||||
# option is omitted entirely, fallback logic is as follows:
|
||||
#
|
||||
# 1. Parsing of the version_locations option falls back to using the legacy
|
||||
# "version_path_separator" key, which if absent then falls back to the legacy
|
||||
# behavior of splitting on spaces and/or commas.
|
||||
# 2. Parsing of the prepend_sys_path option falls back to the legacy
|
||||
# behavior of splitting on spaces, commas, or colons.
|
||||
#
|
||||
# Valid values for path_separator are:
|
||||
#
|
||||
# path_separator = :
|
||||
# path_separator = ;
|
||||
# path_separator = space
|
||||
# path_separator = newline
|
||||
#
|
||||
# Use os.pathsep. Default configuration used for new projects.
|
||||
path_separator = os
|
||||
|
||||
# set to 'true' to search source files recursively
|
||||
# in each "version_locations" directory
|
||||
# new in Alembic version 1.10
|
||||
# recursive_version_locations = false
|
||||
|
||||
# the output encoding used when revision files
|
||||
# are written from script.py.mako
|
||||
# output_encoding = utf-8
|
||||
|
||||
# database URL. This is consumed by the user-maintained env.py script only.
|
||||
# other means of configuring database URLs may be customized within the env.py
|
||||
# file.
|
||||
sqlalchemy.url = driver://user:pass@localhost/dbname
|
||||
|
||||
|
||||
[post_write_hooks]
|
||||
# post_write_hooks defines scripts or Python functions that are run
|
||||
# on newly generated revision scripts. See the documentation for further
|
||||
# detail and examples
|
||||
|
||||
# format using "black" - use the console_scripts runner, against the "black" entrypoint
|
||||
# hooks = black
|
||||
# black.type = console_scripts
|
||||
# black.entrypoint = black
|
||||
# black.options = -l 79 REVISION_SCRIPT_FILENAME
|
||||
|
||||
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
|
||||
# hooks = ruff
|
||||
# ruff.type = module
|
||||
# ruff.module = ruff
|
||||
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
||||
|
||||
# Alternatively, use the exec runner to execute a binary found on your PATH
|
||||
# hooks = ruff
|
||||
# ruff.type = exec
|
||||
# ruff.executable = ruff
|
||||
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
|
||||
|
||||
# Logging configuration. This is also consumed by the user-maintained
|
||||
# env.py script only.
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARNING
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARNING
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
1
backend/alembic/README
Normal file
1
backend/alembic/README
Normal file
@ -0,0 +1 @@
|
||||
Generic single-database configuration.
|
||||
74
backend/alembic/env.py
Normal file
74
backend/alembic/env.py
Normal file
@ -0,0 +1,74 @@
|
||||
"""Alembic 迁移环境。
|
||||
|
||||
**连接串取自 backend/.env**,不写在 alembic.ini —— 避免密码进版本库,
|
||||
也避免运行期配置与迁移配置两处漂移。运行期与迁移共用同一份同步连接串
|
||||
(驱动 pymysql),不存在同步/异步两套配置。
|
||||
"""
|
||||
|
||||
import sys
|
||||
from logging.config import fileConfig
|
||||
from pathlib import Path
|
||||
|
||||
from alembic import context
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
|
||||
# 使 `app` 包在 alembic 执行时可导入(alembic 从 backend/ 目录运行)
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.models import Base
|
||||
|
||||
config = context.config
|
||||
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
# autogenerate 的比对基准
|
||||
target_metadata = Base.metadata
|
||||
|
||||
# 运行期把 .env 里的连接串注入配置,覆盖 alembic.ini 中的占位值
|
||||
config.set_main_option(
|
||||
"sqlalchemy.url",
|
||||
get_settings().database_url.render_as_string(hide_password=False),
|
||||
)
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
"""离线模式:不连库,只输出 SQL。
|
||||
|
||||
用于导出 `db/migration/V{n}__*.sql` 留存脚本。
|
||||
"""
|
||||
context.configure(
|
||||
url=config.get_main_option("sqlalchemy.url"),
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
# 类型变更也要被识别,否则改列类型不会生成迁移
|
||||
compare_type=True,
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
"""在线模式:连库执行迁移。"""
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
# 迁移是一次性任务,用完即弃,不需要连接池
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
with connectable.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=target_metadata,
|
||||
compare_type=True,
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
28
backend/alembic/script.py.mako
Normal file
28
backend/alembic/script.py.mako
Normal file
@ -0,0 +1,28 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
${downgrades if downgrades else "pass"}
|
||||
@ -0,0 +1,353 @@
|
||||
"""初始化 RBAC 与登录日志表
|
||||
|
||||
Revision ID: 6165691326d4
|
||||
Revises:
|
||||
Create Date: 2026-10-09 10:58:04.316902
|
||||
|
||||
"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import mysql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "6165691326d4"
|
||||
down_revision: str | Sequence[str] | None = None
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table(
|
||||
"permissions",
|
||||
sa.Column(
|
||||
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
|
||||
),
|
||||
sa.Column(
|
||||
"perm_code",
|
||||
sa.String(length=128),
|
||||
nullable=False,
|
||||
comment="权限编码,如 system:user:create",
|
||||
),
|
||||
sa.Column("perm_name", sa.String(length=64), nullable=False, comment="权限名称"),
|
||||
sa.Column(
|
||||
"perm_type",
|
||||
mysql.TINYINT(),
|
||||
server_default="1",
|
||||
nullable=False,
|
||||
comment="类型:1菜单 2按钮 3接口",
|
||||
),
|
||||
sa.Column(
|
||||
"parent_id",
|
||||
mysql.BIGINT(unsigned=True),
|
||||
nullable=True,
|
||||
comment="父权限ID(permissions.id),顶级为空",
|
||||
),
|
||||
sa.Column(
|
||||
"sort_order", sa.Integer(), server_default="0", nullable=False, comment="显示排序"
|
||||
),
|
||||
sa.Column(
|
||||
"status",
|
||||
mysql.TINYINT(),
|
||||
server_default="1",
|
||||
nullable=False,
|
||||
comment="状态:1启用 0禁用",
|
||||
),
|
||||
sa.Column(
|
||||
"create_time",
|
||||
sa.DateTime(),
|
||||
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||
nullable=False,
|
||||
comment="创建时间",
|
||||
),
|
||||
sa.Column(
|
||||
"update_time",
|
||||
sa.DateTime(),
|
||||
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
|
||||
nullable=False,
|
||||
comment="更新时间",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["parent_id"],
|
||||
["permissions.id"],
|
||||
name=op.f("fk_permissions_parent_id"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_permissions")),
|
||||
sa.UniqueConstraint("perm_code", name=op.f("uk_permissions_perm_code")),
|
||||
comment="权限表",
|
||||
mysql_charset="utf8mb4",
|
||||
mysql_collate="utf8mb4_general_ci",
|
||||
mysql_engine="InnoDB",
|
||||
)
|
||||
op.create_table(
|
||||
"roles",
|
||||
sa.Column(
|
||||
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
|
||||
),
|
||||
sa.Column(
|
||||
"role_code",
|
||||
sa.String(length=32),
|
||||
nullable=False,
|
||||
comment="角色编码:admin系统管理员 user普通用户",
|
||||
),
|
||||
sa.Column("role_name", sa.String(length=64), nullable=False, comment="角色名称"),
|
||||
sa.Column("description", sa.String(length=255), nullable=True, comment="角色说明"),
|
||||
sa.Column(
|
||||
"sort_order", sa.Integer(), server_default="0", nullable=False, comment="显示排序"
|
||||
),
|
||||
sa.Column(
|
||||
"status",
|
||||
mysql.TINYINT(),
|
||||
server_default="1",
|
||||
nullable=False,
|
||||
comment="状态:1启用 0禁用",
|
||||
),
|
||||
sa.Column(
|
||||
"create_time",
|
||||
sa.DateTime(),
|
||||
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||
nullable=False,
|
||||
comment="创建时间",
|
||||
),
|
||||
sa.Column(
|
||||
"update_time",
|
||||
sa.DateTime(),
|
||||
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
|
||||
nullable=False,
|
||||
comment="更新时间",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_roles")),
|
||||
sa.UniqueConstraint("role_code", name=op.f("uk_roles_role_code")),
|
||||
comment="角色表",
|
||||
mysql_charset="utf8mb4",
|
||||
mysql_collate="utf8mb4_general_ci",
|
||||
mysql_engine="InnoDB",
|
||||
)
|
||||
op.create_table(
|
||||
"users",
|
||||
sa.Column(
|
||||
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
|
||||
),
|
||||
sa.Column("username", sa.String(length=64), nullable=False, comment="登录用户名"),
|
||||
sa.Column(
|
||||
"password",
|
||||
sa.String(length=100),
|
||||
nullable=False,
|
||||
comment="密码哈希(BCrypt,固定60字符)",
|
||||
),
|
||||
sa.Column("real_name", sa.String(length=64), nullable=True, comment="真实姓名"),
|
||||
sa.Column("phone", sa.String(length=20), nullable=True, comment="手机号"),
|
||||
sa.Column("email", sa.String(length=128), nullable=True, comment="邮箱"),
|
||||
sa.Column(
|
||||
"status",
|
||||
mysql.TINYINT(),
|
||||
server_default="1",
|
||||
nullable=False,
|
||||
comment="状态:1启用 0禁用",
|
||||
),
|
||||
sa.Column(
|
||||
"token_version",
|
||||
sa.Integer(),
|
||||
server_default="1",
|
||||
nullable=False,
|
||||
comment="令牌版本号:改密/禁用/强制下线时+1,使已签发Token立即失效",
|
||||
),
|
||||
sa.Column("last_login_time", sa.DateTime(), nullable=True, comment="最后登录时间"),
|
||||
sa.Column("remark", sa.String(length=255), nullable=True, comment="备注"),
|
||||
sa.Column(
|
||||
"create_time",
|
||||
sa.DateTime(),
|
||||
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||
nullable=False,
|
||||
comment="创建时间",
|
||||
),
|
||||
sa.Column(
|
||||
"update_time",
|
||||
sa.DateTime(),
|
||||
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
|
||||
nullable=False,
|
||||
comment="更新时间",
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_users")),
|
||||
sa.UniqueConstraint("username", name=op.f("uk_users_username")),
|
||||
comment="系统用户表",
|
||||
mysql_charset="utf8mb4",
|
||||
mysql_collate="utf8mb4_general_ci",
|
||||
mysql_engine="InnoDB",
|
||||
)
|
||||
op.create_index(op.f("idx_users_phone"), "users", ["phone"], unique=False)
|
||||
op.create_table(
|
||||
"login_logs",
|
||||
sa.Column(
|
||||
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
|
||||
),
|
||||
sa.Column(
|
||||
"user_id",
|
||||
mysql.BIGINT(unsigned=True),
|
||||
nullable=True,
|
||||
comment="用户ID(账号不存在时为空)",
|
||||
),
|
||||
sa.Column(
|
||||
"username", sa.String(length=64), nullable=False, comment="登录用户名(原样记录)"
|
||||
),
|
||||
sa.Column("ip", sa.String(length=45), nullable=True, comment="客户端IP(45位兼容IPv6)"),
|
||||
sa.Column(
|
||||
"user_agent",
|
||||
sa.String(length=255),
|
||||
nullable=True,
|
||||
comment="客户端User-Agent(截断存储)",
|
||||
),
|
||||
sa.Column(
|
||||
"success",
|
||||
mysql.TINYINT(),
|
||||
server_default="0",
|
||||
nullable=False,
|
||||
comment="是否成功:1成功 0失败",
|
||||
),
|
||||
sa.Column(
|
||||
"fail_reason",
|
||||
sa.String(length=64),
|
||||
nullable=True,
|
||||
comment="失败原因:验证码错误/用户名或密码错误/账号锁定/账号禁用",
|
||||
),
|
||||
sa.Column(
|
||||
"create_time",
|
||||
sa.DateTime(),
|
||||
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||
nullable=False,
|
||||
comment="创建时间",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["user_id"], ["users.id"], name=op.f("fk_login_logs_user_id"), ondelete="SET NULL"
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_login_logs")),
|
||||
comment="登录日志表",
|
||||
mysql_charset="utf8mb4",
|
||||
mysql_collate="utf8mb4_general_ci",
|
||||
mysql_engine="InnoDB",
|
||||
)
|
||||
op.create_index("idx_login_logs_create_time", "login_logs", ["create_time"], unique=False)
|
||||
op.create_index("idx_login_logs_username", "login_logs", ["username"], unique=False)
|
||||
op.create_table(
|
||||
"role_permissions",
|
||||
sa.Column(
|
||||
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
|
||||
),
|
||||
sa.Column(
|
||||
"role_id", mysql.BIGINT(unsigned=True), nullable=False, comment="角色ID(roles.id)"
|
||||
),
|
||||
sa.Column(
|
||||
"permission_id",
|
||||
mysql.BIGINT(unsigned=True),
|
||||
nullable=False,
|
||||
comment="权限ID(permissions.id)",
|
||||
),
|
||||
sa.Column(
|
||||
"create_time",
|
||||
sa.DateTime(),
|
||||
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||
nullable=False,
|
||||
comment="创建时间",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["permission_id"],
|
||||
["permissions.id"],
|
||||
name=op.f("fk_role_permissions_permission_id"),
|
||||
ondelete="CASCADE",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["role_id"], ["roles.id"], name=op.f("fk_role_permissions_role_id"), ondelete="CASCADE"
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_role_permissions")),
|
||||
sa.UniqueConstraint(
|
||||
"role_id", "permission_id", name="uk_role_permissions_role_id_permission_id"
|
||||
),
|
||||
comment="角色权限关联表",
|
||||
mysql_charset="utf8mb4",
|
||||
mysql_collate="utf8mb4_general_ci",
|
||||
mysql_engine="InnoDB",
|
||||
)
|
||||
op.create_index(
|
||||
"idx_role_permissions_permission_id", "role_permissions", ["permission_id"], unique=False
|
||||
)
|
||||
op.create_table(
|
||||
"user_roles",
|
||||
sa.Column(
|
||||
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
|
||||
),
|
||||
sa.Column(
|
||||
"user_id", mysql.BIGINT(unsigned=True), nullable=False, comment="用户ID(users.id)"
|
||||
),
|
||||
sa.Column(
|
||||
"role_id", mysql.BIGINT(unsigned=True), nullable=False, comment="角色ID(roles.id)"
|
||||
),
|
||||
sa.Column(
|
||||
"create_time",
|
||||
sa.DateTime(),
|
||||
server_default=sa.text("CURRENT_TIMESTAMP"),
|
||||
nullable=False,
|
||||
comment="创建时间",
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["role_id"], ["roles.id"], name=op.f("fk_user_roles_role_id"), ondelete="CASCADE"
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["user_id"], ["users.id"], name=op.f("fk_user_roles_user_id"), ondelete="CASCADE"
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id", name=op.f("pk_user_roles")),
|
||||
sa.UniqueConstraint("user_id", "role_id", name="uk_user_roles_user_id_role_id"),
|
||||
comment="用户角色关联表",
|
||||
mysql_charset="utf8mb4",
|
||||
mysql_collate="utf8mb4_general_ci",
|
||||
mysql_engine="InnoDB",
|
||||
)
|
||||
op.create_index("idx_user_roles_role_id", "user_roles", ["role_id"], unique=False)
|
||||
# ### end Alembic commands ###
|
||||
|
||||
# 角色为字典数据,随结构初始化 —— 放在迁移而非 init-dev.sql,
|
||||
# 保证生产环境同样具备这两个基础角色(规范要求的两个角色,见功能需规 2.5.1)
|
||||
op.bulk_insert(
|
||||
sa.table(
|
||||
"roles",
|
||||
sa.column("role_code", sa.String),
|
||||
sa.column("role_name", sa.String),
|
||||
sa.column("description", sa.String),
|
||||
sa.column("sort_order", sa.Integer),
|
||||
),
|
||||
[
|
||||
{
|
||||
"role_code": "admin",
|
||||
"role_name": "系统管理员",
|
||||
"description": "全部权限,含计提参数与系数表配置",
|
||||
"sort_order": 1,
|
||||
},
|
||||
{
|
||||
"role_code": "user",
|
||||
"role_name": "普通用户",
|
||||
"description": "业务操作",
|
||||
"sort_order": 2,
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_index("idx_user_roles_role_id", table_name="user_roles")
|
||||
op.drop_table("user_roles")
|
||||
op.drop_index("idx_role_permissions_permission_id", table_name="role_permissions")
|
||||
op.drop_table("role_permissions")
|
||||
op.drop_index("idx_login_logs_username", table_name="login_logs")
|
||||
op.drop_index("idx_login_logs_create_time", table_name="login_logs")
|
||||
op.drop_table("login_logs")
|
||||
op.drop_index(op.f("idx_users_phone"), table_name="users")
|
||||
op.drop_table("users")
|
||||
op.drop_table("roles")
|
||||
op.drop_table("permissions")
|
||||
# ### end Alembic commands ###
|
||||
1
backend/app/__init__.py
Normal file
1
backend/app/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""衍水集团营销人员工资管理系统 —— 后端应用包。"""
|
||||
4
backend/app/api/__init__.py
Normal file
4
backend/app/api/__init__.py
Normal file
@ -0,0 +1,4 @@
|
||||
"""HTTP 接口层:路由声明、依赖注入、参数校验、调用 service、返回统一响应。
|
||||
|
||||
禁止在此层写业务判断或直接拼 SQL。
|
||||
"""
|
||||
73
backend/app/api/deps.py
Normal file
73
backend/app/api/deps.py
Normal file
@ -0,0 +1,73 @@
|
||||
"""FastAPI 公共依赖注入。"""
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Depends
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.db import get_db
|
||||
from app.core.exceptions import AuthError
|
||||
from app.core.security import decode_token
|
||||
from app.models import User
|
||||
from app.repositories import user_repository
|
||||
from app.services import token_blacklist
|
||||
|
||||
# auto_error=False:令牌缺失时由我们自己抛 AuthError,
|
||||
# 才能返回统一响应信封,而不是 FastAPI 默认的 {"detail": "Not authenticated"}
|
||||
_bearer_scheme = HTTPBearer(auto_error=False, description="Bearer 访问令牌")
|
||||
|
||||
BearerCredentials = Annotated[HTTPAuthorizationCredentials | None, Depends(_bearer_scheme)]
|
||||
DbSession = Annotated[Session, Depends(get_db)]
|
||||
|
||||
|
||||
def get_raw_token(credentials: BearerCredentials) -> str:
|
||||
"""取原始令牌字符串。登出时需要它来解析 jti。"""
|
||||
if credentials is None or not credentials.credentials:
|
||||
raise AuthError()
|
||||
return credentials.credentials
|
||||
|
||||
|
||||
def get_current_user(credentials: BearerCredentials, session: DbSession) -> User:
|
||||
"""从 Bearer 令牌解析当前登录用户。
|
||||
|
||||
**三重校验,缺一不可**:
|
||||
|
||||
1. 签名与有效期(`decode_token`)
|
||||
2. 是否已被登出拉黑(Redis `ys:token:blacklist:{jti}`)
|
||||
3. **令牌版本与库中是否一致** —— 禁用 / 改密 / 强制下线会把
|
||||
`users.token_version` 加一,使此前签发的所有令牌立即失效。
|
||||
这一步**不依赖 Redis**,因此 Redis 故障或数据被清空时,
|
||||
离职员工依然无法继续访问
|
||||
|
||||
Redis 不可用时第 2 步会抛异常而非放行 —— **有意的 fail-closed**:
|
||||
对能查看全员薪酬的系统,宁可拒绝请求也不能放行状态不明的令牌。
|
||||
|
||||
:raises AuthError: 任一项校验未通过(401)
|
||||
"""
|
||||
if credentials is None or not credentials.credentials:
|
||||
raise AuthError()
|
||||
|
||||
payload = decode_token(credentials.credentials)
|
||||
|
||||
jti = payload.get("jti")
|
||||
if jti and token_blacklist.is_revoked(jti):
|
||||
raise AuthError("登录状态已失效,请重新登录")
|
||||
|
||||
subject = payload.get("sub")
|
||||
user = user_repository.get_by_id(session, int(subject)) if _is_digits(subject) else None
|
||||
if user is None or user.status != 1:
|
||||
raise AuthError()
|
||||
|
||||
if payload.get("ver") != user.token_version:
|
||||
raise AuthError("登录状态已失效,请重新登录")
|
||||
|
||||
return user
|
||||
|
||||
|
||||
def _is_digits(value: object) -> bool:
|
||||
"""判断令牌中的 sub 是否为合法的主键字符串。
|
||||
|
||||
令牌内容不可信,直接 int() 会因非法输入抛 ValueError 而不是 401。
|
||||
"""
|
||||
return isinstance(value, str) and value.isdigit()
|
||||
52
backend/app/api/health.py
Normal file
52
backend/app/api/health.py
Normal file
@ -0,0 +1,52 @@
|
||||
"""健康检查接口。
|
||||
|
||||
路由写 `def`(同步)而非 `async def`:FastAPI 会把同步路由放入线程池执行,
|
||||
因此这里的数据库与 Redis 探测不会阻塞事件循环。
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.core.db import get_session_factory
|
||||
from app.core.redis_client import get_redis
|
||||
from app.core.response import ApiResponse, ok
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(tags=["系统"])
|
||||
|
||||
|
||||
def _check_db() -> bool:
|
||||
"""探测数据库连通性。失败只记日志并返回 False,不抛出。"""
|
||||
try:
|
||||
factory = get_session_factory()
|
||||
with factory() as session:
|
||||
session.execute(text("SELECT 1"))
|
||||
return True
|
||||
except Exception:
|
||||
logger.warning("数据库健康检查未通过", exc_info=True)
|
||||
return False
|
||||
|
||||
|
||||
def _check_redis() -> bool:
|
||||
"""探测 Redis 连通性。失败只记日志并返回 False,不抛出。"""
|
||||
try:
|
||||
return bool(get_redis().ping())
|
||||
except Exception:
|
||||
logger.warning("Redis 健康检查未通过", exc_info=True)
|
||||
return False
|
||||
|
||||
|
||||
@router.get("/health", response_model=ApiResponse[dict[str, Any]], summary="健康检查")
|
||||
def health() -> ApiResponse[dict[str, Any]]:
|
||||
"""探活接口:上报应用、数据库、Redis 三项状态。
|
||||
|
||||
即使依赖不可用,本接口本身仍返回 200 并在 data 中标记 false ——
|
||||
这样运维能区分「服务没起来」与「服务起来了但依赖挂了」两种情况。
|
||||
|
||||
注意:生产部署应限制本接口仅内网可达,避免对外暴露依赖拓扑。
|
||||
"""
|
||||
return ok({"app": True, "db": _check_db(), "redis": _check_redis()})
|
||||
20
backend/app/api/router.py
Normal file
20
backend/app/api/router.py
Normal file
@ -0,0 +1,20 @@
|
||||
"""API 路由汇总。
|
||||
|
||||
各业务模块的路由在此挂载。业务接口统一放在 `/api/v1/` 之下,
|
||||
健康检查作为运维探针不带版本号,直接位于 `/api/health`。
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from app.api import health
|
||||
from app.api.v1 import auth
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
# 运维探针:/api/health
|
||||
api_router.include_router(health.router)
|
||||
|
||||
# 业务接口:/api/v1/*
|
||||
v1_router = APIRouter(prefix="/v1")
|
||||
v1_router.include_router(auth.router)
|
||||
api_router.include_router(v1_router)
|
||||
7
backend/app/api/v1/__init__.py
Normal file
7
backend/app/api/v1/__init__.py
Normal file
@ -0,0 +1,7 @@
|
||||
"""API v1 版本化路由。
|
||||
|
||||
业务接口按模块拆分为独立文件挂载于此(如 `auth.py`);
|
||||
跨模块共享的依赖(get_db、get_current_user 等)放在上一级 `api/deps.py`。
|
||||
|
||||
健康检查是运维探针,固定挂在 `/api/health`,不带版本号。
|
||||
"""
|
||||
86
backend/app/api/v1/auth.py
Normal file
86
backend/app/api/v1/auth.py
Normal file
@ -0,0 +1,86 @@
|
||||
"""认证接口:验证码、登录、登出、当前用户。"""
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
|
||||
from app.api.deps import DbSession, get_current_user, get_raw_token
|
||||
from app.core.response import ApiResponse, ok
|
||||
from app.models import User
|
||||
from app.repositories.user_repository import get_role_codes
|
||||
from app.schemas.auth import CaptchaOut, CurrentUserOut, LoginIn, LoginOut
|
||||
from app.services import auth_service, captcha_service
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["认证"])
|
||||
|
||||
|
||||
def _client_ip(request: Request) -> str | None:
|
||||
"""取客户端 IP。
|
||||
|
||||
当前取直连地址。生产环境经 Nginx 反代后直连地址会变成代理地址,
|
||||
届时需改为读取 `X-Forwarded-For` 的**第一个**值 —— 且必须确保该头
|
||||
由网关覆写,否则客户端可以伪造。此改动留到部署阶段一并处理。
|
||||
"""
|
||||
return request.client.host if request.client else None
|
||||
|
||||
|
||||
@router.get("/captcha", response_model=ApiResponse[CaptchaOut], summary="获取图形验证码")
|
||||
def get_captcha() -> ApiResponse[CaptchaOut]:
|
||||
"""签发图形验证码。无需认证。
|
||||
|
||||
图片以 base64 内联返回而非单独 URL:保证 id 与图片成对产生,
|
||||
避免前端分两次请求时拿到不匹配的组合。
|
||||
"""
|
||||
captcha_id, image_base64, expires_in = captcha_service.issue()
|
||||
return ok(CaptchaOut(captcha_id=captcha_id, image_base64=image_base64, expires_in=expires_in))
|
||||
|
||||
|
||||
@router.post("/login", response_model=ApiResponse[LoginOut], summary="登录")
|
||||
def login(payload: LoginIn, request: Request, session: DbSession) -> ApiResponse[LoginOut]:
|
||||
"""账号 + 密码 + 图形验证码登录。
|
||||
|
||||
失败一律返回 **400 或 403,绝不使用 401**:前端的 401 拦截器会清空令牌
|
||||
并跳转登录页,若登录接口自身返回 401,用户输错一次密码就会触发
|
||||
「清空 + 跳转 + 报错」的组合动作,登录页反复重挂载、验证码反复刷新。
|
||||
|
||||
另注意:验证码经 GETDEL 消费后即失效,**前端必须在登录失败后刷新验证码**,
|
||||
否则第二次提交必然报「验证码错误」。
|
||||
"""
|
||||
result = auth_service.login(
|
||||
session,
|
||||
username=payload.username,
|
||||
password=payload.password,
|
||||
captcha_id=payload.captcha_id,
|
||||
captcha_code=payload.captcha_code,
|
||||
ip=_client_ip(request),
|
||||
user_agent=request.headers.get("user-agent"),
|
||||
)
|
||||
return ok(result, message="登录成功")
|
||||
|
||||
|
||||
@router.post("/logout", response_model=ApiResponse[None], summary="登出")
|
||||
def logout(
|
||||
token: Annotated[str, Depends(get_raw_token)],
|
||||
_current_user: Annotated[User, Depends(get_current_user)],
|
||||
) -> ApiResponse[None]:
|
||||
"""登出:把当前令牌加入黑名单,使其立即失效。
|
||||
|
||||
同时依赖 `get_current_user` 做真实鉴权 —— 仅凭原始令牌字符串不能证明
|
||||
它是有效的(可能已过期或已被拉黑)。
|
||||
"""
|
||||
auth_service.logout(token)
|
||||
return ok(message="已退出登录")
|
||||
|
||||
|
||||
@router.get("/me", response_model=ApiResponse[CurrentUserOut], summary="当前登录用户")
|
||||
def me(current_user: Annotated[User, Depends(get_current_user)]) -> ApiResponse[CurrentUserOut]:
|
||||
"""返回当前登录用户的信息与角色。前端刷新页面后用它恢复会话。"""
|
||||
return ok(
|
||||
CurrentUserOut(
|
||||
id=current_user.id,
|
||||
username=current_user.username,
|
||||
real_name=current_user.real_name,
|
||||
roles=get_role_codes(current_user),
|
||||
last_login_time=current_user.last_login_time,
|
||||
)
|
||||
)
|
||||
108
backend/app/cli.py
Normal file
108
backend/app/cli.py
Normal file
@ -0,0 +1,108 @@
|
||||
"""命令行管理工具。
|
||||
|
||||
用法(在 backend/ 目录下执行)::
|
||||
|
||||
uv run python -m app.cli create-admin --username admin
|
||||
|
||||
密码优先**交互式输入**而非命令行参数:命令行参数会进入 shell 历史,
|
||||
在多用户机器上还能被 `ps` 看到。`--password` 仅为自动化脚本保留。
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import getpass
|
||||
import sys
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.core.constants import PASSWORD_MAX_BYTES, PASSWORD_MIN_BYTES, ROLE_ADMIN
|
||||
from app.core.db import dispose_engine, get_session_factory
|
||||
from app.core.security import hash_password
|
||||
from app.models import Role, User, UserRole
|
||||
|
||||
|
||||
def _prompt_password() -> str:
|
||||
"""交互式读取并二次确认密码,循环直到符合策略。"""
|
||||
while True:
|
||||
password = getpass.getpass(
|
||||
f"请输入密码({PASSWORD_MIN_BYTES}-{PASSWORD_MAX_BYTES} 字节): "
|
||||
)
|
||||
length = len(password.encode("utf-8"))
|
||||
if length < PASSWORD_MIN_BYTES:
|
||||
print(f" 密码不足 {PASSWORD_MIN_BYTES} 字节,请重新输入。")
|
||||
continue
|
||||
if length > PASSWORD_MAX_BYTES:
|
||||
print(f" 密码超过 {PASSWORD_MAX_BYTES} 字节上限(中文一字占 3 字节),请重新输入。")
|
||||
continue
|
||||
if password != getpass.getpass("请再次输入以确认: "):
|
||||
print(" 两次输入不一致,请重新输入。")
|
||||
continue
|
||||
return password
|
||||
|
||||
|
||||
def _create_admin(args: argparse.Namespace) -> int:
|
||||
"""幂等创建管理员账号。
|
||||
|
||||
已存在时**不覆盖密码** —— 意外重跑脚本不应把已有账号的密码改掉。
|
||||
"""
|
||||
factory = get_session_factory()
|
||||
with factory() as session:
|
||||
existing = session.execute(
|
||||
select(User).where(User.username == args.username)
|
||||
).scalar_one_or_none()
|
||||
if existing is not None:
|
||||
print(f"用户 {args.username!r} 已存在(id={existing.id}),未做任何修改。")
|
||||
return 0
|
||||
|
||||
role = session.execute(
|
||||
select(Role).where(Role.role_code == ROLE_ADMIN)
|
||||
).scalar_one_or_none()
|
||||
if role is None:
|
||||
print(
|
||||
"错误:未找到 admin 角色,请先执行数据库迁移(uv run alembic upgrade head)。",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
password = args.password or _prompt_password()
|
||||
user = User(
|
||||
username=args.username,
|
||||
password=hash_password(password),
|
||||
real_name=args.real_name,
|
||||
status=1,
|
||||
)
|
||||
session.add(user)
|
||||
session.flush() # 取得自增 id 后才能建关联
|
||||
session.add(UserRole(user_id=user.id, role_id=role.id))
|
||||
session.commit()
|
||||
|
||||
print(f"已创建管理员 {args.username!r}(id={user.id}),角色:{role.role_name}。")
|
||||
print("首次登录后请立即修改密码。")
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""命令行入口。"""
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="app.cli", description="衍水集团营销人员工资管理系统 - 命令行工具"
|
||||
)
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
p_admin = sub.add_parser("create-admin", help="创建初始管理员账号(幂等)")
|
||||
p_admin.add_argument("--username", default="admin", help="登录用户名,默认 admin")
|
||||
p_admin.add_argument("--real-name", default="系统管理员", help="真实姓名")
|
||||
p_admin.add_argument(
|
||||
"--password",
|
||||
default=None,
|
||||
help="密码。不传则交互式输入(推荐)——命令行传参会留在 shell 历史中",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
return _create_admin(args)
|
||||
finally:
|
||||
dispose_engine()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
1
backend/app/core/__init__.py
Normal file
1
backend/app/core/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""横切基础设施:配置、安全、连接、异常、响应、日志。不含业务规则。"""
|
||||
160
backend/app/core/captcha_image.py
Normal file
160
backend/app/core/captcha_image.py
Normal file
@ -0,0 +1,160 @@
|
||||
"""图形验证码渲染。
|
||||
|
||||
**为什么自绘而不用 captcha 库的默认渲染**:
|
||||
- 该库默认输出随机彩色字符 + 重噪点 + 弧线,与界面视觉体系严重冲突
|
||||
- 它最后更新于 2025-03,内部还引用了 Pillow 的旧别名(`PIL.ImageDraw.ImageDraw`)
|
||||
|
||||
自绘后配色与界面统一,而识别难度由**逐字旋转 + 垂直抖动 + 干扰曲线 + 噪点**
|
||||
共同保证,强度与原实现相当且可调(见 `_draw_characters` 的注释)。
|
||||
|
||||
仅沿用该库内置的 DroidSansMono 字体文件:自带字体可避开
|
||||
在 Linux 容器里找不到系统字体的经典问题。
|
||||
"""
|
||||
|
||||
import base64
|
||||
import io
|
||||
import math
|
||||
import random
|
||||
import secrets
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
from app.core.constants import CAPTCHA_CHARSET, CAPTCHA_LENGTH
|
||||
|
||||
# 画布尺寸与前端展示槽位对齐(登录页为 116×40)
|
||||
_WIDTH, _HEIGHT = 120, 40
|
||||
_FONT_SIZE = 25
|
||||
_PADDING = 8
|
||||
|
||||
# 与前端配色呼应:墨色字符、青灰干扰、素纸底
|
||||
_INK = (15, 54, 70)
|
||||
_INTERFERENCE = (126, 156, 170)
|
||||
_SPECKLE = (168, 183, 192)
|
||||
_PAPER = (247, 246, 242)
|
||||
|
||||
_SPECKLE_COUNT = 46
|
||||
_INTERFERENCE_LINES = 2
|
||||
|
||||
# 备选字体路径:优先用 captcha 包内置字体(跨平台一致),其余为回退
|
||||
_FONT_CANDIDATES = (
|
||||
"C:/Windows/Fonts/consola.ttf",
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf",
|
||||
)
|
||||
|
||||
|
||||
def _font_path() -> Path | None:
|
||||
"""定位可用的等宽字体文件。"""
|
||||
import captcha
|
||||
|
||||
package_file = getattr(captcha, "__file__", None)
|
||||
if package_file:
|
||||
bundled = Path(package_file).parent / "data" / "DroidSansMono.ttf"
|
||||
if bundled.is_file():
|
||||
return bundled
|
||||
|
||||
for candidate in _FONT_CANDIDATES:
|
||||
path = Path(candidate)
|
||||
if path.is_file():
|
||||
return path
|
||||
return None
|
||||
|
||||
|
||||
def _load_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
|
||||
"""加载字体。找不到任何字体文件时退回 PIL 内置位图字体(仍可用,只是较粗糙)。"""
|
||||
path = _font_path()
|
||||
if path is None:
|
||||
return ImageFont.load_default(size)
|
||||
return ImageFont.truetype(str(path), size)
|
||||
|
||||
|
||||
def generate_text() -> str:
|
||||
"""生成验证码文本。
|
||||
|
||||
用 `secrets` 而非 `random`:验证码是安全凭据,
|
||||
`random` 的 Mersenne Twister 状态可被观测样本反推。
|
||||
"""
|
||||
return "".join(secrets.choice(CAPTCHA_CHARSET) for _ in range(CAPTCHA_LENGTH))
|
||||
|
||||
|
||||
def _draw_speckles(draw: ImageDraw.ImageDraw, count: int) -> None:
|
||||
"""撒噪点。
|
||||
|
||||
用单像素点与 2×2 方块,**不用 `ellipse`** —— 3×3 包围盒画出的椭圆
|
||||
恰好填充中心与四个边中点,形状就是「+」,会形成一种规则的十字纹理,
|
||||
既显眼又容易被整体滤波抹掉,起不到干扰作用。
|
||||
"""
|
||||
for _ in range(count):
|
||||
x = random.randint(0, _WIDTH - 2)
|
||||
y = random.randint(0, _HEIGHT - 2)
|
||||
if random.random() < 0.75:
|
||||
draw.point((x, y), fill=_SPECKLE)
|
||||
else:
|
||||
draw.rectangle((x, y, x + 1, y + 1), fill=_SPECKLE)
|
||||
|
||||
|
||||
def _draw_interference(draw: ImageDraw.ImageDraw) -> None:
|
||||
"""绘制干扰曲线。
|
||||
|
||||
用低对比度的正弦折线而非粗直线:粗直线会把字符主干割断、
|
||||
显著提高正常用户的识错率,而低对比细线对分割算法的干扰并不更差。
|
||||
"""
|
||||
for _ in range(_INTERFERENCE_LINES):
|
||||
amplitude = random.uniform(5.0, 11.0)
|
||||
phase = random.uniform(0, math.tau)
|
||||
base_y = random.uniform(_HEIGHT * 0.25, _HEIGHT * 0.75)
|
||||
points = [
|
||||
(x, base_y + amplitude * math.sin(phase + x / 34.0)) for x in range(0, _WIDTH + 1, 4)
|
||||
]
|
||||
draw.line(points, fill=_INTERFERENCE, width=1)
|
||||
|
||||
|
||||
def _draw_characters(image: Image.Image, text: str) -> None:
|
||||
"""逐字绘制。
|
||||
|
||||
**每个字符单独渲染后旋转再贴回**,而不是整行一起旋转:
|
||||
整行旋转只是把同一个倾斜做一次仿射变换,对齐后即可还原;
|
||||
逐字随机旋转会破坏字符间的版式一致性,模板匹配难以复用。
|
||||
再叠加 ±2px 的垂直抖动,进一步打散基线。
|
||||
"""
|
||||
font = _load_font(_FONT_SIZE)
|
||||
cell = (_WIDTH - 2 * _PADDING) / len(text)
|
||||
|
||||
for index, char in enumerate(text):
|
||||
# 每个字形画在独立画布上以便单独旋转;留出余量避免旋转后切角
|
||||
glyph = Image.new("RGBA", (int(cell) + 16, _HEIGHT), (0, 0, 0, 0))
|
||||
ImageDraw.Draw(glyph).text(
|
||||
(8, 3),
|
||||
char,
|
||||
font=font,
|
||||
fill=(*_INK, 255),
|
||||
# 加 1 像素描边:等宽细体在小字号下笔画过细,
|
||||
# 而本系统的用户是营销人员、屏幕与年龄跨度都大,
|
||||
# 验证码过难会直接变成支持负担。加粗的代价是连通像素略多,
|
||||
# 但相对于"用户反复输错"的代价是划算的。
|
||||
stroke_width=1,
|
||||
stroke_fill=(*_INK, 255),
|
||||
)
|
||||
glyph = glyph.rotate(random.uniform(-14, 14), resample=Image.BICUBIC, expand=False)
|
||||
|
||||
image.paste(glyph, (int(_PADDING + index * cell) - 8, random.randint(-2, 2)), glyph)
|
||||
|
||||
|
||||
def render_png_base64(text: str) -> str:
|
||||
"""把验证码文本渲染为 PNG 并返回 base64(**不含** `data:` 前缀)。
|
||||
|
||||
不含前缀是为了让前端自行拼接;服务端返回纯数据,
|
||||
浏览器 / 小程序 / 桌面端可各自决定呈现方式。
|
||||
"""
|
||||
image = Image.new("RGB", (_WIDTH, _HEIGHT), _PAPER)
|
||||
draw = ImageDraw.Draw(image)
|
||||
|
||||
_draw_speckles(draw, _SPECKLE_COUNT // 2)
|
||||
_draw_interference(draw)
|
||||
_draw_characters(image, text)
|
||||
# 字符之上再撒一层,干扰基于连通域的分割
|
||||
_draw_speckles(draw, _SPECKLE_COUNT // 2)
|
||||
|
||||
buffer = io.BytesIO()
|
||||
image.save(buffer, format="PNG")
|
||||
return base64.b64encode(buffer.getvalue()).decode("ascii")
|
||||
103
backend/app/core/config.py
Normal file
103
backend/app/core/config.py
Normal file
@ -0,0 +1,103 @@
|
||||
"""应用配置。
|
||||
|
||||
从 `backend/.env` 读取配置项,字段名与 `.env` 中的变量名一一对应
|
||||
(pydantic-settings 默认大小写不敏感,`app_secret_key` ←→ `APP_SECRET_KEY`)。
|
||||
|
||||
设计取舍:
|
||||
- `APP_SECRET_KEY` 无默认值 —— 缺失时启动即失败。空密钥签发 JWT 属安全问题
|
||||
而非可用性问题,必须 fail-fast,不能容忍"后端起得来但令牌可被伪造"。
|
||||
- 数据库/Redis 连接不在此处建立 —— 依赖不可用时仍应能启动应用、查看接口文档。
|
||||
"""
|
||||
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import Field, field_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
from sqlalchemy import URL
|
||||
|
||||
# backend/ 目录:本文件位于 backend/app/core/config.py,向上三级
|
||||
BACKEND_DIR = Path(__file__).resolve().parents[2]
|
||||
ENV_FILE = BACKEND_DIR / ".env"
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""应用配置项。"""
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=str(ENV_FILE),
|
||||
# Windows 默认编码为 cp936,不显式指定会导致中文值解析出错
|
||||
env_file_encoding="utf-8",
|
||||
case_sensitive=False,
|
||||
# .env 中可能含本服务用不到的变量(如 docker 编排专用项),忽略而非报错
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
# ===== 应用 =====
|
||||
app_env: str = "dev"
|
||||
app_secret_key: str = Field(description="JWT 签名密钥,至少 32 字符")
|
||||
|
||||
# ===== 鉴权 =====
|
||||
# 规范第 13 节:Token 有效期 ≤ 2 小时
|
||||
jwt_expire_minutes: int = Field(default=120, ge=1, le=120)
|
||||
# 规范第 6 节:验证码 300s
|
||||
captcha_expire_seconds: int = Field(default=300, ge=60, le=300)
|
||||
login_fail_limit: int = Field(default=5, ge=1)
|
||||
login_lock_minutes: int = Field(default=15, ge=1)
|
||||
|
||||
# ===== MySQL =====
|
||||
mysql_host: str = "127.0.0.1"
|
||||
mysql_port: int = 3306
|
||||
mysql_database: str = "payroll"
|
||||
mysql_user: str = "payroll"
|
||||
mysql_password: str = ""
|
||||
|
||||
# ===== Redis =====
|
||||
redis_host: str = "127.0.0.1"
|
||||
redis_port: int = 6379
|
||||
redis_password: str = ""
|
||||
redis_db: int = 0
|
||||
|
||||
@field_validator("app_secret_key")
|
||||
@classmethod
|
||||
def _check_secret_key(cls, v: str) -> str:
|
||||
"""校验密钥强度:过短的密钥可被暴力破解,直接拒绝启动。"""
|
||||
if len(v) < 32:
|
||||
raise ValueError("APP_SECRET_KEY 长度不足 32 字符,请在 backend/.env 中重新生成")
|
||||
return v
|
||||
|
||||
@property
|
||||
def database_url(self) -> URL:
|
||||
"""数据库连接 URL。运行期与 Alembic 迁移共用同一份。
|
||||
|
||||
用 SQLAlchemy 的 URL.create 而非手工拼接字符串 —— 密码含 @ : / 等
|
||||
特殊字符时手工拼接会产生语法错误。
|
||||
|
||||
驱动为同步的 pymysql:本项目并发极低,异步无收益;而绩效核算与
|
||||
pandas 报表都是同步阻塞代码,同步方案不会与之冲突。
|
||||
"""
|
||||
return URL.create(
|
||||
drivername="mysql+pymysql",
|
||||
username=self.mysql_user,
|
||||
password=self.mysql_password,
|
||||
host=self.mysql_host,
|
||||
port=self.mysql_port,
|
||||
database=self.mysql_database,
|
||||
query={"charset": "utf8mb4"},
|
||||
)
|
||||
|
||||
@property
|
||||
def redis_url(self) -> str:
|
||||
"""Redis 连接 URL。密码为空时不拼接认证段。"""
|
||||
auth = f":{self.redis_password}@" if self.redis_password else ""
|
||||
return f"redis://{auth}{self.redis_host}:{self.redis_port}/{self.redis_db}"
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
"""获取配置单例。
|
||||
|
||||
进程内只解析一次 .env;测试中如需覆盖,调用方应使用
|
||||
`get_settings.cache_clear()` 配合环境变量注入。
|
||||
"""
|
||||
return Settings() # type: ignore[call-arg]
|
||||
38
backend/app/core/constants.py
Normal file
38
backend/app/core/constants.py
Normal file
@ -0,0 +1,38 @@
|
||||
"""全局常量。
|
||||
|
||||
集中定义 Redis 键模板、响应码、角色编码等跨模块共享的字面量,
|
||||
避免各处硬编码字符串导致拼写漂移。
|
||||
"""
|
||||
|
||||
# ===== 响应码(与 HTTP 状态码保持一致,见 PROJECT_STANDARDS 第 9 节)=====
|
||||
CODE_SUCCESS = 200
|
||||
CODE_BAD_REQUEST = 400
|
||||
CODE_UNAUTHORIZED = 401
|
||||
CODE_FORBIDDEN = 403
|
||||
CODE_NOT_FOUND = 404
|
||||
CODE_SERVER_ERROR = 500
|
||||
|
||||
# ===== Redis 键模板 =====
|
||||
# 规范第 6 节:键格式 {项目缩写}:{业务}:{标识},项目缩写为 ys
|
||||
REDIS_KEY_CAPTCHA = "ys:captcha:{captcha_id}"
|
||||
REDIS_KEY_LOGIN_FAIL = "ys:login:fail:{username}"
|
||||
REDIS_KEY_TOKEN_BLACKLIST = "ys:token:blacklist:{jti}"
|
||||
|
||||
# ===== JWT =====
|
||||
JWT_ALGORITHM = "HS256"
|
||||
TOKEN_TYPE_BEARER = "Bearer"
|
||||
|
||||
# ===== 角色编码(与 db/migration 初始化数据保持一致)=====
|
||||
ROLE_ADMIN = "admin"
|
||||
ROLE_USER = "user"
|
||||
|
||||
# ===== 密码策略 =====
|
||||
# bcrypt 5.0 起对超过 72 字节的密码由「静默截断」改为「抛 ValueError」,
|
||||
# 而中文一个字符占 3 字节,因此必须按字节数校验,按字符数校验是错的。
|
||||
PASSWORD_MIN_BYTES = 8
|
||||
PASSWORD_MAX_BYTES = 72
|
||||
|
||||
# ===== 验证码 =====
|
||||
# 剔除易混淆字符 0/O/1/I/l,降低人工识别错误率
|
||||
CAPTCHA_CHARSET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"
|
||||
CAPTCHA_LENGTH = 4
|
||||
64
backend/app/core/db.py
Normal file
64
backend/app/core/db.py
Normal file
@ -0,0 +1,64 @@
|
||||
"""数据库连接管理。
|
||||
|
||||
**为什么用同步引擎**:本项目并发极低(内部系统,营销人员数十人),
|
||||
异步拿不到吞吐收益;而重活——绩效批量核算、pandas 报表、Excel 导出——
|
||||
全是同步阻塞代码,放在异步路由里会卡死事件循环,且这类问题在开发期
|
||||
低并发下几乎不暴露。改用同步后,路由写 `def`,FastAPI 会**自动放入
|
||||
线程池执行**,天然规避上述陷阱,也不会阻塞事件循环。
|
||||
|
||||
引擎采用**延迟创建**:应用启动时不连接 MySQL,
|
||||
这样 Docker 未启动时仍能运行服务、查看接口文档。
|
||||
"""
|
||||
|
||||
from collections.abc import Generator
|
||||
|
||||
from sqlalchemy import Engine, create_engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
_engine: Engine | None = None
|
||||
_session_factory: sessionmaker[Session] | None = None
|
||||
|
||||
|
||||
def get_engine() -> Engine:
|
||||
"""获取同步引擎单例(首次调用时创建)。"""
|
||||
global _engine, _session_factory
|
||||
if _engine is None:
|
||||
settings = get_settings()
|
||||
_engine = create_engine(
|
||||
settings.database_url,
|
||||
# 连接池中的连接在使用前先探活,避免 MySQL 重启后取到死连接
|
||||
pool_pre_ping=True,
|
||||
pool_recycle=3600,
|
||||
echo=False,
|
||||
)
|
||||
_session_factory = sessionmaker(_engine, expire_on_commit=False)
|
||||
return _engine
|
||||
|
||||
|
||||
def get_session_factory() -> sessionmaker[Session]:
|
||||
"""获取会话工厂单例。"""
|
||||
get_engine()
|
||||
assert _session_factory is not None # get_engine 已确保初始化
|
||||
return _session_factory
|
||||
|
||||
|
||||
def get_db() -> Generator[Session, None, None]:
|
||||
"""FastAPI 依赖:提供一个数据库会话。
|
||||
|
||||
只负责会话的创建与关闭,**事务边界由 service 层控制** ——
|
||||
这样一次请求内的多个仓储调用可以共处同一事务。
|
||||
"""
|
||||
factory = get_session_factory()
|
||||
with factory() as session:
|
||||
yield session
|
||||
|
||||
|
||||
def dispose_engine() -> None:
|
||||
"""关闭连接池。应用退出时调用。"""
|
||||
global _engine, _session_factory
|
||||
if _engine is not None:
|
||||
_engine.dispose()
|
||||
_engine = None
|
||||
_session_factory = None
|
||||
80
backend/app/core/exception_handlers.py
Normal file
80
backend/app/core/exception_handlers.py
Normal file
@ -0,0 +1,80 @@
|
||||
"""全局异常处理器。
|
||||
|
||||
把各类异常统一转换为 `{code, message, data}` 响应,
|
||||
避免 FastAPI 默认的 422 `detail` 数组结构泄漏到前端。
|
||||
|
||||
注意:参数校验失败按规范映射为 **400**(FastAPI 默认是 422),
|
||||
因为规范第 9 节只定义了 400/401/403/404/500 五个状态码。
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.responses import JSONResponse
|
||||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||
|
||||
from app.core.constants import CODE_BAD_REQUEST, CODE_SERVER_ERROR
|
||||
from app.core.exceptions import AppException
|
||||
from app.core.response import ApiResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _build_response(code: int, message: str) -> JSONResponse:
|
||||
"""按统一格式构造 JSON 响应。"""
|
||||
body = ApiResponse[None](code=code, message=message, data=None)
|
||||
return JSONResponse(status_code=code, content=body.model_dump())
|
||||
|
||||
|
||||
def _format_validation_errors(exc: RequestValidationError) -> str:
|
||||
"""把 pydantic 的校验错误压缩成一行中文提示。
|
||||
|
||||
只取首条错误的字段名与说明,避免把完整的错误数组抛给前端。
|
||||
"""
|
||||
errors = exc.errors()
|
||||
if not errors:
|
||||
return "参数错误"
|
||||
first = errors[0]
|
||||
# loc 形如 ("body", "username"),去掉来源前缀只留字段名
|
||||
location = ".".join(
|
||||
str(part) for part in first.get("loc", ()) if part not in ("body", "query", "path")
|
||||
)
|
||||
detail = first.get("msg", "参数错误")
|
||||
return f"参数错误:{location} {detail}" if location else f"参数错误:{detail}"
|
||||
|
||||
|
||||
async def handle_app_exception(_: Request, exc: AppException) -> JSONResponse:
|
||||
"""处理业务异常,按其 code 返回。"""
|
||||
return _build_response(exc.code, exc.message)
|
||||
|
||||
|
||||
async def handle_validation_error(_: Request, exc: RequestValidationError) -> JSONResponse:
|
||||
"""处理请求参数校验失败,映射为 400。"""
|
||||
return _build_response(CODE_BAD_REQUEST, _format_validation_errors(exc))
|
||||
|
||||
|
||||
async def handle_http_exception(_: Request, exc: StarletteHTTPException) -> JSONResponse:
|
||||
"""处理框架级 HTTP 异常(404 路由不存在、405 方法不允许等)。"""
|
||||
message = exc.detail if isinstance(exc.detail, str) else "请求处理失败"
|
||||
return _build_response(exc.status_code, message)
|
||||
|
||||
|
||||
async def handle_unexpected_exception(_: Request, exc: Exception) -> JSONResponse:
|
||||
"""兜底处理未预期异常。
|
||||
|
||||
对外只返回通用文案,不泄漏堆栈;完整异常记录到日志供排查。
|
||||
"""
|
||||
logger.exception("未预期的服务端异常")
|
||||
return _build_response(CODE_SERVER_ERROR, "服务端异常,请稍后重试")
|
||||
|
||||
|
||||
def register_exception_handlers(app: FastAPI) -> None:
|
||||
"""注册全部全局异常处理器。
|
||||
|
||||
注册顺序不影响匹配 —— FastAPI 按异常类型精确查找,越具体的类型优先。
|
||||
"""
|
||||
app.add_exception_handler(AppException, handle_app_exception) # type: ignore[arg-type]
|
||||
app.add_exception_handler(RequestValidationError, handle_validation_error) # type: ignore[arg-type]
|
||||
app.add_exception_handler(StarletteHTTPException, handle_http_exception) # type: ignore[arg-type]
|
||||
app.add_exception_handler(Exception, handle_unexpected_exception)
|
||||
54
backend/app/core/exceptions.py
Normal file
54
backend/app/core/exceptions.py
Normal file
@ -0,0 +1,54 @@
|
||||
"""业务异常定义。
|
||||
|
||||
`code` 直接作为 HTTP 状态码返回,使前端拦截器既能读 HTTP 状态也能读响应体的 code。
|
||||
|
||||
重要约定:**401 只表示"受保护接口的鉴权失败"**,登录接口自身的失败一律用 400/403。
|
||||
否则前端"401 即跳登录页"的拦截器会在用户输错密码时触发跳转,导致登录页反复重挂载。
|
||||
"""
|
||||
|
||||
from app.core.constants import (
|
||||
CODE_BAD_REQUEST,
|
||||
CODE_FORBIDDEN,
|
||||
CODE_NOT_FOUND,
|
||||
CODE_SERVER_ERROR,
|
||||
CODE_UNAUTHORIZED,
|
||||
)
|
||||
|
||||
|
||||
class AppException(Exception):
|
||||
"""业务异常基类。业务逻辑中主动抛出,由全局处理器转换为统一响应。"""
|
||||
|
||||
code: int = CODE_SERVER_ERROR
|
||||
message: str = "服务端异常"
|
||||
|
||||
def __init__(self, message: str | None = None) -> None:
|
||||
self.message = message or self.message
|
||||
super().__init__(self.message)
|
||||
|
||||
|
||||
class BadRequestError(AppException):
|
||||
"""参数错误或业务规则校验不通过。"""
|
||||
|
||||
code = CODE_BAD_REQUEST
|
||||
message = "参数错误"
|
||||
|
||||
|
||||
class AuthError(AppException):
|
||||
"""未认证:令牌缺失、无效、过期或已登出。"""
|
||||
|
||||
code = CODE_UNAUTHORIZED
|
||||
message = "登录状态已失效,请重新登录"
|
||||
|
||||
|
||||
class ForbiddenError(AppException):
|
||||
"""已认证但无权限,或账号被禁用/锁定。"""
|
||||
|
||||
code = CODE_FORBIDDEN
|
||||
message = "无权限"
|
||||
|
||||
|
||||
class NotFoundError(AppException):
|
||||
"""请求的资源不存在。"""
|
||||
|
||||
code = CODE_NOT_FOUND
|
||||
message = "资源不存在"
|
||||
94
backend/app/core/logging.py
Normal file
94
backend/app/core/logging.py
Normal file
@ -0,0 +1,94 @@
|
||||
"""日志配置。
|
||||
|
||||
规范要求(PROJECT_STANDARDS 第 8 节):生产 INFO 级别、按天+大小切分、
|
||||
保留 30 天、禁止输出密码/手机号/身份证/Token。
|
||||
|
||||
Windows 默认编码为 cp936,日志文件 handler 必须显式指定 utf-8,
|
||||
否则中文日志会变成乱码。
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
import sys
|
||||
from logging.handlers import TimedRotatingFileHandler
|
||||
|
||||
from app.core.config import BACKEND_DIR
|
||||
|
||||
LOG_DIR = BACKEND_DIR / "logs"
|
||||
|
||||
# 文本脱敏规则。日志是最容易泄漏敏感信息的地方,且多半来自异常堆栈里的
|
||||
# 请求体字符串,无法靠"调用方注意"来保证,所以在这里统一兜底。
|
||||
_SENSITIVE_RULES: tuple[tuple[re.Pattern[str], str], ...] = (
|
||||
# 含 password / passwd / pwd / secret / token 的键名,或 app_key / access_key 之类的短名
|
||||
(
|
||||
re.compile(
|
||||
r'((?:\w*(?:password|passwd|pwd|secret|token)\w*|app_?key|access_?key)["\']?\s*[:=]\s*["\']?)([^"\'\s,}&]+)',
|
||||
re.IGNORECASE,
|
||||
),
|
||||
r"\1***",
|
||||
),
|
||||
# Authorization: Bearer xxx
|
||||
(re.compile(r"(Bearer\s+)[A-Za-z0-9\-._~+/]+=*"), r"\1***"),
|
||||
# 手机号:138****8888
|
||||
(re.compile(r"\b(1[3-9]\d)\d{4}(\d{4})\b"), r"\1****\2"),
|
||||
# 18 位身份证:保留前 6 位与后 4 位
|
||||
(re.compile(r"\b(\d{6})\d{8}(\d{3}[\dXx])\b"), r"\1********\2"),
|
||||
)
|
||||
|
||||
|
||||
def scrub(text: str) -> str:
|
||||
"""对文本按规则脱敏。导出供单测直接验证。"""
|
||||
for pattern, replacement in _SENSITIVE_RULES:
|
||||
text = pattern.sub(replacement, text)
|
||||
return text
|
||||
|
||||
|
||||
class SensitiveFormatter(logging.Formatter):
|
||||
"""脱敏格式化器。
|
||||
|
||||
脱敏必须在父类完成 %-格式化**之后**进行,而不是在 filter 阶段改写 ``record.msg``。
|
||||
后者会把 ``"登录失败 password=%s"`` 里的占位符一并抹成 ``password=***``,
|
||||
随后 logging 内部执行 ``msg % args`` 时因参数无处安放抛 TypeError,
|
||||
导致整条日志丢失 —— 这比不脱敏更危险。
|
||||
"""
|
||||
|
||||
def format(self, record: logging.LogRecord) -> str:
|
||||
return scrub(super().format(record))
|
||||
|
||||
|
||||
def setup_logging(level: str = "INFO", log_to_file: bool = True) -> None:
|
||||
"""初始化根日志配置。
|
||||
|
||||
重复调用是安全的:先清空已有 handler,避免 uvicorn --reload
|
||||
或测试中重复挂载导致日志重复输出。
|
||||
|
||||
:param level: 日志级别
|
||||
:param log_to_file: 是否写文件;测试环境可关闭以避免产生日志文件
|
||||
"""
|
||||
root = logging.getLogger()
|
||||
root.setLevel(level)
|
||||
for handler in list(root.handlers):
|
||||
root.removeHandler(handler)
|
||||
|
||||
formatter = SensitiveFormatter(
|
||||
fmt="%(asctime)s | %(levelname)-8s | %(name)s | %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
|
||||
# 控制台:Windows 终端默认 cp936,中文日志可能报编码错误,显式重配为 utf-8
|
||||
if hasattr(sys.stdout, "reconfigure"):
|
||||
sys.stdout.reconfigure(encoding="utf-8") # type: ignore[union-attr]
|
||||
console = logging.StreamHandler(sys.stdout)
|
||||
console.setFormatter(formatter)
|
||||
root.addHandler(console)
|
||||
|
||||
if log_to_file:
|
||||
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
file_handler = TimedRotatingFileHandler(
|
||||
filename=LOG_DIR / "app.log",
|
||||
when="midnight",
|
||||
backupCount=30, # 规范要求保留 30 天
|
||||
encoding="utf-8",
|
||||
)
|
||||
file_handler.setFormatter(formatter)
|
||||
root.addHandler(file_handler)
|
||||
41
backend/app/core/redis_client.py
Normal file
41
backend/app/core/redis_client.py
Normal file
@ -0,0 +1,41 @@
|
||||
"""Redis 连接管理。
|
||||
|
||||
承担三类职责:图形验证码、登录失败限流、JWT 登出黑名单。
|
||||
键名模板统一在 `app.core.constants` 中定义,禁止在业务代码里拼接字符串。
|
||||
|
||||
与其他基础设施一致采用**同步客户端**:路由写 `def` 由 FastAPI 放入线程池执行。
|
||||
若 DB 同步而 Redis 异步,同一请求里就会出现"要么路由必须写 async(于是同步 DB
|
||||
阻塞事件循环)、要么把 Redis 调用包进 run_in_threadpool"的两难,两头都别扭。
|
||||
"""
|
||||
|
||||
from redis import Redis
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
_client: Redis | None = None
|
||||
|
||||
|
||||
def get_redis() -> Redis:
|
||||
"""获取 Redis 客户端单例(首次调用时创建)。
|
||||
|
||||
延迟创建:Redis 未启动时不影响应用启动。
|
||||
返回的是**同步**客户端,直接调用即可,不要 await。
|
||||
"""
|
||||
global _client
|
||||
if _client is None:
|
||||
settings = get_settings()
|
||||
_client = Redis.from_url(
|
||||
settings.redis_url,
|
||||
encoding="utf-8",
|
||||
decode_responses=True,
|
||||
socket_connect_timeout=3,
|
||||
)
|
||||
return _client
|
||||
|
||||
|
||||
def close_redis() -> None:
|
||||
"""关闭连接池。应用退出时调用。"""
|
||||
global _client
|
||||
if _client is not None:
|
||||
_client.close()
|
||||
_client = None
|
||||
62
backend/app/core/response.py
Normal file
62
backend/app/core/response.py
Normal file
@ -0,0 +1,62 @@
|
||||
"""统一响应模型。
|
||||
|
||||
所有接口响应体形如 `{code, message, data}`(PROJECT_STANDARDS 第 3 节)。
|
||||
分页响应字段为 `list/total/pageNum/pageSize`(同规范第 14 节)。
|
||||
|
||||
字段名采用驼峰(规范约定)而 Python 属性用蛇形(语言惯例),
|
||||
两者通过 pydantic 的 alias 映射,序列化时输出驼峰。
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.core.constants import CODE_SUCCESS
|
||||
|
||||
|
||||
class ApiResponse[T](BaseModel):
|
||||
"""统一响应包装。泛型参数为 `data` 的类型。"""
|
||||
|
||||
code: int = Field(default=CODE_SUCCESS, description="状态码,与 HTTP 状态码一致")
|
||||
message: str = Field(default="success", description="提示信息")
|
||||
data: T | None = Field(default=None, description="业务数据")
|
||||
|
||||
|
||||
def ok[T](data: T | None = None, message: str = "success") -> ApiResponse[T]:
|
||||
"""构造成功响应。
|
||||
|
||||
统一出口便于日后调整成功码或附加字段时只改一处。
|
||||
"""
|
||||
return ApiResponse(code=CODE_SUCCESS, message=message, data=data)
|
||||
|
||||
|
||||
class PageResult[T](BaseModel):
|
||||
"""分页结果。
|
||||
|
||||
Python 侧使用蛇形属性名,序列化后输出规范要求的驼峰字段名。
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
records: list[T] = Field(
|
||||
default_factory=list, alias="list", serialization_alias="list", description="当前页数据"
|
||||
)
|
||||
total: int = Field(default=0, description="总记录数")
|
||||
page_num: int = Field(
|
||||
default=1, alias="pageNum", serialization_alias="pageNum", description="当前页码"
|
||||
)
|
||||
page_size: int = Field(
|
||||
default=10, alias="pageSize", serialization_alias="pageSize", description="每页条数"
|
||||
)
|
||||
|
||||
|
||||
class PageQuery(BaseModel):
|
||||
"""分页查询入参基类。业务查询参数继承本类扩展。"""
|
||||
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
page_num: int = Field(default=1, ge=1, alias="pageNum", description="页码,从 1 开始")
|
||||
page_size: int = Field(default=10, ge=1, le=200, alias="pageSize", description="每页条数")
|
||||
|
||||
@property
|
||||
def offset(self) -> int:
|
||||
"""转换为 SQL OFFSET。"""
|
||||
return (self.page_num - 1) * self.page_size
|
||||
102
backend/app/core/security.py
Normal file
102
backend/app/core/security.py
Normal file
@ -0,0 +1,102 @@
|
||||
"""密码哈希与 JWT 签发校验。
|
||||
|
||||
**为什么不用 passlib**:passlib 1.7.4 自 2020 年起停止维护,且会读取
|
||||
bcrypt 4.1 起已删除的 `bcrypt.__about__` 属性(表现为 "(trapped) error
|
||||
reading bcrypt version"),与 bcrypt 5.x 组合没有任何官方支持。
|
||||
FastAPI 官方文档亦已改用 pwdlib。
|
||||
|
||||
**BCrypt 的 72 字节硬限制**:bcrypt 5.0 起对超过 72 字节的密码**直接抛
|
||||
ValueError**(此前是静默截断)。中文一个字符占 3 字节,24 个汉字即到顶 ——
|
||||
因此必须按字节数校验,按字符数校验是错的。
|
||||
本模块做防御性兜底,调用方(schemas 层)应更早拦截以给出友好提示。
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
import jwt
|
||||
from pwdlib import PasswordHash
|
||||
from pwdlib.hashers.bcrypt import BcryptHasher
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.constants import JWT_ALGORITHM, PASSWORD_MAX_BYTES
|
||||
from app.core.exceptions import AuthError
|
||||
|
||||
# **显式指定 bcrypt,不用 `PasswordHash.recommended()`**:
|
||||
# 后者在 pwdlib 0.3.x 推荐的是 Argon2,且会随库版本变化 —— 结果是
|
||||
# 同一批密码用不同算法哈希、与规范要求的「密码 BCrypt」不符。
|
||||
# 哈希算法属于会被写入数据、影响长期兼容性的决策,必须钉死。
|
||||
_password_hasher = PasswordHash((BcryptHasher(),))
|
||||
|
||||
|
||||
def hash_password(plain: str) -> str:
|
||||
"""计算密码哈希。返回值为 BCrypt 格式,定长 60 字符。
|
||||
|
||||
:raises ValueError: 密码超过 72 字节(bcrypt 算法上限)
|
||||
"""
|
||||
if len(plain.encode("utf-8")) > PASSWORD_MAX_BYTES:
|
||||
raise ValueError(f"密码超过 {PASSWORD_MAX_BYTES} 字节上限,无法哈希")
|
||||
return _password_hasher.hash(plain)
|
||||
|
||||
|
||||
def verify_password(plain: str, hashed: str) -> bool:
|
||||
"""校验密码是否匹配哈希。
|
||||
|
||||
超长密码直接判为不匹配而不抛异常 —— 校验路径不应因输入长度而报错,
|
||||
否则会给攻击者提供区分"密码过长"与"密码错误"的侧信道。
|
||||
"""
|
||||
if len(plain.encode("utf-8")) > PASSWORD_MAX_BYTES:
|
||||
return False
|
||||
try:
|
||||
return _password_hasher.verify(plain, hashed)
|
||||
except Exception:
|
||||
# 哈希串损坏或格式不符时视为校验失败,不向上抛
|
||||
return False
|
||||
|
||||
|
||||
# ==================== JWT ====================
|
||||
|
||||
|
||||
def create_access_token(
|
||||
*, user_id: int, token_version: int, roles: list[str]
|
||||
) -> tuple[str, str, int]:
|
||||
"""签发访问令牌。
|
||||
|
||||
载荷含 `ver`(对应用户的 `token_version`)—— 鉴权时与库中值比对,
|
||||
使「禁用账号 / 改密 / 强制下线」能立即让已签发令牌失效,**不依赖 Redis**。
|
||||
|
||||
:returns: ``(token, jti, 有效期秒数)``。jti 供登出时拉黑使用。
|
||||
"""
|
||||
settings = get_settings()
|
||||
now = datetime.now(UTC)
|
||||
expires_in = settings.jwt_expire_minutes * 60
|
||||
jti = uuid.uuid4().hex
|
||||
|
||||
payload = {
|
||||
"sub": str(user_id), # PyJWT 要求 sub 为字符串
|
||||
"jti": jti,
|
||||
"ver": token_version,
|
||||
"roles": roles, # 仅供前端展示菜单,鉴权时以数据库为准
|
||||
"iat": now,
|
||||
"exp": now + timedelta(seconds=expires_in),
|
||||
}
|
||||
token = jwt.encode(payload, settings.app_secret_key, algorithm=JWT_ALGORITHM)
|
||||
return token, jti, expires_in
|
||||
|
||||
|
||||
def decode_token(token: str) -> dict[str, Any]:
|
||||
"""解析并校验令牌的签名与有效期。
|
||||
|
||||
时间一律使用 **UTC 感知时间**:JWT 的 exp 是 UTC 时间戳,若与本地时间
|
||||
(容器 TZ=Asia/Shanghai)比较会出现 8 小时偏差,表现为「刚登录就过期」
|
||||
或「过期了还能用」。
|
||||
|
||||
:raises AuthError: 签名无效、格式错误或已过期
|
||||
"""
|
||||
try:
|
||||
return jwt.decode(token, get_settings().app_secret_key, algorithms=[JWT_ALGORITHM])
|
||||
except jwt.ExpiredSignatureError as exc:
|
||||
raise AuthError("登录已过期,请重新登录") from exc
|
||||
except jwt.PyJWTError as exc:
|
||||
raise AuthError("登录凭证无效,请重新登录") from exc
|
||||
66
backend/app/main.py
Normal file
66
backend/app/main.py
Normal file
@ -0,0 +1,66 @@
|
||||
"""应用入口。
|
||||
|
||||
启动:``uv run uvicorn app.main:app --reload --port 8000``
|
||||
"""
|
||||
|
||||
import logging
|
||||
from collections.abc import AsyncGenerator
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
|
||||
from app.api.router import api_router
|
||||
from app.core.config import get_settings
|
||||
from app.core.db import dispose_engine
|
||||
from app.core.exception_handlers import register_exception_handlers
|
||||
from app.core.logging import setup_logging
|
||||
from app.core.redis_client import close_redis
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
|
||||
"""应用生命周期管理。
|
||||
|
||||
启动阶段不建立数据库/Redis 连接(延迟到首次使用),
|
||||
但退出时必须显式释放连接池,否则 uvicorn 热重载会残留连接。
|
||||
"""
|
||||
settings = get_settings()
|
||||
logger.info(
|
||||
"应用启动 | 环境=%s | 数据库=%s/%s",
|
||||
settings.app_env,
|
||||
settings.mysql_host,
|
||||
settings.mysql_database,
|
||||
)
|
||||
yield
|
||||
dispose_engine()
|
||||
close_redis()
|
||||
logger.info("应用已停止,连接池已释放")
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
"""构造并配置 FastAPI 应用实例。
|
||||
|
||||
独立成函数便于测试中构造互不干扰的实例。
|
||||
"""
|
||||
settings = get_settings()
|
||||
setup_logging(level="DEBUG" if settings.app_env == "dev" else "INFO")
|
||||
|
||||
app = FastAPI(
|
||||
title="衍水集团营销人员工资管理系统",
|
||||
description="营销人员绩效工资核算系统 —— 后端服务",
|
||||
version="0.1.0",
|
||||
# 接口文档挂在 /api 下,开发期可通过 Vite 代理从同一个端口访问
|
||||
docs_url="/api/docs",
|
||||
redoc_url=None,
|
||||
openapi_url="/api/openapi.json",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
register_exception_handlers(app)
|
||||
app.include_router(api_router, prefix="/api")
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
26
backend/app/models/__init__.py
Normal file
26
backend/app/models/__init__.py
Normal file
@ -0,0 +1,26 @@
|
||||
"""ORM 模型层:只做表结构映射。
|
||||
|
||||
分层约束:
|
||||
- 不含业务逻辑
|
||||
- 不得被 `api/` 层直接使用 —— ORM 对象必须经 `schemas/` 转换后才能出参,
|
||||
否则表结构变更会直接泄漏到接口契约上
|
||||
|
||||
**本模块的导入列表必须保持完整**:Alembic autogenerate 依赖
|
||||
`Base.metadata` 包含全部表,漏掉一张就会生成「删除该表」的迁移。
|
||||
"""
|
||||
|
||||
from app.models.base import Base, TimestampMixin
|
||||
from app.models.login_log import LoginLog
|
||||
from app.models.role import Permission, Role, RolePermission
|
||||
from app.models.user import User, UserRole
|
||||
|
||||
__all__ = [
|
||||
"Base",
|
||||
"LoginLog",
|
||||
"Permission",
|
||||
"Role",
|
||||
"RolePermission",
|
||||
"TimestampMixin",
|
||||
"User",
|
||||
"UserRole",
|
||||
]
|
||||
83
backend/app/models/base.py
Normal file
83
backend/app/models/base.py
Normal file
@ -0,0 +1,83 @@
|
||||
"""ORM 基类、命名约定与公共列。
|
||||
|
||||
命名约定按 PROJECT_STANDARDS 红线设置,使 Alembic autogenerate 产出的
|
||||
约束名自动符合规范(唯一键 `uk_表_字段`、索引 `idx_表_字段`),
|
||||
无需在每张表上手工指定 —— 手工指定迟早会漏。
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, MetaData, text
|
||||
from sqlalchemy.dialects.mysql import BIGINT, TINYINT
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
|
||||
# 约束命名模板。`column_0_name` 取约束涉及的第一列,
|
||||
# 对单列唯一键/索引即为字段名,符合规范的 uk_表_字段 / idx_表_字段
|
||||
NAMING_CONVENTION = {
|
||||
"ix": "idx_%(table_name)s_%(column_0_name)s",
|
||||
"uq": "uk_%(table_name)s_%(column_0_name)s",
|
||||
"ck": "ck_%(table_name)s_%(constraint_name)s",
|
||||
"fk": "fk_%(table_name)s_%(column_0_name)s",
|
||||
"pk": "pk_%(table_name)s",
|
||||
}
|
||||
|
||||
# 主键与外键统一使用无符号 BIGINT(规范模板为 BIGINT UNSIGNED)。
|
||||
# 两边类型必须完全一致,否则 MySQL 拒绝建立外键约束。
|
||||
BIGINT_UNSIGNED = BIGINT(unsigned=True)
|
||||
|
||||
# 状态类字段统一用 TINYINT(0/1 或少量枚举值)
|
||||
TINYINT_TYPE = TINYINT
|
||||
|
||||
__all__ = ["BIGINT_UNSIGNED", "TINYINT_TYPE", "Base", "TimestampMixin", "mysql_table_args"]
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""全部 ORM 模型的基类。"""
|
||||
|
||||
metadata = MetaData(naming_convention=NAMING_CONVENTION)
|
||||
|
||||
|
||||
class TimestampMixin:
|
||||
"""创建时间 / 更新时间公共列。
|
||||
|
||||
取默认值交给数据库而非应用层:即使有人直接用 SQL 改数据,时间戳依然准确;
|
||||
应用层赋值则会被绕过。
|
||||
|
||||
`update_time` 必须用 `text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP")`
|
||||
而非 `server_onupdate=func.current_timestamp()` —— 后者只是告诉 ORM
|
||||
「这个值由服务端维护」,**不会生成 ON UPDATE 子句**,结果是更新时间永远不刷新。
|
||||
"""
|
||||
|
||||
create_time: Mapped[datetime] = mapped_column(
|
||||
DateTime,
|
||||
nullable=False,
|
||||
server_default=text("CURRENT_TIMESTAMP"),
|
||||
comment="创建时间",
|
||||
)
|
||||
update_time: Mapped[datetime] = mapped_column(
|
||||
DateTime,
|
||||
nullable=False,
|
||||
server_default=text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
|
||||
comment="更新时间",
|
||||
)
|
||||
|
||||
|
||||
def mysql_table_args(comment: str, *extra: object) -> tuple[object, ...]:
|
||||
"""构造 MySQL 表的公共参数,返回可直接赋给 `__table_args__` 的元组。
|
||||
|
||||
显式指定引擎、字符集与排序规则:compose 已把服务端排序规则覆盖为
|
||||
`utf8mb4_general_ci`,若某处按 MySQL 8.0 默认的 `utf8mb4_0900_ai_ci` 建表,
|
||||
跨表 JOIN 会报 `Illegal mix of collations`。
|
||||
|
||||
:param comment: 表注释(规范红线要求中文 COMMENT)
|
||||
:param extra: 额外的约束对象(UniqueConstraint / Index 等)
|
||||
"""
|
||||
return (
|
||||
*extra,
|
||||
{
|
||||
"mysql_engine": "InnoDB",
|
||||
"mysql_charset": "utf8mb4",
|
||||
"mysql_collate": "utf8mb4_general_ci",
|
||||
"comment": comment,
|
||||
},
|
||||
)
|
||||
53
backend/app/models/login_log.py
Normal file
53
backend/app/models/login_log.py
Normal file
@ -0,0 +1,53 @@
|
||||
"""登录日志模型。"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Index, String, text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.models.base import BIGINT_UNSIGNED, TINYINT_TYPE, Base, mysql_table_args
|
||||
|
||||
|
||||
class LoginLog(Base):
|
||||
"""登录日志。需求 2.5.1 要求记录用户登录行为。
|
||||
|
||||
不继承 TimestampMixin:日志是追加写、不可变的,没有"更新时间"语义,
|
||||
也不使用 update_time 的 ON UPDATE 子句。
|
||||
|
||||
记录原则:
|
||||
- **只记登录尝试,绝不记录密码**(明文或哈希都不记)
|
||||
- 用户名原样记录而非仅存 user_id —— 账号不存在时也要能追溯
|
||||
- 写入使用独立事务,失败不阻断登录(审计可丢,业务不可断)
|
||||
"""
|
||||
|
||||
__tablename__ = "login_logs"
|
||||
__table_args__ = mysql_table_args(
|
||||
"登录日志表",
|
||||
Index("idx_login_logs_username", "username"),
|
||||
Index("idx_login_logs_create_time", "create_time"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(
|
||||
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||
)
|
||||
user_id: Mapped[int | None] = mapped_column(
|
||||
BIGINT_UNSIGNED,
|
||||
ForeignKey("users.id", ondelete="SET NULL"),
|
||||
comment="用户ID(账号不存在时为空)",
|
||||
)
|
||||
username: Mapped[str] = mapped_column(
|
||||
String(64), nullable=False, comment="登录用户名(原样记录)"
|
||||
)
|
||||
ip: Mapped[str | None] = mapped_column(String(45), comment="客户端IP(45位兼容IPv6)")
|
||||
user_agent: Mapped[str | None] = mapped_column(
|
||||
String(255), comment="客户端User-Agent(截断存储)"
|
||||
)
|
||||
success: Mapped[int] = mapped_column(
|
||||
TINYINT_TYPE, nullable=False, server_default="0", comment="是否成功:1成功 0失败"
|
||||
)
|
||||
fail_reason: Mapped[str | None] = mapped_column(
|
||||
String(64), comment="失败原因:验证码错误/用户名或密码错误/账号锁定/账号禁用"
|
||||
)
|
||||
create_time: Mapped[datetime] = mapped_column(
|
||||
DateTime, nullable=False, server_default=text("CURRENT_TIMESTAMP"), comment="创建时间"
|
||||
)
|
||||
105
backend/app/models/role.py
Normal file
105
backend/app/models/role.py
Normal file
@ -0,0 +1,105 @@
|
||||
"""角色、权限及其关联模型(RBAC 标准五表的后四张)。
|
||||
|
||||
权限与角色是多对多:一个权限(如「计提参数配置」)可授予多个角色,
|
||||
因此关联必须独立成表,不能把 role_id 塞进 permissions 表。
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Index, Integer, String, UniqueConstraint, text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import BIGINT_UNSIGNED, TINYINT_TYPE, Base, TimestampMixin, mysql_table_args
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.user import UserRole
|
||||
|
||||
|
||||
class Role(Base, TimestampMixin):
|
||||
"""角色表。本期仅 admin / user 两个角色,数据随迁移脚本初始化。"""
|
||||
|
||||
__tablename__ = "roles"
|
||||
__table_args__ = mysql_table_args("角色表")
|
||||
|
||||
id: Mapped[int] = mapped_column(
|
||||
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||
)
|
||||
role_code: Mapped[str] = mapped_column(
|
||||
String(32), nullable=False, unique=True, comment="角色编码:admin系统管理员 user普通用户"
|
||||
)
|
||||
role_name: Mapped[str] = mapped_column(String(64), nullable=False, comment="角色名称")
|
||||
description: Mapped[str | None] = mapped_column(String(255), comment="角色说明")
|
||||
sort_order: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, server_default="0", comment="显示排序"
|
||||
)
|
||||
status: Mapped[int] = mapped_column(
|
||||
TINYINT_TYPE, nullable=False, server_default="1", comment="状态:1启用 0禁用"
|
||||
)
|
||||
|
||||
user_roles: Mapped[list["UserRole"]] = relationship(back_populates="role")
|
||||
|
||||
|
||||
class Permission(Base, TimestampMixin):
|
||||
"""权限表。
|
||||
|
||||
perm_type 区分菜单/按钮/接口三类,parent_id 支撑树形结构。
|
||||
本期只建表不填数据 —— 具体权限清单待需求澄清后确定。
|
||||
"""
|
||||
|
||||
__tablename__ = "permissions"
|
||||
__table_args__ = mysql_table_args("权限表")
|
||||
|
||||
id: Mapped[int] = mapped_column(
|
||||
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||
)
|
||||
perm_code: Mapped[str] = mapped_column(
|
||||
String(128), nullable=False, unique=True, comment="权限编码,如 system:user:create"
|
||||
)
|
||||
perm_name: Mapped[str] = mapped_column(String(64), nullable=False, comment="权限名称")
|
||||
perm_type: Mapped[int] = mapped_column(
|
||||
TINYINT_TYPE, nullable=False, server_default="1", comment="类型:1菜单 2按钮 3接口"
|
||||
)
|
||||
parent_id: Mapped[int | None] = mapped_column(
|
||||
BIGINT_UNSIGNED,
|
||||
ForeignKey("permissions.id", ondelete="CASCADE"),
|
||||
comment="父权限ID(permissions.id),顶级为空",
|
||||
)
|
||||
sort_order: Mapped[int] = mapped_column(
|
||||
Integer, nullable=False, server_default="0", comment="显示排序"
|
||||
)
|
||||
status: Mapped[int] = mapped_column(
|
||||
TINYINT_TYPE, nullable=False, server_default="1", comment="状态:1启用 0禁用"
|
||||
)
|
||||
|
||||
|
||||
class RolePermission(Base):
|
||||
"""角色-权限关联表。"""
|
||||
|
||||
__tablename__ = "role_permissions"
|
||||
__table_args__ = mysql_table_args(
|
||||
"角色权限关联表",
|
||||
UniqueConstraint(
|
||||
"role_id", "permission_id", name="uk_role_permissions_role_id_permission_id"
|
||||
),
|
||||
Index("idx_role_permissions_permission_id", "permission_id"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(
|
||||
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||
)
|
||||
role_id: Mapped[int] = mapped_column(
|
||||
BIGINT_UNSIGNED,
|
||||
ForeignKey("roles.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
comment="角色ID(roles.id)",
|
||||
)
|
||||
permission_id: Mapped[int] = mapped_column(
|
||||
BIGINT_UNSIGNED,
|
||||
ForeignKey("permissions.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
comment="权限ID(permissions.id)",
|
||||
)
|
||||
create_time: Mapped[datetime] = mapped_column(
|
||||
DateTime, nullable=False, server_default=text("CURRENT_TIMESTAMP"), comment="创建时间"
|
||||
)
|
||||
102
backend/app/models/user.py
Normal file
102
backend/app/models/user.py
Normal file
@ -0,0 +1,102 @@
|
||||
"""系统用户与用户-角色关联模型。"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import (
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
UniqueConstraint,
|
||||
text,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import BIGINT_UNSIGNED, TINYINT_TYPE, Base, TimestampMixin, mysql_table_args
|
||||
|
||||
if TYPE_CHECKING: # 仅用于类型标注,避免运行期循环导入
|
||||
from app.models.role import Role
|
||||
|
||||
|
||||
class User(Base, TimestampMixin):
|
||||
"""系统登录账号。
|
||||
|
||||
与营销人员业务数据**严格分离**:营销人员是核算对象,数据来自 T+ 同步,
|
||||
其中多数不需要登录系统(内勤、离职人员、仅参与核算的业务员)。
|
||||
合并成一张表会让 T+ 全量同步覆盖掉 password,导致全员无法登录。
|
||||
|
||||
采用「删除即禁用」(status=0)而非软删除列:软删除与
|
||||
`uk_users_username` 唯一键会冲突(删除后的用户名无法重新启用)。
|
||||
"""
|
||||
|
||||
__tablename__ = "users"
|
||||
__table_args__ = mysql_table_args("系统用户表")
|
||||
|
||||
id: Mapped[int] = mapped_column(
|
||||
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||
)
|
||||
username: Mapped[str] = mapped_column(
|
||||
String(64), nullable=False, unique=True, comment="登录用户名"
|
||||
)
|
||||
password: Mapped[str] = mapped_column(
|
||||
String(100), nullable=False, comment="密码哈希(BCrypt,固定60字符)"
|
||||
)
|
||||
real_name: Mapped[str | None] = mapped_column(String(64), comment="真实姓名")
|
||||
phone: Mapped[str | None] = mapped_column(String(20), index=True, comment="手机号")
|
||||
email: Mapped[str | None] = mapped_column(String(128), comment="邮箱")
|
||||
status: Mapped[int] = mapped_column(
|
||||
TINYINT_TYPE, nullable=False, server_default="1", comment="状态:1启用 0禁用"
|
||||
)
|
||||
# 「离职即失效」的核心:每次鉴权比对 JWT 的 ver 声明与本列。
|
||||
# 相比只在 Redis 维护黑名单,这种方式在 Redis 不可用时依然有效
|
||||
token_version: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
nullable=False,
|
||||
server_default="1",
|
||||
comment="令牌版本号:改密/禁用/强制下线时+1,使已签发Token立即失效",
|
||||
)
|
||||
last_login_time: Mapped[datetime | None] = mapped_column(DateTime, comment="最后登录时间")
|
||||
remark: Mapped[str | None] = mapped_column(String(255), comment="备注")
|
||||
|
||||
user_roles: Mapped[list["UserRole"]] = relationship(
|
||||
back_populates="user", cascade="all, delete-orphan", lazy="selectin"
|
||||
)
|
||||
|
||||
|
||||
class UserRole(Base):
|
||||
"""用户-角色关联表。
|
||||
|
||||
不继承 TimestampMixin(关联关系不会被"更新",只有建立与删除),
|
||||
但保留 create_time 以便审计「角色何时授予」。
|
||||
"""
|
||||
|
||||
__tablename__ = "user_roles"
|
||||
__table_args__ = mysql_table_args(
|
||||
"用户角色关联表",
|
||||
UniqueConstraint("user_id", "role_id", name="uk_user_roles_user_id_role_id"),
|
||||
Index("idx_user_roles_role_id", "role_id"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(
|
||||
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
|
||||
)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
BIGINT_UNSIGNED,
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
comment="用户ID(users.id)",
|
||||
)
|
||||
role_id: Mapped[int] = mapped_column(
|
||||
BIGINT_UNSIGNED,
|
||||
ForeignKey("roles.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
comment="角色ID(roles.id)",
|
||||
)
|
||||
create_time: Mapped[datetime] = mapped_column(
|
||||
DateTime, nullable=False, server_default=text("CURRENT_TIMESTAMP"), comment="创建时间"
|
||||
)
|
||||
|
||||
user: Mapped[User] = relationship(back_populates="user_roles")
|
||||
role: Mapped["Role"] = relationship(back_populates="user_roles", lazy="selectin")
|
||||
7
backend/app/repositories/__init__.py
Normal file
7
backend/app/repositories/__init__.py
Normal file
@ -0,0 +1,7 @@
|
||||
"""数据访问层:只负责数据读写。
|
||||
|
||||
分层约束:
|
||||
- **只 flush,不 commit** —— 事务边界由 `services/` 层控制
|
||||
- 不抛业务异常;查不到数据返回 None 或空集合,由 service 决定如何处置
|
||||
- 不在此层做权限判断或业务规则校验
|
||||
"""
|
||||
38
backend/app/repositories/login_log_repository.py
Normal file
38
backend/app/repositories/login_log_repository.py
Normal file
@ -0,0 +1,38 @@
|
||||
"""登录日志数据访问。"""
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import LoginLog
|
||||
|
||||
# user_agent 列宽,超长需截断否则插入报错
|
||||
_USER_AGENT_MAX_LEN = 255
|
||||
|
||||
|
||||
def add(
|
||||
session: Session,
|
||||
*,
|
||||
user_id: int | None,
|
||||
username: str,
|
||||
ip: str | None,
|
||||
user_agent: str | None,
|
||||
success: bool,
|
||||
fail_reason: str | None = None,
|
||||
) -> None:
|
||||
"""写入一条登录日志。只 flush 不 commit。
|
||||
|
||||
**绝不记录密码** —— 明文与哈希都不写。日志是最容易泄漏凭据的地方。
|
||||
|
||||
:param user_id: 账号不存在时传 None(列本身可空)
|
||||
:param username: 用户名原样记录,便于账号不存在时也能追溯
|
||||
"""
|
||||
session.add(
|
||||
LoginLog(
|
||||
user_id=user_id,
|
||||
username=username,
|
||||
ip=ip,
|
||||
user_agent=user_agent[:_USER_AGENT_MAX_LEN] if user_agent else None,
|
||||
success=1 if success else 0,
|
||||
fail_reason=fail_reason,
|
||||
)
|
||||
)
|
||||
session.flush()
|
||||
36
backend/app/repositories/user_repository.py
Normal file
36
backend/app/repositories/user_repository.py
Normal file
@ -0,0 +1,36 @@
|
||||
"""用户与角色数据访问。
|
||||
|
||||
本层只 flush 不 commit —— 事务边界由 services 层控制。
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models import User
|
||||
|
||||
|
||||
def get_by_username(session: Session, username: str) -> User | None:
|
||||
"""按用户名查询用户。不存在返回 None。"""
|
||||
return session.execute(select(User).where(User.username == username)).scalar_one_or_none()
|
||||
|
||||
|
||||
def get_by_id(session: Session, user_id: int) -> User | None:
|
||||
"""按主键查询用户。不存在返回 None。"""
|
||||
return session.get(User, user_id)
|
||||
|
||||
|
||||
def get_role_codes(user: User) -> list[str]:
|
||||
"""取用户的角色编码列表。
|
||||
|
||||
依赖 `User.user_roles` 的 `lazy="selectin"` 预加载(见模型定义),
|
||||
故此处遍历不会触发 N+1 查询。
|
||||
"""
|
||||
return [link.role.role_code for link in user.user_roles if link.role is not None]
|
||||
|
||||
|
||||
def touch_last_login(session: Session, user: User, moment: datetime) -> None:
|
||||
"""更新最后登录时间。只 flush,提交由 service 决定。"""
|
||||
user.last_login_time = moment
|
||||
session.flush()
|
||||
9
backend/app/schemas/__init__.py
Normal file
9
backend/app/schemas/__init__.py
Normal file
@ -0,0 +1,9 @@
|
||||
"""请求/响应模型层(Pydantic)。
|
||||
|
||||
分层约束:
|
||||
- 禁止把 ORM 对象直接作为 `response_model` 返回,必须在本层完成转换
|
||||
- 入参校验(含长度、格式、取值范围)在此层声明,业务校验放 `services/`
|
||||
|
||||
本项目特有要求:密码字段必须按**字节数**校验而非字符数 ——
|
||||
bcrypt 5.0 起对超过 72 字节的密码直接抛 ValueError,而中文一个字符占 3 字节。
|
||||
"""
|
||||
73
backend/app/schemas/auth.py
Normal file
73
backend/app/schemas/auth.py
Normal file
@ -0,0 +1,73 @@
|
||||
"""认证相关的请求与响应模型。"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
from app.core.constants import CAPTCHA_LENGTH, PASSWORD_MAX_BYTES
|
||||
|
||||
|
||||
class CaptchaOut(BaseModel):
|
||||
"""验证码响应。
|
||||
|
||||
图片以 base64 内联返回而非单独的 URL:让 id 与图片在同一响应中成对产生,
|
||||
避免前端分两次请求时拿到不匹配的组合。
|
||||
"""
|
||||
|
||||
captcha_id: str = Field(description="验证码标识,登录时需回传")
|
||||
image_base64: str = Field(description="PNG 图片的 base64,不含 data: 前缀")
|
||||
expires_in: int = Field(description="有效期(秒)")
|
||||
|
||||
|
||||
class LoginIn(BaseModel):
|
||||
"""登录请求。"""
|
||||
|
||||
model_config = ConfigDict(str_strip_whitespace=True)
|
||||
|
||||
username: str = Field(min_length=1, max_length=64, description="登录用户名")
|
||||
password: str = Field(min_length=1, description="密码")
|
||||
captcha_id: str = Field(min_length=1, max_length=64, description="验证码标识")
|
||||
captcha_code: str = Field(
|
||||
min_length=CAPTCHA_LENGTH,
|
||||
max_length=CAPTCHA_LENGTH,
|
||||
description="用户填写的验证码",
|
||||
)
|
||||
|
||||
@field_validator("password")
|
||||
@classmethod
|
||||
def _check_password_bytes(cls, value: str) -> str:
|
||||
"""按**字节数**校验密码长度。
|
||||
|
||||
必须在这里拦下:bcrypt 5.0 起对超过 72 字节的密码直接抛 ValueError,
|
||||
而中文一个字符占 3 字节——按字符数校验(如 max_length=20)根本拦不住,
|
||||
24 个汉字就会让哈希函数抛异常、登录接口 500。
|
||||
"""
|
||||
if len(value.encode("utf-8")) > PASSWORD_MAX_BYTES:
|
||||
raise ValueError(f"密码超过 {PASSWORD_MAX_BYTES} 字节上限")
|
||||
return value
|
||||
|
||||
|
||||
class UserBrief(BaseModel):
|
||||
"""用户简要信息。"""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
username: str
|
||||
real_name: str | None = None
|
||||
roles: list[str] = Field(default_factory=list, description="角色编码列表")
|
||||
|
||||
|
||||
class LoginOut(BaseModel):
|
||||
"""登录成功响应。"""
|
||||
|
||||
access_token: str
|
||||
token_type: str = "Bearer"
|
||||
expires_in: int = Field(description="有效期(秒)")
|
||||
user: UserBrief
|
||||
|
||||
|
||||
class CurrentUserOut(UserBrief):
|
||||
"""当前登录用户信息。"""
|
||||
|
||||
last_login_time: datetime | None = None
|
||||
8
backend/app/services/__init__.py
Normal file
8
backend/app/services/__init__.py
Normal file
@ -0,0 +1,8 @@
|
||||
"""业务服务层:业务编排与事务边界。
|
||||
|
||||
分层约束:
|
||||
- **唯一允许出现 commit/rollback 的层**。repository 只 flush,由本层决定何时提交,
|
||||
这样一次请求内的多次数据写入能共处同一事务
|
||||
- 可调用多个 repository 组合业务逻辑
|
||||
- 业务规则不满足时抛 `AppException` 子类,由全局异常处理器转换为统一响应
|
||||
"""
|
||||
167
backend/app/services/auth_service.py
Normal file
167
backend/app/services/auth_service.py
Normal file
@ -0,0 +1,167 @@
|
||||
"""认证业务编排。
|
||||
|
||||
**事务边界在本层**:一次登录的「更新最后登录时间 + 写登录日志」在同一事务内提交。
|
||||
|
||||
**失败日志为什么单独提交**:登录失败时抛出的异常会让请求事务回滚,
|
||||
若日志与业务同事务,最需要留痕的失败记录反而被丢掉。故失败路径先提交日志再抛。
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.exceptions import AuthError, BadRequestError, ForbiddenError
|
||||
from app.core.security import create_access_token, decode_token, verify_password
|
||||
from app.repositories import login_log_repository, user_repository
|
||||
from app.schemas.auth import LoginOut, UserBrief
|
||||
from app.services import captcha_service, login_limiter, token_blacklist
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 统一凭据错误文案:不区分「用户不存在」与「密码错误」,防止账号枚举。
|
||||
# 攻击者据此无法判断某个用户名是否真实存在。
|
||||
INVALID_CREDENTIALS = "用户名或密码错误"
|
||||
|
||||
|
||||
def _record(
|
||||
session: Session,
|
||||
*,
|
||||
user_id: int | None,
|
||||
username: str,
|
||||
ip: str | None,
|
||||
user_agent: str | None,
|
||||
success: bool,
|
||||
fail_reason: str | None = None,
|
||||
) -> None:
|
||||
"""写登录日志并立即提交。单独提交是刻意为之,见模块 docstring。"""
|
||||
login_log_repository.add(
|
||||
session,
|
||||
user_id=user_id,
|
||||
username=username,
|
||||
ip=ip,
|
||||
user_agent=user_agent,
|
||||
success=success,
|
||||
fail_reason=fail_reason,
|
||||
)
|
||||
session.commit()
|
||||
|
||||
|
||||
def login(
|
||||
session: Session,
|
||||
*,
|
||||
username: str,
|
||||
password: str,
|
||||
captcha_id: str,
|
||||
captcha_code: str,
|
||||
ip: str | None,
|
||||
user_agent: str | None,
|
||||
) -> LoginOut:
|
||||
"""执行登录,返回访问令牌与用户信息。
|
||||
|
||||
判定顺序是有意设计的,每一步对应一类攻击面:
|
||||
|
||||
1. **锁定检查放在最前** —— 否则攻击者无需通过验证码即可消耗失败计数
|
||||
2. **验证码校验并消费** —— 阻断自动化撞库
|
||||
3. **凭据校验** —— 用户不存在与密码错误共用同一文案
|
||||
4. **状态检查放在最后** —— 若提前到密码校验之前,会向未持有正确密码者
|
||||
暴露「该账号存在但已禁用」
|
||||
|
||||
:raises ForbiddenError: 账号锁定或已禁用(403)
|
||||
:raises BadRequestError: 验证码错误或凭据错误(400)
|
||||
"""
|
||||
# ---- 1. 锁定检查 ----
|
||||
if login_limiter.is_locked(username):
|
||||
remaining = login_limiter.remaining_lock_seconds(username)
|
||||
minutes = max(1, (remaining + 59) // 60) # 向上取整,避免显示"0 分钟"
|
||||
_record(
|
||||
session,
|
||||
user_id=None,
|
||||
username=username,
|
||||
ip=ip,
|
||||
user_agent=user_agent,
|
||||
success=False,
|
||||
fail_reason="账号锁定",
|
||||
)
|
||||
raise ForbiddenError(f"账号已锁定,请 {minutes} 分钟后重试")
|
||||
|
||||
# ---- 2. 验证码校验(无论成败,该验证码都已失效)----
|
||||
if not captcha_service.verify_and_consume(captcha_id, captcha_code):
|
||||
_record(
|
||||
session,
|
||||
user_id=None,
|
||||
username=username,
|
||||
ip=ip,
|
||||
user_agent=user_agent,
|
||||
success=False,
|
||||
fail_reason="验证码错误",
|
||||
)
|
||||
raise BadRequestError("验证码错误或已过期,请刷新验证码")
|
||||
|
||||
# ---- 3. 凭据校验 ----
|
||||
user = user_repository.get_by_username(session, username)
|
||||
if user is None or not verify_password(password, user.password):
|
||||
login_limiter.record_failure(username)
|
||||
_record(
|
||||
session,
|
||||
user_id=user.id if user else None,
|
||||
username=username,
|
||||
ip=ip,
|
||||
user_agent=user_agent,
|
||||
success=False,
|
||||
fail_reason=INVALID_CREDENTIALS,
|
||||
)
|
||||
raise BadRequestError(INVALID_CREDENTIALS)
|
||||
|
||||
# ---- 4. 状态检查 ----
|
||||
if user.status != 1:
|
||||
_record(
|
||||
session,
|
||||
user_id=user.id,
|
||||
username=username,
|
||||
ip=ip,
|
||||
user_agent=user_agent,
|
||||
success=False,
|
||||
fail_reason="账号禁用",
|
||||
)
|
||||
raise ForbiddenError("账号已被禁用,请联系管理员")
|
||||
|
||||
# ---- 成功 ----
|
||||
login_limiter.clear(username)
|
||||
role_codes = user_repository.get_role_codes(user)
|
||||
# 库列存本地时间(容器 TZ=Asia/Shanghai);JWT 内部另用 UTC,两者不混算
|
||||
user_repository.touch_last_login(session, user, datetime.now())
|
||||
token, _, expires_in = create_access_token(
|
||||
user_id=user.id, token_version=user.token_version, roles=role_codes
|
||||
)
|
||||
_record(session, user_id=user.id, username=username, ip=ip, user_agent=user_agent, success=True)
|
||||
|
||||
logger.info("用户登录成功 | username=%s | ip=%s", username, ip)
|
||||
return LoginOut(
|
||||
access_token=token,
|
||||
expires_in=expires_in,
|
||||
user=UserBrief(
|
||||
id=user.id, username=user.username, real_name=user.real_name, roles=role_codes
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def logout(access_token: str) -> None:
|
||||
"""登出:把当前令牌加入黑名单。
|
||||
|
||||
令牌已自然过期或格式非法时**静默返回** —— 此时没有需要撤销的凭证,
|
||||
对调用方而言登出都是"已达成";返回 401 只会让前端多弹一次无意义的报错。
|
||||
"""
|
||||
try:
|
||||
payload = decode_token(access_token)
|
||||
except AuthError:
|
||||
return
|
||||
|
||||
jti = payload.get("jti")
|
||||
exp = payload.get("exp")
|
||||
if not jti or not exp:
|
||||
return
|
||||
|
||||
# 黑名单 TTL 必须等于令牌**剩余**有效期,写死固定值会导致"登出后又活了"
|
||||
remaining = int(exp - datetime.now(UTC).timestamp())
|
||||
token_blacklist.revoke(jti, remaining)
|
||||
48
backend/app/services/captcha_service.py
Normal file
48
backend/app/services/captcha_service.py
Normal file
@ -0,0 +1,48 @@
|
||||
"""图形验证码的签发与校验。
|
||||
|
||||
校验使用 Redis `GETDEL`——**原子取出即删**,同一验证码用两次必然失败,防重放。
|
||||
若写成「先 GET 再 DEL」,两条命令之间存在竞态窗口,并发请求可用同一验证码
|
||||
重复提交。
|
||||
|
||||
这是设计代价而非缺陷:用户输错一次密码后验证码已被消费,必须换一张。
|
||||
**前端必须在登录失败后自动刷新验证码**,否则第二次提交必然报「验证码错误」。
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
from app.core.captcha_image import generate_text, render_png_base64
|
||||
from app.core.config import get_settings
|
||||
from app.core.constants import REDIS_KEY_CAPTCHA
|
||||
from app.core.redis_client import get_redis
|
||||
|
||||
|
||||
def issue() -> tuple[str, str, int]:
|
||||
"""签发验证码。
|
||||
|
||||
:returns: ``(captcha_id, PNG 的 base64, 有效期秒数)``
|
||||
图片与 ID 在同一次调用中成对产生,避免前端分两次请求导致错配。
|
||||
"""
|
||||
settings = get_settings()
|
||||
captcha_id = uuid.uuid4().hex
|
||||
text = generate_text()
|
||||
# 用 set(ex=…) 而非 setex(…):后者在 redis-py 8 已弃用(规范禁用弃用 API)
|
||||
get_redis().set(
|
||||
REDIS_KEY_CAPTCHA.format(captcha_id=captcha_id),
|
||||
text.upper(),
|
||||
ex=settings.captcha_expire_seconds,
|
||||
)
|
||||
return captcha_id, render_png_base64(text), settings.captcha_expire_seconds
|
||||
|
||||
|
||||
def verify_and_consume(captcha_id: str, code: str) -> bool:
|
||||
"""校验验证码并消费它。
|
||||
|
||||
**无论校验成败,该验证码都会失效** —— 通过 GETDEL 取出后键已被删除。
|
||||
这阻断了「用一个验证码反复试密码」的路径。
|
||||
|
||||
:returns: 校验是否通过。验证码不存在(过期或已被使用)时返回 False。
|
||||
"""
|
||||
expected = get_redis().getdel(REDIS_KEY_CAPTCHA.format(captcha_id=captcha_id))
|
||||
if expected is None:
|
||||
return False
|
||||
return expected == code.strip().upper()
|
||||
54
backend/app/services/login_limiter.py
Normal file
54
backend/app/services/login_limiter.py
Normal file
@ -0,0 +1,54 @@
|
||||
"""登录失败限流。
|
||||
|
||||
键 `ys:login:fail:{username}` 记录连续失败次数,TTL 等于锁定时长。
|
||||
达到阈值后即使密码正确也拒绝登录,须等锁定期结束。
|
||||
|
||||
**为什么按用户名而非 IP**:本系统用户是确定的几十个营销人员,
|
||||
按用户名限流才能防住针对性撞库;按 IP 会被内网 NAT 和攻击者换 IP 绕过。
|
||||
代价是攻击者可故意把他人账号锁 15 分钟 —— 该风险在内部系统中可接受,
|
||||
且管理员可通过 `clear()` 手动解锁。
|
||||
"""
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.constants import REDIS_KEY_LOGIN_FAIL
|
||||
from app.core.redis_client import get_redis
|
||||
|
||||
|
||||
def _key(username: str) -> str:
|
||||
return REDIS_KEY_LOGIN_FAIL.format(username=username)
|
||||
|
||||
|
||||
def failure_count(username: str) -> int:
|
||||
"""当前连续失败次数。键不存在时为 0。"""
|
||||
value = get_redis().get(_key(username))
|
||||
return int(value) if value else 0
|
||||
|
||||
|
||||
def is_locked(username: str) -> bool:
|
||||
"""账号当前是否处于锁定期。"""
|
||||
return failure_count(username) >= get_settings().login_fail_limit
|
||||
|
||||
|
||||
def remaining_lock_seconds(username: str) -> int:
|
||||
"""锁定期剩余秒数。未锁定时为 0。"""
|
||||
ttl = get_redis().ttl(_key(username))
|
||||
return ttl if ttl and ttl > 0 else 0
|
||||
|
||||
|
||||
def record_failure(username: str) -> int:
|
||||
"""记录一次登录失败,返回累计次数。
|
||||
|
||||
每次失败都刷新 TTL,形成**滑动窗口**:任意 15 分钟区间内累计 5 次即锁定。
|
||||
若不刷新,则"4 次失败 + 等 14 分钟 + 1 次失败"就不会触发锁定,
|
||||
正好给撞库留出缝隙。
|
||||
"""
|
||||
key = _key(username)
|
||||
redis = get_redis()
|
||||
count = int(redis.incr(key))
|
||||
redis.expire(key, get_settings().login_lock_minutes * 60)
|
||||
return count
|
||||
|
||||
|
||||
def clear(username: str) -> None:
|
||||
"""登录成功后清除失败计数。"""
|
||||
get_redis().delete(_key(username))
|
||||
39
backend/app/services/token_blacklist.py
Normal file
39
backend/app/services/token_blacklist.py
Normal file
@ -0,0 +1,39 @@
|
||||
"""JWT 登出黑名单。
|
||||
|
||||
**TTL 必须等于令牌剩余有效期**:写死固定值会导致「登出 N 分钟后又活了」;
|
||||
不设过期则 Redis 会随登出次数无限增长。两者都是错的。
|
||||
|
||||
**范围限定**:本模块只解决「用户主动登出」。
|
||||
「禁用账号 / 改密 / 强制下线」由 `users.token_version` 承担 ——
|
||||
那种场景按用户维度写入 N 个黑名单键既低效,又会在 Redis 故障时完全失效
|
||||
(那时离职员工仍能用旧令牌查看全员薪酬)。鉴权时二者都要检查。
|
||||
"""
|
||||
|
||||
from app.core.constants import REDIS_KEY_TOKEN_BLACKLIST
|
||||
from app.core.redis_client import get_redis
|
||||
|
||||
|
||||
def _key(jti: str) -> str:
|
||||
return REDIS_KEY_TOKEN_BLACKLIST.format(jti=jti)
|
||||
|
||||
|
||||
def revoke(jti: str, remaining_seconds: int) -> None:
|
||||
"""把令牌加入黑名单。
|
||||
|
||||
:param jti: 令牌唯一标识
|
||||
:param remaining_seconds: 令牌剩余有效期;**必须传真实剩余值**。
|
||||
非正数表示令牌已自然过期,无需拉黑,本函数直接忽略。
|
||||
"""
|
||||
if remaining_seconds <= 0:
|
||||
return
|
||||
# 用 set(ex=…) 而非 setex(…):后者在 redis-py 8 已弃用(规范禁用弃用 API)
|
||||
get_redis().set(_key(jti), "1", ex=remaining_seconds)
|
||||
|
||||
|
||||
def is_revoked(jti: str) -> bool:
|
||||
"""令牌是否已被拉黑。
|
||||
|
||||
Redis 不可用时本函数会抛异常 —— 这是**有意的 fail-closed**:
|
||||
对能查看全员薪酬的系统,宁可拒绝请求也不能放行无法确定状态的令牌。
|
||||
"""
|
||||
return bool(get_redis().exists(_key(jti)))
|
||||
107
backend/pyproject.toml
Normal file
107
backend/pyproject.toml
Normal file
@ -0,0 +1,107 @@
|
||||
[project]
|
||||
name = "payroll-backend"
|
||||
version = "0.1.0"
|
||||
description = "衍水集团营销人员工资管理系统 —— 后端服务"
|
||||
readme = "README.md"
|
||||
# 锁定 3.12:系统默认是 3.14,第三方库轮子覆盖不如 3.12 完备
|
||||
requires-python = ">=3.12,<3.13"
|
||||
|
||||
dependencies = [
|
||||
"fastapi>=0.115",
|
||||
# starlette 已进入 1.x(当前 1.7.0)。FastAPI 对它的约束是 `starlette>=0.46.0`,
|
||||
# **没有上界** —— uv lock --upgrade 时可能漂移到未验证的大版本。
|
||||
# 我们的异常处理器与 TestClient 直接依赖其 API,故显式限定主版本。
|
||||
# 当前组合(fastapi 0.143 + starlette 1.7.0)已实测通过全部用例。
|
||||
"starlette>=1.7,<2",
|
||||
"uvicorn[standard]>=0.32",
|
||||
"pydantic>=2.9",
|
||||
"pydantic-settings>=2.6",
|
||||
# ORM 与驱动:采用同步 —— 本项目并发极低,异步无吞吐收益;
|
||||
# 而重活(绩效批量核算、pandas 报表、Excel 导出)全是同步阻塞代码,
|
||||
# 在异步路由里跑会卡死事件循环。同步路由由 FastAPI 自动放入线程池执行。
|
||||
"sqlalchemy>=2.0.36",
|
||||
# 必须带 [rsa]:MySQL 8.0 默认 caching_sha2_password,容器重启后首次连接走
|
||||
# 全量认证需要 cryptography 做 RSA 交换,缺失时 PyMySQL 直接抛 RuntimeError
|
||||
# (报错出现在建连阶段,极易被误读成"密码错误")
|
||||
"pymysql[rsa]>=1.2.3",
|
||||
"alembic>=1.14",
|
||||
# 验证码、登录限流、Token 黑名单
|
||||
"redis[hiredis]>=5.2",
|
||||
# 鉴权与密码哈希
|
||||
# 注意:不用 passlib —— 它与 bcrypt>=4.1 存在 __about__ 属性缺失的不兼容问题
|
||||
"pyjwt>=2.10",
|
||||
"pwdlib[bcrypt]>=0.2",
|
||||
# 图形验证码(纯 Python,依赖 Pillow)
|
||||
"captcha>=0.6",
|
||||
"pillow>=11.0",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pytest>=8.3",
|
||||
"pytest-cov>=7",
|
||||
"httpx>=0.28,<1.0",
|
||||
# 用假 Redis 做单测,无需起容器即可覆盖 GETDEL 一次性语义
|
||||
"fakeredis>=2.30",
|
||||
# lint + format 二合一,替代 flake8 + isort + black
|
||||
"ruff>=0.14",
|
||||
]
|
||||
|
||||
# 本项目按应用而非库来管理,不打包安装自身
|
||||
[tool.uv]
|
||||
package = false
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
markers = [
|
||||
"integration: 需要 MySQL/Redis 容器或完整应用实例的测试",
|
||||
]
|
||||
|
||||
# ==================== 代码检查与格式化 ====================
|
||||
|
||||
[tool.ruff]
|
||||
# 与项目根 .editorconfig 保持一致:Python 用 4 空格(缩进由 formatter 控制)
|
||||
target-version = "py312"
|
||||
line-length = 100
|
||||
src = ["app", "tests"]
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = [
|
||||
"E", # pycodestyle 错误
|
||||
"W", # pycodestyle 警告
|
||||
"F", # pyflakes:未使用的导入、未定义的名称等
|
||||
"I", # isort:导入顺序
|
||||
"B", # flake8-bugbear:常见陷阱(可变默认参数、循环引用等)
|
||||
"C4", # flake8-comprehensions:推导式可简化处
|
||||
"UP", # pyupgrade:按目标 Python 版本升级旧写法
|
||||
"SIM", # flake8-simplify:可简化的分支与判断
|
||||
"RUF", # ruff 自有规则
|
||||
]
|
||||
ignore = [
|
||||
# 行宽已由 formatter 控制,lint 不再重复报——长中文注释很容易触发
|
||||
"E501",
|
||||
# FastAPI 的依赖注入要求在参数默认值处调用 Depends(),是框架约定而非缺陷。
|
||||
# 项目内已尽量改用 Annotated 形式(见 api/deps.py),此处兜底。
|
||||
"B008",
|
||||
# RUF001/002/003 检查「易混淆的 Unicode 字符」,分别针对字符串字面量、
|
||||
# 文档字符串、注释 —— 这套规则的默认前提是英文文本。
|
||||
# 对中文会把全角逗号、句号、括号、冒号全部判定为混淆字符(本项目实测 800+ 条),
|
||||
# 而中文里全角标点正是规范写法,故一并关闭。
|
||||
"RUF001",
|
||||
"RUF002",
|
||||
"RUF003",
|
||||
]
|
||||
|
||||
[tool.ruff.lint.isort]
|
||||
# 必须显式声明:本项目 `[tool.uv] package = false`(不作为包安装),
|
||||
# ruff 无法从项目布局推断出 `app` 是第一方包,否则会把第三方依赖
|
||||
# 排到 `app.*` 之后(按字母序把两者当成同一组)。
|
||||
known-first-party = ["app", "tests"]
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
# Alembic 自动生成的迁移脚本不按手写代码的标准要求。
|
||||
# 这些文件由 `alembic revision --autogenerate` 产出,手工修 lint 会在下次生成时被覆盖。
|
||||
"alembic/versions/*.py" = ["E501", "I001", "W291"]
|
||||
|
||||
[tool.ruff.format]
|
||||
quote-style = "double"
|
||||
98
backend/tests/README.md
Normal file
98
backend/tests/README.md
Normal file
@ -0,0 +1,98 @@
|
||||
# 后端测试
|
||||
|
||||
> AI摘要:本项目后端的测试用例清单与运行记录。按「基础设施层随实现即时测、业务模块整模块完成时统一测」分层组织。当前 184 个用例全绿(单元 155 / 集成 29)。
|
||||
|
||||
## 一、测试约定
|
||||
|
||||
### 编写时机(分层对待)
|
||||
|
||||
- `core/` 等**基础设施**随实现即时补测 —— 它们是被业务复用多次的地基,越早锁定越省事
|
||||
- **业务模块**(系统管理、基础数据管理、业务数据管理、绩效工资核算、报表统计)在**整个模块功能完成时**统一编写用例并执行,不在模块做到一半时零散补测
|
||||
|
||||
### 目录二分
|
||||
|
||||
| 目录 | 依赖 | 用途 |
|
||||
| --- | --- | --- |
|
||||
| `tests/unit/` | 无(不碰 Docker、不连库) | 纯函数与元数据验证,秒级跑完,开发中可高频执行 |
|
||||
| `tests/integration/` | 完整应用实例或 MySQL/Redis 容器 | 接口级、跨产物一致性验证 |
|
||||
|
||||
单元测试里的 Redis 依赖通过 `fake_redis` 夹具(fakeredis)替换,因此验证码 GETDEL、限流计数、黑名单 TTL 这些逻辑无需起容器即可覆盖。
|
||||
|
||||
### 注释要求
|
||||
|
||||
- 测试类用中文 docstring 说明「这组用例守的是什么」——即**如果不测会出什么问题**
|
||||
- 用例名描述场景与预期(`test_中文按字节而非字符计算边界`)
|
||||
- 关键断言补注释说明为何该断言重要,尤其是防回归的用例
|
||||
|
||||
### 运行方式
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
uv run pytest tests/unit -q # 纯单元,无需容器,秒级
|
||||
uv run pytest -q # 全部(集成部分需容器在跑)
|
||||
uv run pytest tests/integration -q # 仅集成
|
||||
```
|
||||
|
||||
## 二、用例清单
|
||||
|
||||
### 基础设施层(core)
|
||||
|
||||
| 测试文件 | 守什么 | 用例数 | 关键覆盖点 |
|
||||
| --- | --- | :---: | --- |
|
||||
| `unit/test_models.py` | 建表规范红线 | 49 | 表名小写蛇形复数;每列中文注释**且确实输出到 DDL**;`uk_`/`idx_`/`fk_` 命名前缀;ENGINE/CHARSET 显式指定;`ON UPDATE CURRENT_TIMESTAMP` 存在(`server_onupdate` 不会生成该子句,是 SQLAlchemy 的行为陷阱);日志表反向断言不含该子句 |
|
||||
| `unit/test_logging.py` | 日志脱敏与格式化 | 16 | 密码/令牌/Bearer/手机号/身份证五类脱敏;**脱敏必须后置于 %-格式化**(早期实现在 filter 阶段改写 `record.msg`,抹掉 `%s` 占位符导致 logging 抛 TypeError、整条日志丢失);setup_logging 幂等 |
|
||||
| `unit/test_config.py` | 配置加载与安全边界 | 14 | 密钥强度 fail-fast;JWT ≤120 分钟、验证码 ≤300 秒的硬约束;密码含 `@ : / #` 时 URL 构造不破坏;渲染 URL 默认隐藏密码 |
|
||||
| `unit/test_security.py` | 密码哈希 | 11 | BCrypt 格式与定长 60;**72 字节边界**(24 个汉字正好、25 个超限);超长密码校验时返回 False 而非抛异常(防侧信道);哈希串损坏不炸 |
|
||||
| `unit/test_response.py` | 统一响应契约 | 11 | 字段固定为 `code/message/data`;分页序列化为规范要求的驼峰 `list/total/pageNum/pageSize` |
|
||||
| `unit/test_exception_handlers.py` | 异常转统一信封 | 5 | 校验错误 422→400 并压成单行中文;HTTP 状态码与 body 的 `code` 必须一致 |
|
||||
|
||||
### 系统管理 · 登录鉴权
|
||||
|
||||
| 测试文件 | 守什么 | 用例数 | 关键覆盖点 |
|
||||
| --- | --- | :---: | --- |
|
||||
| `unit/test_jwt.py` | 令牌有效期语义与版本机制 | 12 | `exp` 基于 **UTC** 而非本地时间(混算会产生 8 小时偏差,表现为「刚登录就过期」或「过期了还能用」);过期/篡改签名/**alg=none 攻击**/畸形字符串一律收敛为 401 而非 500;jti 唯一 |
|
||||
| `unit/test_captcha.py` | 验证码**一次性语义** | 12 | GETDEL 后二次使用必然失败(防重放);**校验失败也消费**(否则可用一个验证码反复试密码);字符集剔除 `0O1Il`;每次渲染含随机干扰不可比对;TTL 300 秒 |
|
||||
| `unit/test_login_limiter.py` | 阈值与**滑动窗口** | 9 | 达阈值即锁;每次失败刷新 TTL(否则「4 次失败 + 等 14 分钟 + 1 次失败」可绕过锁定);按用户名隔离,一个账号被攻击不牵连他人;登录成功清除计数 |
|
||||
| `unit/test_token_blacklist.py` | 黑名单 **TTL 语义** | 7 | TTL 等于令牌剩余有效期(写死固定值会导致「登出 N 分钟后又活了」,不设过期则 Redis 无限增长);已过期令牌不写入;jti 互不串号 |
|
||||
| `unit/test_auth_schemas.py` | 密码**按字节**校验 | 9 | 24 个汉字 = 72 字节正好、25 个被拒 —— 按字符数校验(如 `max_length=20`)根本拦不住,会让登录接口 500 |
|
||||
| `integration/test_auth_flow.py` | 整条链路**串联正确性** | 16 | 验证码→登录→持令牌访问→登出失效;验证码重放被拒;失败原因如实落库且**日志表无任何密码字段**;账号不存在与密码错误文案完全相同(防枚举);禁用账号与 `token_version` 提升均使旧令牌立即失效 |
|
||||
|
||||
### 应用骨架与交付产物
|
||||
|
||||
| 测试文件 | 守什么 | 用例数 | 关键覆盖点 |
|
||||
| --- | --- | :---: | --- |
|
||||
| `integration/test_health.py` | 统一信封与全局异常 | 7 | `/api/health` 始终 200 且依赖状态如实上报;404/405 返回统一信封而非框架默认 `detail` 数组;`/api/docs` 可访问 |
|
||||
| `integration/test_schema_sync.py` | DDL 不漂移 | 6 | `db/migration/V1__*.sql` 与 `db/init/init-dev.sql` 的**表集合与列集合逐表比对**——两份 DDL 手工同步迟早漂移,且漂移后果隐蔽(开发库结构与生产不一致,上线才发现) |
|
||||
|
||||
## 三、运行记录
|
||||
|
||||
| 日期 | 范围 | 用例数 | 结果 | 备注 |
|
||||
| --- | --- | :---: | --- | --- |
|
||||
| 2026-10-09 | 全部 | 120 | ✅ 通过(4.99s) | 阶段 1 骨架 + 阶段 2 数据库完成后首次全量运行 |
|
||||
| 2026-10-09 | 全部 | 119 | ✅ 通过(5.01s) | ORM 由异步改回同步后回归;净减 1 例(`sync_database_url` 独立配置已合并) |
|
||||
| 2026-10-09 | 全部 | 184 | ✅ 通过(25.46s) | **登录鉴权模块完成**后统一编写并执行该模块测试套件(新增 65 例)。集成用例需容器在跑 |
|
||||
|
||||
## 四、测试发现并修复的缺陷
|
||||
|
||||
| 日期 | 缺陷 | 影响 | 处理 |
|
||||
| --- | --- | --- | --- |
|
||||
| 2026-10-09 | 日志脱敏在 `logging.Filter` 阶段改写 `record.msg` | 抹掉 `%s` 占位符 → logging 内部 `msg % args` 抛 TypeError → **整条日志丢失**(比不脱敏更危险) | 改为 `SensitiveFormatter` 在格式化**之后**脱敏;补回归用例 |
|
||||
| 2026-10-09 | `PasswordHash.recommended()` 在 pwdlib 0.3.x 推荐 Argon2 而非 BCrypt | 密码算法与规范不符,且随库版本变化——同一批密码可能混用两种算法 | 改为显式 `PasswordHash((BcryptHasher(),))` 钉死算法 |
|
||||
| 2026-10-09 | 模型未设置表级 `comment`,`update_time` 用 `server_onupdate` | 违反规范红线;`server_onupdate` 只是 ORM 标记,**不生成 `ON UPDATE CURRENT_TIMESTAMP` 子句**,更新时间永不刷新 | 补表注释;改用 `text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP")` |
|
||||
| 2026-10-09 | `pymysql` 未带 `[rsa]` extra,`cryptography` 缺失 | MySQL 8.0 默认 `caching_sha2_password`,容器重启后首次连接走全量认证时抛 `RuntimeError`,且报错在建连阶段易被误读为「密码错误」 | 依赖改为 `pymysql[rsa]` |
|
||||
| 2026-10-09 | `setex` 在 redis-py 8 已弃用 | 规范明确**禁用弃用 API**;弃用接口在未来大版本会被移除 | 验证码与黑名单改用 `set(key, value, ex=…)` |
|
||||
| 2026-10-09 | 测试中 TTL 断言写死等值 | 属**测试自身缺陷**:Redis 的 TTL 以毫秒精度存绝对过期时刻、返回时向下取整,刚写入就查询必然可能少 1 秒——会变成偶发失败的闪烁测试 | 断言改为容忍 1 秒偏差,并注明原因 |
|
||||
| 2026-10-09 | 验证码沿用 `captcha` 库默认渲染 | 随机彩色字符 + 重噪点,与界面视觉体系冲突;该库最后更新于 2025-03,内部还引用了 Pillow 的旧别名 | 改为自绘(`core/captcha_image.py`):配色与界面统一,难度由逐字旋转、抖动、干扰曲线与噪点保证;仅沿用其内置字体文件 |
|
||||
| 2026-10-09 | 噪点用 `ellipse` 绘制 | 3×3 包围盒画出的椭圆恰好填充中心与四个边中点,形状就是「+」,形成规则的十字纹理——既显眼又容易被整体滤波抹掉,起不到干扰作用 | 改用单像素点与 2×2 方块 |
|
||||
| 2026-10-09 | `tests/unit/test_jwt.py` 两处赋值了未使用的 `settings`(ruff F841) | 变量未被引用,属无效代码 | 删除赋值;该文件的 `get_settings` 导入仍被其他用例使用 |
|
||||
|
||||
## 五、待补用例
|
||||
|
||||
| 模块 | 状态 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 系统管理 · 用户与角色管理 | 待编写 | 需求 2.5.1 的用户增删改查、密码重置、启用/禁用、角色分配 — 该模块功能尚未实现 |
|
||||
| 系统管理 · 操作日志 | 待编写 | 除登录日志外的操作审计(当前仅登录日志落库) |
|
||||
| 基础数据管理 | 未开始 | 依赖 T+ 对接方式确认 |
|
||||
| 业务数据管理 | 未开始 | 依赖需求澄清(回款天数口径等 10 项) |
|
||||
| 绩效工资核算 | 未开始 | 依赖需求澄清;金额精度需专项用例(`Fraction` 与 1/90 日斜率) |
|
||||
| 报表统计 | 未开始 | 依赖报表口径确认 |
|
||||
1
backend/tests/__init__.py
Normal file
1
backend/tests/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""后端测试包。"""
|
||||
38
backend/tests/conftest.py
Normal file
38
backend/tests/conftest.py
Normal file
@ -0,0 +1,38 @@
|
||||
"""测试共用夹具。"""
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def client() -> TestClient:
|
||||
"""构造应用测试客户端(会话级复用,避免重复构建应用)。
|
||||
|
||||
注意:测试环境未必有 MySQL/Redis 在跑,健康检查会如实返回 false,
|
||||
因此断言应聚焦于响应结构而非依赖的连通性。
|
||||
"""
|
||||
from app.main import app
|
||||
|
||||
with TestClient(app) as test_client:
|
||||
yield test_client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fake_redis(monkeypatch: pytest.MonkeyPatch):
|
||||
"""把 Redis 客户端替换为进程内假实现。
|
||||
|
||||
验证码的 GETDEL、限流的 INCR/EXPIRE/TTL、黑名单的 SETEX——都只用到
|
||||
基础字符串命令,fakeredis 能完整覆盖,因此这些逻辑无需起容器即可测试,
|
||||
单测得以保持秒级。
|
||||
|
||||
替换的是 `app.core.redis_client` 的模块级单例,各 service 通过
|
||||
`get_redis()` 取用,故一处替换即全局生效。
|
||||
"""
|
||||
import fakeredis
|
||||
|
||||
from app.core import redis_client
|
||||
|
||||
fake = fakeredis.FakeRedis(decode_responses=True)
|
||||
monkeypatch.setattr(redis_client, "_client", fake)
|
||||
yield fake
|
||||
fake.close()
|
||||
5
backend/tests/integration/__init__.py
Normal file
5
backend/tests/integration/__init__.py
Normal file
@ -0,0 +1,5 @@
|
||||
"""需要构造完整应用或依赖外部服务(MySQL/Redis)的测试。
|
||||
|
||||
`tests/unit/` 下的用例必须能在没有 Docker 的机器上秒跑;
|
||||
凡是要连容器或用例起整个 FastAPI 应用的,归入本目录。
|
||||
"""
|
||||
271
backend/tests/integration/test_auth_flow.py
Normal file
271
backend/tests/integration/test_auth_flow.py
Normal file
@ -0,0 +1,271 @@
|
||||
"""登录链路端到端测试(需要 MySQL 与 Redis)。
|
||||
|
||||
守的是整条链路的**串联正确性**:验证码 → 登录 → 持令牌访问 → 登出失效。
|
||||
各环节细节已由单元测试覆盖,这里验证它们接起来是否真的可用 ——
|
||||
尤其是「判定顺序」「失败时日志是否真的落库」「禁用与提版能否立即生效」
|
||||
这类只有串联运行才会暴露的问题。
|
||||
|
||||
运行前置:``docker compose -f docker/docker-compose.dev.yml up -d``
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import delete, select
|
||||
|
||||
from app.core.constants import REDIS_KEY_CAPTCHA, REDIS_KEY_LOGIN_FAIL
|
||||
from app.core.db import get_session_factory
|
||||
from app.core.redis_client import get_redis
|
||||
from app.core.security import hash_password
|
||||
from app.models import LoginLog, User
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
TEST_PASSWORD = "TestPwd@2026"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_user():
|
||||
"""创建独立测试账号,用后即删。
|
||||
|
||||
不复用 admin:测试会触发账号锁定、提升 token_version 等操作,
|
||||
污染真实账号会让后续手工验证产生困惑。
|
||||
"""
|
||||
factory = get_session_factory()
|
||||
username = f"t_{uuid.uuid4().hex[:8]}"
|
||||
with factory() as session:
|
||||
user = User(
|
||||
username=username,
|
||||
password=hash_password(TEST_PASSWORD),
|
||||
real_name="测试账号",
|
||||
status=1,
|
||||
)
|
||||
session.add(user)
|
||||
session.commit()
|
||||
user_id = user.id
|
||||
|
||||
yield {"id": user_id, "username": username}
|
||||
|
||||
with factory() as session:
|
||||
session.execute(delete(LoginLog).where(LoginLog.username == username))
|
||||
session.execute(delete(User).where(User.id == user_id)) # user_roles 级联删除
|
||||
session.commit()
|
||||
get_redis().delete(REDIS_KEY_LOGIN_FAIL.format(username=username))
|
||||
|
||||
|
||||
def _fetch_captcha(client: TestClient) -> tuple[str, str]:
|
||||
"""取验证码及其明文答案。
|
||||
|
||||
直接从 Redis 读取答案 —— **仅测试可用**,生产环境当然不能如此,
|
||||
这里是为了让自动化用例不必做图像识别。
|
||||
"""
|
||||
data = client.get("/api/v1/auth/captcha").json()["data"]
|
||||
captcha_id = data["captcha_id"]
|
||||
answer = get_redis().get(REDIS_KEY_CAPTCHA.format(captcha_id=captcha_id))
|
||||
return captcha_id, answer
|
||||
|
||||
|
||||
def _login(client: TestClient, username: str, password: str, **overrides):
|
||||
"""发起一次登录,默认使用新鲜验证码。"""
|
||||
captcha_id, code = _fetch_captcha(client)
|
||||
payload = {
|
||||
"username": username,
|
||||
"password": password,
|
||||
"captcha_id": captcha_id,
|
||||
"captcha_code": code,
|
||||
}
|
||||
payload.update(overrides)
|
||||
return client.post("/api/v1/auth/login", json=payload)
|
||||
|
||||
|
||||
def _bearer(token: str) -> dict[str, str]:
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
class TestHappyPath:
|
||||
"""正常链路。"""
|
||||
|
||||
def test_验证码到登出的完整链路(self, client: TestClient, test_user: dict) -> None:
|
||||
captcha_id, code = _fetch_captcha(client)
|
||||
login = client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={
|
||||
"username": test_user["username"],
|
||||
"password": TEST_PASSWORD,
|
||||
"captcha_id": captcha_id,
|
||||
"captcha_code": code,
|
||||
},
|
||||
)
|
||||
assert login.status_code == 200
|
||||
body = login.json()
|
||||
assert body["code"] == 200
|
||||
token = body["data"]["access_token"]
|
||||
assert body["data"]["token_type"] == "Bearer"
|
||||
assert body["data"]["user"]["username"] == test_user["username"]
|
||||
|
||||
me = client.get("/api/v1/auth/me", headers=_bearer(token))
|
||||
assert me.json()["data"]["username"] == test_user["username"]
|
||||
|
||||
assert client.post("/api/v1/auth/logout", headers=_bearer(token)).json()["code"] == 200
|
||||
after = client.get("/api/v1/auth/me", headers=_bearer(token))
|
||||
assert after.status_code == 401
|
||||
assert after.json()["code"] == 401
|
||||
|
||||
def test_登录成功后清除失败计数(self, client: TestClient, test_user: dict) -> None:
|
||||
username = test_user["username"]
|
||||
for _ in range(3):
|
||||
_login(client, username, "WrongPwd@1")
|
||||
assert get_redis().exists(REDIS_KEY_LOGIN_FAIL.format(username=username)) == 1
|
||||
|
||||
assert _login(client, username, TEST_PASSWORD).status_code == 200
|
||||
assert get_redis().exists(REDIS_KEY_LOGIN_FAIL.format(username=username)) == 0
|
||||
|
||||
|
||||
class TestCaptcha:
|
||||
"""验证码环节。"""
|
||||
|
||||
def test_同一验证码不可重放(self, client: TestClient, test_user: dict) -> None:
|
||||
captcha_id, code = _fetch_captcha(client)
|
||||
payload = {
|
||||
"username": test_user["username"],
|
||||
"password": TEST_PASSWORD,
|
||||
"captcha_id": captcha_id,
|
||||
"captcha_code": code,
|
||||
}
|
||||
assert client.post("/api/v1/auth/login", json=payload).status_code == 200
|
||||
replay = client.post("/api/v1/auth/login", json=payload)
|
||||
assert replay.status_code == 400
|
||||
assert "验证码" in replay.json()["message"]
|
||||
|
||||
def test_验证码错误时拒绝登录(self, client: TestClient, test_user: dict) -> None:
|
||||
response = _login(client, test_user["username"], TEST_PASSWORD, captcha_code="ZZZZ")
|
||||
assert response.status_code == 400
|
||||
assert "验证码" in response.json()["message"]
|
||||
|
||||
def test_验证码错误不消耗失败次数(self, client: TestClient, test_user: dict) -> None:
|
||||
"""验证码与密码是两类失败,混在一起计数会让用户被误锁。"""
|
||||
username = test_user["username"]
|
||||
for _ in range(5):
|
||||
_login(client, username, TEST_PASSWORD, captcha_code="ZZZZ")
|
||||
# 之后用正确验证码仍可正常登录
|
||||
assert _login(client, username, TEST_PASSWORD).status_code == 200
|
||||
|
||||
|
||||
class TestCredentials:
|
||||
"""凭据校验。"""
|
||||
|
||||
def test_密码错误被拒(self, client: TestClient, test_user: dict) -> None:
|
||||
response = _login(client, test_user["username"], "WrongPwd@1")
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_账号不存在与密码错误的提示完全相同(self, client: TestClient) -> None:
|
||||
"""文案不一致会形成账号枚举信道,攻击者可据此筛出真实用户名。"""
|
||||
missing = _login(client, f"nobody_{uuid.uuid4().hex[:8]}", "Whatever@1")
|
||||
assert missing.status_code == 400
|
||||
assert missing.json()["message"] == "用户名或密码错误"
|
||||
|
||||
def test_无令牌访问受保护接口返回401(self, client: TestClient) -> None:
|
||||
assert client.get("/api/v1/auth/me").status_code == 401
|
||||
|
||||
def test_伪造令牌返回401(self, client: TestClient) -> None:
|
||||
response = client.get("/api/v1/auth/me", headers=_bearer("not.a.real.token"))
|
||||
assert response.status_code == 401
|
||||
assert response.json()["code"] == 401
|
||||
|
||||
|
||||
class TestAccountState:
|
||||
"""账号状态与令牌失效。"""
|
||||
|
||||
def test_禁用账号无法登录(self, client: TestClient, test_user: dict) -> None:
|
||||
with get_session_factory()() as session:
|
||||
user = session.get(User, test_user["id"])
|
||||
user.status = 0
|
||||
session.commit()
|
||||
|
||||
response = _login(client, test_user["username"], TEST_PASSWORD)
|
||||
assert response.status_code == 403
|
||||
assert "禁用" in response.json()["message"]
|
||||
|
||||
def test_禁用后已签发的令牌立即失效(self, client: TestClient, test_user: dict) -> None:
|
||||
token = _login(client, test_user["username"], TEST_PASSWORD).json()["data"]["access_token"]
|
||||
assert client.get("/api/v1/auth/me", headers=_bearer(token)).status_code == 200
|
||||
|
||||
with get_session_factory()() as session:
|
||||
user = session.get(User, test_user["id"])
|
||||
user.status = 0
|
||||
session.commit()
|
||||
|
||||
assert client.get("/api/v1/auth/me", headers=_bearer(token)).status_code == 401
|
||||
|
||||
def test_提升token_version使旧令牌立即失效(self, client: TestClient, test_user: dict) -> None:
|
||||
"""这是「离职即失效」的实现手段,且**不依赖 Redis**。"""
|
||||
token = _login(client, test_user["username"], TEST_PASSWORD).json()["data"]["access_token"]
|
||||
assert client.get("/api/v1/auth/me", headers=_bearer(token)).status_code == 200
|
||||
|
||||
with get_session_factory()() as session:
|
||||
user = session.get(User, test_user["id"])
|
||||
user.token_version += 1 # 相当于改密 / 强制下线
|
||||
session.commit()
|
||||
|
||||
assert client.get("/api/v1/auth/me", headers=_bearer(token)).status_code == 401
|
||||
|
||||
|
||||
class TestRateLimit:
|
||||
"""登录限流。"""
|
||||
|
||||
def test_连续失败达阈值后正确密码也被拒(self, client: TestClient, test_user: dict) -> None:
|
||||
username = test_user["username"]
|
||||
for _ in range(5):
|
||||
_login(client, username, "WrongPwd@1")
|
||||
|
||||
response = _login(client, username, TEST_PASSWORD)
|
||||
assert response.status_code == 403
|
||||
assert "锁定" in response.json()["message"]
|
||||
|
||||
def test_锁定期内失败计数未被继续放大(self, client: TestClient, test_user: dict) -> None:
|
||||
"""锁定后仍不断尝试不应无限刷新 TTL——否则会被用来永久锁死他人账号。"""
|
||||
username = test_user["username"]
|
||||
for _ in range(5):
|
||||
_login(client, username, "WrongPwd@1")
|
||||
ttl_before = get_redis().ttl(REDIS_KEY_LOGIN_FAIL.format(username=username))
|
||||
|
||||
for _ in range(3):
|
||||
_login(client, username, TEST_PASSWORD)
|
||||
|
||||
ttl_after = get_redis().ttl(REDIS_KEY_LOGIN_FAIL.format(username=username))
|
||||
assert ttl_after <= ttl_before
|
||||
|
||||
|
||||
class TestLoginLogs:
|
||||
"""登录日志落库。"""
|
||||
|
||||
def _logs(self, username: str) -> list[LoginLog]:
|
||||
with get_session_factory()() as session:
|
||||
return list(
|
||||
session.execute(
|
||||
select(LoginLog).where(LoginLog.username == username).order_by(LoginLog.id)
|
||||
).scalars()
|
||||
)
|
||||
|
||||
def test_成功与失败均落库且记录失败原因(self, client: TestClient, test_user: dict) -> None:
|
||||
username = test_user["username"]
|
||||
_login(client, username, TEST_PASSWORD)
|
||||
_login(client, username, "WrongPwd@1")
|
||||
_login(client, username, TEST_PASSWORD, captcha_code="ZZZZ")
|
||||
|
||||
reasons = [(log.success, log.fail_reason) for log in self._logs(username)]
|
||||
assert (1, None) in reasons
|
||||
assert (0, "用户名或密码错误") in reasons
|
||||
assert (0, "验证码错误") in reasons
|
||||
|
||||
def test_日志不含任何密码字段(self, client: TestClient, test_user: dict) -> None:
|
||||
"""日志是最容易泄漏凭据的地方,这条断言防止日后有人「顺手」加字段。"""
|
||||
username = test_user["username"]
|
||||
_login(client, username, TEST_PASSWORD)
|
||||
_login(client, username, "WrongPwd@1")
|
||||
|
||||
columns = {column.name for column in LoginLog.__table__.columns}
|
||||
assert not (columns & {"password", "passwd", "pwd", "credential"})
|
||||
for log in self._logs(username):
|
||||
assert TEST_PASSWORD not in str(log.__dict__)
|
||||
55
backend/tests/integration/test_health.py
Normal file
55
backend/tests/integration/test_health.py
Normal file
@ -0,0 +1,55 @@
|
||||
"""应用级 HTTP 测试:统一响应格式与全局异常处理。
|
||||
|
||||
归在 integration 下是因为需要构造完整 FastAPI 应用;
|
||||
但用例本身不要求 MySQL/Redis 在跑 —— 健康检查会把依赖状态如实体现在
|
||||
data 中,接口始终返回 200。
|
||||
"""
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
class TestHealthEndpoint:
|
||||
"""健康检查接口。"""
|
||||
|
||||
def test_返回统一响应格式(self, client: TestClient) -> None:
|
||||
response = client.get("/api/health")
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["code"] == 200
|
||||
assert body["message"] == "success"
|
||||
assert body["data"]["app"] is True
|
||||
|
||||
def test_上报数据库与Redis状态为布尔值(self, client: TestClient) -> None:
|
||||
data = client.get("/api/health").json()["data"]
|
||||
assert isinstance(data["db"], bool)
|
||||
assert isinstance(data["redis"], bool)
|
||||
|
||||
def test_不要求认证(self, client: TestClient) -> None:
|
||||
assert client.get("/api/health").status_code == 200
|
||||
|
||||
|
||||
class TestGlobalExceptionHandling:
|
||||
"""全局异常处理器:不得把框架默认的 detail 数组泄漏给前端。"""
|
||||
|
||||
def test_未知路由返回统一格式(self, client: TestClient) -> None:
|
||||
response = client.get("/api/definitely-not-exist")
|
||||
assert response.status_code == 404
|
||||
body = response.json()
|
||||
assert body["code"] == 404
|
||||
assert "detail" not in body
|
||||
|
||||
def test_方法不允许返回统一格式(self, client: TestClient) -> None:
|
||||
response = client.post("/api/health")
|
||||
assert response.status_code == 405
|
||||
assert response.json()["code"] == 405
|
||||
|
||||
|
||||
class TestApiDocs:
|
||||
"""接口文档(规范第 10 节要求提供)。文档挂在 /api 下,便于经 Vite 代理访问。"""
|
||||
|
||||
def test_swagger文档可访问(self, client: TestClient) -> None:
|
||||
assert client.get("/api/docs").status_code == 200
|
||||
|
||||
def test_openapi结构可访问(self, client: TestClient) -> None:
|
||||
schema = client.get("/api/openapi.json").json()
|
||||
assert "/api/health" in schema["paths"]
|
||||
89
backend/tests/integration/test_schema_sync.py
Normal file
89
backend/tests/integration/test_schema_sync.py
Normal file
@ -0,0 +1,89 @@
|
||||
"""DDL 漂移检测。
|
||||
|
||||
结构定义同时存在于两处:
|
||||
- `db/migration/V1__*.sql` —— 事实来源,生产迁移用
|
||||
- `db/init/init-dev.sql` —— 开发环境重置脚本,Docker 首次启动唯一会自动执行的位置
|
||||
|
||||
两份 DDL 手工同步迟早会漂移,且后果隐蔽:开发库的表结构与迁移脚本不一致,
|
||||
新人克隆下来建出的库和生产不一样,等到上线才发现。
|
||||
本测试把「靠自觉」变成「可自动拦截」,成本极低。
|
||||
|
||||
注:本文件不需要容器,放在 integration 下是因为它校验的是交付产物而非应用代码。
|
||||
"""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[3]
|
||||
MIGRATION_FILE = PROJECT_ROOT / "db" / "migration" / "V1__init_rbac_and_login_logs.sql"
|
||||
INIT_DEV_FILE = PROJECT_ROOT / "db" / "init" / "init-dev.sql"
|
||||
|
||||
_CREATE_TABLE = re.compile(r"CREATE TABLE\s+`?(\w+)`?\s*\((.*?)\n\)", re.DOTALL)
|
||||
_COLUMN_LINE = re.compile(r"^\s{4}`?(\w+)`?\s", re.MULTILINE)
|
||||
# 这些开头的是约束定义,不是列定义
|
||||
_NON_COLUMN_KEYWORDS = {"CONSTRAINT", "PRIMARY", "UNIQUE", "FOREIGN", "KEY", "INDEX"}
|
||||
|
||||
|
||||
def _parse_tables(sql: str) -> dict[str, set[str]]:
|
||||
"""从 SQL 文本中提取 {表名: {列名集合}}。"""
|
||||
tables: dict[str, set[str]] = {}
|
||||
for table_name, body in _CREATE_TABLE.findall(sql):
|
||||
tables[table_name] = {
|
||||
column
|
||||
for column in _COLUMN_LINE.findall(body)
|
||||
if column.upper() not in _NON_COLUMN_KEYWORDS
|
||||
}
|
||||
return tables
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def migration_tables() -> dict[str, set[str]]:
|
||||
return _parse_tables(MIGRATION_FILE.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def init_dev_tables() -> dict[str, set[str]]:
|
||||
return _parse_tables(INIT_DEV_FILE.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
class TestSchemaSync:
|
||||
"""两份 DDL 的结构一致性。"""
|
||||
|
||||
def test_文件存在(self) -> None:
|
||||
assert MIGRATION_FILE.is_file(), f"缺少迁移脚本:{MIGRATION_FILE}"
|
||||
assert INIT_DEV_FILE.is_file(), f"缺少开发初始化脚本:{INIT_DEV_FILE}"
|
||||
|
||||
def test_表集合一致(self, migration_tables: dict, init_dev_tables: dict) -> None:
|
||||
assert set(init_dev_tables) == set(migration_tables), (
|
||||
"两份 DDL 的表集合不一致 —— 表结构变更时需同时维护 "
|
||||
"db/migration/V{n}__*.sql 与 db/init/init-dev.sql"
|
||||
)
|
||||
|
||||
def test_每张表的列集合一致(self, migration_tables: dict, init_dev_tables: dict) -> None:
|
||||
mismatched = {
|
||||
table: {
|
||||
"仅迁移脚本有": sorted(migration_tables[table] - init_dev_tables[table]),
|
||||
"仅初始化脚本有": sorted(init_dev_tables[table] - migration_tables[table]),
|
||||
}
|
||||
for table in set(migration_tables) & set(init_dev_tables)
|
||||
if migration_tables[table] != init_dev_tables[table]
|
||||
}
|
||||
assert not mismatched, f"以下表的列不一致:{mismatched}"
|
||||
|
||||
|
||||
class TestSeedData:
|
||||
"""字典数据在两处应保持同步。"""
|
||||
|
||||
@pytest.mark.parametrize("role_code", ["admin", "user"])
|
||||
def test_角色初始化数据在两处都存在(self, role_code: str) -> None:
|
||||
migration_sql = MIGRATION_FILE.read_text(encoding="utf-8")
|
||||
init_sql = INIT_DEV_FILE.read_text(encoding="utf-8")
|
||||
assert f"'{role_code}'" in migration_sql
|
||||
assert f"'{role_code}'" in init_sql
|
||||
|
||||
def test_开发脚本标记了迁移版本(self) -> None:
|
||||
"""不写 alembic_version 的话,在开发库上执行 upgrade head 会重复建表而失败。"""
|
||||
init_sql = INIT_DEV_FILE.read_text(encoding="utf-8")
|
||||
assert "INSERT INTO alembic_version" in init_sql
|
||||
1
backend/tests/unit/__init__.py
Normal file
1
backend/tests/unit/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""不依赖外部服务的单元测试。"""
|
||||
71
backend/tests/unit/test_auth_schemas.py
Normal file
71
backend/tests/unit/test_auth_schemas.py
Normal file
@ -0,0 +1,71 @@
|
||||
"""认证请求模型单元测试。
|
||||
|
||||
守的是:**密码按字节数校验**。
|
||||
bcrypt 5.0 起对超过 72 字节的密码直接抛 ValueError(此前是静默截断),
|
||||
而中文一个字符占 3 字节 —— 只按字符数校验(如 max_length=20)根本拦不住,
|
||||
24 个汉字就会让哈希函数抛异常、登录接口返回 500。
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.core.constants import CAPTCHA_LENGTH, PASSWORD_MAX_BYTES
|
||||
from app.schemas.auth import LoginIn
|
||||
|
||||
|
||||
def _payload(**overrides: object) -> dict[str, object]:
|
||||
base: dict[str, object] = {
|
||||
"username": "admin",
|
||||
"password": "Pwd@12345",
|
||||
"captcha_id": "a" * 32,
|
||||
"captcha_code": "A" * CAPTCHA_LENGTH,
|
||||
}
|
||||
base.update(overrides)
|
||||
return base
|
||||
|
||||
|
||||
class TestPasswordByteLimit:
|
||||
"""72 字节边界 —— 与 bcrypt 算法上限严格对齐。"""
|
||||
|
||||
def test_合法密码通过(self) -> None:
|
||||
assert LoginIn(**_payload()).password == "Pwd@12345"
|
||||
|
||||
def test_恰好72字节ASCII通过(self) -> None:
|
||||
password = "a" * PASSWORD_MAX_BYTES
|
||||
assert LoginIn(**_payload(password=password)).password == password
|
||||
|
||||
def test_超过72字节ASCII被拒(self) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
LoginIn(**_payload(password="a" * (PASSWORD_MAX_BYTES + 1)))
|
||||
|
||||
def test_24个汉字恰好72字节通过(self) -> None:
|
||||
"""一个汉字 3 字节:24 个正好等于上限。"""
|
||||
password = "中" * 24
|
||||
assert len(password.encode("utf-8")) == PASSWORD_MAX_BYTES
|
||||
LoginIn(**_payload(password=password))
|
||||
|
||||
def test_25个汉字被拒(self) -> None:
|
||||
"""若只按字符数校验,25 个字符远低于常见的 max_length=128,
|
||||
会一路放行到哈希函数才炸成 500。"""
|
||||
with pytest.raises(ValidationError):
|
||||
LoginIn(**_payload(password="中" * 25))
|
||||
|
||||
|
||||
class TestFieldConstraints:
|
||||
"""其余字段的边界。"""
|
||||
|
||||
def test_验证码位数不符被拒(self) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
LoginIn(**_payload(captcha_code="AB"))
|
||||
|
||||
def test_用户名为空被拒(self) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
LoginIn(**_payload(username=""))
|
||||
|
||||
def test_用户名超长被拒(self) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
LoginIn(**_payload(username="a" * 65))
|
||||
|
||||
def test_首尾空白被自动去除(self) -> None:
|
||||
"""用户复制粘贴账号时常带空格,自动去除比报错友好。"""
|
||||
assert LoginIn(**_payload(username=" admin ")).username == "admin"
|
||||
91
backend/tests/unit/test_captcha.py
Normal file
91
backend/tests/unit/test_captcha.py
Normal file
@ -0,0 +1,91 @@
|
||||
"""图形验证码单元测试。
|
||||
|
||||
守的是:**验证码的一次性语义**。若 GETDEL 退化为「先 GET 再 DEL」,
|
||||
两条命令之间会出现竞态窗口,攻击者可用同一验证码并发重复提交 ——
|
||||
而这个问题在正常使用下完全看不出来。
|
||||
"""
|
||||
|
||||
import base64
|
||||
|
||||
from app.core.captcha_image import generate_text, render_png_base64
|
||||
from app.core.constants import CAPTCHA_CHARSET, CAPTCHA_LENGTH, REDIS_KEY_CAPTCHA
|
||||
from app.services import captcha_service
|
||||
|
||||
PNG_MAGIC = b"\x89PNG"
|
||||
|
||||
|
||||
class TestCaptchaText:
|
||||
"""验证码文本生成。"""
|
||||
|
||||
def test_长度固定(self) -> None:
|
||||
assert len(generate_text()) == CAPTCHA_LENGTH
|
||||
|
||||
def test_字符集不含易混淆字符(self) -> None:
|
||||
"""0/O、1/I/l 在图片上难以区分,会显著提高用户输错率。"""
|
||||
text = "".join(generate_text() for _ in range(200))
|
||||
assert set(text) <= set(CAPTCHA_CHARSET)
|
||||
assert not (set(text) & set("0O1Il"))
|
||||
|
||||
def test_具备随机性(self) -> None:
|
||||
assert len({generate_text() for _ in range(50)}) > 40
|
||||
|
||||
|
||||
class TestCaptchaImage:
|
||||
"""图片渲染。"""
|
||||
|
||||
def test_输出为合法PNG的base64(self) -> None:
|
||||
raw = base64.b64decode(render_png_base64("AB3D"))
|
||||
assert raw.startswith(PNG_MAGIC)
|
||||
|
||||
def test_不含data前缀(self) -> None:
|
||||
"""返回纯 base64,由前端自行拼接 data:image/png;base64, ——
|
||||
服务端不替调用方决定呈现方式。"""
|
||||
assert not render_png_base64("AB3D").startswith("data:")
|
||||
|
||||
def test_同一文本两次渲染结果不同(self) -> None:
|
||||
"""每次绘制含随机干扰线与噪点,防止通过图片比对反推答案。"""
|
||||
assert render_png_base64("AB3D") != render_png_base64("AB3D")
|
||||
|
||||
|
||||
class TestCaptchaService:
|
||||
"""签发与校验(用假 Redis)。"""
|
||||
|
||||
def test_签发后可用正确验证码通过(self, fake_redis) -> None:
|
||||
captcha_id, image, expires_in = captcha_service.issue()
|
||||
assert expires_in == 300
|
||||
assert base64.b64decode(image).startswith(PNG_MAGIC)
|
||||
answer = fake_redis.get(REDIS_KEY_CAPTCHA.format(captcha_id=captcha_id))
|
||||
assert captcha_service.verify_and_consume(captcha_id, answer) is True
|
||||
|
||||
def test_同一验证码二次使用必然失败(self, fake_redis) -> None:
|
||||
"""GETDEL 一次性语义的核心断言 —— 防重放。"""
|
||||
captcha_id, _, _ = captcha_service.issue()
|
||||
answer = fake_redis.get(REDIS_KEY_CAPTCHA.format(captcha_id=captcha_id))
|
||||
assert captcha_service.verify_and_consume(captcha_id, answer) is True
|
||||
assert captcha_service.verify_and_consume(captcha_id, answer) is False
|
||||
|
||||
def test_校验失败后验证码同样被消费(self, fake_redis) -> None:
|
||||
"""失败也要消费:否则攻击者可拿一个验证码反复试密码。"""
|
||||
captcha_id, _, _ = captcha_service.issue()
|
||||
answer = fake_redis.get(REDIS_KEY_CAPTCHA.format(captcha_id=captcha_id))
|
||||
assert captcha_service.verify_and_consume(captcha_id, "XXXX") is False
|
||||
assert captcha_service.verify_and_consume(captcha_id, answer) is False
|
||||
|
||||
def test_不存在的验证码返回False(self, fake_redis) -> None:
|
||||
assert captcha_service.verify_and_consume("nonexistent-id", "AB3D") is False
|
||||
|
||||
def test_校验时忽略大小写与首尾空格(self, fake_redis) -> None:
|
||||
"""用户从图片抄写时容易带上空格;答案统一按大写存储。"""
|
||||
captcha_id, _, _ = captcha_service.issue()
|
||||
answer = fake_redis.get(REDIS_KEY_CAPTCHA.format(captcha_id=captcha_id))
|
||||
assert captcha_service.verify_and_consume(captcha_id, f" {answer.lower()} ") is True
|
||||
|
||||
def test_有效期300秒符合规范(self, fake_redis) -> None:
|
||||
"""规范第 6 节要求验证码 300 秒。
|
||||
|
||||
容忍 1 秒偏差:Redis 的 TTL 以毫秒精度存绝对过期时刻,
|
||||
命令返回时向下取整,刚写入就查询必然是 N 或 N-1。
|
||||
"""
|
||||
captcha_id, _, _ = captcha_service.issue()
|
||||
ttl = fake_redis.ttl(REDIS_KEY_CAPTCHA.format(captcha_id=captcha_id))
|
||||
assert ttl in (300, 299)
|
||||
83
backend/tests/unit/test_config.py
Normal file
83
backend/tests/unit/test_config.py
Normal file
@ -0,0 +1,83 @@
|
||||
"""配置模块单元测试。
|
||||
|
||||
用 `_env_file=None` 绕过 backend/.env,使断言不受开发者本地配置影响。
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.core.config import Settings
|
||||
|
||||
VALID_SECRET = "x" * 32
|
||||
|
||||
|
||||
def _settings(**overrides: object) -> Settings:
|
||||
"""构造一个不读取 .env 的配置实例。"""
|
||||
params: dict[str, object] = {"_env_file": None, "app_secret_key": VALID_SECRET}
|
||||
params.update(overrides)
|
||||
return Settings(**params) # type: ignore[arg-type]
|
||||
|
||||
|
||||
class TestSecretKey:
|
||||
"""密钥强度校验。"""
|
||||
|
||||
def test_缺失密钥时拒绝构造(self) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
Settings(_env_file=None) # type: ignore[call-arg]
|
||||
|
||||
@pytest.mark.parametrize("length", [0, 1, 31])
|
||||
def test_密钥短于32字符时拒绝(self, length: int) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
_settings(app_secret_key="x" * length)
|
||||
|
||||
def test_密钥恰好32字符时通过(self) -> None:
|
||||
assert _settings(app_secret_key="x" * 32).app_secret_key == "x" * 32
|
||||
|
||||
|
||||
class TestSecurityBounds:
|
||||
"""安全边界:规范要求 JWT ≤120 分钟、验证码 300 秒。"""
|
||||
|
||||
def test_jwt有效期超过120分钟时拒绝(self) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
_settings(jwt_expire_minutes=121)
|
||||
|
||||
def test_jwt有效期为120分钟时通过(self) -> None:
|
||||
assert _settings(jwt_expire_minutes=120).jwt_expire_minutes == 120
|
||||
|
||||
def test_验证码有效期超过300秒时拒绝(self) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
_settings(captcha_expire_seconds=301)
|
||||
|
||||
def test_验证码有效期为300秒时通过(self) -> None:
|
||||
assert _settings(captcha_expire_seconds=300).captcha_expire_seconds == 300
|
||||
|
||||
|
||||
class TestConnectionUrl:
|
||||
"""连接 URL 构造:密码含特殊字符时不可手工拼接。"""
|
||||
|
||||
def test_数据库URL使用同步驱动(self) -> None:
|
||||
"""运行期与迁移共用同步连接串,不存在同步/异步两套配置。"""
|
||||
url = _settings().database_url
|
||||
assert url.drivername == "mysql+pymysql"
|
||||
assert url.database == "payroll"
|
||||
assert url.query.get("charset") == "utf8mb4"
|
||||
|
||||
def test_密码含特殊字符时被原样保存于URL对象(self) -> None:
|
||||
url = _settings(mysql_password="p@ss:w/ord#1").database_url
|
||||
# URL.create 以结构化方式保存各组件,不做编码 —— 编码发生在驱动建连时,
|
||||
# 这正是手工拼接字符串会出错(如把 : 误认为端口分隔符)而它不会的原因
|
||||
assert url.password == "p@ss:w/ord#1"
|
||||
assert url.username == "payroll"
|
||||
assert url.port == 3306
|
||||
|
||||
def test_渲染URL时默认隐藏密码(self) -> None:
|
||||
"""异常或日志中打印 URL 时不得泄露密码。"""
|
||||
url = _settings(mysql_password="p@ss:w/ord#1").database_url
|
||||
assert "p@ss:w/ord#1" not in url.render_as_string()
|
||||
|
||||
def test_Redis无密码时不拼接认证段(self) -> None:
|
||||
assert _settings(redis_password="").redis_url == "redis://127.0.0.1:6379/0"
|
||||
|
||||
def test_Redis有密码时拼接认证段(self) -> None:
|
||||
url = _settings(redis_password="secret").redis_url
|
||||
assert url == "redis://:secret@127.0.0.1:6379/0"
|
||||
61
backend/tests/unit/test_exception_handlers.py
Normal file
61
backend/tests/unit/test_exception_handlers.py
Normal file
@ -0,0 +1,61 @@
|
||||
"""全局异常处理器单元测试。
|
||||
|
||||
重点验证校验错误的文案压缩逻辑:FastAPI 默认返回 422 与 detail 数组,
|
||||
本项目需映射为 400 与单行中文提示。
|
||||
"""
|
||||
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
|
||||
from app.core.constants import CODE_BAD_REQUEST
|
||||
from app.core.exception_handlers import _build_response, _format_validation_errors
|
||||
|
||||
|
||||
class TestFormatValidationErrors:
|
||||
"""校验错误文案压缩。"""
|
||||
|
||||
def test_包含字段名与原始说明(self) -> None:
|
||||
exc = RequestValidationError(
|
||||
[{"loc": ("body", "username"), "msg": "Field required", "type": "missing"}]
|
||||
)
|
||||
assert _format_validation_errors(exc) == "参数错误:username Field required"
|
||||
|
||||
def test_去掉来源前缀只保留字段路径(self) -> None:
|
||||
exc = RequestValidationError(
|
||||
[
|
||||
{
|
||||
"loc": ("query", "pageNum"),
|
||||
"msg": "Input should be greater than 0",
|
||||
"type": "greater_than",
|
||||
}
|
||||
]
|
||||
)
|
||||
assert "query" not in _format_validation_errors(exc)
|
||||
assert "pageNum" in _format_validation_errors(exc)
|
||||
|
||||
def test_多个错误时只取第一条(self) -> None:
|
||||
exc = RequestValidationError(
|
||||
[
|
||||
{"loc": ("body", "a"), "msg": "错误甲", "type": "missing"},
|
||||
{"loc": ("body", "b"), "msg": "错误乙", "type": "missing"},
|
||||
]
|
||||
)
|
||||
message = _format_validation_errors(exc)
|
||||
assert "错误甲" in message
|
||||
assert "错误乙" not in message
|
||||
|
||||
def test_错误列表为空时兜底(self) -> None:
|
||||
assert _format_validation_errors(RequestValidationError([])) == "参数错误"
|
||||
|
||||
|
||||
class TestBuildResponse:
|
||||
"""统一响应构造:HTTP 状态码与响应体 code 必须一致。"""
|
||||
|
||||
def test_状态码与响应体code一致(self) -> None:
|
||||
response = _build_response(CODE_BAD_REQUEST, "参数错误")
|
||||
assert response.status_code == CODE_BAD_REQUEST
|
||||
import json
|
||||
|
||||
body = json.loads(response.body)
|
||||
assert body["code"] == CODE_BAD_REQUEST
|
||||
assert body["message"] == "参数错误"
|
||||
assert body["data"] is None
|
||||
112
backend/tests/unit/test_jwt.py
Normal file
112
backend/tests/unit/test_jwt.py
Normal file
@ -0,0 +1,112 @@
|
||||
"""JWT 签发与校验单元测试。
|
||||
|
||||
守的是:令牌的**有效期语义**与**版本号机制**。
|
||||
这两点出错都不会在开发期暴露,但后果严重 ——
|
||||
有效期算错 8 小时会表现为「刚登录就过期」或「过期了还能用」;
|
||||
版本号机制失效则意味着禁用账号后旧令牌仍可访问全员薪酬。
|
||||
"""
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.constants import JWT_ALGORITHM
|
||||
from app.core.exceptions import AuthError
|
||||
from app.core.security import create_access_token, decode_token
|
||||
|
||||
|
||||
class TestCreateAccessToken:
|
||||
"""令牌签发。"""
|
||||
|
||||
def test_返回令牌_jti与有效期(self) -> None:
|
||||
token, jti, expires_in = create_access_token(user_id=1, token_version=1, roles=["admin"])
|
||||
assert isinstance(token, str) and token.count(".") == 2 # header.payload.signature
|
||||
assert len(jti) == 32
|
||||
assert expires_in == get_settings().jwt_expire_minutes * 60
|
||||
|
||||
def test_载荷包含鉴定所需的全部字段(self) -> None:
|
||||
token, jti, _ = create_access_token(user_id=7, token_version=3, roles=["admin", "user"])
|
||||
payload = decode_token(token)
|
||||
assert payload["sub"] == "7" # PyJWT 要求 sub 为字符串
|
||||
assert payload["jti"] == jti
|
||||
assert payload["ver"] == 3
|
||||
assert payload["roles"] == ["admin", "user"]
|
||||
|
||||
def test_每次签发的jti都不同(self) -> None:
|
||||
"""jti 是登出拉黑的唯一键,重复会导致误拉黑他人令牌。"""
|
||||
jtis = set()
|
||||
for _ in range(20):
|
||||
_, jti, _ = create_access_token(user_id=1, token_version=1, roles=[])
|
||||
jtis.add(jti)
|
||||
assert len(jtis) == 20
|
||||
|
||||
def test_有效期基于UTC而非本地时间(self) -> None:
|
||||
"""库列存本地时间、JWT 用 UTC,两者混算会产生 8 小时偏差。
|
||||
|
||||
这里断言 exp 与 UTC 当前时间的差值确实等于配置值 ——
|
||||
若误用 datetime.now()(本地时间)生成,差值会差 8 小时。
|
||||
"""
|
||||
token, _, expires_in = create_access_token(user_id=1, token_version=1, roles=[])
|
||||
exp = decode_token(token)["exp"]
|
||||
drift = exp - datetime.now(UTC).timestamp()
|
||||
assert abs(drift - expires_in) < 5 # 允许几秒执行耗时
|
||||
|
||||
|
||||
class TestDecodeToken:
|
||||
"""令牌解析与校验。"""
|
||||
|
||||
def test_正常令牌可解析(self) -> None:
|
||||
token, _, _ = create_access_token(user_id=1, token_version=1, roles=["admin"])
|
||||
assert decode_token(token)["sub"] == "1"
|
||||
|
||||
def test_已过期令牌抛AuthError(self) -> None:
|
||||
"""直接构造过期令牌 —— create_access_token 的最小有效期为 1 分钟,
|
||||
无法用它验证过期分支。"""
|
||||
settings = get_settings()
|
||||
past = datetime.now(UTC) - timedelta(hours=1)
|
||||
expired = jwt.encode(
|
||||
{
|
||||
"sub": "1",
|
||||
"jti": uuid.uuid4().hex,
|
||||
"ver": 1,
|
||||
"iat": past,
|
||||
"exp": past + timedelta(seconds=60),
|
||||
},
|
||||
settings.app_secret_key,
|
||||
algorithm=JWT_ALGORITHM,
|
||||
)
|
||||
with pytest.raises(AuthError):
|
||||
decode_token(expired)
|
||||
|
||||
def test_签名被篡改时抛AuthError(self) -> None:
|
||||
forged = jwt.encode(
|
||||
{
|
||||
"sub": "1",
|
||||
"jti": uuid.uuid4().hex,
|
||||
"ver": 1,
|
||||
"exp": datetime.now(UTC) + timedelta(hours=1),
|
||||
},
|
||||
"wrong-secret-key-that-is-long-enough-to-pass-validation",
|
||||
algorithm=JWT_ALGORITHM,
|
||||
)
|
||||
with pytest.raises(AuthError):
|
||||
decode_token(forged)
|
||||
|
||||
@pytest.mark.parametrize("garbage", ["", "not.a.token", "a.b", "...."])
|
||||
def test_格式非法的令牌抛AuthError而非其他异常(self, garbage: str) -> None:
|
||||
"""令牌内容不可信,任何畸形输入都必须收敛为 401 而不是 500。"""
|
||||
with pytest.raises(AuthError):
|
||||
decode_token(garbage)
|
||||
|
||||
def test_算法被改为none时拒绝(self) -> None:
|
||||
"""经典的 JWT 算法混淆攻击:把 alg 改成 none 伪造无签名令牌。"""
|
||||
unsigned = jwt.encode(
|
||||
{"sub": "1", "exp": datetime.now(UTC) + timedelta(hours=1)},
|
||||
key="",
|
||||
algorithm="none",
|
||||
)
|
||||
with pytest.raises(AuthError):
|
||||
decode_token(unsigned)
|
||||
111
backend/tests/unit/test_logging.py
Normal file
111
backend/tests/unit/test_logging.py
Normal file
@ -0,0 +1,111 @@
|
||||
"""日志脱敏单元测试。
|
||||
|
||||
规范第 8 节禁止日志输出密码、手机号、身份证、Token。
|
||||
脱敏是最容易在重构中悄悄失效的一环,故用参数化用例覆盖各条规则。
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core.logging import SensitiveFormatter, scrub
|
||||
|
||||
|
||||
class TestScrub:
|
||||
"""文本脱敏规则。"""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("raw", "leaked"),
|
||||
[
|
||||
('{"password": "Admin@123"}', "Admin@123"),
|
||||
("password=Secret123", "Secret123"),
|
||||
("pwd: hunter2xyz", "hunter2xyz"),
|
||||
('{"app_secret_key": "abcdef123456"}', "abcdef123456"),
|
||||
("token=eyJhbGciOiJIUzI1NiJ9", "eyJhbGciOiJIUzI1NiJ9"),
|
||||
],
|
||||
)
|
||||
def test_凭据值被脱敏(self, raw: str, leaked: str) -> None:
|
||||
assert leaked not in scrub(raw)
|
||||
|
||||
def test_Bearer令牌被脱敏(self) -> None:
|
||||
result = scrub("Authorization: Bearer eyJhbGciOi.J9.abc-_123")
|
||||
assert "eyJhbGciOi.J9.abc-_123" not in result
|
||||
assert "Bearer ***" in result
|
||||
|
||||
def test_手机号保留前三后四(self) -> None:
|
||||
assert scrub("联系电话 13812345678") == "联系电话 138****5678"
|
||||
|
||||
def test_身份证保留前六后四(self) -> None:
|
||||
assert scrub("证件号 110101199003071234") == "证件号 110101********1234"
|
||||
|
||||
def test_普通文本不被误伤(self) -> None:
|
||||
text = "用户 admin 登录成功,耗时 120ms"
|
||||
assert scrub(text) == text
|
||||
|
||||
|
||||
def _format(msg: str, args: object = None) -> str:
|
||||
"""用 SensitiveFormatter 渲染一条日志,返回最终文本。"""
|
||||
record = logging.LogRecord(
|
||||
name="t",
|
||||
level=logging.INFO,
|
||||
pathname="",
|
||||
lineno=0,
|
||||
msg=msg,
|
||||
args=args,
|
||||
exc_info=None,
|
||||
)
|
||||
return SensitiveFormatter("%(message)s").format(record)
|
||||
|
||||
|
||||
class TestSensitiveFormatter:
|
||||
"""脱敏必须在 %-格式化之后进行。"""
|
||||
|
||||
def test_脱敏位置参数中的敏感值(self) -> None:
|
||||
# 回归用例:早期实现在 filter 阶段改写 record.msg,把 %s 占位符一并抹掉,
|
||||
# 导致 logging 内部 msg % args 抛 TypeError,整条日志丢失
|
||||
result = _format("登录失败 password=%s", ("Secret123",))
|
||||
assert "Secret123" not in result
|
||||
assert "登录失败" in result
|
||||
|
||||
def test_脱敏字典参数中的敏感值(self) -> None:
|
||||
# LogRecord 要求 args 为元组;字典参数须包成单元素元组,
|
||||
# 这正是 logger.info(msg, {...}) 的内部形态
|
||||
result = _format("token=%(token)s", ({"token": "abc123xyz"},))
|
||||
assert "abc123xyz" not in result
|
||||
|
||||
def test_脱敏消息模板内联的敏感值(self) -> None:
|
||||
"""敏感值直接写在消息模板里(无占位符)时同样要脱敏。"""
|
||||
result = _format('请求体 {"password": "Admin@123"}')
|
||||
assert "Admin@123" not in result
|
||||
|
||||
def test_普通日志不被改动(self) -> None:
|
||||
assert (
|
||||
_format("用户 admin 登录成功,耗时 %d ms", (120,)) == "用户 admin 登录成功,耗时 120 ms"
|
||||
)
|
||||
|
||||
def test_无参数日志正常通过(self) -> None:
|
||||
assert _format("服务已启动") == "服务已启动"
|
||||
|
||||
|
||||
class TestSetupLogging:
|
||||
"""setup_logging 必须挂上脱敏格式化器,否则规则形同虚设。"""
|
||||
|
||||
def test_所有handler均使用脱敏格式化器(self) -> None:
|
||||
import logging as _logging
|
||||
|
||||
from app.core.logging import setup_logging
|
||||
|
||||
setup_logging(level="INFO", log_to_file=False)
|
||||
handlers = _logging.getLogger().handlers
|
||||
assert handlers, "根日志器应至少有一个 handler"
|
||||
assert all(isinstance(h.formatter, SensitiveFormatter) for h in handlers)
|
||||
|
||||
def test_重复调用不会累积handler(self) -> None:
|
||||
import logging as _logging
|
||||
|
||||
from app.core.logging import setup_logging
|
||||
|
||||
setup_logging(log_to_file=False)
|
||||
first = len(_logging.getLogger().handlers)
|
||||
setup_logging(log_to_file=False)
|
||||
assert len(_logging.getLogger().handlers) == first
|
||||
82
backend/tests/unit/test_login_limiter.py
Normal file
82
backend/tests/unit/test_login_limiter.py
Normal file
@ -0,0 +1,82 @@
|
||||
"""登录失败限流单元测试。
|
||||
|
||||
守的是:锁定阈值与**滑动窗口**语义。若记录失败时不刷新 TTL,
|
||||
「4 次失败 + 等 14 分钟 + 1 次失败」就不会触发锁定,
|
||||
正好给慢速撞库留出缝隙。
|
||||
"""
|
||||
|
||||
from app.core.config import get_settings
|
||||
from app.core.constants import REDIS_KEY_LOGIN_FAIL
|
||||
from app.services import login_limiter
|
||||
|
||||
|
||||
def _ttl(username: str) -> int:
|
||||
from app.core.redis_client import get_redis
|
||||
|
||||
return get_redis().ttl(REDIS_KEY_LOGIN_FAIL.format(username=username))
|
||||
|
||||
|
||||
class TestThreshold:
|
||||
"""阈值判定。"""
|
||||
|
||||
def test_初始状态未锁定且计数为零(self, fake_redis) -> None:
|
||||
assert login_limiter.failure_count("u") == 0
|
||||
assert login_limiter.is_locked("u") is False
|
||||
|
||||
def test_未达阈值时不锁定(self, fake_redis) -> None:
|
||||
for _ in range(get_settings().login_fail_limit - 1):
|
||||
login_limiter.record_failure("u")
|
||||
assert login_limiter.is_locked("u") is False
|
||||
|
||||
def test_达到阈值即锁定(self, fake_redis) -> None:
|
||||
for _ in range(get_settings().login_fail_limit):
|
||||
login_limiter.record_failure("u")
|
||||
assert login_limiter.is_locked("u") is True
|
||||
|
||||
def test_不同用户之间互不影响(self, fake_redis) -> None:
|
||||
"""按用户名隔离,避免一个账号被攻击导致全员被锁。"""
|
||||
for _ in range(get_settings().login_fail_limit):
|
||||
login_limiter.record_failure("victim")
|
||||
assert login_limiter.is_locked("victim") is True
|
||||
assert login_limiter.is_locked("others") is False
|
||||
|
||||
|
||||
class TestSlidingWindow:
|
||||
"""滑动窗口:每次失败都刷新过期时间。"""
|
||||
|
||||
def test_锁定时长与配置一致(self, fake_redis) -> None:
|
||||
"""容忍 1 秒偏差:Redis 的 TTL 向下取整,刚写入就查询可能少 1 秒。"""
|
||||
login_limiter.record_failure("u")
|
||||
expected = get_settings().login_lock_minutes * 60
|
||||
assert _ttl("u") in (expected, expected - 1)
|
||||
|
||||
def test_再次失败会刷新过期时间(self, fake_redis) -> None:
|
||||
"""把 TTL 改小模拟「距上次失败已过 14 分钟」,再失败一次应重新计满。
|
||||
|
||||
若不刷新,此处 TTL 仍是改小后的值,"慢速撞库"就能绕过锁定。
|
||||
"""
|
||||
login_limiter.record_failure("u")
|
||||
fake_redis.expire(REDIS_KEY_LOGIN_FAIL.format(username="u"), 10)
|
||||
|
||||
login_limiter.record_failure("u")
|
||||
assert _ttl("u") > 600
|
||||
|
||||
|
||||
class TestClearAndRemaining:
|
||||
"""解锁与剩余时长。"""
|
||||
|
||||
def test_登录成功清除失败计数(self, fake_redis) -> None:
|
||||
for _ in range(get_settings().login_fail_limit):
|
||||
login_limiter.record_failure("u")
|
||||
login_limiter.clear("u")
|
||||
assert login_limiter.is_locked("u") is False
|
||||
assert login_limiter.failure_count("u") == 0
|
||||
|
||||
def test_未锁定时剩余秒数为零(self, fake_redis) -> None:
|
||||
assert login_limiter.remaining_lock_seconds("u") == 0
|
||||
|
||||
def test_锁定后剩余秒数为正且不超过锁定时长(self, fake_redis) -> None:
|
||||
for _ in range(get_settings().login_fail_limit):
|
||||
login_limiter.record_failure("u")
|
||||
remaining = login_limiter.remaining_lock_seconds("u")
|
||||
assert 0 < remaining <= get_settings().login_lock_minutes * 60
|
||||
127
backend/tests/unit/test_models.py
Normal file
127
backend/tests/unit/test_models.py
Normal file
@ -0,0 +1,127 @@
|
||||
"""ORM 模型元数据测试。
|
||||
|
||||
建表规范(表小写蛇形复数、字段中文 COMMENT、主键 id、索引 uk_/idx_ 前缀)
|
||||
属于「红线」,靠人工 review 迟早会漏,这里用测试把它固定下来。
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.dialects import mysql
|
||||
|
||||
from app.models import Base
|
||||
|
||||
EXPECTED_TABLES = {"users", "roles", "user_roles", "permissions", "role_permissions", "login_logs"}
|
||||
|
||||
|
||||
def _ddl(table_name: str) -> str:
|
||||
"""返回某张表的建表 DDL 文本。"""
|
||||
from sqlalchemy.schema import CreateTable
|
||||
|
||||
table = Base.metadata.tables[table_name]
|
||||
return str(CreateTable(table).compile(dialect=mysql.dialect()))
|
||||
|
||||
|
||||
class TestTableSet:
|
||||
"""表集合必须完整 —— 漏一张就会让 alembic 生成「删除该表」的迁移。"""
|
||||
|
||||
def test_表集合与预期一致(self) -> None:
|
||||
assert set(Base.metadata.tables) == EXPECTED_TABLES
|
||||
|
||||
def test_所有表名均为小写蛇形复数(self) -> None:
|
||||
for name in Base.metadata.tables:
|
||||
assert re.fullmatch(r"[a-z][a-z0-9_]*", name), f"表名 {name} 不符合小写蛇形"
|
||||
assert name.endswith("s"), f"表名 {name} 应为复数"
|
||||
|
||||
|
||||
class TestNamingConvention:
|
||||
"""约束命名:规范要求 uk_表_字段 / idx_表_字段。"""
|
||||
|
||||
@pytest.mark.parametrize("table_name", sorted(EXPECTED_TABLES))
|
||||
def test_主键命名为pk_表名(self, table_name: str) -> None:
|
||||
pk = Base.metadata.tables[table_name].primary_key
|
||||
assert pk.name == f"pk_{table_name}"
|
||||
|
||||
def test_唯一键以uk_开头(self) -> None:
|
||||
for table in Base.metadata.tables.values():
|
||||
for constraint in table.constraints:
|
||||
if constraint.__class__.__name__ == "UniqueConstraint":
|
||||
assert constraint.name.startswith(f"uk_{table.name}_"), constraint.name
|
||||
|
||||
def test_索引以idx_开头(self) -> None:
|
||||
for table in Base.metadata.tables.values():
|
||||
for index in table.indexes:
|
||||
assert index.name.startswith(f"idx_{table.name}_"), index.name
|
||||
|
||||
def test_外键以fk_开头(self) -> None:
|
||||
for table in Base.metadata.tables.values():
|
||||
for fk in table.foreign_keys:
|
||||
assert fk.constraint.name.startswith(f"fk_{table.name}_"), fk.constraint.name
|
||||
|
||||
|
||||
class TestComments:
|
||||
"""规范红线要求中文 COMMENT。"""
|
||||
|
||||
@pytest.mark.parametrize("table_name", sorted(EXPECTED_TABLES))
|
||||
def test_表级中文注释存在(self, table_name: str) -> None:
|
||||
assert Base.metadata.tables[table_name].comment, f"{table_name} 缺少表注释"
|
||||
|
||||
@pytest.mark.parametrize("table_name", sorted(EXPECTED_TABLES))
|
||||
def test_每列都有中文注释(self, table_name: str) -> None:
|
||||
table = Base.metadata.tables[table_name]
|
||||
missing = [c.name for c in table.columns if not c.comment]
|
||||
assert not missing, f"{table_name} 缺注释的列:{missing}"
|
||||
|
||||
@pytest.mark.parametrize("table_name", sorted(EXPECTED_TABLES))
|
||||
def test_注释确实输出到DDL(self, table_name: str) -> None:
|
||||
ddl = _ddl(table_name)
|
||||
assert "COMMENT '" in ddl
|
||||
assert f"COMMENT='{Base.metadata.tables[table_name].comment}'" in ddl
|
||||
|
||||
|
||||
class TestMysqlTableOptions:
|
||||
"""引擎与字符集:不显式指定则跟随服务端,跨环境可能不一致。"""
|
||||
|
||||
@pytest.mark.parametrize("table_name", sorted(EXPECTED_TABLES))
|
||||
def test_显式指定引擎与字符集(self, table_name: str) -> None:
|
||||
ddl = _ddl(table_name)
|
||||
assert "ENGINE=InnoDB" in ddl
|
||||
assert "CHARSET=utf8mb4" in ddl
|
||||
assert "utf8mb4_general_ci" in ddl
|
||||
|
||||
@pytest.mark.parametrize("table_name", sorted(EXPECTED_TABLES))
|
||||
def test_主键为无符号BIGINT(self, table_name: str) -> None:
|
||||
assert "id BIGINT UNSIGNED NOT NULL" in _ddl(table_name)
|
||||
|
||||
|
||||
class TestTimestampColumns:
|
||||
"""更新时间必须由数据库维护 —— 这是 server_onupdate 做不到的。"""
|
||||
|
||||
@pytest.mark.parametrize("table_name", ["users", "roles", "permissions"])
|
||||
def test_带时间戳的表含ON_UPDATE子句(self, table_name: str) -> None:
|
||||
assert "ON UPDATE CURRENT_TIMESTAMP" in _ddl(table_name)
|
||||
|
||||
@pytest.mark.parametrize("table_name", ["login_logs"])
|
||||
def test_仅追加的日志表不含ON_UPDATE子句(self, table_name: str) -> None:
|
||||
"""日志是不可变记录,没有"更新时间"语义。"""
|
||||
assert "ON UPDATE CURRENT_TIMESTAMP" not in _ddl(table_name)
|
||||
|
||||
def test_关联表保留create_time便于审计(self) -> None:
|
||||
for table_name in ("user_roles", "role_permissions"):
|
||||
assert "create_time" in Base.metadata.tables[table_name].columns
|
||||
|
||||
|
||||
class TestEnumColumns:
|
||||
"""状态类字段的默认值。"""
|
||||
|
||||
def test_用户默认启用且令牌版本为1(self) -> None:
|
||||
users = Base.metadata.tables["users"]
|
||||
assert users.columns["status"].server_default.arg == "1"
|
||||
assert users.columns["token_version"].server_default.arg == "1"
|
||||
|
||||
def test_角色编码唯一(self) -> None:
|
||||
ddl = _ddl("roles")
|
||||
assert "uk_roles_role_code UNIQUE (role_code)" in ddl
|
||||
|
||||
def test_用户名唯一(self) -> None:
|
||||
assert "uk_users_username UNIQUE (username)" in _ddl("users")
|
||||
67
backend/tests/unit/test_response.py
Normal file
67
backend/tests/unit/test_response.py
Normal file
@ -0,0 +1,67 @@
|
||||
"""统一响应模型单元测试。"""
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
from app.core.response import ApiResponse, PageQuery, PageResult, ok
|
||||
|
||||
|
||||
class TestApiResponse:
|
||||
"""响应信封结构。"""
|
||||
|
||||
def test_默认值符合规范(self) -> None:
|
||||
resp = ApiResponse[None]()
|
||||
assert resp.code == 200
|
||||
assert resp.message == "success"
|
||||
assert resp.data is None
|
||||
|
||||
def test_序列化字段固定为code_message_data(self) -> None:
|
||||
keys = set(ApiResponse[None]().model_dump().keys())
|
||||
assert keys == {"code", "message", "data"}
|
||||
|
||||
|
||||
class TestOkHelper:
|
||||
"""成功响应构造助手。"""
|
||||
|
||||
def test_携带业务数据(self) -> None:
|
||||
resp = ok({"id": 1})
|
||||
assert resp.code == 200
|
||||
assert resp.data == {"id": 1}
|
||||
|
||||
def test_可自定义提示语(self) -> None:
|
||||
assert ok(message="登录成功").message == "登录成功"
|
||||
|
||||
|
||||
class TestPageResult:
|
||||
"""分页结果:Python 蛇形属性名,序列化为规范要求的驼峰字段。"""
|
||||
|
||||
def test_序列化输出驼峰字段(self) -> None:
|
||||
dumped = PageResult[int](records=[1, 2], total=2, page_num=1, page_size=10).model_dump(
|
||||
by_alias=True
|
||||
)
|
||||
assert dumped == {"list": [1, 2], "total": 2, "pageNum": 1, "pageSize": 10}
|
||||
|
||||
def test_空结果时list为空数组而非null(self) -> None:
|
||||
dumped = PageResult[int]().model_dump(by_alias=True)
|
||||
assert dumped["list"] == []
|
||||
|
||||
|
||||
class TestPageQuery:
|
||||
"""分页入参。"""
|
||||
|
||||
def test_offset按页码换算(self) -> None:
|
||||
assert PageQuery(page_num=1, page_size=10).offset == 0
|
||||
assert PageQuery(page_num=3, page_size=20).offset == 40
|
||||
|
||||
@pytest.mark.parametrize("bad_page", [0, -1])
|
||||
def test_页码小于1时拒绝(self, bad_page: int) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
PageQuery(page_num=bad_page)
|
||||
|
||||
def test_每页条数超上限时拒绝(self) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
PageQuery(page_size=201)
|
||||
|
||||
def test_接受驼峰输入(self) -> None:
|
||||
query = PageQuery.model_validate({"pageNum": 2, "pageSize": 5})
|
||||
assert (query.page_num, query.page_size) == (2, 5)
|
||||
66
backend/tests/unit/test_security.py
Normal file
66
backend/tests/unit/test_security.py
Normal file
@ -0,0 +1,66 @@
|
||||
"""密码哈希单元测试。
|
||||
|
||||
重点覆盖 bcrypt 5.0 的 72 字节硬限制 —— 此前是静默截断,现在是抛 ValueError,
|
||||
若不在边界上把关,用户设一个长密码就会在登录时炸出 500。
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core.constants import PASSWORD_MAX_BYTES
|
||||
from app.core.security import hash_password, verify_password
|
||||
|
||||
|
||||
class TestHashPassword:
|
||||
"""哈希生成。"""
|
||||
|
||||
def test_输出为BCrypt格式且定长60字符(self) -> None:
|
||||
hashed = hash_password("Admin@2026")
|
||||
assert hashed.startswith("$2")
|
||||
assert len(hashed) == 60
|
||||
|
||||
def test_相同明文两次哈希结果不同(self) -> None:
|
||||
"""BCrypt 内含随机盐,相同明文每次结果不同 —— 这是防彩虹表的基础。"""
|
||||
assert hash_password("SamePwd@1") != hash_password("SamePwd@1")
|
||||
|
||||
def test_恰好72字节时允许(self) -> None:
|
||||
password = "a" * PASSWORD_MAX_BYTES
|
||||
assert len(password.encode("utf-8")) == PASSWORD_MAX_BYTES
|
||||
assert len(hash_password(password)) == 60
|
||||
|
||||
def test_超过72字节时抛ValueError(self) -> None:
|
||||
with pytest.raises(ValueError):
|
||||
hash_password("a" * (PASSWORD_MAX_BYTES + 1))
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("text", "count", "should_pass"),
|
||||
[("中", 24, True), ("中", 25, False)],
|
||||
)
|
||||
def test_中文按字节而非字符计算边界(self, text: str, count: int, should_pass: bool) -> None:
|
||||
"""一个汉字 3 字节:24 个正好 72 字节,25 个即 75 字节超限。"""
|
||||
password = text * count
|
||||
if should_pass:
|
||||
assert len(hash_password(password)) == 60
|
||||
else:
|
||||
with pytest.raises(ValueError):
|
||||
hash_password(password)
|
||||
|
||||
|
||||
class TestVerifyPassword:
|
||||
"""哈希校验。"""
|
||||
|
||||
def test_正确密码校验通过(self) -> None:
|
||||
assert verify_password("Admin@2026", hash_password("Admin@2026")) is True
|
||||
|
||||
def test_错误密码校验失败(self) -> None:
|
||||
assert verify_password("WrongPwd@1", hash_password("Admin@2026")) is False
|
||||
|
||||
def test_超长密码返回False而不抛异常(self) -> None:
|
||||
"""校验路径不得因输入长度报错 —— 否则会形成「密码过长」与「密码错误」
|
||||
可区分的侧信道,且会让恶意长输入直接打出 500。"""
|
||||
assert verify_password("a" * 200, hash_password("Admin@2026")) is False
|
||||
|
||||
def test_损坏的哈希串返回False而不抛异常(self) -> None:
|
||||
assert verify_password("Admin@2026", "not-a-bcrypt-hash") is False
|
||||
|
||||
def test_空哈希串返回False(self) -> None:
|
||||
assert verify_password("Admin@2026", "") is False
|
||||
60
backend/tests/unit/test_token_blacklist.py
Normal file
60
backend/tests/unit/test_token_blacklist.py
Normal file
@ -0,0 +1,60 @@
|
||||
"""登出黑名单单元测试。
|
||||
|
||||
守的是:黑名单的 **TTL 必须等于令牌剩余有效期**。
|
||||
写死固定值会出现「登出 N 分钟后令牌又活了」;
|
||||
完全不设过期则 Redis 会随登出次数无限增长。两者都是真实事故。
|
||||
"""
|
||||
|
||||
from app.core.constants import REDIS_KEY_TOKEN_BLACKLIST
|
||||
from app.services import token_blacklist
|
||||
|
||||
|
||||
def _ttl(jti: str) -> int:
|
||||
from app.core.redis_client import get_redis
|
||||
|
||||
return get_redis().ttl(REDIS_KEY_TOKEN_BLACKLIST.format(jti=jti))
|
||||
|
||||
|
||||
class TestRevoke:
|
||||
"""拉黑。"""
|
||||
|
||||
def test_拉黑后可检出(self, fake_redis) -> None:
|
||||
token_blacklist.revoke("jti-1", 600)
|
||||
assert token_blacklist.is_revoked("jti-1") is True
|
||||
|
||||
def test_TTL等于传入的剩余秒数(self, fake_redis) -> None:
|
||||
"""容忍 1 秒偏差:Redis 的 TTL 以毫秒精度存绝对过期时刻,
|
||||
命令返回时向下取整,刚写入就查询必然是 N 或 N-1。
|
||||
断言死等会变成偶发失败的"闪烁测试"。"""
|
||||
token_blacklist.revoke("jti-1", 1234)
|
||||
assert _ttl("jti-1") in (1234, 1233)
|
||||
|
||||
def test_键带过期时间而非永久存在(self, fake_redis) -> None:
|
||||
"""永久存储会让 Redis 随登出次数无限增长。"""
|
||||
token_blacklist.revoke("jti-1", 600)
|
||||
assert _ttl("jti-1") > 0
|
||||
|
||||
def test_已过期令牌不写入(self, fake_redis) -> None:
|
||||
"""剩余有效期为 0 或负数时无凭证可撤销,写入只会浪费内存。"""
|
||||
token_blacklist.revoke("jti-expired", 0)
|
||||
token_blacklist.revoke("jti-expired2", -100)
|
||||
assert token_blacklist.is_revoked("jti-expired") is False
|
||||
assert token_blacklist.is_revoked("jti-expired2") is False
|
||||
|
||||
def test_重复拉黑同一jti不报错且TTL更新(self, fake_redis) -> None:
|
||||
token_blacklist.revoke("jti-1", 600)
|
||||
token_blacklist.revoke("jti-1", 300)
|
||||
assert _ttl("jti-1") in (300, 299)
|
||||
|
||||
|
||||
class TestIsRevoked:
|
||||
"""检测。"""
|
||||
|
||||
def test_未拉黑的令牌返回False(self, fake_redis) -> None:
|
||||
assert token_blacklist.is_revoked("never-revoked") is False
|
||||
|
||||
def test_不同jti之间互不影响(self, fake_redis) -> None:
|
||||
"""jti 是唯一键,串号会导致误踢其他用户的登录态。"""
|
||||
token_blacklist.revoke("jti-1", 600)
|
||||
assert token_blacklist.is_revoked("jti-1") is True
|
||||
assert token_blacklist.is_revoked("jti-2") is False
|
||||
847
backend/uv.lock
generated
Normal file
847
backend/uv.lock
generated
Normal file
@ -0,0 +1,847 @@
|
||||
version = 1
|
||||
revision = 3
|
||||
requires-python = "==3.12.*"
|
||||
|
||||
[[package]]
|
||||
name = "alembic"
|
||||
version = "1.20.0"
|
||||
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
|
||||
dependencies = [
|
||||
{ name = "mako" },
|
||||
{ name = "sqlalchemy" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ed/aa/02910bdb8e2f1444f6654d5b296cd827d126f82209050ee7b1000f92ac4b/alembic-1.20.0.tar.gz", hash = "sha256:db505480647bc60386c5369402f4a57a506b7539c9e9ef5e270d45cbbe4939bf", size = 2093272, upload-time = "2026-09-11T19:09:11.126Z" }
|
||||
wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/3f/27/78a89b55b0904d222183164e079b4ca56208e94eff1d35ad1f1ad5be9b06/alembic-1.20.0-py3-none-any.whl", hash = "sha256:77eb101048d95f982c0353e9233404889dcd7a6fc244c107836c0e2fc9cf7d9d", size = 268719, upload-time = "2026-09-11T19:09:12.88Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "annotated-doc"
|
||||
version = "0.0.5"
|
||||
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
|
||||
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758, upload-time = "2026-07-28T13:50:58.129Z" }
|
||||
wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302, upload-time = "2026-07-28T13:50:57.239Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "annotated-types"
|
||||
version = "0.8.0"
|
||||
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
|
||||
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" }
|
||||
wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyio"
|
||||
version = "4.15.1"
|
||||
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
|
||||
dependencies = [
|
||||
{ name = "idna" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" }
|
||||
wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bcrypt"
|
||||
version = "5.0.0"
|
||||
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
|
||||
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d4/36/3329e2518d70ad8e2e5817d5a4cac6bba05a47767ec416c7d020a965f408/bcrypt-5.0.0.tar.gz", hash = "sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd", size = 25386, upload-time = "2025-09-25T19:50:47.829Z" }
|
||||
wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/84/29/6237f151fbfe295fe3e074ecc6d44228faa1e842a81f6d34a02937ee1736/bcrypt-5.0.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:fc746432b951e92b58317af8e0ca746efe93e66555f1b40888865ef5bf56446b", size = 494553, upload-time = "2025-09-25T19:49:49.006Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/45/b6/4c1205dde5e464ea3bd88e8742e19f899c16fa8916fb8510a851fae985b5/bcrypt-5.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c2388ca94ffee269b6038d48747f4ce8df0ffbea43f31abfa18ac72f0218effb", size = 275009, upload-time = "2025-09-25T19:49:50.581Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/3b/71/427945e6ead72ccffe77894b2655b695ccf14ae1866cd977e185d606dd2f/bcrypt-5.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:560ddb6ec730386e7b3b26b8b4c88197aaed924430e7b74666a586ac997249ef", size = 278029, upload-time = "2025-09-25T19:49:52.533Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/17/72/c344825e3b83c5389a369c8a8e58ffe1480b8a699f46c127c34580c4666b/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d79e5c65dcc9af213594d6f7f1fa2c98ad3fc10431e7aa53c176b441943efbdd", size = 275907, upload-time = "2025-09-25T19:49:54.709Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/0b/7e/d4e47d2df1641a36d1212e5c0514f5291e1a956a7749f1e595c07a972038/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2b732e7d388fa22d48920baa267ba5d97cca38070b69c0e2d37087b381c681fd", size = 296500, upload-time = "2025-09-25T19:49:56.013Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/0f/c3/0ae57a68be2039287ec28bc463b82e4b8dc23f9d12c0be331f4782e19108/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0c8e093ea2532601a6f686edbc2c6b2ec24131ff5c52f7610dd64fa4553b5464", size = 278412, upload-time = "2025-09-25T19:49:57.356Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/45/2b/77424511adb11e6a99e3a00dcc7745034bee89036ad7d7e255a7e47be7d8/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5b1589f4839a0899c146e8892efe320c0fa096568abd9b95593efac50a87cb75", size = 275486, upload-time = "2025-09-25T19:49:59.116Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/43/0a/405c753f6158e0f3f14b00b462d8bca31296f7ecfc8fc8bc7919c0c7d73a/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:89042e61b5e808b67daf24a434d89bab164d4de1746b37a8d173b6b14f3db9ff", size = 277940, upload-time = "2025-09-25T19:50:00.869Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/62/83/b3efc285d4aadc1fa83db385ec64dcfa1707e890eb42f03b127d66ac1b7b/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e3cf5b2560c7b5a142286f69bde914494b6d8f901aaa71e453078388a50881c4", size = 310776, upload-time = "2025-09-25T19:50:02.393Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/95/7d/47ee337dacecde6d234890fe929936cb03ebc4c3a7460854bbd9c97780b8/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f632fd56fc4e61564f78b46a2269153122db34988e78b6be8b32d28507b7eaeb", size = 312922, upload-time = "2025-09-25T19:50:04.232Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/d6/3a/43d494dfb728f55f4e1cf8fd435d50c16a2d75493225b54c8d06122523c6/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:801cad5ccb6b87d1b430f183269b94c24f248dddbbc5c1f78b6ed231743e001c", size = 341367, upload-time = "2025-09-25T19:50:05.559Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/55/ab/a0727a4547e383e2e22a630e0f908113db37904f58719dc48d4622139b5c/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3cf67a804fc66fc217e6914a5635000259fbbbb12e78a99488e4d5ba445a71eb", size = 359187, upload-time = "2025-09-25T19:50:06.916Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/1b/bb/461f352fdca663524b4643d8b09e8435b4990f17fbf4fea6bc2a90aa0cc7/bcrypt-5.0.0-cp38-abi3-win32.whl", hash = "sha256:3abeb543874b2c0524ff40c57a4e14e5d3a66ff33fb423529c88f180fd756538", size = 153752, upload-time = "2025-09-25T19:50:08.515Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/41/aa/4190e60921927b7056820291f56fc57d00d04757c8b316b2d3c0d1d6da2c/bcrypt-5.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:35a77ec55b541e5e583eb3436ffbbf53b0ffa1fa16ca6782279daf95d146dcd9", size = 150881, upload-time = "2025-09-25T19:50:09.742Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/54/12/cd77221719d0b39ac0b55dbd39358db1cd1246e0282e104366ebbfb8266a/bcrypt-5.0.0-cp38-abi3-win_arm64.whl", hash = "sha256:cde08734f12c6a4e28dc6755cd11d3bdfea608d93d958fffbe95a7026ebe4980", size = 144931, upload-time = "2025-09-25T19:50:11.016Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/5d/ba/2af136406e1c3839aea9ecadc2f6be2bcd1eff255bd451dd39bcf302c47a/bcrypt-5.0.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0c418ca99fd47e9c59a301744d63328f17798b5947b0f791e9af3c1c499c2d0a", size = 495313, upload-time = "2025-09-25T19:50:12.309Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/ac/ee/2f4985dbad090ace5ad1f7dd8ff94477fe089b5fab2040bd784a3d5f187b/bcrypt-5.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddb4e1500f6efdd402218ffe34d040a1196c072e07929b9820f363a1fd1f4191", size = 275290, upload-time = "2025-09-25T19:50:13.673Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/e4/6e/b77ade812672d15cf50842e167eead80ac3514f3beacac8902915417f8b7/bcrypt-5.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7aeef54b60ceddb6f30ee3db090351ecf0d40ec6e2abf41430997407a46d2254", size = 278253, upload-time = "2025-09-25T19:50:15.089Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/36/c4/ed00ed32f1040f7990dac7115f82273e3c03da1e1a1587a778d8cea496d8/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f0ce778135f60799d89c9693b9b398819d15f1921ba15fe719acb3178215a7db", size = 276084, upload-time = "2025-09-25T19:50:16.699Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/e7/c4/fa6e16145e145e87f1fa351bbd54b429354fd72145cd3d4e0c5157cf4c70/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a71f70ee269671460b37a449f5ff26982a6f2ba493b3eabdd687b4bf35f875ac", size = 297185, upload-time = "2025-09-25T19:50:18.525Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/24/b4/11f8a31d8b67cca3371e046db49baa7c0594d71eb40ac8121e2fc0888db0/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8429e1c410b4073944f03bd778a9e066e7fad723564a52ff91841d278dfc822", size = 278656, upload-time = "2025-09-25T19:50:19.809Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/ac/31/79f11865f8078e192847d2cb526e3fa27c200933c982c5b2869720fa5fce/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:edfcdcedd0d0f05850c52ba3127b1fce70b9f89e0fe5ff16517df7e81fa3cbb8", size = 275662, upload-time = "2025-09-25T19:50:21.567Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/d4/8d/5e43d9584b3b3591a6f9b68f755a4da879a59712981ef5ad2a0ac1379f7a/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a", size = 278240, upload-time = "2025-09-25T19:50:23.305Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/89/48/44590e3fc158620f680a978aafe8f87a4c4320da81ed11552f0323aa9a57/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:db99dca3b1fdc3db87d7c57eac0c82281242d1eabf19dcb8a6b10eb29a2e72d1", size = 311152, upload-time = "2025-09-25T19:50:24.597Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/5f/85/e4fbfc46f14f47b0d20493669a625da5827d07e8a88ee460af6cd9768b44/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:5feebf85a9cefda32966d8171f5db7e3ba964b77fdfe31919622256f80f9cf42", size = 313284, upload-time = "2025-09-25T19:50:26.268Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/25/ae/479f81d3f4594456a01ea2f05b132a519eff9ab5768a70430fa1132384b1/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3ca8a166b1140436e058298a34d88032ab62f15aae1c598580333dc21d27ef10", size = 341643, upload-time = "2025-09-25T19:50:28.02Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/df/d2/36a086dee1473b14276cd6ea7f61aef3b2648710b5d7f1c9e032c29b859f/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:61afc381250c3182d9078551e3ac3a41da14154fbff647ddf52a769f588c4172", size = 359698, upload-time = "2025-09-25T19:50:31.347Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/c0/f6/688d2cd64bfd0b14d805ddb8a565e11ca1fb0fd6817175d58b10052b6d88/bcrypt-5.0.0-cp39-abi3-win32.whl", hash = "sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683", size = 153725, upload-time = "2025-09-25T19:50:34.384Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/9f/b9/9d9a641194a730bda138b3dfe53f584d61c58cd5230e37566e83ec2ffa0d/bcrypt-5.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2", size = 150912, upload-time = "2025-09-25T19:50:35.69Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/27/44/d2ef5e87509158ad2187f4dd0852df80695bb1ee0cfe0a684727b01a69e0/bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927", size = 144953, upload-time = "2025-09-25T19:50:37.32Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "captcha"
|
||||
version = "0.7.1"
|
||||
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
|
||||
dependencies = [
|
||||
{ name = "pillow" },
|
||||
]
|
||||
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b4/65/8e186bb798f33ba390eab897c995b0fcee92bc030e0f40cb8ea01f34dd07/captcha-0.7.1.tar.gz", hash = "sha256:a1b462bcc633a64d8db5efa7754548a877c698d98f87716c620a707364cabd6b", size = 226561, upload-time = "2025-03-01T05:00:13.395Z" }
|
||||
wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/da/ff/3f0982ecd37c2d6a7266c22e7ea2e47d0773fe449984184c5316459d2776/captcha-0.7.1-py3-none-any.whl", hash = "sha256:8b73b5aba841ad1e5bdb856205bf5f09560b728ee890eb9dae42901219c8c599", size = 147606, upload-time = "2025-03-01T05:00:10.433Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2026.7.22"
|
||||
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
|
||||
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" }
|
||||
wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cffi"
|
||||
version = "2.1.1"
|
||||
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
|
||||
dependencies = [
|
||||
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
|
||||
]
|
||||
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" }
|
||||
wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "click"
|
||||
version = "8.5.0"
|
||||
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
|
||||
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" }
|
||||
wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.6"
|
||||
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
|
||||
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
||||
wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "coverage"
|
||||
version = "7.16.2"
|
||||
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
|
||||
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/2f/55/d1eaf3e73781174340a00dc1ba2aee8a65f82fadb18e2797b192b6b3925b/coverage-7.16.2.tar.gz", hash = "sha256:ca64d9f1f384f151b9511bec01126072acd2f313439f8ed015a22d8790aab6fa", size = 971999, upload-time = "2026-09-27T12:29:01.118Z" }
|
||||
wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/5e/2c/f8296c63c5d542f3d21aed685e56b7031a419037d155bb3382fc0940d249/coverage-7.16.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:218d742afca2b5ad5ca759e93eddedfbcc6eadf8322f080dcefc40b7bd4e2d48", size = 223969, upload-time = "2026-09-27T12:26:16.753Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/90/23/6f3dcb1423a0d43216e402ea1746e4a7c7c44f38896b97dd573790f56a40/coverage-7.16.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a9a638be322a8d76a41cdb17781c7f82aaee6a66493d8ffb7e2c09ee22423d99", size = 224328, upload-time = "2026-09-27T12:26:18.15Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/ac/7d/8f3b6dc920e3fc6732f7678785a2091db439f186afbec30dbf2214d9b1f7/coverage-7.16.2-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:724bd0f1e81856b35e59fc98cf7b4e544a3cb662e4e0864dca73d4326ee9d808", size = 255832, upload-time = "2026-09-27T12:26:19.799Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/d1/36/6c45f15be4eca4ac1062c6a55a323286494c99726a7e58951fe85967ac08/coverage-7.16.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5375ebd99038021b35e99dc88255022912c06565d316212f4a576e4b08d30f5d", size = 258571, upload-time = "2026-09-27T12:26:21.199Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/34/fb/b54cbeba3ad89082c2e441278681859e538322cc34b84b2af7ebff00080f/coverage-7.16.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a076277ca9f5750cc230f0f578ebd2620cec60255b25707361699fef6fb465c", size = 259680, upload-time = "2026-09-27T12:26:22.822Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/6e/a2/0dc65ec3d61930e1e4c2e371763b15eb4290896eb343a12d5d3091308116/coverage-7.16.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:58d4a54c6ea672afef66d49be922a2c69826c5ae1a42a9cd94f0c9c2bacdf800", size = 261945, upload-time = "2026-09-27T12:26:24.336Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/d6/93/5fad7a61f2c14e08e98946fc31c1c7ffc1195061bf3fdc351db3be77a863/coverage-7.16.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0dcbcfcc059117284c603ff8cb61a65872512882f84a8cf0339241f7f7c2f148", size = 256191, upload-time = "2026-09-27T12:26:25.89Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/2d/47/74e5de9227b939ece9f64e729645ddc4296bea10dbfa98721c1333c8be2e/coverage-7.16.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:afdf43b72ef3876c1fe66423b91466e37877c9e81e8cec70542b7e8525b9d1b7", size = 257610, upload-time = "2026-09-27T12:26:27.35Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/13/fe/2cf28d40b43645d1b72388fe3ee7f7c747533a6a9557bb8c24a7ae74fe1a/coverage-7.16.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:9acc7f7ec4a1b5f89bd929fde5b8a714f6fafdc6cc18725413d510aa082b47ad", size = 255757, upload-time = "2026-09-27T12:26:28.949Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/d7/3d/7c149fd99fc8bbc39c80db5e688d1d39fd040be2ecb78b8335a51a55b9c0/coverage-7.16.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:80d3f7b48d43ee8fc5e8707a8adb43d743a5a1a85256c25a24f9d6d0e2238fa6", size = 259822, upload-time = "2026-09-27T12:26:30.515Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/e6/3f/b283fce09d5995e227bd8e513358dd7471bedc0f78abc85a925ebdb0a2f6/coverage-7.16.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:126d1af8804d7224421fe991ff65d3ce649081560df7a98b1a5ffff07f9923bd", size = 255325, upload-time = "2026-09-27T12:26:32.037Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/bf/91/f3325edf0c4223fb1fe1532b8dbef2a1d2f729459a9a7d1a44d073bae534/coverage-7.16.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c19cd6d025c1673f22afcd22c7df8a662d779e05d8e3fa6820c22afb895b0206", size = 257191, upload-time = "2026-09-27T12:26:33.525Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/c4/89/21eb5e83ecf2eed523c4eb3d65ae513cd082c8fd1b6deb34c4cb6c332f97/coverage-7.16.2-cp312-cp312-win32.whl", hash = "sha256:152877cdc8a07264882cfcd503ba56a3ef6cba56a70e8c70f6eb8ffd7384789a", size = 226034, upload-time = "2026-09-27T12:26:35.021Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/db/de/e3ad6d864c0833624b4f1f9b53f9e58e116c945e5e965c3f1e172c5e84cd/coverage-7.16.2-cp312-cp312-win_amd64.whl", hash = "sha256:e6c52d3307824ff93b39efd99e4185d557db40bd841452abfb32e5d9151ca162", size = 226569, upload-time = "2026-09-27T12:26:36.604Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/3e/c1/bccc58ebe5489cc70628f635c1932fd371f5d7da850dbcf960f95f4c4afc/coverage-7.16.2-cp312-cp312-win_arm64.whl", hash = "sha256:a678c0b6b22086ec2427359d22e37445d4a792f5fdbbc744112c7dade65cad02", size = 226372, upload-time = "2026-09-27T12:26:38.406Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/3f/0c/7a64e1ac90541a8edf50daef0914848011fb057a5bf55284a4811e21939a/coverage-7.16.2-py3-none-any.whl", hash = "sha256:11d28e9123a9156cb405d8d27b44256c9a58fb5decc2073a8f17862057e3aa0f", size = 215754, upload-time = "2026-09-27T12:28:59.075Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cryptography"
|
||||
version = "50.0.2"
|
||||
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
|
||||
dependencies = [
|
||||
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
|
||||
]
|
||||
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9d/af/182eb91b0df3fe75c4d9f26fe70684569566745f6ba7e5c9c73a862c5252/cryptography-50.0.2.tar.gz", hash = "sha256:7b46165bb56eb4704e2eaaf86f3c940d19154535d9b0ca7d6d590b04060e00d5", size = 880623, upload-time = "2026-09-30T15:30:04.884Z" }
|
||||
wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/e5/56/d194340cc4a57535e82e1bee9e89667ac4b7c13b5d3f59686deae3094dd5/cryptography-50.0.2-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:fa8f5efb344d6908a1ce62f4a24e2e5780f825d6f53f5f50ec5ffacac72936cb", size = 3914904, upload-time = "2026-09-30T14:43:44.339Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/d9/69/c9bd862c3bf43d6399c433caf002df16e2dffd4be49bdf515cda38038711/cryptography-50.0.2-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:79def8d059362e7831389ed3be0ecdf58a89386e1271e35dd9f5af84e81bffd0", size = 4731146, upload-time = "2026-09-30T14:43:47.113Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/21/69/64cef1f702bf6657e0cc186ed1a2891d50d29fb41586b254e1c07adea261/cryptography-50.0.2-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:630ebfea3bf689d075f82316324ff7433dc447fe6bc1bfc76524b74b4a9567d2", size = 4719841, upload-time = "2026-09-30T14:43:49.01Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/38/6b/61a3f8d8c5e1e49a6cddccafc4015cc1c0021360ab0acb4080e7a423644a/cryptography-50.0.2-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f9f6143a8c75945eb960d9eb98905a441394abfa24afaae239d514ffb2586480", size = 4738340, upload-time = "2026-09-30T14:43:50.932Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/7b/2e/7212ca32fd43dc91f2f41db20160b268098874b4c9a0e7be94d6835f5b2e/cryptography-50.0.2-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:a582ab2ae1d34f67112cadc86702774c9ea4374df6bca6afe672817203c99134", size = 5367029, upload-time = "2026-09-30T14:43:52.911Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/1a/f1/b474e930c4d910328780e3940da76f5aa5cbc48ce1fc14e44d239d9ea9db/cryptography-50.0.2-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:4061c0079120205fb760c58acab6443e217307dcf05e3702cf970e0689972856", size = 4753050, upload-time = "2026-09-30T14:43:55.272Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/7c/52/9af10e80ac16b0fcc2123f9cbd5e7afbd0fd5075bb7a607c592258a39cda/cryptography-50.0.2-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:ac9ed99d81760c62fe89d5f0815cdfa1ba9a35141cf30f1c2d044f04b4803d2e", size = 4376724, upload-time = "2026-09-30T14:43:57.24Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/71/37/6202e488cc1eb625ea110c292c6bda92823176e023f427d8d5660ce8d632/cryptography-50.0.2-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:87e9ce85beb6b328ba370cc6e6aea483c92617b4c95b1d33a49297eb662bfb04", size = 4737859, upload-time = "2026-09-30T14:43:59.541Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/8f/30/e86d7d518489b0ae2497091a35287abcb1a2ce4037837a34afbe9b1d6964/cryptography-50.0.2-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f265528741e048bce55c3463ed721fb0aa45a5888d8add8cfeccb3035451bbdc", size = 5324103, upload-time = "2026-09-30T14:44:01.901Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/d3/69/2c833a049475e0a3444e94c7d0aca0aa51d166374a449b09e92ac98138de/cryptography-50.0.2-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dab55f57c74c3cad24c323bacbbd04be4705ba6eb0d92e920b1fc4837ed5079", size = 4752576, upload-time = "2026-09-30T14:44:04.545Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/6c/5d/906970b83bbfc1f5bbfb677a143c181f2801f23b6a7204a3b47c42c97e65/cryptography-50.0.2-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:25784ce8b9621c90c643efb9e1e2162ab3b0224cae446ad5e70e7fcb1ce18b51", size = 4870819, upload-time = "2026-09-30T14:44:06.884Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/68/e3/f2298d3bb55e0c4a91841ec4d01b3f020ba8c5fbf15ccdcc6dcf03f97025/cryptography-50.0.2-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:85d0d9a31b9098e98534226d5686b47264b95e62ce459dc2e62fdfc809f9fe93", size = 5030152, upload-time = "2026-09-30T14:44:09.443Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/9a/4f/adfc442765721292fff86d314ce385d3249d22db42295c0dd057727b60f3/cryptography-50.0.2-cp311-abi3-win_amd64.whl", hash = "sha256:7afa5a6602a9f29af1f3a2965f831bae7c9d5d597b7cbb716d41ab3b7d89879c", size = 3824692, upload-time = "2026-09-30T14:44:11.671Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/2d/49/93f6a6e7a87c9aa68d44d3e1cdb5fe8f60c90d5d2f46acae9a56892816b8/cryptography-50.0.2-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:edc3342adf8f697fc5f59c887a304356f147b397809440ed64e2fa6af2f50f37", size = 4133708, upload-time = "2026-09-30T14:44:41.807Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/8c/75/32ac2a56243d778805c16ca6a32b8f74fb757df7e28d7ecb560afafb59cf/cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d370b8d1dfcdf7130178137f6fbee6140774a1acc6cacefc4b42643ec11d0a3a", size = 4956267, upload-time = "2026-09-30T14:44:43.693Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/aa/a4/2c8d734e43d97f0842ee9f1b7b4bfb3d0cf5e19edebf43c2afe6675c2320/cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f2f9bd7f90c64fe89253f0a2c05e3c4856072660429ce8831b4235bf29403a67", size = 4966465, upload-time = "2026-09-30T14:44:45.769Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/c2/58/ee288c829a6f41f6235ae9dd33d82fd19b45442b65b4c8a3da36963d9f7a/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_aarch64.whl", hash = "sha256:e275096ea1e60cc595cda2836fd4a6c725d1125108b868be17f53684d164e2cc", size = 4959356, upload-time = "2026-09-30T14:44:48.211Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/92/20/9ded6d51ddd9897f6b6e81fb9ebea7951d7cc5d6c890b0ed8abf77a51a80/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_ppc64le.whl", hash = "sha256:b13478603dcd0a2479ff8e87e2c19a7d525734686fe3c49542472293a204212d", size = 5548822, upload-time = "2026-09-30T14:44:50.86Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/02/a8/8df951850d6b31d2a00218f19e2b3f999523437ed7a819df7fa427942fca/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_x86_64.whl", hash = "sha256:58a0c478eeca76fe5e07993c5a0703def34a6dc6a0cda4f5564639b33112ffe7", size = 5001199, upload-time = "2026-09-30T14:44:53.379Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/8b/f9/36b3022218ce75b7cdf068fb95f809f9bd0d820e4955ef43b90c255cc7ac/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_31_armv7l.whl", hash = "sha256:d38cdff612d06fa6a32840d5e1b1f7a27cee4a349aa9085d94a67789d6bfd408", size = 4629333, upload-time = "2026-09-30T14:44:55.635Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/8c/72/20f99a219f6af47cdd1cbd978c243b92d71496e168a746138af44ded4f29/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_aarch64.whl", hash = "sha256:fdd28f912fccfec1846a94e2e1e8f9b0012f557f0c46fe4f3eb0d7a87afcf90b", size = 4958822, upload-time = "2026-09-30T14:44:59.639Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/f2/20/196f112617fb08eb4d608a2a6c422373d46f9cc2857f38fc0667033c0899/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_ppc64le.whl", hash = "sha256:cbc8738fd8526d80f35cb3a40d41f41a2e7030bb3b18b09a6778ef63d291c2fd", size = 5506351, upload-time = "2026-09-30T14:45:02.267Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/24/95/83378121ef3eaaaf71d4b781577ff794acb39b9e1b87a3f156898c8497ed/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_x86_64.whl", hash = "sha256:e105ab60406787da31fccc883fc0f733af1efd78f0136a4599692c4083a73d0c", size = 5000859, upload-time = "2026-09-30T14:45:05.009Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/22/f7/70fd7ae4d1dbfa7ba29b02e1b9068771519a86027756510b700ce81086a8/cryptography-50.0.2-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:6f8700550aa1474a91e5dc07049c46f98b423b5b1ddd0483e0b51362eeeaf5be", size = 5092151, upload-time = "2026-09-30T15:29:15.932Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/d4/be/688367b74de86984bd58d8efacfc7c9e68b89a6a22ced0fb4f38db50254a/cryptography-50.0.2-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:c71be1cbfa5cd9a41ee452acf1eccd82b2c05950358b106ec8ceb83411d1a020", size = 5286120, upload-time = "2026-09-30T15:29:18.309Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/39/d1/55f8a3f2ef5d1529e16835ef10cf0fe3d559ce237b46dddc440c0bba3649/cryptography-50.0.2-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:c423ab384a46c4dff7217b2ea5ba2e11cffdeab6441acd04cf65a369caf0366c", size = 4111557, upload-time = "2026-09-30T15:29:20.155Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/23/ad/ac987755d00e1e64273760228d2635ae38dae2be83e3c6e0d3289d91dec3/cryptography-50.0.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:0ec5f09541743261e66e291b4a0cbf0fb2997aeaab6d9e9c740b9dba1b58d1c2", size = 3943588, upload-time = "2026-09-30T15:29:22.265Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/d5/8d/6d585339bedf85d45044c85d8412dac53f2bb6f918e8b7777efba1787844/cryptography-50.0.2-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c5e67125c7dca78d199ec4e116aa93dbb83494808ecbb8211a2cb09b1bf41dbd", size = 4756166, upload-time = "2026-09-30T15:29:24.58Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/bf/f1/1c1f6874e8550cfddd4b688ceb38cefb6ed15ceed224d56f133f3d88c214/cryptography-50.0.2-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ee247f5c245c9a2fe7c8e2214e295918838e44e00a45a6718451e4004219e767", size = 4749145, upload-time = "2026-09-30T15:29:26.807Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/c1/63/61b15dc1a8de03fe0adbe3fd7608b3ad5c73bf50993bbcb1faaa930afe33/cryptography-50.0.2-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:dfe9763530994147d9af1def057a5b9658b00e8f8fe8743d144d1e0911c2e454", size = 4763638, upload-time = "2026-09-30T15:29:28.588Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/fc/35/b345bdfa40c9126df1a9d33236aa98418367931b8725f84fc3ae2b98dc59/cryptography-50.0.2-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:58ddb5a8e3179d12f19e4ea34d2d32e9d63a4baa142c875c1eb59f41b7243acd", size = 5382217, upload-time = "2026-09-30T15:29:30.589Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/4f/87/ef344a9e616871f2519c22d6afcda79ddd5d35e9592d95eb6e677608d055/cryptography-50.0.2-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f21e8a22c8605750c7af886bab299a363721264061b4ac0a30efb73cfd58efc5", size = 4781387, upload-time = "2026-09-30T15:29:32.605Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/90/5b/f2fdb13cd0b96f6f932c8627bb292a45f11c64d21620a8e120aee9a3b848/cryptography-50.0.2-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:9c8402a82ea0dc4ceeab793db05f0fafa8ca139ca34fcde5df0f596103c74107", size = 4403790, upload-time = "2026-09-30T15:29:34.374Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/bc/ce/7e4f662b1e3c393513569e402cfc85ac7da0bd3d5435e122a3140219eb2d/cryptography-50.0.2-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:0ddc924c04591c2811ca024d62ecad4f7f6f08af8939c211438f48a16bd23602", size = 4764319, upload-time = "2026-09-30T15:29:36.149Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/3c/3f/86ff33ce34cc0de6847fb96e035a1a760d81652e38643f617c02ad32ef7a/cryptography-50.0.2-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a6557e5f38e065ca9fbdaf7cfc7435ecb1d113aa81a022d1b51921ee7432e227", size = 5338560, upload-time = "2026-09-30T15:29:39.053Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/40/cf/6b5c8e2fd9202d98988ab7cb5cc5c991704c4ad55f492ff408e4969f83f1/cryptography-50.0.2-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:1981f1db4630889b9ef7803fadef12b056f428cb6b85c27ba57b774793b6093c", size = 4780973, upload-time = "2026-09-30T15:29:41.251Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/10/bf/8d6ebc7dded797bd0f0160d52188021211f011a2b164ef0ae1dac4587465/cryptography-50.0.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7a8701d6b584d76e909e3d305b7d126b41439876a5aaf76cddc67fc230eafa2e", size = 4897738, upload-time = "2026-09-30T15:29:43.106Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/d4/aa/f3f6e0de7e6253b8baa8b2d8fb9d50924fa75cee3d4624bd4bc1208ee923/cryptography-50.0.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ce47f66801c20ec6c6632453bb5960fe38939e9306970b48b3a5a26de7745d94", size = 5058280, upload-time = "2026-09-30T15:29:44.827Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/f6/b6/a1faf3a27ae9405fb34b1713cc73b2d8a26b04d5c561578fa2e6ef3e5bb9/cryptography-50.0.2-cp39-abi3-win_amd64.whl", hash = "sha256:4e81d95e5bafc2d6e34e4bed780e53e4d5b9a2f928573428aa4d35fbec1eb0de", size = 3854095, upload-time = "2026-09-30T15:29:46.782Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fakeredis"
|
||||
version = "2.39.0"
|
||||
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
|
||||
dependencies = [
|
||||
{ name = "redis" },
|
||||
{ name = "sortedcontainers" },
|
||||
]
|
||||
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/2f/27/3ed3eee5e5a929345c37024b814a70f6e2452ffdab77a2680c2ebba3614a/fakeredis-2.39.0.tar.gz", hash = "sha256:e89c3410f290330042638ff5cca3e22788fa267dcaf28a64b4f483e14577208d", size = 301722, upload-time = "2026-10-01T12:35:19.404Z" }
|
||||
wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/35/ca/8bf657139922808196e6480ec6ed94008897e23d603abd5b27538cfdf811/fakeredis-2.39.0-py3-none-any.whl", hash = "sha256:acd1450575259634db2942d5bae93e383aac32bb9968aab29fe7b0c2ab880bb8", size = 186508, upload-time = "2026-10-01T12:35:17.899Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastapi"
|
||||
version = "0.143.0"
|
||||
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
|
||||
dependencies = [
|
||||
{ name = "annotated-doc" },
|
||||
{ name = "opentelemetry-api" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "starlette" },
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0b/d7/6a8753ab6c1d432dc53703c3e1b92974a94531b7d047c32bbaae461ea844/fastapi-0.143.0.tar.gz", hash = "sha256:1acffe48206a80917cf7dac21992b5c44b25384e8902bf745c1fd9dabcf6c51f", size = 468391, upload-time = "2026-10-08T12:29:46.54Z" }
|
||||
wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/bd/f4/27e386913417ad32aae42bba48b0c0cce40e9ff2fba1a871ca2702c37324/fastapi-0.143.0-py3-none-any.whl", hash = "sha256:3e9395fd35276425b61b516a31fdd7c77fe2af83e41b4da22e30696fb1304c5d", size = 144665, upload-time = "2026-10-08T12:29:44.853Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h11"
|
||||
version = "0.16.0"
|
||||
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
|
||||
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
|
||||
wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hiredis"
|
||||
version = "3.4.2"
|
||||
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
|
||||
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/38/da/41b341ebed1eb6f1074112936af98bb52880724737887ae9bade9d7ce107/hiredis-3.4.2.tar.gz", hash = "sha256:9a566dc70e9dd84be3550babc56a8e109bb65cafcac635aea027fa425196a7d7", size = 138058, upload-time = "2026-09-22T12:39:20.363Z" }
|
||||
wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/f4/fb/aee5f09ba3b483700b0fb4556f9c09e752791d255bb677310485e76a3e37/hiredis-3.4.2-cp312-cp312-macosx_10_15_universal2.whl", hash = "sha256:eb98b46a781a960bc9044050cc166e38c19b327a7a8c62afee9c78d72d80dd18", size = 140912, upload-time = "2026-09-22T12:37:48.554Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/41/0c/d29b76ac581200ebf0e0194edda8c7aef51dd497079d556aabfd44336de7/hiredis-3.4.2-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:05d06f3edcdeb484aa47610fd520c07d637a763d4ab1cd7793550829afe27ccb", size = 75186, upload-time = "2026-09-22T12:37:50.066Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/d5/6f/9092acfd69d9a76fecce4723bba624a36d321447f92e88f80296038dfaee/hiredis-3.4.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ddfdd5006d1cbe2ee961852b90f89d676b44dd8e0eb2f032dc2383c16a54bfc9", size = 71987, upload-time = "2026-09-22T12:37:51.09Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/3c/29/65e823bc79be70322dfab5b7bf46bdbac2d029b848950fed9621adac7445/hiredis-3.4.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4cf7924e86c5f9d4e212d9643a99e607008628941e771df015c72cd6dc4d15e", size = 307130, upload-time = "2026-09-22T12:37:52.538Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/69/51/f8b21afd788b8da4be6368cec3777b44151c166b054d3b6dd38349b4323b/hiredis-3.4.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:258741a87fb551e58e5e008ffc989e1bc980b26e2156be365a12b7088b2c48c9", size = 340169, upload-time = "2026-09-22T12:37:54.192Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/cc/2c/0f535418703886f755fb8edba8c4ae174e02663ec61b1029d248afa7d835/hiredis-3.4.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:aa9fef272956109d72a46016f2ca8431d8af36fcf9cd155da53aeba642d201e7", size = 351929, upload-time = "2026-09-22T12:37:55.735Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/b1/4c/d4d16acb0c9d4d4741d4d8c8bd72e7b7e881c9a41867a6b7d24748099a57/hiredis-3.4.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:018fdee902038f74b21e18a6d2fe7819bb63bdaec878d9d5f27280005b778ad7", size = 313508, upload-time = "2026-09-22T12:37:56.879Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/98/b7/b7ceb4f6975a91e8100da63d53b41ff075a706472f095e442c8e998bd521/hiredis-3.4.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2d7282fba5602013d11c068c0f6218c28b67c4c80064f0b3882ffaf0290bbfa9", size = 301438, upload-time = "2026-09-22T12:37:57.994Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/00/dc/1ae6dca5684631595685482a8478179503e54d3be40097b3e345f2aa4e93/hiredis-3.4.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:254c880fbd087527c326ec7672562dde4ac9dfe1c38b2ce923a387858c7a2618", size = 331982, upload-time = "2026-09-22T12:37:59.266Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/da/7c/767f89bdded81ba7be1a185f0718d8662b8e8eee1009ab88e9e1decc6bb4/hiredis-3.4.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:12f05180d1dbc11647a11c967984873dd8baa7f4cdfc4f1b3eff42983fa80d4a", size = 333346, upload-time = "2026-09-22T12:38:00.337Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/3c/38/5715f89fa8d6ca724ae073d92474628525c9751864eda7a3811033a846fa/hiredis-3.4.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc446964ce1ae16ca7689b27991dfb769094531e69f3972e2eaaf03f19037a1e", size = 312252, upload-time = "2026-09-22T12:38:01.518Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/0c/c0/3f1f58df82e59f740d3b3eb76c14144917e9cc7345695b184e88669a60a6/hiredis-3.4.2-cp312-cp312-win32.whl", hash = "sha256:cdd19191555763455d34d63697becfe480a5bb907a33fe90e5505fadfd7bc9ae", size = 38728, upload-time = "2026-09-22T12:38:02.697Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/0b/e2/de4c556ca70124b3f45396ffe2f339a35d80639e1595abc67c3aa09fba4c/hiredis-3.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:51add939c00482b855b9ef6ea1354d4ea942f0c281f32aec514a94f07c3e2148", size = 40530, upload-time = "2026-09-22T12:38:03.656Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/83/c2/cd2deae4d071718303449c376e29ca3b5000489ca7c6e19d1230e4c7c641/hiredis-3.4.2-cp312-cp312-win_arm64.whl", hash = "sha256:9f298b8a2c2af3166a7381c3d9b6a80c3bf2cf38785dbe06bf030882584eb4f8", size = 36929, upload-time = "2026-09-22T12:38:04.553Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpcore"
|
||||
version = "1.0.9"
|
||||
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
{ name = "h11" },
|
||||
]
|
||||
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
|
||||
wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httptools"
|
||||
version = "0.8.0"
|
||||
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
|
||||
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342, upload-time = "2026-05-25T22:17:48.841Z" }
|
||||
wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/14/88/1d21a36da8f5cb0fa49eafd4b169eba5608d57e75bbcf61845cbc6243216/httptools-0.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:880490234c10f70a9830743097e8958d6e4b9f5a0ffc24515023afeef984054d", size = 208247, upload-time = "2026-05-25T22:17:07.843Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/a5/42/cc4feea2945cb3051038f090c9b36bd5b8a9d7f5a894a506a8983e33fd1c/httptools-0.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5931891fb7b441b8a3853cf1b85c82c903defce084dd5f6771ca46e31bf862c5", size = 113064, upload-time = "2026-05-25T22:17:09.136Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/e3/a6/febbb8b8db0f58b38e44ad6cb946e6a255ae49b55f2e8543408fb7501ccd/httptools-0.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b15fc622b0f869d19207c4089a501d9bcc63ca5e071ffdd2f03f922df882dcb2", size = 523851, upload-time = "2026-05-25T22:17:10.106Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/b7/e4/f90a0df0b83beff265b7e3b65f2a4cefd95792d4be0ac3e16049f2acd3c2/httptools-0.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:425f83884fd6343828d8c565f046cb72b6d19063f6924093e11bcd8e1548cd09", size = 518842, upload-time = "2026-05-25T22:17:11.218Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/9e/2d/0c9ac76dd2c893841fbf6498d6acec4f2442e1b7067f6e3e316a80e494e8/httptools-0.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7c3c97f4311c7be57e2986629df89d49cb434dbff78eafcd48c2bff986b15a", size = 501238, upload-time = "2026-05-25T22:17:12.728Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/ca/42/906adc91ae3a5fa9c59c0a2f21c139725bd7e5b41ae6acd485cd14123ebf/httptools-0.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a1afd7c9fbff0d9f5d489c4ce2768bd09c84a46ddefc7161e6aa82ae35c85745", size = 509567, upload-time = "2026-05-25T22:17:13.842Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/05/0b/4240efeb672751ee5b9b380cb0e3fdc050bc05f68adc7a8aefc4fcd9a69a/httptools-0.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:cd96f29b4bab1d42fa6e3d008711c75e0f79e94e06827330160e3a304227f150", size = 90918, upload-time = "2026-05-25T22:17:15.155Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpx"
|
||||
version = "0.28.1"
|
||||
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "certifi" },
|
||||
{ name = "httpcore" },
|
||||
{ name = "idna" },
|
||||
]
|
||||
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
|
||||
wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.20"
|
||||
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
|
||||
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" }
|
||||
wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iniconfig"
|
||||
version = "2.3.1"
|
||||
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
|
||||
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/01/e1/2069291243c926a2ff1cd706c7f3eeb9b62144bf60f77c9fb9ff2fb26bd3/iniconfig-2.3.1.tar.gz", hash = "sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960", size = 21209, upload-time = "2026-10-06T22:48:38.076Z" }
|
||||
wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7", size = 7552, upload-time = "2026-10-06T22:48:36.959Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mako"
|
||||
version = "1.4.3"
|
||||
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
|
||||
dependencies = [
|
||||
{ name = "markupsafe" },
|
||||
]
|
||||
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5a/09/e07c4b5579a79f4b16f8d4f29f6c54514ac787c4ad506b8c4f28a0e6b0bf/mako-1.4.3.tar.gz", hash = "sha256:cd6537fe88d5fec315c55c2f8529bc4ce7a9a352ad7db3eeaa6a66e2dd4ec37a", size = 412799, upload-time = "2026-09-22T20:54:31.509Z" }
|
||||
wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/6d/a0/053d6af3e8f871e0073b4a36732d9e65be77a72e5434c31b94f6af78a6bb/mako-1.4.3-py3-none-any.whl", hash = "sha256:723296007c870bfd6b3f0c3230dba7198096e5269297ebf5e4eff9e7ffa39d4f", size = 80164, upload-time = "2026-09-22T20:54:33.128Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "markupsafe"
|
||||
version = "3.0.4"
|
||||
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
|
||||
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/38/9b/e422a865e1d5d57d0e509b4e0bf1c1a70a7f6382c29a5aa428df994c8bc8/markupsafe-3.0.4.tar.gz", hash = "sha256:2e9ad7dd851bf45fab9f75cbff4cb493fee9979e8d8c7c9c3ee119022518edd6", size = 153777, upload-time = "2026-10-02T23:07:22.29Z" }
|
||||
wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/81/09/4c59d56b8461ae8eb0d8ba34bb25b7e618547044679d58a82ef9b2479fc1/markupsafe-3.0.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:61631e08084be9e21a8967ec3139c7616ed7c5e9368e05c86d1b39562c8a57b6", size = 11658, upload-time = "2026-10-02T23:04:51.876Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/a2/f0/d6613774d86fbf6d145751d43c59875e47a6f9f17daee0aef173bd36d90e/markupsafe-3.0.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0930db9bdc62d22944e10b066448bb65dc9abe9112880c7cab8da54db4284d5f", size = 12050, upload-time = "2026-10-02T23:04:52.931Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/0d/f2/8f18e0b806eb13c1f8d07d917a720831ead54253a6dec011fbc78098a6f8/markupsafe-3.0.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6a45c3d514f2436064db00d7fc8778d888f0236ebfed649b53d13a59e69ad51b", size = 24363, upload-time = "2026-10-02T23:04:53.895Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/60/ce/fa07dbe8a5675558fa36dea033e19995bc783de2dec5f540ccb9030b06aa/markupsafe-3.0.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1e1451fab512d1bcc3dc26988ec1edb0b82c2db909132872cd9356070a6b63df", size = 28525, upload-time = "2026-10-02T23:04:54.905Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/85/40/be87c01f3868ec217f8a2015089d71c22c8c5a75324822e5ed1cdd87210d/markupsafe-3.0.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bd3ce56ae2cbae3ba82b683bc425cd7e48d2ed8b10f3e818186b6f5646d9271c", size = 24733, upload-time = "2026-10-02T23:04:56.229Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/4f/a7/aeedb5140afa41fc74c225e9184ab96723a6e873b6ee1c9fede7283456d8/markupsafe-3.0.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e124f974786f831d6043728e38296969d3579db8896fe004682f5758e613581", size = 22985, upload-time = "2026-10-02T23:04:57.521Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/c3/fc/e91352bb08c6a59da3ef0909d457bf95a5f5908fbf151b30a06d9dbcfbb4/markupsafe-3.0.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c02e8f18bdedba082cef725942ac823b9b60656db07f7e265cb31618dfd00d77", size = 22001, upload-time = "2026-10-02T23:04:58.597Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/5d/f8/bffee5e7d2a3deb59748a797650a48af7e672025cf641a79344a771ad106/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9f098115c247e11d138ab83a28fa0323c77015007ea2df73ba5fd714dfefd67c", size = 23793, upload-time = "2026-10-02T23:04:59.686Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/ed/59/b853d6628ecb4d658e1d637224846d5e9bb4adf4f8df97f3be9f29dce2ec/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d5f93ebbeb8032d47e349328ec8662d973d9b05a70b3c35df1f91fe419b84749", size = 22640, upload-time = "2026-10-02T23:05:00.768Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/09/b2/1506df394f0f075797c418d0301498f49e43be194e3ffcb49e6fe6ccf022/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:64511c54db4e4987aef4c41923235927428729e8174c5dba488429be70a998ed", size = 24074, upload-time = "2026-10-02T23:05:01.813Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/c7/81/5ed69cda630ac69ef60d06c09ba5a7f84ff66a2e28cf986fd5614ab3c6e6/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e1a622f13970d81f95d0c72f9dc090dce9085fccfa4c9f2174377ee32bd15786", size = 21563, upload-time = "2026-10-02T23:05:03.239Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/0c/fe/fb1e79be0fea60aa32602ebefc9c35a82bb42b4df157285ab7dfec12341a/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c9a7f43c0b202b334cc9184af09bb8f21d3a209e038efaf106936fb69e6b026e", size = 23048, upload-time = "2026-10-02T23:05:04.479Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/c8/52/7632a53360671a9b750cdbabaf9cdd89f18b42248b8e4cb42c0b0296e459/markupsafe-3.0.4-cp312-cp312-win32.whl", hash = "sha256:f0ec3b750b59375eab5b0fb2b9254810c00a3375be6d789899f1055a1d556237", size = 14108, upload-time = "2026-10-02T23:05:05.513Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/3f/bf/62495e180b7000aaf30000fff849e933f74264638057176cf46852500adc/markupsafe-3.0.4-cp312-cp312-win_amd64.whl", hash = "sha256:11935df9bf455ed0c04eb87bcd720f02b1fe5e02128a9430f23aed6f93336fc7", size = 14303, upload-time = "2026-10-02T23:05:06.538Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/c5/8e/4c24208776a65878d656996945aacfbfe010d3720d1a98fc0eb8491fc03b/markupsafe-3.0.4-cp312-cp312-win_arm64.whl", hash = "sha256:a4bbd2d87dd233b9fc5812160c3d0ffbe42edc22a26ce0469f58479ede633fe9", size = 14174, upload-time = "2026-10-02T23:05:07.617Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry-api"
|
||||
version = "1.45.1"
|
||||
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/2e/02/6e0ae9cc61bd3169d401077b507b3ebc344745171e1051ab430be012dcd9/opentelemetry_api-1.45.1.tar.gz", hash = "sha256:aa38ed19bcc084ba42782a73255b3582283eced7ad6dddbd6695189e69adfb75", size = 72804, upload-time = "2026-10-06T17:32:58.133Z" }
|
||||
wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/1e/41/f7dcf80b81ee8e71c1a2b59f14208bc723edbd89ed027a73b175abf6348e/opentelemetry_api-1.45.1-py3-none-any.whl", hash = "sha256:b31553efa588ae44bc306f863c785c5333a9ecc091248c6ee68b4b6c87fdedfb", size = 60256, upload-time = "2026-10-06T17:32:33.506Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "26.3"
|
||||
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
|
||||
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" }
|
||||
wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "payroll-backend"
|
||||
version = "0.1.0"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "alembic" },
|
||||
{ name = "captcha" },
|
||||
{ name = "fastapi" },
|
||||
{ name = "pillow" },
|
||||
{ name = "pwdlib", extra = ["bcrypt"] },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pydantic-settings" },
|
||||
{ name = "pyjwt" },
|
||||
{ name = "pymysql", extra = ["rsa"] },
|
||||
{ name = "redis", extra = ["hiredis"] },
|
||||
{ name = "sqlalchemy" },
|
||||
{ name = "starlette" },
|
||||
{ name = "uvicorn", extra = ["standard"] },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "fakeredis" },
|
||||
{ name = "httpx" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-cov" },
|
||||
{ name = "ruff" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "alembic", specifier = ">=1.14" },
|
||||
{ name = "captcha", specifier = ">=0.6" },
|
||||
{ name = "fastapi", specifier = ">=0.115" },
|
||||
{ name = "pillow", specifier = ">=11.0" },
|
||||
{ name = "pwdlib", extras = ["bcrypt"], specifier = ">=0.2" },
|
||||
{ name = "pydantic", specifier = ">=2.9" },
|
||||
{ name = "pydantic-settings", specifier = ">=2.6" },
|
||||
{ name = "pyjwt", specifier = ">=2.10" },
|
||||
{ name = "pymysql", extras = ["rsa"], specifier = ">=1.2.3" },
|
||||
{ name = "redis", extras = ["hiredis"], specifier = ">=5.2" },
|
||||
{ name = "sqlalchemy", specifier = ">=2.0.36" },
|
||||
{ name = "starlette", specifier = ">=1.7,<2" },
|
||||
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.32" },
|
||||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
{ name = "fakeredis", specifier = ">=2.30" },
|
||||
{ name = "httpx", specifier = ">=0.28,<1.0" },
|
||||
{ name = "pytest", specifier = ">=8.3" },
|
||||
{ name = "pytest-cov", specifier = ">=7" },
|
||||
{ name = "ruff", specifier = ">=0.14" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pillow"
|
||||
version = "12.3.0"
|
||||
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
|
||||
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" }
|
||||
wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.6.0"
|
||||
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
|
||||
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
||||
wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pwdlib"
|
||||
version = "0.3.1"
|
||||
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
|
||||
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/78/a6/644f28a21cb68920d06b264e72b864ac9bff2377f4757dd4648b5110b3ad/pwdlib-0.3.1.tar.gz", hash = "sha256:2ffd1955c92190d5bfbdc5071bf7d422568b83f943453ebd3d43d031ccba37b1", size = 216951, upload-time = "2026-08-12T08:38:38.383Z" }
|
||||
wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/6b/2e/51979293b2337f508597d13ee20db1ac1d43e48086b2ed7de6da213ed46b/pwdlib-0.3.1-py3-none-any.whl", hash = "sha256:944fa439198bed8ac3ea63dac76ea4ae43e2a35f4f21e9e725e82d3c2b97d6a5", size = 9272, upload-time = "2026-08-12T08:38:37.483Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
bcrypt = [
|
||||
{ name = "bcrypt" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pycparser"
|
||||
version = "3.1"
|
||||
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
|
||||
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ac/d3/eb1d3bc30dda12f7e69640ae2ac8cb10240b71fb73024ad528b7d2ae73da/pycparser-3.1.tar.gz", hash = "sha256:b3fc6dec06a8b2fefa0ed4ff92285306a5e3be9987bc5603c9edbdc4e492418f", size = 105946, upload-time = "2026-10-08T12:26:34.349Z" }
|
||||
wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/99/ce/b3ae9ee0324d991c860187be2a6ee436d27a3d02397eaddbb101ec901f3d/pycparser-3.1-py3-none-any.whl", hash = "sha256:f09d358c840bd147b79e55f2bc494f18ea869dc897f5852a8f5766b74f787882", size = 48709, upload-time = "2026-10-08T12:26:32.97Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic"
|
||||
version = "2.14.0"
|
||||
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
|
||||
dependencies = [
|
||||
{ name = "annotated-types" },
|
||||
{ name = "pydantic-core" },
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/6b/fb/6e44b63b26efea1cec48c26d8362313310202ef5ed6e7a52f1669e64e2cd/pydantic-2.14.0.tar.gz", hash = "sha256:8a51a7aaddd60f55566d1f07bdd87b92b463903f39a8f26b71a06314cd1548ae", size = 898613, upload-time = "2026-10-08T14:34:48.341Z" }
|
||||
wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/2d/eb/9146591cc819d040475bf7f2be786710c7f7eb8083693bf859728da2ca9c/pydantic-2.14.0-py3-none-any.whl", hash = "sha256:15fab1bea6f1dc5003b54fc2ecab230c1fd1dbade2acd4addc52d81e32416d4b", size = 487109, upload-time = "2026-10-08T14:34:46.864Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-core"
|
||||
version = "2.50.0"
|
||||
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e6/6d/196e8c819e0e934f35a1a33b3530396feadb0af4ca38fe9f995249e55794/pydantic_core-2.50.0.tar.gz", hash = "sha256:84d2d38f7d163c4dec292f379e9de1960c661795442aca6c90d706436cb3749e", size = 513355, upload-time = "2026-10-08T14:30:58.245Z" }
|
||||
wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/e3/d8/e0fe374bc0082dfd337ca319505c9302ce383aeeeacc33d93383edd159e5/pydantic_core-2.50.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:79e8fc9c135ef628c45cb8aa5deef8d21de13d33bb4c59a859fa73d335ceb40a", size = 2222860, upload-time = "2026-10-08T14:26:39.575Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/ef/7d/0a2f829e3e1d809393faab907e3d9307245cd6043ebf54fad439f72f0003/pydantic_core-2.50.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0abe1b44d361b948404b6b2ed80be2583e0077340572e071afa6e0eda4e1de30", size = 2025752, upload-time = "2026-10-08T14:26:41.786Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/67/d4/e2808af12de4809dd6a4b532e3877baa5f1000df51bd8ebb8756450c6c92/pydantic_core-2.50.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ae45853d25a23fba56681d2f9ed41f3e3f12f0a3b2393fefa08ff6406320a1f5", size = 2048432, upload-time = "2026-10-08T14:26:43.593Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/18/58/46ad42a321051585d89f4e563b115015332caf79ba4e96f2785715c7f52d/pydantic_core-2.50.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd841dcf394ff261c26763a9d7be754176d4f1e6d26cb2a5d5331e91b6b56a5f", size = 2173565, upload-time = "2026-10-08T14:26:45.299Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/92/69/541206e657ecea865e5f6d49a4a0d8062586d05da482d9d7ed77fdcdfc86/pydantic_core-2.50.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c2e97985641fe53ad7824d1b5bfeb7990a5ff788c4559ee44d7522642560ddc2", size = 2361974, upload-time = "2026-10-08T14:26:47.024Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/f6/c0/cf4850441d0d3d3736abe5e8b6fcb49a50166aa0651ed8444172f81f4b4f/pydantic_core-2.50.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7987866a2569396e6765c54d643fd6a7b3b234e89ee8d45a3729a7b4b2726145", size = 2440817, upload-time = "2026-10-08T14:26:48.937Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/ca/e2/2f793fa2f1b338aa03124efd4362956c84d7bf1b331dc86050bc0151e67d/pydantic_core-2.50.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f187030fc3d62c668feb0f09e92852e0eb414d7fcefc4748f2e67d245aade37e", size = 2160358, upload-time = "2026-10-08T14:26:50.74Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/db/00/35e314c08e721ec1f070408a4e41926b90347ecd2863c08c78daad5f8a16/pydantic_core-2.50.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d187f43d1c5b844adc871c5b8c22b4aa12a116aaca4e9bd1521bc9ce479aae1f", size = 2231416, upload-time = "2026-10-08T14:26:52.733Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/0f/3a/6f7c36afe35a9eca24e5f74f33ca641ac8cf308aae4ac7af4e585d464a40/pydantic_core-2.50.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:cb4fcaabb28cabf21396a9b816b9afe091f8c776805fdf03e2cbc606b64dfa7c", size = 2242496, upload-time = "2026-10-08T14:26:54.485Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/8d/af/77adf30285836c25f6a927e170df45f8dd4af713c89135b22abcbf3c6d67/pydantic_core-2.50.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:1751e92d56fbb623b937d73985e500b1a2b1053e56f718a6c564660d99bb9cb0", size = 2279176, upload-time = "2026-10-08T14:26:56.432Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/08/37/4e2a05247f82b59337bf45e9bb14aed85e2d83aecd21e49ad6cedf91882b/pydantic_core-2.50.0-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:70df7ff903aea05383298715ea53551c8f27c8f70cbe54ba7f05606cd822e7e4", size = 2450488, upload-time = "2026-10-08T14:26:58.218Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/85/e6/75f25906212ecb94d69a0c8ed16f5c4ac312dc9961c2e93ff2a7fdf9a07a/pydantic_core-2.50.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:24302bf47319a64e5c5c7c29e971d2b7a20a190c64e59035a3df9582dec636fa", size = 2476664, upload-time = "2026-10-08T14:26:59.98Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/75/72/ebb97b3becd0c0f2c722f698d34dec951ab1aa6086edf535f8769b545e3e/pydantic_core-2.50.0-cp312-cp312-win32.whl", hash = "sha256:5dbf9f18c8af11db719e67633be0af556d7d765bee0ca9419bd706fe4b7ed9fe", size = 2102409, upload-time = "2026-10-08T14:27:01.846Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/3f/7b/5ebf3e62f5d0f6e3503ffdfffd7d0c2f8d45690afd37fa0cfdcb1786d7d6/pydantic_core-2.50.0-cp312-cp312-win_amd64.whl", hash = "sha256:1541c334af5d42cb9eb03862a9b4d2cfbfc670fd05172ec51f3ce02d704550f1", size = 2171536, upload-time = "2026-10-08T14:27:03.797Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/58/1c/879ee9d5b63c60e5a077bab74ad93a4a48476090ca9c97534ab4263d0bae/pydantic_core-2.50.0-cp312-cp312-win_arm64.whl", hash = "sha256:b1399f918aea8fb76ffa99b474c9b768accea1f079fde407ee538bec89f20fa7", size = 2162946, upload-time = "2026-10-08T14:27:05.876Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/a9/c8/45e2cbac6379b8cec8cffd7e6aed50754fce13d93f0cff28bbbfe18c1d1c/pydantic_core-2.50.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:b916ff828d604d4311a5639b7b3da51eaea3923833ec3e300a5ee35eade99691", size = 2217049, upload-time = "2026-10-08T14:30:23.532Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/61/8c/b81c4139bff6305e7a98fa7845a99e05df1461d3c33d0fef417e02114ae7/pydantic_core-2.50.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:732efbb50977ab7cf18f01d4c255834aeb14cb2425459f7bff681a1ba3a4ffa1", size = 2039121, upload-time = "2026-10-08T14:30:26.423Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/0f/49/679293741809cf835290bccf80216aebfcf5d94cb84a34709f18b21abfd5/pydantic_core-2.50.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad9185366893714cd4514ae5e1a098227704fa395cb41a7855d75968ecedc826", size = 2109280, upload-time = "2026-10-08T14:30:29.216Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/f5/49/76f167aec9b9d77a82e38131539ca6c6fcf69d720965f76eda20b87b454d/pydantic_core-2.50.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0def1dc09802a790e4a1b3cbc4401f0b53c58f273ce3671df9867f7bdf1fbe20", size = 2269313, upload-time = "2026-10-08T14:30:32.096Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-settings"
|
||||
version = "2.15.0"
|
||||
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
|
||||
dependencies = [
|
||||
{ name = "pydantic" },
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/68/ca/31c57507b13119d7d3cfa1576dad2911a4861e3be07b579395f4e9d393f9/pydantic_settings-2.15.0.tar.gz", hash = "sha256:694b793e84f766ba76a90ebdefc01d0a9a045dab0382bee70393da93712ad117", size = 261253, upload-time = "2026-08-07T09:24:57.419Z" }
|
||||
wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/30/a4/2bffa9f8e804325a09867f0e9d30795c80ea9f8d62560bd1b6ad6220eb2f/pydantic_settings-2.15.0-py3-none-any.whl", hash = "sha256:0ba092c291c94baceb5eff768aa0d56400a457585bc0175925a5a5510303da42", size = 69413, upload-time = "2026-08-07T09:24:55.839Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.21.0"
|
||||
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
|
||||
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" }
|
||||
wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyjwt"
|
||||
version = "2.15.1"
|
||||
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
|
||||
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/43/ea/5194e52748b0da83d71e082d75496eaec6e58f419f5e184786ded517e6a9/pyjwt-2.15.1.tar.gz", hash = "sha256:4f259e80cdfb6b3fc18a7de51fd1ef9ec79652f25019bae68975ca2468a34df8", size = 121252, upload-time = "2026-09-28T18:40:42.598Z" }
|
||||
wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/50/ca/44de4e75f8aadc457f0634be3b542815078ded46dca30efb960edeecad6e/pyjwt-2.15.1-py3-none-any.whl", hash = "sha256:42d59d631f7768a1028a64c7ff581a9bf7519804daf91fc5b6c56e30eec5e193", size = 33860, upload-time = "2026-09-28T18:40:41.429Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pymysql"
|
||||
version = "1.2.3"
|
||||
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
|
||||
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b1/d4/c15b459e25a23767d2f4065ef40968920320f04e302889574310c21c96a3/pymysql-1.2.3.tar.gz", hash = "sha256:d5b288529782e536ae171866df3ca9dc4f6cbfb3cc2f18e6f837fbb90dbc262b", size = 50629, upload-time = "2026-09-17T12:22:49.146Z" }
|
||||
wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/a9/4b/0a906d8184f011ff8dbd4722743783867589b33269d2c5fff238d636fdcb/pymysql-1.2.3-py3-none-any.whl", hash = "sha256:14f1c68e2ed859243ae5ca41ffbe677027fc46bc136a9f0be8a4e928e5e7415a", size = 46740, upload-time = "2026-09-17T12:22:47.826Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
rsa = [
|
||||
{ name = "cryptography" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest"
|
||||
version = "9.1.1"
|
||||
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "iniconfig" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pluggy" },
|
||||
{ name = "pygments" },
|
||||
]
|
||||
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
|
||||
wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pytest-cov"
|
||||
version = "7.1.0"
|
||||
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
|
||||
dependencies = [
|
||||
{ name = "coverage" },
|
||||
{ name = "pluggy" },
|
||||
{ name = "pytest" },
|
||||
]
|
||||
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" }
|
||||
wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-dotenv"
|
||||
version = "1.2.4"
|
||||
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
|
||||
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/74/26/2fbeedb218a787a5eea551c7532cac4e009f83d689dd2faa0d0353473f86/python_dotenv-1.2.4.tar.gz", hash = "sha256:f0d53e69935a851c0dcc78f3ab7aaccd8cabef0b92382b576b824212902873c0", size = 60824, upload-time = "2026-10-01T05:36:10Z" }
|
||||
wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/60/d1/38f3a3405989a89ac18390803e70c6ad7c7760da4f9b83cbeca0c44a0c72/python_dotenv-1.2.4-py3-none-any.whl", hash = "sha256:42269a8a5b3fd54ffa6f3d84b18abed50064717576b4ecf03dc4a55d8aa04fdc", size = 23266, upload-time = "2026-10-01T05:36:08.633Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyyaml"
|
||||
version = "6.0.3"
|
||||
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
|
||||
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
|
||||
wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redis"
|
||||
version = "8.1.0"
|
||||
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
|
||||
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a8/99/604f0b666d4c616d891cf77ebb9db6bb21601344c051aebf1b72b9ff915f/redis-8.1.0.tar.gz", hash = "sha256:6e1a19beef9225c83efd689c7e6b7da2d5215b1f42cd13b7fc3714d0a09c7b25", size = 5254356, upload-time = "2026-07-30T08:51:00.269Z" }
|
||||
wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/66/9d/c5731f6e3608663d4d3656fd8d3aecee8b509c3082818f5a13eae925baea/redis-8.1.0-py3-none-any.whl", hash = "sha256:a4fe1aac3d3b3cc791d4b3d5931c5a956045dc951ee74d1c913ee3ac4d2ee9fb", size = 560618, upload-time = "2026-07-30T08:50:58.497Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
hiredis = [
|
||||
{ name = "hiredis" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.16.10"
|
||||
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
|
||||
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c4/49/23802c45f093eb14bde54b141d2b2f058edfa63a06db7beed047308cc08f/ruff-0.16.10.tar.gz", hash = "sha256:eff4728c4eaae93f0955cd264d24b2ab348e74bf59986ccf282ba6dc16b3b017", size = 4958724, upload-time = "2026-10-01T18:03:21.697Z" }
|
||||
wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/2f/21/ebce22e1d90cdb2cd691026b9c6e9bec6499f0b396089481755b5d49efff/ruff-0.16.10-py3-none-linux_armv6l.whl", hash = "sha256:488b0fe3f3574210e5cf80d9f59b9e3ab17a127a8155de3f307b392589cfb511", size = 10095557, upload-time = "2026-10-01T18:02:36.072Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/cb/98/a54de85876a8b2612bfa0d84c7b9abfb39c6a3354aee7800b09c1649b9e3/ruff-0.16.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e748ff95c934c4e978783b8e687bc174e7bd84e8ad24e3243e1ecfcda5e0282d", size = 10340577, upload-time = "2026-10-01T18:02:39.258Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/9f/16/1a5a4a2657effe4806110f2b907313802f1367fcdbb29e8122766e407fab/ruff-0.16.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3031a4a2e8e7b8a46f70be45f198c35a11ece509a94b80334d8d397a33c67550", size = 9774282, upload-time = "2026-10-01T18:02:41.79Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/6e/fb/470085af734da396e68cd80fb0a3e7c109a459716ae59588c0f6fab8a17d/ruff-0.16.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:494401c86df4c4c25f69b9419605d944467ee98c42fb6ad405ef4fa40b8fb67d", size = 9920895, upload-time = "2026-10-01T18:02:44.458Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/57/de/f10cffe4f88a37ea6615ec460f01bf76f0bc1477737a35d9ee61a630a78b/ruff-0.16.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d203abc0ff2b773ee33d00ab8df0bb67046fbc7c07b119332f08b9b345cf8221", size = 9902707, upload-time = "2026-10-01T18:02:47.041Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/ff/44/3fdcedf83ae60ef837dd239170e480dce606a9142cfa2db911afdf855fd9/ruff-0.16.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bd83d1235a5258d318477bc5b576303974cbdef5df0c01a1bff14efcc23bd12a", size = 10619287, upload-time = "2026-10-01T18:02:49.485Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/c1/62/02e76a5574002153618eafbb70e159468addc72a5d2c488e65cbb4639d2d/ruff-0.16.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bc2610fb269fa56dd8a68669ae470fa6272902668c0fc2ebc3aa112b2633d5b8", size = 11339942, upload-time = "2026-10-01T18:02:52.008Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/6b/c4/cde27d47ad8d4126c587e608c67c46e7feac11763f606d261a1bc8a489e6/ruff-0.16.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3e70175e29cc94c26ea296c80e470180b744b7419026898e58f520c6ab32578e", size = 10934316, upload-time = "2026-10-01T18:02:54.811Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/e4/03/17234145f302a645a123e8c3bb2411ecf4669fbc350de3b0d803230a1729/ruff-0.16.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f33f43a864a8483eebd160e713336c8bab02c934feaff0a33cf5ccb41546d09a", size = 10387968, upload-time = "2026-10-01T18:02:57.497Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/71/29/2493af60240ee7644b38a4b821f5f9c3b5a4fa3178770fe1d0c685217395/ruff-0.16.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:1dfc6f0088149fb6a362c1c446bcbb3fd2157b3852fe2fa68409276eab9ad9b3", size = 10537906, upload-time = "2026-10-01T18:03:00.006Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/2e/9a/f56b28f3b143bb9e518c34fb89ab191b626bca8b70dbf8af0d2e2d473572/ruff-0.16.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6553498afc35f580f036030795810b9e6bcea31604b0fd9e8d352795473042e3", size = 10012938, upload-time = "2026-10-01T18:03:03.006Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/c2/c4/fca37362848ea4d4d80712df13632e645e7c7cfb1bdedf140699ac7a090b/ruff-0.16.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a3b8471dea115d37f123882be852bed13403746d3a76c11de4a19ec5f9ff5a03", size = 9897945, upload-time = "2026-10-01T18:03:05.818Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/7d/c7/e0bc57664d6e0c61fd22f620af260fe9662d7e1ddb320ea7f160e76ef165/ruff-0.16.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:92e59a70bcbd9d3a5483656da906ec28edfdacfce00afd99edb8b4e9d15644be", size = 10333134, upload-time = "2026-10-01T18:03:08.25Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/21/aa/5c9f3b68737c0e4a33a1db5dfab44f7b786d96ca91a7233112ddab1e6dc2/ruff-0.16.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:7ae7375f803b5520dc9f546bed7e3a0acb70b91812e9bb4b19927de22f25b77d", size = 10741702, upload-time = "2026-10-01T18:03:10.775Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/78/fa/0f9c2020dc316c53d983be011720b8157cc052b97e3f4dfa7db6d0f880a6/ruff-0.16.10-py3-none-win32.whl", hash = "sha256:2a12e01cb9156c10c466f63b46eaae5ecea28dfbd21b5836353ae498e7d1349a", size = 10139176, upload-time = "2026-10-01T18:03:13.267Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/99/29/cfb0df9448d4d4ad48c2de029ada9ebd71baa6da983a6c77ee6c6cd0fe82/ruff-0.16.10-py3-none-win_amd64.whl", hash = "sha256:97f2015c92aa97105b0eab19eb5d224884399281cfc5da86a92db4ab5e7fb2ca", size = 10584734, upload-time = "2026-10-01T18:03:16.006Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/fc/05/c16957eb287c3fc062e032619a25868d93d408a844b725bcf514f7a378ff/ruff-0.16.10-py3-none-win_arm64.whl", hash = "sha256:25a65fe998c4e6861ec079ada5826a2fc605e6cbccbe9dcd7fac1f54e791621b", size = 10366440, upload-time = "2026-10-01T18:03:19.04Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sortedcontainers"
|
||||
version = "2.4.0"
|
||||
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
|
||||
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" }
|
||||
wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sqlalchemy"
|
||||
version = "2.1.4"
|
||||
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/1f/44/311bac6b6ef81e4dfd0287d04900108b1f5c00c9761dd3c0a2b7b9d0f86b/sqlalchemy-2.1.4.tar.gz", hash = "sha256:7bd7ad604487daa7eab8716471c29a7185f17b5287ce73bb7bc79fea050d8cfd", size = 10544216, upload-time = "2026-10-07T17:33:59.116Z" }
|
||||
wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/49/5e/cb5b078e007340661b010fa8bd31ce27468f88e09b35266544df4e0c52ca/sqlalchemy-2.1.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f953be9ba26039a24a5205c65d33518b608ce6f4f0f4e9b9c14eaf42a10dfc52", size = 2466469, upload-time = "2026-10-07T18:17:24.049Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/b1/98/44e2fdc5bc053dae559bf4f4eb7967ceecbad162299ecfc8de2edc3fcbe7/sqlalchemy-2.1.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ac64fce94c5b389062d2e3806db5dc780447591e0dfd5ead218c884f0703f2e", size = 4668213, upload-time = "2026-10-07T18:37:42.294Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/08/25/ed2262f964687b06f10c2c98b2dc9c9ed211f7cc11702879969a9ac217e4/sqlalchemy-2.1.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3e5045fb6aadbb0f978ab9b9d8822f7b7a97d2281814e7d13d791155664eace3", size = 4720857, upload-time = "2026-10-07T18:24:46.842Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/4d/d4/fab64c61d5d22ddbb077afd1e6b29b498bdacdf6406a03f53566e7e01686/sqlalchemy-2.1.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e3a026436c51f296aa1d01243909a3b76490950e927824b10899a083cc26e7c3", size = 4369351, upload-time = "2026-10-07T18:59:45.483Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/d9/e4/33413f0fafbcf3b332320aac2c1e40f3b4f17e56359a9474cb10de4bee8b/sqlalchemy-2.1.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:71040390ef01c85e9d26e5c83cb0c5942dcc8725c49186430af160ce2f54234d", size = 4591496, upload-time = "2026-10-07T18:37:44.433Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/bb/65/19821440cbd5c93da053d627b3e402eff11ff252bfae37700645b3c155a4/sqlalchemy-2.1.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:07c60abaffb980b7382f2c75be8a5279c2b5df2626a0f5d751dd942799bf3b5c", size = 4362379, upload-time = "2026-10-07T18:59:48.278Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/01/e3/168a0f93efd6ec40f59645a7e45ab08918e0bc8ecf07656e4ca09acdcc30/sqlalchemy-2.1.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a577e2127e52b0fe2bc54c73abb375a20ffe6f59fbc5568ccafc233f5bfcf8ef", size = 4667997, upload-time = "2026-10-07T18:24:48.72Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/54/79/0a852ef65864acd8d577d7aa6f67146167382bd6faee7a7586b9e6e28275/sqlalchemy-2.1.4-cp312-cp312-win32.whl", hash = "sha256:6c79e0c824d51c586757ecd342160bbdede9010df04bb71b9bbfffd5c7b6ee29", size = 2376664, upload-time = "2026-10-07T18:25:00.637Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/27/b9/a5934263bb1d712f743289ca224ab3b87e3570ac157802291e37ab85d365/sqlalchemy-2.1.4-cp312-cp312-win_amd64.whl", hash = "sha256:dffa69d2f3ba1933c1c1882dbef8fb3231b33eb19263e8b8c5cea24995071f06", size = 2429357, upload-time = "2026-10-07T18:25:02.565Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/a5/fa/a2323d81384ff214aa189057b7455b63623e66f28208b982e86c3cb042f5/sqlalchemy-2.1.4-cp312-cp312-win_arm64.whl", hash = "sha256:e30524ae24e31d83e1b5f734862882c442f4158e3566f2c5f5e9bd3c659bb517", size = 2388756, upload-time = "2026-10-07T18:22:36.025Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/f7/62/dbf11a262f6fbb41390cab2d8e47a30ec0961018b68201607b599dd489f5/sqlalchemy-2.1.4-py3-none-any.whl", hash = "sha256:0b96edcc2cd60fe1e35f67a46f4eb076e57297841b9eae949ac5f196593f00a7", size = 2054935, upload-time = "2026-10-07T18:01:16.403Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "starlette"
|
||||
version = "1.7.0"
|
||||
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7b/2b/3850dc6bf7ef71b088962eba31dafc6cffd2f96e577ebb0bb316df96da3e/starlette-1.7.0.tar.gz", hash = "sha256:c79f74ea63cff761804fbbfb182f1e0b440c2d07b164d24700c5a1bab5d6ff5d", size = 2736246, upload-time = "2026-09-23T07:30:26.35Z" }
|
||||
wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/4e/d6/1ec1b290f9e0fb067899b61e1d37a30c923068bad260b216dbe37a7d2967/starlette-1.7.0-py3-none-any.whl", hash = "sha256:67f8e99895493dd2911a03f11314af6ceebeae4e704bb9f43dfc6a9db151c93e", size = 78980, upload-time = "2026-09-23T07:30:24.567Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.16.0"
|
||||
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
|
||||
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
|
||||
wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-inspection"
|
||||
version = "0.4.4"
|
||||
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" }
|
||||
wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uvicorn"
|
||||
version = "0.54.0"
|
||||
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
{ name = "h11" },
|
||||
]
|
||||
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/da/34/30e9280707135d2cfc589dfff3cb796bd07a3aeb1a3e415ba09dd89d7bb4/uvicorn-0.54.0.tar.gz", hash = "sha256:a2e33cbfaa0306f8e6b0c13e0cb89d7d7a2da3e62b90c66e18c33d9807b28620", size = 112283, upload-time = "2026-09-25T06:52:37.601Z" }
|
||||
wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/38/0c/b54a4fdd7f90a3af8b02ebc9ce6712c2c208b7926a2f7bad95c33ebbe943/uvicorn-0.54.0-py3-none-any.whl", hash = "sha256:505bdb0f318731d45f1f712071fc781a8981f6847a31c902c9f5e652d4f67faf", size = 87427, upload-time = "2026-09-25T06:52:35.829Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
standard = [
|
||||
{ name = "httptools" },
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" },
|
||||
{ name = "watchfiles" },
|
||||
{ name = "websockets" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uvloop"
|
||||
version = "0.23.0"
|
||||
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
|
||||
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/fa/42/02c739ce85fb2ee8d99212c61417da8140c6b87e9d97c430bea520d76044/uvloop-0.23.0.tar.gz", hash = "sha256:28d160f51ab4da3b187063652e643dea6831072add4adc1e6d62afbe73b6be27", size = 2559185, upload-time = "2026-10-01T03:17:04.4Z" }
|
||||
wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/05/98/04e766a6de99e6f7f955ecb7829e8d5a557de3427cb85be2236de54dda0c/uvloop-0.23.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:93935ab27b6eaef4c3e5489aebc84284f0644592f7ab516df60ee1b27eaf5eb3", size = 1393055, upload-time = "2026-10-01T03:15:42.526Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/33/8a/499e7b863a848ede009539bce39806b66205da5f8779354228e785601144/uvloop-0.23.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4448e9124537620f9c25d004c227bb5104440b58955c19bbd312d910af919a63", size = 768909, upload-time = "2026-10-01T03:15:43.974Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/3d/95/a880f8ce3b87ac5b307c354e8ee480be4658d24bf01f87921d57e3530b4a/uvloop-0.23.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7548ede3ee908cfabc0d068106e303a9a2d811af959cdf6ab85676344cedcda", size = 4419106, upload-time = "2026-10-01T03:15:45.551Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/51/27/c1d2f9fa977f8f42ea294604166df10e0027e6dc6cd17f85ede386c9bf36/uvloop-0.23.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:090865d8ce7a03986755a3ce711b7dd0d4b44eb14ab74368b717f3fad1180208", size = 4532597, upload-time = "2026-10-01T03:15:47.258Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/42/dd/2cb6a2c8a30ca55c07a882dd4ae4ceae0fa7d8c15b25b3b7cb9a4b6cf4ca/uvloop-0.23.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bd6f2f81c7b9da99d301c0b16b82044e76fe887086e42e1590ecf520b94dbdac", size = 4230048, upload-time = "2026-10-01T03:15:49.119Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/f4/52/29989cbaa4022dc4ef35c1dd60a4ab989e4c2065f341ed483ae71d2bd950/uvloop-0.23.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a6ac96da66c35bf789bdcde78a88dc7d56b7907d8379648c54adc1c61594575d", size = 4394152, upload-time = "2026-10-01T03:15:50.829Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "watchfiles"
|
||||
version = "1.3.0"
|
||||
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
]
|
||||
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b3/68/e6aa0b77d217b31f8f486ec0cdfe5e00e6e38dc0be657e7d85819b9faf0a/watchfiles-1.3.0.tar.gz", hash = "sha256:99aee4a07847c06820765fd7b1b49ceac4f3f711ccb7d104655a33231de1c207", size = 108119, upload-time = "2026-09-21T09:08:55.664Z" }
|
||||
wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/68/fa/c0b840d5d8bafe640925408ce11095948c8945bb61be1e67d0ab629b872a/watchfiles-1.3.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:000b9688fc8133037a8b075c8ebf98f32844ff8964dda61e85c1db547dafc441", size = 399703, upload-time = "2026-09-21T09:08:13.518Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/c7/8a/894799b485fe9473ad10422a0d9668e53fb78e3a2b5cc6061159572844ad/watchfiles-1.3.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:bbc1198edfdc90fda0600f825aa94150f428dfcbf8138746f55998e0e660d64c", size = 397647, upload-time = "2026-09-21T09:08:15.16Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/5a/61/f277cd5ead05f5d37b98b41fbdd0c943ee0c04d49738f62e902cb291cf43/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4af9c464d410e8c44b58ddf1cbec7c3a02c1cb2c6c80df9c74659ba2fbda0ab5", size = 454994, upload-time = "2026-09-21T09:08:16.587Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/46/66/ea4c01382975e53b1ac1b0a0ea1b353416b8127fb6e04f5c1c313119aab6/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb94e40b9a0db19636b5e3fed0a9803452ecc7381df13b385272abb779ee83ca", size = 460105, upload-time = "2026-09-21T09:08:17.961Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/31/c3/1a501b597817096c9831c255f6270c124ce0e2faeb74124be24dcc93b86d/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7398930a2b76b9dc67a4e6b8bfc7baf30946892a86f417a3920c07bdd6febc30", size = 493761, upload-time = "2026-09-21T09:08:19.327Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/5a/16/77352d05e152c7786a016e9f1decdb4507e8aa5bcf3343c72142a0413d14/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:025b108f2d5cc2799cb941f79380df3e8b22bfb589c218e801767fe78d598c95", size = 577500, upload-time = "2026-09-21T09:08:20.723Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/95/fe/81001c55b24466fe9911135e50269fd3a29f43e446627a92adfab54553b6/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:be9ef3cd403d756a304a0a08a112163c63da7ec72c8c9d083c98818291ff8f29", size = 469209, upload-time = "2026-09-21T09:08:22.346Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/8a/cf/e3ad894ed5909a7c44b0707e89bc25d19a3d78b3a7aec846119443b6ae56/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b5768b49e426fd5b550b012c866db347cdf15c398ef98dc557b6e6b72fa74cd1", size = 458353, upload-time = "2026-09-21T09:08:23.7Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/7f/f5/2f106829e6e2bc91ad74614730f6df484202b9a4d20d927dbf2f08d37d44/watchfiles-1.3.0-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:d36c72fcdc08f143d87316a4c4c3b064e340da2c7383d39803950740ed418935", size = 468707, upload-time = "2026-09-21T09:08:25.167Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/15/37/2741ab776b978669c1f3bba1e573f1a184f727ab8b4d3a6d9c61090800ed/watchfiles-1.3.0-cp310-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:f0c9865240e065a2247f4b5248528ae3d01e0e8ae071462c250c884dc16e3ee3", size = 632510, upload-time = "2026-09-21T09:08:26.532Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/48/64/db4a4684275fe9b696ee5118c194705c1423b3d42181c3f8ee70194d5c73/watchfiles-1.3.0-cp310-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:380f513d26cc2e598266b88d66456e07d67ef4be8f0f435a1154d9c44b43d509", size = 661723, upload-time = "2026-09-21T09:08:28.004Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/52/80/95ddb24f6a2f595c031c24432ab7b8aa7d6d1ada0c6d63a552166e06e8d1/watchfiles-1.3.0-cp310-abi3-win32.whl", hash = "sha256:509d9f74d2bec5c1f4cd868ddcfe0c9601f7ea53066dfcb19d8ede742c4de661", size = 277761, upload-time = "2026-09-21T09:08:29.335Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/48/a1/1c4a1b3c8030cd03206232612be8a6e8799093490c220b0fcb935d02b75a/watchfiles-1.3.0-cp310-abi3-win_amd64.whl", hash = "sha256:1acabde19b67e673274e89a04d613b7fb1f122e2c4b8ec9a581e59c149264b61", size = 290643, upload-time = "2026-09-21T09:08:30.45Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/82/e0/d2db577c632d26d08078c4390dd870b8555c4e558df25d22270f0166c71f/watchfiles-1.3.0-cp310-abi3-win_arm64.whl", hash = "sha256:d978cc1dd7ba44f5590d7de74f7a9c8be7262d9e469c2c0efb8bbfc2574321dc", size = 285510, upload-time = "2026-09-21T09:08:31.561Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "websockets"
|
||||
version = "17.2"
|
||||
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
|
||||
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/01/89/3f825ab71c242fffb62ea8fe638741c290f62f8d7aadf8125ff897747af3/websockets-17.2.tar.gz", hash = "sha256:36c2fb94c990cc2545143b12690e2de6c16300f9dbe5b4f33fa300cf57dc8792", size = 188355, upload-time = "2026-10-03T14:56:53.5Z" }
|
||||
wheels = [
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/bc/de/87854af9b38fe4738fd85f7f21c5b49558ae20aec898880894e435f33375/websockets-17.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:916ebdfd82e7fc68041d36b2b5f60361b9abce1e087454da15f8bd004839e090", size = 217757, upload-time = "2026-10-03T14:53:23.029Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/3a/2e/1e80b5efa41544f626d56bd15ccb53dbfc56bf28bf80ab9cd6f82c4b1d20/websockets-17.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3621f3686397708b8eeabfd0a9d75267c1f29a7537d2fe31e65d099e71587fa4", size = 215439, upload-time = "2026-10-03T14:53:24.531Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/3b/6e/82c78b595aee05be76a7ee78539323da1593c1848e4fef51c704c696568f/websockets-17.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a81e19710d48da88653473b6b9c366d47e99fe4f58e37ce415be47966748f31f", size = 215703, upload-time = "2026-10-03T14:53:26.226Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/f8/c4/905ef6aa80423c03dba99e1e26fc0acf63a2a9a6a2d9e8c0e6a63caaf952/websockets-17.2-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f2731f9067976c8c4127212c0d2f2ada42d497d935e470419e029802365b12bb", size = 225023, upload-time = "2026-10-03T14:53:27.744Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/03/c0/a6d8be9c43e4456fb9597fdf8b5e0ce1f0a5df41503acce6d869536e4e23/websockets-17.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6627b913b8586b1c06db9516b31dd0dfbc621de3bb9312616d92a7e44f268a5b", size = 225299, upload-time = "2026-10-03T14:53:29.171Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/2f/d4/976d34b5491258b0a86c2ce9b9aabb9fdd68919ffd7fe65999c14a502a98/websockets-17.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0198c4ec6a3406a2f7557c032967de426474c2c995c81076585e09d29a9f407b", size = 226540, upload-time = "2026-10-03T14:53:31.635Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/83/2f/c4cfd42f53c697a8ed123fd82b8f85fcd13b6360d47f9f1d1d45d6ec6627/websockets-17.2-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:88c6a42c2632ff469e84155e44f6ed92cb15ccb047bf5fcb59225ae5a12fd33d", size = 229371, upload-time = "2026-10-03T14:53:33.061Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/e7/55/9a221b29c6232ff9282eecb2fc102402cb9e42a3479264db0e5fc4fe6835/websockets-17.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:eb0023e6cdb4b8ece0b33875188dd16104ad8c335361d396a98394f99e30ff7a", size = 227173, upload-time = "2026-10-03T14:53:34.502Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/8f/07/125e6d010c56c253d3d2b93cabaea0f96d33898151a16b49066a594acecf/websockets-17.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c1c09d5d4646eb96bda2cfb97493bcea21a0956a981de116e6b1f4a9de07f3fd", size = 225929, upload-time = "2026-10-03T14:53:36.071Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/23/a8/aad3bd902aee84e1b261ad6ab83b405e4a564af43101b8ad1dc0293ff4f4/websockets-17.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0360c4dc13ac569cc245e0efa2f4d4b1e4733d24c47b8ab3f3747227b1356348", size = 223167, upload-time = "2026-10-03T14:53:37.528Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/1f/f4/ec8ab9be1a5310b4fea829f088c7aa2b7a58b61d34bce1b2a9338635ff12/websockets-17.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:76693a16dead737946b651375ee3109d7db7ad9569a1c55c60aaed3ef85cfcc6", size = 225974, upload-time = "2026-10-03T14:53:38.959Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/65/45/ba6503f8257d3f98b0f07ebaad0fd099c9023eae744fd5b775416743597e/websockets-17.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:77a42cc507993ec5471b5283f7eef869239173b6000031543e3938a86d1af0fd", size = 224581, upload-time = "2026-10-03T14:53:40.496Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/d0/45/05cca59a876c6776727d96fc7ba59e0b6f9aa496afbf13e7e04ad0b63678/websockets-17.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3bbc5543e39ee025d524077c5c15c2d67bc11c9f6676afe5b531839e24d701f6", size = 225347, upload-time = "2026-10-03T14:53:42.061Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/1c/00/cf0e43292ae949b13f67535be84317102891d69fd1986ec2bf2ead42747b/websockets-17.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8da58558bfb0ca6ccac2419773521f1111e40654038b1afabdfc69c02cb82614", size = 226457, upload-time = "2026-10-03T14:53:43.575Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/79/0d/9a5c61a18f0cc9876d94c70ccb3daf7614a9fee56abbb37c0e64e757fb96/websockets-17.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:01420cb1cb47433e8e7075d32cb8017ad3ffed0654bd1e48c0251b865920dec3", size = 224011, upload-time = "2026-10-03T14:53:45.077Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/34/ed/991c1ab80ab2ce40e1c939fef6fa8f971c3ef3b21caf988a7a107e0ad27d/websockets-17.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:c49c9edd47d0e44d360299e2d8865e2950d2fcf1b4098782c9d7dcd070919e5a", size = 224990, upload-time = "2026-10-03T14:53:46.8Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/e7/7a/363c835d17923e967fb66376188e67b9a261c85d826a0cd5e4dd3471221d/websockets-17.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:96f6c8d0fe21930d1f982bfce2382789d2e8d005d2ab63d21280660f95ef8fe1", size = 225265, upload-time = "2026-10-03T14:53:48.382Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/c8/90/6c51f6d78636bd1cd6781fae8ea5ea7bf1d5b4059354f3c1f5f8de793338/websockets-17.2-cp312-cp312-win32.whl", hash = "sha256:b25659ab2d655d742701487d5591e3f98e8f8b329fc999e05e3d59691ab344a1", size = 218228, upload-time = "2026-10-03T14:53:49.867Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/c6/2a/90008411c652dcfae34345a2169f4becd066a4ba71eebfa8dd801e0445e1/websockets-17.2-cp312-cp312-win_amd64.whl", hash = "sha256:faa763b677e96f1beccc6b4d7e8c079dfeed2f249f57a19debc321b519ee64ec", size = 218528, upload-time = "2026-10-03T14:53:51.486Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/1f/a1/b8ad6c17f8e75ba2215422fffe0d7f0c4b690dcff1c47c0473db0d253d51/websockets-17.2-cp312-cp312-win_arm64.whl", hash = "sha256:63499fc49efe48bccc2fca40723bc7adb198866cbe159093dd979905316994b6", size = 218457, upload-time = "2026-10-03T14:53:52.938Z" },
|
||||
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/8a/58/835cd51934d6780fa586f275b5d9901eead6d81569b4343b3767cdbaae4c/websockets-17.2-py3-none-any.whl", hash = "sha256:6aa59f0ef92e796b2db6f5f26550c4713c0e4036899fadf02f55e2ed4db0b7ae", size = 211883, upload-time = "2026-10-03T14:56:51.898Z" },
|
||||
]
|
||||
152
db/init/init-dev.sql
Normal file
152
db/init/init-dev.sql
Normal file
@ -0,0 +1,152 @@
|
||||
-- ============================================================================
|
||||
-- 开发环境初始化脚本
|
||||
--
|
||||
-- 执行时机:docker-entrypoint-initdb.d —— **仅在 MySQL 数据卷为空时的首次启动执行**
|
||||
-- 数据卷已存在时修改本文件不会生效,需要:
|
||||
-- docker compose -f docker/docker-compose.dev.yml down -v && up -d
|
||||
--
|
||||
-- 内容 = V1 迁移的完整结构(额外带 DROP,开发环境允许重置)
|
||||
-- 与 db/migration/V1__init_rbac_and_login_logs.sql 必须保持一致,
|
||||
-- 由 backend/tests/integration/test_schema_sync.py 自动校验,防止两份 DDL 漂移
|
||||
--
|
||||
-- 结尾写入 alembic_version 记录,使 `alembic upgrade head` 在本库上成为空操作
|
||||
-- ============================================================================
|
||||
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
-- 删除顺序遵循外键依赖:先删子表
|
||||
DROP TABLE IF EXISTS `role_permissions`;
|
||||
DROP TABLE IF EXISTS `user_roles`;
|
||||
DROP TABLE IF EXISTS `login_logs`;
|
||||
DROP TABLE IF EXISTS `permissions`;
|
||||
DROP TABLE IF EXISTS `users`;
|
||||
DROP TABLE IF EXISTS `roles`;
|
||||
DROP TABLE IF EXISTS `alembic_version`;
|
||||
|
||||
-- 服务端字符集应为 utf8mb4 / utf8mb4_general_ci(与 compose 的启动参数一致)
|
||||
SELECT @@character_set_server AS charset, @@collation_server AS collation;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 权限表
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE permissions (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
|
||||
perm_code VARCHAR(128) NOT NULL COMMENT '权限编码,如 system:user:create',
|
||||
perm_name VARCHAR(64) NOT NULL COMMENT '权限名称',
|
||||
perm_type TINYINT NOT NULL COMMENT '类型:1菜单 2按钮 3接口' DEFAULT '1',
|
||||
parent_id BIGINT UNSIGNED COMMENT '父权限ID(permissions.id),顶级为空',
|
||||
sort_order INTEGER NOT NULL COMMENT '显示排序' DEFAULT '0',
|
||||
status TINYINT NOT NULL COMMENT '状态:1启用 0禁用' DEFAULT '1',
|
||||
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
|
||||
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT pk_permissions PRIMARY KEY (id),
|
||||
CONSTRAINT fk_permissions_parent_id FOREIGN KEY(parent_id) REFERENCES permissions (id) ON DELETE CASCADE,
|
||||
CONSTRAINT uk_permissions_perm_code UNIQUE (perm_code)
|
||||
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='权限表' COLLATE utf8mb4_general_ci;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 角色表
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE roles (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
|
||||
role_code VARCHAR(32) NOT NULL COMMENT '角色编码:admin系统管理员 user普通用户',
|
||||
role_name VARCHAR(64) NOT NULL COMMENT '角色名称',
|
||||
description VARCHAR(255) COMMENT '角色说明',
|
||||
sort_order INTEGER NOT NULL COMMENT '显示排序' DEFAULT '0',
|
||||
status TINYINT NOT NULL COMMENT '状态:1启用 0禁用' DEFAULT '1',
|
||||
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
|
||||
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT pk_roles PRIMARY KEY (id),
|
||||
CONSTRAINT uk_roles_role_code UNIQUE (role_code)
|
||||
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='角色表' COLLATE utf8mb4_general_ci;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 系统用户表
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE users (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
|
||||
username VARCHAR(64) NOT NULL COMMENT '登录用户名',
|
||||
password VARCHAR(100) NOT NULL COMMENT '密码哈希(BCrypt,固定60字符)',
|
||||
real_name VARCHAR(64) COMMENT '真实姓名',
|
||||
phone VARCHAR(20) COMMENT '手机号',
|
||||
email VARCHAR(128) COMMENT '邮箱',
|
||||
status TINYINT NOT NULL COMMENT '状态:1启用 0禁用' DEFAULT '1',
|
||||
token_version INTEGER NOT NULL COMMENT '令牌版本号:改密/禁用/强制下线时+1,使已签发Token立即失效' DEFAULT '1',
|
||||
last_login_time DATETIME COMMENT '最后登录时间',
|
||||
remark VARCHAR(255) COMMENT '备注',
|
||||
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
|
||||
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT pk_users PRIMARY KEY (id),
|
||||
CONSTRAINT uk_users_username UNIQUE (username)
|
||||
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='系统用户表' COLLATE utf8mb4_general_ci;
|
||||
|
||||
CREATE INDEX idx_users_phone ON users (phone);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 登录日志表
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE login_logs (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
|
||||
user_id BIGINT UNSIGNED COMMENT '用户ID(账号不存在时为空)',
|
||||
username VARCHAR(64) NOT NULL COMMENT '登录用户名(原样记录)',
|
||||
ip VARCHAR(45) COMMENT '客户端IP(45位兼容IPv6)',
|
||||
user_agent VARCHAR(255) COMMENT '客户端User-Agent(截断存储)',
|
||||
success TINYINT NOT NULL COMMENT '是否成功:1成功 0失败' DEFAULT '0',
|
||||
fail_reason VARCHAR(64) COMMENT '失败原因:验证码错误/用户名或密码错误/账号锁定/账号禁用',
|
||||
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT pk_login_logs PRIMARY KEY (id),
|
||||
CONSTRAINT fk_login_logs_user_id FOREIGN KEY(user_id) REFERENCES users (id) ON DELETE SET NULL
|
||||
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='登录日志表' COLLATE utf8mb4_general_ci;
|
||||
|
||||
CREATE INDEX idx_login_logs_create_time ON login_logs (create_time);
|
||||
|
||||
CREATE INDEX idx_login_logs_username ON login_logs (username);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 角色权限关联表
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE role_permissions (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
|
||||
role_id BIGINT UNSIGNED NOT NULL COMMENT '角色ID(roles.id)',
|
||||
permission_id BIGINT UNSIGNED NOT NULL COMMENT '权限ID(permissions.id)',
|
||||
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT pk_role_permissions PRIMARY KEY (id),
|
||||
CONSTRAINT fk_role_permissions_permission_id FOREIGN KEY(permission_id) REFERENCES permissions (id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_role_permissions_role_id FOREIGN KEY(role_id) REFERENCES roles (id) ON DELETE CASCADE,
|
||||
CONSTRAINT uk_role_permissions_role_id_permission_id UNIQUE (role_id, permission_id)
|
||||
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='角色权限关联表' COLLATE utf8mb4_general_ci;
|
||||
|
||||
CREATE INDEX idx_role_permissions_permission_id ON role_permissions (permission_id);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 用户角色关联表
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE user_roles (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
|
||||
user_id BIGINT UNSIGNED NOT NULL COMMENT '用户ID(users.id)',
|
||||
role_id BIGINT UNSIGNED NOT NULL COMMENT '角色ID(roles.id)',
|
||||
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT pk_user_roles PRIMARY KEY (id),
|
||||
CONSTRAINT fk_user_roles_role_id FOREIGN KEY(role_id) REFERENCES roles (id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_user_roles_user_id FOREIGN KEY(user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||
CONSTRAINT uk_user_roles_user_id_role_id UNIQUE (user_id, role_id)
|
||||
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='用户角色关联表' COLLATE utf8mb4_general_ci;
|
||||
|
||||
CREATE INDEX idx_user_roles_role_id ON user_roles (role_id);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 字典数据:角色(与 V1 迁移中的初始化数据一致)
|
||||
-- ---------------------------------------------------------------------------
|
||||
INSERT INTO roles (role_code, role_name, description, sort_order) VALUES ('admin', '系统管理员', '全部权限,含计提参数与系数表配置', 1);
|
||||
|
||||
INSERT INTO roles (role_code, role_name, description, sort_order) VALUES ('user', '普通用户', '业务操作', 2);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- 迁移版本记录:标记 V1 已应用,使 alembic upgrade head 成为空操作
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE alembic_version (
|
||||
version_num VARCHAR(32) NOT NULL,
|
||||
CONSTRAINT alembic_version_pkc PRIMARY KEY (version_num)
|
||||
)CHARSET=utf8mb4 ENGINE=InnoDB COLLATE utf8mb4_general_ci;
|
||||
|
||||
INSERT INTO alembic_version (version_num) VALUES ('6165691326d4');
|
||||
103
db/migration/V1__init_rbac_and_login_logs.sql
Normal file
103
db/migration/V1__init_rbac_and_login_logs.sql
Normal file
@ -0,0 +1,103 @@
|
||||
CREATE TABLE alembic_version (
|
||||
version_num VARCHAR(32) NOT NULL,
|
||||
CONSTRAINT alembic_version_pkc PRIMARY KEY (version_num)
|
||||
);
|
||||
|
||||
-- Running upgrade -> 6165691326d4
|
||||
|
||||
CREATE TABLE permissions (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
|
||||
perm_code VARCHAR(128) NOT NULL COMMENT '权限编码,如 system:user:create',
|
||||
perm_name VARCHAR(64) NOT NULL COMMENT '权限名称',
|
||||
perm_type TINYINT NOT NULL COMMENT '类型:1菜单 2按钮 3接口' DEFAULT '1',
|
||||
parent_id BIGINT UNSIGNED COMMENT '父权限ID(permissions.id),顶级为空',
|
||||
sort_order INTEGER NOT NULL COMMENT '显示排序' DEFAULT '0',
|
||||
status TINYINT NOT NULL COMMENT '状态:1启用 0禁用' DEFAULT '1',
|
||||
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
|
||||
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT pk_permissions PRIMARY KEY (id),
|
||||
CONSTRAINT fk_permissions_parent_id FOREIGN KEY(parent_id) REFERENCES permissions (id) ON DELETE CASCADE,
|
||||
CONSTRAINT uk_permissions_perm_code UNIQUE (perm_code)
|
||||
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='权限表' COLLATE utf8mb4_general_ci;
|
||||
|
||||
CREATE TABLE roles (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
|
||||
role_code VARCHAR(32) NOT NULL COMMENT '角色编码:admin系统管理员 user普通用户',
|
||||
role_name VARCHAR(64) NOT NULL COMMENT '角色名称',
|
||||
description VARCHAR(255) COMMENT '角色说明',
|
||||
sort_order INTEGER NOT NULL COMMENT '显示排序' DEFAULT '0',
|
||||
status TINYINT NOT NULL COMMENT '状态:1启用 0禁用' DEFAULT '1',
|
||||
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
|
||||
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT pk_roles PRIMARY KEY (id),
|
||||
CONSTRAINT uk_roles_role_code UNIQUE (role_code)
|
||||
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='角色表' COLLATE utf8mb4_general_ci;
|
||||
|
||||
CREATE TABLE users (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
|
||||
username VARCHAR(64) NOT NULL COMMENT '登录用户名',
|
||||
password VARCHAR(100) NOT NULL COMMENT '密码哈希(BCrypt,固定60字符)',
|
||||
real_name VARCHAR(64) COMMENT '真实姓名',
|
||||
phone VARCHAR(20) COMMENT '手机号',
|
||||
email VARCHAR(128) COMMENT '邮箱',
|
||||
status TINYINT NOT NULL COMMENT '状态:1启用 0禁用' DEFAULT '1',
|
||||
token_version INTEGER NOT NULL COMMENT '令牌版本号:改密/禁用/强制下线时+1,使已签发Token立即失效' DEFAULT '1',
|
||||
last_login_time DATETIME COMMENT '最后登录时间',
|
||||
remark VARCHAR(255) COMMENT '备注',
|
||||
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
|
||||
update_time DATETIME NOT NULL COMMENT '更新时间' DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
CONSTRAINT pk_users PRIMARY KEY (id),
|
||||
CONSTRAINT uk_users_username UNIQUE (username)
|
||||
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='系统用户表' COLLATE utf8mb4_general_ci;
|
||||
|
||||
CREATE INDEX idx_users_phone ON users (phone);
|
||||
|
||||
CREATE TABLE login_logs (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
|
||||
user_id BIGINT UNSIGNED COMMENT '用户ID(账号不存在时为空)',
|
||||
username VARCHAR(64) NOT NULL COMMENT '登录用户名(原样记录)',
|
||||
ip VARCHAR(45) COMMENT '客户端IP(45位兼容IPv6)',
|
||||
user_agent VARCHAR(255) COMMENT '客户端User-Agent(截断存储)',
|
||||
success TINYINT NOT NULL COMMENT '是否成功:1成功 0失败' DEFAULT '0',
|
||||
fail_reason VARCHAR(64) COMMENT '失败原因:验证码错误/用户名或密码错误/账号锁定/账号禁用',
|
||||
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT pk_login_logs PRIMARY KEY (id),
|
||||
CONSTRAINT fk_login_logs_user_id FOREIGN KEY(user_id) REFERENCES users (id) ON DELETE SET NULL
|
||||
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='登录日志表' COLLATE utf8mb4_general_ci;
|
||||
|
||||
CREATE INDEX idx_login_logs_create_time ON login_logs (create_time);
|
||||
|
||||
CREATE INDEX idx_login_logs_username ON login_logs (username);
|
||||
|
||||
CREATE TABLE role_permissions (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
|
||||
role_id BIGINT UNSIGNED NOT NULL COMMENT '角色ID(roles.id)',
|
||||
permission_id BIGINT UNSIGNED NOT NULL COMMENT '权限ID(permissions.id)',
|
||||
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT pk_role_permissions PRIMARY KEY (id),
|
||||
CONSTRAINT fk_role_permissions_permission_id FOREIGN KEY(permission_id) REFERENCES permissions (id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_role_permissions_role_id FOREIGN KEY(role_id) REFERENCES roles (id) ON DELETE CASCADE,
|
||||
CONSTRAINT uk_role_permissions_role_id_permission_id UNIQUE (role_id, permission_id)
|
||||
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='角色权限关联表' COLLATE utf8mb4_general_ci;
|
||||
|
||||
CREATE INDEX idx_role_permissions_permission_id ON role_permissions (permission_id);
|
||||
|
||||
CREATE TABLE user_roles (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '主键ID' AUTO_INCREMENT,
|
||||
user_id BIGINT UNSIGNED NOT NULL COMMENT '用户ID(users.id)',
|
||||
role_id BIGINT UNSIGNED NOT NULL COMMENT '角色ID(roles.id)',
|
||||
create_time DATETIME NOT NULL COMMENT '创建时间' DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT pk_user_roles PRIMARY KEY (id),
|
||||
CONSTRAINT fk_user_roles_role_id FOREIGN KEY(role_id) REFERENCES roles (id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_user_roles_user_id FOREIGN KEY(user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||
CONSTRAINT uk_user_roles_user_id_role_id UNIQUE (user_id, role_id)
|
||||
)CHARSET=utf8mb4 ENGINE=InnoDB COMMENT='用户角色关联表' COLLATE utf8mb4_general_ci;
|
||||
|
||||
CREATE INDEX idx_user_roles_role_id ON user_roles (role_id);
|
||||
|
||||
INSERT INTO roles (role_code, role_name, description, sort_order) VALUES ('admin', '系统管理员', '全部权限,含计提参数与系数表配置', 1);
|
||||
|
||||
INSERT INTO roles (role_code, role_name, description, sort_order) VALUES ('user', '普通用户', '业务操作', 2);
|
||||
|
||||
INSERT INTO alembic_version (version_num) VALUES ('6165691326d4');
|
||||
|
||||
0
db/queries/.gitkeep
Normal file
0
db/queries/.gitkeep
Normal file
14
docker/.env.example
Normal file
14
docker/.env.example
Normal file
@ -0,0 +1,14 @@
|
||||
# 容器编排配置模板——复制为 docker/.env 并填入真实值(.env 已 gitignore)
|
||||
#
|
||||
# Compose 会自动加载与 compose 文件同目录的 .env,无需 --env-file 参数。
|
||||
# 注意:MYSQL_PASSWORD / REDIS_PASSWORD 需与 backend/.env 保持一致(改一处要改两处)。
|
||||
|
||||
# ===== MySQL =====
|
||||
# root 密码仅供容器首次初始化使用;后端一律用业务账号 MYSQL_USER 连接
|
||||
MYSQL_ROOT_PASSWORD=
|
||||
MYSQL_DATABASE=payroll
|
||||
MYSQL_USER=payroll
|
||||
MYSQL_PASSWORD=
|
||||
|
||||
# ===== Redis =====
|
||||
REDIS_PASSWORD=
|
||||
55
docker/docker-compose.dev.yml
Normal file
55
docker/docker-compose.dev.yml
Normal file
@ -0,0 +1,55 @@
|
||||
# 开发环境容器编排
|
||||
# 启动(在项目根目录执行;Compose 会自动加载同目录的 docker/.env):
|
||||
# docker compose -f docker/docker-compose.dev.yml up -d
|
||||
#
|
||||
# name 显式指定 Compose 项目名。不指定时会取 compose 文件所在目录名(即 docker),
|
||||
# 导致网络/卷被命名为 docker_default、docker_mysql-dev-data 这类无意义名称。
|
||||
name: ys-payroll
|
||||
|
||||
services:
|
||||
mysql:
|
||||
image: mysql:8.0
|
||||
container_name: ys-mysql-dev
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
|
||||
MYSQL_DATABASE: ${MYSQL_DATABASE}
|
||||
# 应用连接用专用账号,避免后端以 root 身份连库
|
||||
MYSQL_USER: ${MYSQL_USER}
|
||||
MYSQL_PASSWORD: ${MYSQL_PASSWORD}
|
||||
TZ: Asia/Shanghai
|
||||
command: --character-set-server=utf8mb4 --collation-server=utf8mb4_general_ci
|
||||
volumes:
|
||||
- mysql-dev-data:/var/lib/mysql
|
||||
# 首次启动自动执行 db/init 下的初始化脚本
|
||||
- ../db/init:/docker-entrypoint-initdb.d
|
||||
ports:
|
||||
- "3306:3306"
|
||||
healthcheck:
|
||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-uroot", "-p${MYSQL_ROOT_PASSWORD}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
start_period: 30s
|
||||
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: ys-redis-dev
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
TZ: Asia/Shanghai
|
||||
# appendonly 开启 AOF,容器重启后验证码黑名单等数据不丢
|
||||
command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD}
|
||||
volumes:
|
||||
- redis-dev-data:/data
|
||||
ports:
|
||||
- "6379:6379"
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "--no-auth-warning", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
volumes:
|
||||
mysql-dev-data:
|
||||
redis-dev-data:
|
||||
BIN
docs/2026.9.8修改方案.docx
Normal file
BIN
docs/2026.9.8修改方案.docx
Normal file
Binary file not shown.
BIN
docs/业务确认问题清单.docx
Normal file
BIN
docs/业务确认问题清单.docx
Normal file
Binary file not shown.
BIN
docs/衍水绩效管理系统功能需规.docx
Normal file
BIN
docs/衍水绩效管理系统功能需规.docx
Normal file
Binary file not shown.
9
frontend/.env.development
Normal file
9
frontend/.env.development
Normal file
@ -0,0 +1,9 @@
|
||||
# 开发环境配置(纳入版本管理 —— 不含任何敏感信息)
|
||||
#
|
||||
# VITE_ 前缀是 Vite 的约定:只有该前缀的变量会被注入客户端代码。
|
||||
# 因此**绝不要**在此类文件中放密钥 —— 它们会被打包进浏览器可见的产物。
|
||||
|
||||
VITE_APP_TITLE=衍水集团营销人员工资管理系统
|
||||
# 走 Vite 代理,指向相对路径而非后端绝对地址(规范禁止前端硬编码后端 URL)
|
||||
VITE_API_BASE_URL=/api
|
||||
VITE_PORT=5173
|
||||
8
frontend/.env.production
Normal file
8
frontend/.env.production
Normal file
@ -0,0 +1,8 @@
|
||||
# 生产环境配置(纳入版本管理 —— 不含任何敏感信息)
|
||||
#
|
||||
# 保持相对路径 /api:生产由 Nginx 反向代理到后端。
|
||||
# 若写成后端绝对地址,一是违反「前端禁硬编码后端 URL」的规范,
|
||||
# 二是换个部署域名就要重新打包。
|
||||
|
||||
VITE_APP_TITLE=衍水集团营销人员工资管理系统
|
||||
VITE_API_BASE_URL=/api
|
||||
122
frontend/README.md
Normal file
122
frontend/README.md
Normal file
@ -0,0 +1,122 @@
|
||||
# 前端
|
||||
|
||||
> AI摘要:衍水集团营销人员工资管理系统的前端。Vue 3 + TypeScript + Vite + Element Plus + Pinia。关键文件 `src/api/request.ts`(统一请求封装)、`src/router/index.ts`(路由守卫)、`src/store/user.ts`(会话状态)、`src/views/login/index.vue`(登录页)。当前完成登录链路,业务模块页面待接入。
|
||||
|
||||
## 职责
|
||||
|
||||
- 登录页与图形验证码交互
|
||||
- 会话状态管理与路由守卫
|
||||
- 后续:基础数据、业务数据、绩效核算、报表等业务页面
|
||||
|
||||
## 技术栈与版本约束
|
||||
|
||||
| 组件 | 版本 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| Vue | 3.5.x | |
|
||||
| Vue Router | **5.x** | 新大版本;对 vite/pinia 的 peer 均为 optional |
|
||||
| Pinia | **4.x** | **`@vue/devtools-api` 是其非可选 peer**,必须显式安装,否则装不上或运行时报警 |
|
||||
| Element Plus | 2.14.x | 全量引入,产物约 1MB(gzip 347KB);后续可改按需引入 |
|
||||
| Vite | 8.x | |
|
||||
| TypeScript | **~5.9.3** | **不可用 7.x** —— TS 7 是 Go 重写的原生编译器,而 vue-tsc 的 peer 只写 `>=5.0.0` 拦不住,兼容性未经验证 |
|
||||
| 包管理 | pnpm | |
|
||||
|
||||
## 目录结构
|
||||
|
||||
```text
|
||||
src/
|
||||
├── api/
|
||||
│ ├── request.ts # axios 实例、拦截器、统一信封解包
|
||||
│ └── auth.ts # 认证接口封装与类型
|
||||
├── assets/styles/ # 全局样式
|
||||
├── components/ # 通用组件(待补充)
|
||||
├── composables/ # 组合式函数(待补充)
|
||||
├── router/index.ts # 路由表与全局守卫
|
||||
├── store/
|
||||
│ ├── index.ts # Pinia 实例
|
||||
│ └── user.ts # 会话状态:token、用户信息、登录/登出
|
||||
├── utils/storage.ts # localStorage 读写
|
||||
└── views/
|
||||
├── login/ # 登录页
|
||||
└── dashboard/ # 首页(登录链路验证页)
|
||||
```
|
||||
|
||||
## 启动
|
||||
|
||||
```bash
|
||||
# 前置:后端已在 127.0.0.1:8000 运行
|
||||
pnpm install
|
||||
pnpm dev # http://localhost:5173
|
||||
pnpm type-check # vue-tsc 类型检查
|
||||
pnpm build # 类型检查 + 生产构建
|
||||
```
|
||||
|
||||
## 几个关键约定
|
||||
|
||||
**开发期用 Vite 代理,不开 CORS**
|
||||
`/api` 被代理到 `http://127.0.0.1:8000`。用 `127.0.0.1` 而非 `localhost` 是因为 Node 17+ 对 `localhost` 优先解析 IPv6 的 `::1`,而 uvicorn 默认只监听 IPv4。
|
||||
反向地,dev server 自身显式绑定 `host: '127.0.0.1'` —— 不指定时 Vite 只监听 `[::1]`,会出现「`localhost:5173` 能开、`127.0.0.1:5173` 连不上」的困惑。此配置让两种写法都可用。
|
||||
代理成立后 `/api/docs`(Swagger)也能从 5173 端口打开,联调期只需一个域名。
|
||||
|
||||
**401 只表示「登录态失效」**
|
||||
登录接口自身的失败返回 **400/403**,绝不会是 401。因此前端拦截器可以放心地把 401 一律当作「清 token + 跳登录页」处理,不会出现「用户输错一次密码就被弹回登录页」的循环。
|
||||
并发 401 用模块级标志去重,避免同时弹出多条提示并反复跳转。
|
||||
|
||||
**登录失败后必须刷新验证码**
|
||||
后端用 Redis `GETDEL` 原子消费验证码,**任何一次提交后该验证码都已失效**。登录页在 `catch` 分支里调用了 `refreshCaptcha()` —— 漏掉这一步,用户第二次提交必然报「验证码错误」,且极难自查。
|
||||
|
||||
**令牌存 localStorage**
|
||||
实现最简,代价是 XSS 可窃取。缓解手段是 JWT 有效期上限 2 小时,以及后端支持 `token_version` 提版即时失效。若后续要求更高,可改为 httpOnly Cookie,届时只需改 `src/utils/storage.ts` 与后端签发方式。
|
||||
|
||||
## 视觉设计
|
||||
|
||||
**设计立意**:公司名「衍水」是水,而中文「流水账」字面就是**流水之账**——系统的全部工作正是记录回款怎么流、绩效怎么算。视觉方向由此而生:深水面板 + 流动的层理线,即公司名的意象,也是业务的隐喻。
|
||||
|
||||
**配色**(金色分两档是有意的:亮金只用于深色底、深金只用于浅色底,两处对比度都达标)
|
||||
|
||||
| 名 | 值 | 用途 |
|
||||
| --- | --- | --- |
|
||||
| 深渊 | `#061A22` | 品牌面板底色 |
|
||||
| 沉碧 | `#0F3646` | 面板渐变、水纹线 |
|
||||
| 青金 | `#C9A227` | **深色底上**的细线与边框 |
|
||||
| 赭金 | `#8F6520` | **浅色底上**的主按钮(配白字约 6.2:1) |
|
||||
| 素纸 | `#FAFAF7` | 表单侧底色 |
|
||||
| 墨 | `#16262D` | 正文 |
|
||||
|
||||
**字体**:品牌名用**宋体**(衬线,拟正式、拟账册),正文用系统无衬线,数字与验证码用等宽。
|
||||
**界面不引入网络字体**——内网环境字体加载不可靠,靠字重、字距与留白做层次更稳妥。新页面请沿用这套字号节奏。
|
||||
|
||||
**签名元素**:品牌面板的水纹层理线,越往下越密(如潜向深处),载入时用 `stroke-dashoffset` 逐条画出——**一个统一的入场动作,而不是散落的特效**。
|
||||
|
||||
**构图**:品牌内容(名称 / 金线 / 系统名 / 主标语 / 环节行)**垂直居中聚成一块**,配一层柔和径向光晕作落点。
|
||||
早期版本用 `space-between` 把品牌与标语顶到两端,中间空出大半屏,两处都成了孤岛——超宽屏会把这种疏离放大得更明显。**内容块要限宽**(`max-width`),否则宽屏下文字会被拉散。
|
||||
|
||||
### 写代码时的三条约定
|
||||
|
||||
- 覆盖 Element Plus 主题请改 **CSS 变量**(`--el-button-bg-color` 等),不要直接写 `background-color` —— 后者与 `.el-button--primary` 同权重,会受样式注入顺序影响而不稳定
|
||||
- 中文可在任意字间断行,**关键文案的断行位置要显式控制**(如主标语拆成两个 `<span>`),交给浏览器自动换行会出现单字独占一行的难看结果
|
||||
- 新页面需响应式到移动端、保留键盘焦点可见、并尊重 `prefers-reduced-motion`
|
||||
|
||||
## 验证码图片
|
||||
|
||||
后端自绘渲染(`backend/app/core/captcha_image.py`),**不再使用 `captcha` 库的默认输出**——那是随机彩色字符 + 重噪点,与界面视觉体系冲突。自绘后字符为墨蓝、底为素纸、干扰线与噪点取自同一套配色。
|
||||
|
||||
识别难度由**逐字随机旋转(±14°)+ 垂直抖动(±2px)+ 正弦干扰曲线 + 双层噪点**共同保证,与原实现相当;笔画加了 1px 描边——本系统用户是营销人员,屏幕与年龄跨度大,验证码过难会直接变成支持负担。
|
||||
|
||||
## 端到端测试
|
||||
|
||||
`e2e/login_flow.py` 用 Playwright 驱动真实浏览器,覆盖 8 项:路由守卫、验证码渲染、失败后自动刷新、登录跳转、用户信息展示、刷新恢复会话、登出、登出后令牌失效。
|
||||
|
||||
```bash
|
||||
# 前置:MySQL/Redis 容器、后端、前端 dev server 均在运行
|
||||
python e2e/login_flow.py
|
||||
```
|
||||
|
||||
需要 Python `playwright` 包与 chromium(`python -m playwright install chromium`)。
|
||||
|
||||
## 常见问题
|
||||
|
||||
**登录后刷新页面被踢回登录页** —— 检查 `/api/v1/auth/me` 是否可达;路由守卫靠它恢复会话。
|
||||
|
||||
**验证码图片不显示** —— 多为代理未生效。访问 `http://localhost:5173/api/health` 应返回统一信封格式 JSON。
|
||||
|
||||
**`pnpm install` 报 peer 依赖错误** —— 确认 `@vue/devtools-api` 在 `dependencies` 中(pinia 4 的必需 peer)。
|
||||
190
frontend/e2e/login_flow.py
Normal file
190
frontend/e2e/login_flow.py
Normal file
@ -0,0 +1,190 @@
|
||||
"""浏览器端端到端验证:登录 + 图形验证码。
|
||||
|
||||
验证的是**只有真实浏览器才能暴露**的问题:
|
||||
|
||||
- 路由守卫是否真的拦住了未登录访问
|
||||
- 验证码图片能否经 Vite 代理正常渲染(跨前后端的完整链路)
|
||||
- 登录失败后验证码是否自动刷新 —— 后端用 GETDEL 消费了验证码,
|
||||
前端不刷新的话第二次提交必然失败,而这个问题在人工测试时极易被忽略
|
||||
- 刷新页面能否用 /me 恢复会话
|
||||
- 登出后令牌是否立即失效
|
||||
|
||||
**运行前置**(缺一不可)::
|
||||
|
||||
docker compose -f docker/docker-compose.dev.yml up -d # MySQL + Redis
|
||||
cd backend && uv run uvicorn app.main:app --port 8000 # 后端
|
||||
cd frontend && pnpm dev # 前端
|
||||
|
||||
运行::
|
||||
|
||||
cd frontend && python e2e/login_flow.py
|
||||
|
||||
依赖:Python `playwright` 包与 chromium(`python -m playwright install chromium`)。
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from playwright.sync_api import Page, sync_playwright
|
||||
|
||||
BASE_URL = "http://127.0.0.1:5173"
|
||||
USERNAME = "admin"
|
||||
PASSWORD = "Admin@2026"
|
||||
REDIS_CONTAINER = "ys-redis-dev"
|
||||
|
||||
|
||||
def _redis_password() -> str:
|
||||
"""从 docker/.env 读取 Redis 密码。
|
||||
|
||||
测试脚本无法用应用配置(那需要后端 venv),故直接读环境文件。
|
||||
"""
|
||||
if value := os.environ.get("REDIS_PASSWORD"):
|
||||
return value
|
||||
env_file = os.path.join(os.path.dirname(__file__), "..", "..", "docker", ".env")
|
||||
with open(env_file, encoding="utf-8") as handle:
|
||||
for line in handle:
|
||||
if line.startswith("REDIS_PASSWORD="):
|
||||
return line.split("=", 1)[1].strip()
|
||||
raise RuntimeError("未能从 docker/.env 读取 REDIS_PASSWORD")
|
||||
|
||||
|
||||
def redis_get(key: str) -> str | None:
|
||||
"""读取 Redis 键值。仅用于自动化测试 —— 生产当然不能这样取验证码答案。"""
|
||||
result = subprocess.run(
|
||||
["docker", "exec", REDIS_CONTAINER, "redis-cli", "-a", _redis_password(),
|
||||
"--no-auth-warning", "get", key],
|
||||
capture_output=True, text=True, encoding="utf-8", check=False,
|
||||
)
|
||||
return result.stdout.strip() or None
|
||||
|
||||
|
||||
def _fail(step: str, detail: str) -> None:
|
||||
print(f" ✗ {step}:{detail}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def run(page: Page) -> None:
|
||||
captured_captcha_ids: list[str] = []
|
||||
console_errors: list[str] = []
|
||||
|
||||
def on_response(response) -> None:
|
||||
if "/api/v1/auth/captcha" not in response.url or response.status != 200:
|
||||
return
|
||||
try:
|
||||
captured_captcha_ids.append(response.json()["data"]["captcha_id"])
|
||||
except Exception:
|
||||
# 页面导航后响应体已不可读(Playwright 会抛协议错误)。
|
||||
# 事件回调里未捕获的异常会成为不稳定的噪声来源,故静默跳过。
|
||||
pass
|
||||
|
||||
page.on("response", on_response)
|
||||
page.on("console", lambda msg: console_errors.append(msg.text) if msg.type == "error" else None)
|
||||
page.on("pageerror", lambda exc: console_errors.append(str(exc)))
|
||||
|
||||
def at_dashboard() -> bool:
|
||||
"""判断是否真的在首页。
|
||||
|
||||
不能用 `**/dashboard` 这类 glob —— 它会把
|
||||
`/login?redirect=/dashboard` 也匹配上,导致"登录失败"被误判为成功。
|
||||
"""
|
||||
from urllib.parse import urlparse
|
||||
|
||||
return urlparse(page.url).path == "/dashboard"
|
||||
|
||||
def toast_text() -> str:
|
||||
locator = page.locator(".el-message")
|
||||
return locator.first.text_content().strip() if locator.count() else ""
|
||||
|
||||
# ---- 1. 路由守卫:未登录访问首页应被拦回登录页 ----
|
||||
page.goto(BASE_URL, wait_until="networkidle")
|
||||
if "/login" not in page.url:
|
||||
_fail("路由守卫", f"未登录访问首页未被拦截,当前 URL={page.url}")
|
||||
print(f"✓ 1. 路由守卫 未登录访问 / → 重定向到 {page.url}")
|
||||
|
||||
# ---- 2. 验证码渲染(证明 浏览器→Vite代理→uvicorn→Redis 全链路通)----
|
||||
image = page.locator(".captcha__image img")
|
||||
image.wait_for(state="visible", timeout=10_000)
|
||||
src = image.get_attribute("src") or ""
|
||||
if not src.startswith("data:image/png;base64,"):
|
||||
_fail("验证码渲染", f"图片 src 前缀异常:{src[:40]}")
|
||||
if len(src) < 1000:
|
||||
_fail("验证码渲染", f"图片数据过小({len(src)}),可能未真正渲染")
|
||||
print(f"✓ 2. 验证码渲染 PNG base64 长度 {len(src)}(代理链路已通)")
|
||||
|
||||
# ---- 3. 输错验证码 → 应提示且验证码自动刷新 ----
|
||||
first_id = captured_captcha_ids[-1]
|
||||
page.fill('input[autocomplete="username"]', USERNAME)
|
||||
page.fill('input[autocomplete="current-password"]', PASSWORD)
|
||||
page.fill('input[autocomplete="off"]', "XXXX")
|
||||
page.click("button:has-text('登 录')")
|
||||
page.wait_for_timeout(1500)
|
||||
|
||||
if "/login" not in page.url:
|
||||
_fail("失败处理", f"登录失败后不应离开登录页,当前 URL={page.url}")
|
||||
second_id = captured_captcha_ids[-1]
|
||||
if second_id == first_id:
|
||||
_fail("验证码刷新", "登录失败后验证码未刷新 —— 第二次提交必然报「验证码错误」")
|
||||
print("✓ 3. 失败自动刷新 验证码已换新(GETDEL 消费后的必要处理)")
|
||||
|
||||
# ---- 4. 正确登录 ----
|
||||
answer = redis_get(f"ys:captcha:{second_id}")
|
||||
if not answer:
|
||||
_fail("取验证码", f"Redis 中未找到 ys:captcha:{second_id}")
|
||||
page.fill('input[autocomplete="off"]', answer)
|
||||
page.click("button:has-text('登 录')")
|
||||
page.wait_for_timeout(2000)
|
||||
|
||||
if not at_dashboard():
|
||||
detail = f"仍停留在 {page.url}"
|
||||
if message := toast_text():
|
||||
detail += f",提示:{message}"
|
||||
if console_errors:
|
||||
detail += f",控制台错误:{console_errors[:3]}"
|
||||
_fail("登录", detail)
|
||||
page.wait_for_load_state("networkidle")
|
||||
print(f"✓ 4. 登录成功 跳转到 {page.url}")
|
||||
|
||||
if USERNAME not in page.content():
|
||||
_fail("用户信息", "首页未显示当前用户名")
|
||||
print("✓ 5. 用户信息 首页已展示当前账号")
|
||||
|
||||
# ---- 6. 刷新页面 → 守卫应调用 /me 恢复会话 ----
|
||||
page.reload(wait_until="networkidle")
|
||||
if "/dashboard" not in page.url:
|
||||
_fail("会话恢复", f"刷新后被踢回登录页,当前 URL={page.url}")
|
||||
print("✓ 6. 会话恢复 刷新页面后仍保持登录(/me 恢复成功)")
|
||||
|
||||
# ---- 7. 登出 → 令牌立即失效 ----
|
||||
page.click("text=退出登录")
|
||||
page.wait_for_url("**/login**", timeout=10_000)
|
||||
print(f"✓ 7. 登出 回到 {page.url}")
|
||||
|
||||
page.goto(f"{BASE_URL}/dashboard", wait_until="networkidle")
|
||||
page.wait_for_timeout(1000)
|
||||
if at_dashboard():
|
||||
_fail("登出失效", "登出后仍能访问受保护页面")
|
||||
print("✓ 8. 令牌失效 登出后直接访问 /dashboard 被拦回登录页")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
with sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(headless=True)
|
||||
page = browser.new_page()
|
||||
page.set_default_timeout(10_000)
|
||||
try:
|
||||
run(page)
|
||||
except Exception as exc: # noqa: BLE001 — 测试脚本需要打印完整失败原因
|
||||
page.screenshot(path="e2e/failure.png", full_page=True)
|
||||
print(f"\n✗ 执行中断:{type(exc).__name__}: {exc}")
|
||||
print(" 已保存截图 e2e/failure.png")
|
||||
return 1
|
||||
finally:
|
||||
browser.close()
|
||||
|
||||
print("\n全部通过。")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
18
frontend/env.d.ts
vendored
Normal file
18
frontend/env.d.ts
vendored
Normal file
@ -0,0 +1,18 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
declare module '*.vue' {
|
||||
import type { DefineComponent } from 'vue'
|
||||
const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, unknown>
|
||||
export default component
|
||||
}
|
||||
|
||||
/** 环境变量类型声明,使 import.meta.env.VITE_* 具备补全与类型检查 */
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_APP_TITLE: string
|
||||
readonly VITE_API_BASE_URL: string
|
||||
readonly VITE_PORT?: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
12
frontend/index.html
Normal file
12
frontend/index.html
Normal file
@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>衍水集团营销人员工资管理系统</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
28
frontend/package.json
Normal file
28
frontend/package.json
Normal file
@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "payroll-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "衍水集团营销人员工资管理系统 —— 前端",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"type-check": "vue-tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vue/devtools-api": "^8.1.5",
|
||||
"axios": "^1.20.0",
|
||||
"element-plus": "^2.14.7",
|
||||
"pinia": "^4.0.3",
|
||||
"vue": "^3.5.43",
|
||||
"vue-router": "^5.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.0.0",
|
||||
"@vitejs/plugin-vue": "^6.0.9",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "^8.3.4",
|
||||
"vue-tsc": "^3.3.12"
|
||||
}
|
||||
}
|
||||
1523
frontend/pnpm-lock.yaml
generated
Normal file
1523
frontend/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
2
frontend/pnpm-workspace.yaml
Normal file
2
frontend/pnpm-workspace.yaml
Normal file
@ -0,0 +1,2 @@
|
||||
minimumReleaseAgeExclude:
|
||||
- vite@8.3.4
|
||||
7
frontend/src/App.vue
Normal file
7
frontend/src/App.vue
Normal file
@ -0,0 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
// 根组件只承载路由出口;布局由各页面自行决定
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
51
frontend/src/api/auth.ts
Normal file
51
frontend/src/api/auth.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import { request } from './request'
|
||||
|
||||
/** 图形验证码 */
|
||||
export interface CaptchaResult {
|
||||
captcha_id: string
|
||||
/** PNG 图片的 base64,**不含** data: 前缀,由前端自行拼接 */
|
||||
image_base64: string
|
||||
expires_in: number
|
||||
}
|
||||
|
||||
export interface LoginParams {
|
||||
username: string
|
||||
password: string
|
||||
captcha_id: string
|
||||
captcha_code: string
|
||||
}
|
||||
|
||||
export interface UserInfo {
|
||||
id: number
|
||||
username: string
|
||||
real_name: string | null
|
||||
roles: string[]
|
||||
last_login_time?: string | null
|
||||
}
|
||||
|
||||
export interface LoginResult {
|
||||
access_token: string
|
||||
token_type: string
|
||||
expires_in: number
|
||||
user: UserInfo
|
||||
}
|
||||
|
||||
/** 获取图形验证码 */
|
||||
export function getCaptcha(): Promise<CaptchaResult> {
|
||||
return request<CaptchaResult>({ url: '/v1/auth/captcha', method: 'get' })
|
||||
}
|
||||
|
||||
/** 登录 */
|
||||
export function login(data: LoginParams): Promise<LoginResult> {
|
||||
return request<LoginResult>({ url: '/v1/auth/login', method: 'post', data })
|
||||
}
|
||||
|
||||
/** 登出 */
|
||||
export function logout(): Promise<null> {
|
||||
return request<null>({ url: '/v1/auth/logout', method: 'post' })
|
||||
}
|
||||
|
||||
/** 获取当前登录用户(刷新页面后用它恢复会话) */
|
||||
export function getCurrentUser(): Promise<UserInfo> {
|
||||
return request<UserInfo>({ url: '/v1/auth/me', method: 'get' })
|
||||
}
|
||||
88
frontend/src/api/request.ts
Normal file
88
frontend/src/api/request.ts
Normal file
@ -0,0 +1,88 @@
|
||||
import axios, { type AxiosError, type AxiosRequestConfig, type AxiosResponse } from 'axios'
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
import { clearToken, getToken } from '@/utils/storage'
|
||||
|
||||
/** 后端统一响应信封(见 PROJECT_STANDARDS 第 3 节) */
|
||||
export interface ApiResult<T = unknown> {
|
||||
code: number
|
||||
message: string
|
||||
data: T
|
||||
}
|
||||
|
||||
const service = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL,
|
||||
timeout: 15000,
|
||||
})
|
||||
|
||||
// 请求拦截:注入 Bearer 令牌
|
||||
service.interceptors.request.use((config) => {
|
||||
const token = getToken()
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
/**
|
||||
* 令牌失效时的跳转去重标志。
|
||||
*
|
||||
* 页面初始化时常常并发多个请求,令牌过期会同时触发多次 401;
|
||||
* 没有这个标志就会弹出多条提示并反复跳转。
|
||||
*/
|
||||
let redirecting = false
|
||||
|
||||
function handleUnauthorized(message: string): void {
|
||||
if (redirecting) return
|
||||
redirecting = true
|
||||
|
||||
clearToken()
|
||||
ElMessage.error(message)
|
||||
|
||||
// 用整页跳转而非 router.replace:既避免 request → router → store → request
|
||||
// 的循环依赖,也能顺带清空内存中的应用状态
|
||||
const current = window.location.pathname + window.location.search
|
||||
window.location.href = `/login?redirect=${encodeURIComponent(current)}`
|
||||
|
||||
window.setTimeout(() => {
|
||||
redirecting = false
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
service.interceptors.response.use(
|
||||
(response: AxiosResponse<ApiResult>) => {
|
||||
const body = response.data
|
||||
if (body.code !== 200) {
|
||||
// 防御性分支:后端非 2xx 走 reject 拦截器,这里是 2xx 但业务码异常的情况
|
||||
ElMessage.error(body.message || '请求失败')
|
||||
return Promise.reject(new Error(body.message || '请求失败'))
|
||||
}
|
||||
return response
|
||||
},
|
||||
(error: AxiosError<ApiResult>) => {
|
||||
const status = error.response?.status
|
||||
const message = error.response?.data?.message ?? '网络异常,请稍后重试'
|
||||
|
||||
if (status === 401) {
|
||||
// 401 严格表示「受保护接口的鉴权失败」。登录接口自身失败返回 400/403,
|
||||
// 因此不会出现「用户输错一次密码就被弹回登录页」的循环。
|
||||
handleUnauthorized(message)
|
||||
} else {
|
||||
ElMessage.error(message)
|
||||
}
|
||||
return Promise.reject(error)
|
||||
},
|
||||
)
|
||||
|
||||
/**
|
||||
* 发起请求并按统一信封解包。
|
||||
*
|
||||
* 成功时直接返回 `data` 字段,调用方不必层层 `.data`;
|
||||
* 失败时已由拦截器提示并 reject,调用方通常只需 try/catch 做状态回滚。
|
||||
*/
|
||||
export async function request<T>(config: AxiosRequestConfig): Promise<T> {
|
||||
const response = await service.request<ApiResult<T>>(config)
|
||||
return response.data.data
|
||||
}
|
||||
|
||||
export default service
|
||||
22
frontend/src/assets/styles/index.css
Normal file
22
frontend/src/assets/styles/index.css
Normal file
@ -0,0 +1,22 @@
|
||||
/* 全局样式:重置 + 基础版式 */
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Helvetica Neue', Helvetica, 'PingFang SC', 'Hiragino Sans GB',
|
||||
'Microsoft YaHei', Arial, sans-serif;
|
||||
font-size: 14px;
|
||||
color: #303133;
|
||||
background-color: #f5f7fa;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
0
frontend/src/components/.gitkeep
Normal file
0
frontend/src/components/.gitkeep
Normal file
0
frontend/src/composables/.gitkeep
Normal file
0
frontend/src/composables/.gitkeep
Normal file
12
frontend/src/main.ts
Normal file
12
frontend/src/main.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import ElementPlus from 'element-plus'
|
||||
import zhCn from 'element-plus/es/locale/lang/zh-cn'
|
||||
import { createApp } from 'vue'
|
||||
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import pinia from './store'
|
||||
|
||||
import 'element-plus/dist/index.css'
|
||||
import './assets/styles/index.css'
|
||||
|
||||
createApp(App).use(pinia).use(router).use(ElementPlus, { locale: zhCn }).mount('#app')
|
||||
65
frontend/src/router/index.ts
Normal file
65
frontend/src/router/index.ts
Normal file
@ -0,0 +1,65 @@
|
||||
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
|
||||
|
||||
import { useUserStore } from '@/store/user'
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{ path: '/', redirect: '/dashboard' },
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
component: () => import('@/views/login/index.vue'),
|
||||
meta: { public: true, title: '登录' },
|
||||
},
|
||||
{
|
||||
path: '/dashboard',
|
||||
name: 'dashboard',
|
||||
component: () => import('@/views/dashboard/index.vue'),
|
||||
meta: { title: '首页' },
|
||||
},
|
||||
// 未匹配路由回首页;若未登录会被守卫拦到登录页
|
||||
{ path: '/:pathMatch(.*)*', redirect: '/dashboard' },
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes,
|
||||
})
|
||||
|
||||
/**
|
||||
* 全局前置守卫。
|
||||
*
|
||||
* 三层判断:
|
||||
* 1. `meta.public` 标记的页面(登录页)直接放行
|
||||
* 2. 无令牌 → 跳登录页,并把原目标记在 query 里供登录后回跳
|
||||
* 3. 有令牌但内存中无用户信息(说明是刷新页面)→ 调 /me 恢复会话;
|
||||
* 失败则视为令牌已失效,清状态并跳登录页
|
||||
*/
|
||||
router.beforeEach(async (to) => {
|
||||
const userStore = useUserStore()
|
||||
|
||||
if (to.meta.public) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (!userStore.token) {
|
||||
return { path: '/login', query: { redirect: to.fullPath } }
|
||||
}
|
||||
|
||||
if (!userStore.userInfo) {
|
||||
try {
|
||||
await userStore.fetchUserInfo()
|
||||
} catch {
|
||||
userStore.resetAuth()
|
||||
return { path: '/login', query: { redirect: to.fullPath } }
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
router.afterEach((to) => {
|
||||
const title = to.meta.title as string | undefined
|
||||
document.title = title ? `${title} - ${import.meta.env.VITE_APP_TITLE}` : import.meta.env.VITE_APP_TITLE
|
||||
})
|
||||
|
||||
export default router
|
||||
5
frontend/src/store/index.ts
Normal file
5
frontend/src/store/index.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { createPinia } from 'pinia'
|
||||
|
||||
export const pinia = createPinia()
|
||||
|
||||
export default pinia
|
||||
53
frontend/src/store/user.ts
Normal file
53
frontend/src/store/user.ts
Normal file
@ -0,0 +1,53 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import * as authApi from '@/api/auth'
|
||||
import { clearToken, getToken, setToken } from '@/utils/storage'
|
||||
|
||||
/**
|
||||
* 用户会话状态。
|
||||
*
|
||||
* 令牌同时存在于 localStorage 与本 store:前者用于刷新页面后恢复,
|
||||
* 后者用于响应式驱动界面。二者只在 login / resetAuth 中同步修改。
|
||||
*/
|
||||
export const useUserStore = defineStore('user', () => {
|
||||
const token = ref<string>(getToken())
|
||||
const userInfo = ref<authApi.UserInfo | null>(null)
|
||||
|
||||
const roles = computed<string[]>(() => userInfo.value?.roles ?? [])
|
||||
const displayName = computed<string>(() => userInfo.value?.real_name || userInfo.value?.username || '')
|
||||
|
||||
/** 登录并保存令牌 */
|
||||
async function login(params: authApi.LoginParams): Promise<void> {
|
||||
const result = await authApi.login(params)
|
||||
token.value = result.access_token
|
||||
setToken(result.access_token)
|
||||
userInfo.value = result.user
|
||||
}
|
||||
|
||||
/** 拉取当前用户信息 —— 路由守卫在刷新页面后用它恢复会话 */
|
||||
async function fetchUserInfo(): Promise<void> {
|
||||
userInfo.value = await authApi.getCurrentUser()
|
||||
}
|
||||
|
||||
/** 登出:无论后端是否成功,本地状态都要清干净 */
|
||||
async function logout(): Promise<void> {
|
||||
try {
|
||||
await authApi.logout()
|
||||
} catch {
|
||||
// 令牌可能已过期或被拉黑,此时后端会返回 401;
|
||||
// 对用户而言登出已经达成,不必因此报错
|
||||
} finally {
|
||||
resetAuth()
|
||||
}
|
||||
}
|
||||
|
||||
/** 清空本地登录态 */
|
||||
function resetAuth(): void {
|
||||
token.value = ''
|
||||
userInfo.value = null
|
||||
clearToken()
|
||||
}
|
||||
|
||||
return { token, userInfo, roles, displayName, login, fetchUserInfo, logout, resetAuth }
|
||||
})
|
||||
21
frontend/src/utils/storage.ts
Normal file
21
frontend/src/utils/storage.ts
Normal file
@ -0,0 +1,21 @@
|
||||
/**
|
||||
* 本地存储读写。
|
||||
*
|
||||
* 令牌单独封装而非在业务代码里直接操作 localStorage:
|
||||
* 一是避免键名各处拼写漂移,二是将来若改为 httpOnly Cookie 存储,
|
||||
* 只需改这一个文件。
|
||||
*/
|
||||
|
||||
const TOKEN_KEY = 'ys_token'
|
||||
|
||||
export function getToken(): string {
|
||||
return localStorage.getItem(TOKEN_KEY) ?? ''
|
||||
}
|
||||
|
||||
export function setToken(token: string): void {
|
||||
localStorage.setItem(TOKEN_KEY, token)
|
||||
}
|
||||
|
||||
export function clearToken(): void {
|
||||
localStorage.removeItem(TOKEN_KEY)
|
||||
}
|
||||
98
frontend/src/views/dashboard/index.vue
Normal file
98
frontend/src/views/dashboard/index.vue
Normal file
@ -0,0 +1,98 @@
|
||||
<script setup lang="ts">
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { useUserStore } from '@/store/user'
|
||||
|
||||
const router = useRouter()
|
||||
const userStore = useUserStore()
|
||||
|
||||
async function handleLogout(): Promise<void> {
|
||||
await userStore.logout()
|
||||
ElMessage.success('已退出登录')
|
||||
await router.replace('/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<el-container class="layout">
|
||||
<el-header class="layout-header">
|
||||
<span class="layout-title">衍水集团营销人员工资管理系统</span>
|
||||
<div class="layout-user">
|
||||
<el-tag v-for="role in userStore.roles" :key="role" size="small" type="info">
|
||||
{{ role }}
|
||||
</el-tag>
|
||||
<span class="layout-username">{{ userStore.displayName }}</span>
|
||||
<el-button link type="primary" @click="handleLogout">退出登录</el-button>
|
||||
</div>
|
||||
</el-header>
|
||||
|
||||
<el-main class="layout-main">
|
||||
<el-card shadow="never">
|
||||
<template #header>
|
||||
<span>登录状态验证通过</span>
|
||||
</template>
|
||||
|
||||
<el-descriptions :column="2" border>
|
||||
<el-descriptions-item label="用户 ID">{{ userStore.userInfo?.id }}</el-descriptions-item>
|
||||
<el-descriptions-item label="用户名">{{ userStore.userInfo?.username }}</el-descriptions-item>
|
||||
<el-descriptions-item label="姓名">
|
||||
{{ userStore.userInfo?.real_name || '—' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="角色">
|
||||
{{ userStore.roles.join('、') || '—' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="上次登录">
|
||||
{{ userStore.userInfo?.last_login_time || '—' }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<el-alert
|
||||
class="layout-hint"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="这是登录链路的验证页"
|
||||
description="刷新页面可验证会话恢复(守卫会调用 /me);点击右上角退出登录可验证令牌即时失效。业务模块页面将在后续阶段接入。"
|
||||
/>
|
||||
</el-card>
|
||||
</el-main>
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.layout {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.layout-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
color: #fff;
|
||||
background-color: #1f4e79;
|
||||
}
|
||||
|
||||
.layout-title {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.layout-user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.layout-username {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.layout-main {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.layout-hint {
|
||||
margin-top: 18px;
|
||||
}
|
||||
</style>
|
||||
478
frontend/src/views/login/index.vue
Normal file
478
frontend/src/views/login/index.vue
Normal file
@ -0,0 +1,478 @@
|
||||
<script setup lang="ts">
|
||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import { getCaptcha } from '@/api/auth'
|
||||
import { useUserStore } from '@/store/user'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const formRef = ref<FormInstance>()
|
||||
const submitting = ref(false)
|
||||
const captchaLoading = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
username: '',
|
||||
password: '',
|
||||
captcha_code: '',
|
||||
})
|
||||
|
||||
const captchaId = ref('')
|
||||
const captchaImage = ref('')
|
||||
|
||||
const rules: FormRules = {
|
||||
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
|
||||
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
|
||||
captcha_code: [
|
||||
{ required: true, message: '请输入验证码', trigger: 'blur' },
|
||||
{ len: 4, message: '验证码为 4 位', trigger: 'blur' },
|
||||
],
|
||||
}
|
||||
|
||||
/** 加载或刷新验证码。点击图片亦可触发。 */
|
||||
async function refreshCaptcha(): Promise<void> {
|
||||
captchaLoading.value = true
|
||||
try {
|
||||
const data = await getCaptcha()
|
||||
captchaId.value = data.captcha_id
|
||||
// 后端返回纯 base64,data URL 前缀由前端拼接
|
||||
captchaImage.value = `data:image/png;base64,${data.image_base64}`
|
||||
form.captcha_code = ''
|
||||
} catch {
|
||||
// 失败提示已由 axios 拦截器统一处理,这里只需保证界面回到可用状态
|
||||
} finally {
|
||||
captchaLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit(): Promise<void> {
|
||||
if (!formRef.value) return
|
||||
const valid = await formRef.value.validate().catch(() => false)
|
||||
if (!valid) return
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
await userStore.login({
|
||||
username: form.username.trim(),
|
||||
password: form.password,
|
||||
captcha_id: captchaId.value,
|
||||
captcha_code: form.captcha_code.trim(),
|
||||
})
|
||||
ElMessage.success('登录成功')
|
||||
await router.replace((route.query.redirect as string) || '/dashboard')
|
||||
} catch {
|
||||
// **必须刷新验证码**:后端用 GETDEL 消费验证码,无论何种失败它都已失效。
|
||||
// 不刷新的话,用户第二次提交必然报「验证码错误」,且极难自查原因。
|
||||
await refreshCaptcha()
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(refreshCaptcha)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="login">
|
||||
<!-- 品牌面板:以「水」为意象 —— 公司名衍水,业务是流水之账 -->
|
||||
<section class="brand">
|
||||
<!-- 水纹层理:越往下越密,如潜向深处。纯装饰,对读屏隐藏 -->
|
||||
<svg class="brand-flow" viewBox="0 0 600 1000" preserveAspectRatio="xMidYMid slice" aria-hidden="true">
|
||||
<path class="brand-flow__line" style="--i: 0" d="M-40,150 C 150,95 260,205 420,155 S 620,95 660,120" />
|
||||
<path class="brand-flow__line" style="--i: 1" d="M-40,265 C 170,200 300,320 460,255 S 630,190 660,215" />
|
||||
<path class="brand-flow__line" style="--i: 2" d="M-40,372 C 160,318 320,420 470,360 S 640,300 660,320" />
|
||||
<path class="brand-flow__line" style="--i: 3" d="M-40,470 C 180,428 310,510 480,462 S 645,410 660,425" />
|
||||
<path class="brand-flow__line" style="--i: 4" d="M-40,562 C 150,532 330,596 470,560 S 640,520 660,530" />
|
||||
<path class="brand-flow__line" style="--i: 5" d="M-40,650 C 190,626 300,678 490,648 S 650,618 660,624" />
|
||||
<path class="brand-flow__line" style="--i: 6" d="M-40,732 C 160,714 320,752 460,730 S 640,708 660,712" />
|
||||
<path class="brand-flow__line" style="--i: 7" d="M-40,810 C 175,798 305,826 485,808 S 645,792 660,795" />
|
||||
<path class="brand-flow__line" style="--i: 8" d="M-40,882 C 155,873 330,894 470,880 S 645,868 660,870" />
|
||||
<path class="brand-flow__line" style="--i: 9" d="M-40,946 C 185,939 300,955 490,944 S 650,934 660,935" />
|
||||
</svg>
|
||||
|
||||
<!-- 品牌内容整体居中成块。此前用 space-between 把品牌与标语顶到两端,
|
||||
中间空出大片,两处都成了孤岛;聚成一块才有构图 -->
|
||||
<div class="brand-inner">
|
||||
<div class="brand-block">
|
||||
<h1 class="brand-name">衍水集团</h1>
|
||||
<hr class="brand-rule" />
|
||||
<p class="brand-system">营销人员工资管理系统</p>
|
||||
|
||||
<!-- 断行位置显式控制:中文可在任意字间断行,交给浏览器自动换行
|
||||
会把「楚」这样的单字挤到末行 -->
|
||||
<p class="brand-hero">
|
||||
<span>每一笔回款,</span>
|
||||
<span>都算得清楚</span>
|
||||
</p>
|
||||
<p class="brand-stages">回款 · 计提 · 核算 · 发放</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 表单面板 -->
|
||||
<section class="panel">
|
||||
<div class="panel-inner">
|
||||
<h2 class="panel-title">登录</h2>
|
||||
<p class="panel-hint">请使用管理员分配的账号</p>
|
||||
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
label-position="top"
|
||||
size="large"
|
||||
@keyup.enter="handleSubmit"
|
||||
>
|
||||
<el-form-item label="用户名" prop="username">
|
||||
<el-input
|
||||
v-model="form.username"
|
||||
placeholder="请输入用户名"
|
||||
autocomplete="username"
|
||||
clearable
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="密码" prop="password">
|
||||
<el-input
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
autocomplete="current-password"
|
||||
show-password
|
||||
/>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="验证码" prop="captcha_code">
|
||||
<div class="captcha">
|
||||
<el-input
|
||||
v-model="form.captcha_code"
|
||||
placeholder="图中字符"
|
||||
maxlength="4"
|
||||
autocomplete="off"
|
||||
class="captcha__input"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="captcha__image"
|
||||
:aria-busy="captchaLoading"
|
||||
title="点击换一张"
|
||||
@click="refreshCaptcha"
|
||||
>
|
||||
<img v-if="captchaImage" :src="captchaImage" alt="验证码" draggable="false" />
|
||||
<span v-else class="captcha__placeholder">加载中</span>
|
||||
</button>
|
||||
</div>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item class="form-actions">
|
||||
<el-button type="primary" class="submit" :loading="submitting" @click="handleSubmit">
|
||||
登 录
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<p class="panel-foot">衍水集团 · 内部系统</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/*
|
||||
* 配色说明:金色分两档 —— 亮金只用于深色底、深金只用于浅色底,
|
||||
* 保证两处对比度都达标(深金配白字约 6.2:1)。
|
||||
*/
|
||||
.login {
|
||||
--ink-900: #061a22;
|
||||
--ink-800: #0a2833;
|
||||
--ink-700: #0f3646;
|
||||
--ink-600: #1a4f63;
|
||||
--gold-bright: #c9a227;
|
||||
--gold-deep: #8f6520;
|
||||
--paper: #fafaf7;
|
||||
--text: #16262d;
|
||||
--muted: #6b7b84;
|
||||
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* ============ 品牌面板 ============ */
|
||||
|
||||
.brand {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex: 1 1 56%;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(158deg, var(--ink-800) 0%, var(--ink-900) 62%, #04141b 100%);
|
||||
}
|
||||
|
||||
/* 水下的光:给居中的内容一个落点,否则深色面板会显得空 */
|
||||
.brand::before {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
content: '';
|
||||
background: radial-gradient(58% 46% at 32% 46%, rgb(32 116 146 / 26%), transparent 72%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.brand-flow {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.brand-flow__line {
|
||||
fill: none;
|
||||
stroke: var(--ink-600);
|
||||
stroke-width: 1;
|
||||
opacity: 0.55;
|
||||
/* 逐条画出:一个统一的入场动作,而非散落特效 */
|
||||
stroke-dasharray: 1200;
|
||||
stroke-dashoffset: 1200;
|
||||
animation: flow-draw 2.4s cubic-bezier(0.22, 0.61, 0.36, 1) forwards;
|
||||
animation-delay: calc(var(--i) * 0.13s);
|
||||
}
|
||||
|
||||
@keyframes flow-draw {
|
||||
to {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.brand-inner {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 56px clamp(40px, 6vw, 104px);
|
||||
}
|
||||
|
||||
/* 内容成块并限宽:超宽屏下文字块不会被拉散 */
|
||||
.brand-block {
|
||||
max-width: 30rem;
|
||||
}
|
||||
|
||||
.brand-name {
|
||||
margin: 0;
|
||||
font-family: 'Source Han Serif SC', 'Noto Serif SC', 'Songti SC', 'SimSun', serif;
|
||||
font-size: clamp(36px, 3.6vw, 54px);
|
||||
font-weight: 600;
|
||||
line-height: 1.15;
|
||||
letter-spacing: 0.2em;
|
||||
color: #f2f6f7;
|
||||
}
|
||||
|
||||
.brand-rule {
|
||||
width: 52px;
|
||||
height: 2px;
|
||||
margin: 24px 0 18px;
|
||||
background-color: var(--gold-bright);
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.brand-system {
|
||||
margin: 0;
|
||||
font-size: clamp(13px, 1.1vw, 16px);
|
||||
font-weight: 300;
|
||||
letter-spacing: 0.28em;
|
||||
color: #9db6c0;
|
||||
}
|
||||
|
||||
.brand-hero {
|
||||
margin: 58px 0 0;
|
||||
font-size: clamp(16px, 1.4vw, 21px);
|
||||
font-weight: 400;
|
||||
line-height: 1.8;
|
||||
letter-spacing: 0.06em;
|
||||
color: #d9e5ea;
|
||||
}
|
||||
|
||||
.brand-hero span {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* 三个业务环节:用间隔点连成一行,比带框的标签轻得多,
|
||||
也不会被误读成可点击的按钮 */
|
||||
.brand-stages {
|
||||
margin: 18px 0 0;
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.24em;
|
||||
color: #6f8b97;
|
||||
}
|
||||
|
||||
/* ============ 表单面板 ============ */
|
||||
|
||||
.panel {
|
||||
display: flex;
|
||||
flex: 1 1 44%;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 48px clamp(28px, 5vw, 80px);
|
||||
background-color: var(--paper);
|
||||
}
|
||||
|
||||
.panel-inner {
|
||||
width: 100%;
|
||||
max-width: 356px;
|
||||
animation: panel-rise 0.7s cubic-bezier(0.22, 0.61, 0.36, 1) 0.35s both;
|
||||
}
|
||||
|
||||
@keyframes panel-rise {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(14px);
|
||||
}
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
margin: 0 0 6px;
|
||||
font-size: 25px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.16em;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.panel-hint {
|
||||
margin: 0 0 30px;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.captcha {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.captcha__input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.captcha__input :deep(input) {
|
||||
font-family: 'SF Mono', 'Cascadia Mono', Consolas, monospace;
|
||||
letter-spacing: 0.2em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.captcha__image {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 120px;
|
||||
height: 40px;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
background-color: #fff;
|
||||
border: 1px solid #dcdfe6;
|
||||
border-radius: 4px;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.captcha__image:hover {
|
||||
border-color: var(--gold-deep);
|
||||
}
|
||||
|
||||
.captcha__image img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.captcha__placeholder {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.form-actions :deep(.el-form-item__content) {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
/* 覆盖 Element Plus 的按钮变量而非直接写 background-color ——
|
||||
后者与 .el-button--primary 同权重,会受样式注入顺序影响而不稳定 */
|
||||
.submit {
|
||||
width: 100%;
|
||||
letter-spacing: 0.4em;
|
||||
--el-button-bg-color: var(--gold-deep);
|
||||
--el-button-border-color: var(--gold-deep);
|
||||
--el-button-hover-bg-color: #a3762a;
|
||||
--el-button-hover-border-color: #a3762a;
|
||||
--el-button-active-bg-color: #7d5719;
|
||||
--el-button-active-border-color: #7d5719;
|
||||
}
|
||||
|
||||
.panel-foot {
|
||||
margin: 32px 0 0;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
letter-spacing: 0.1em;
|
||||
color: #9aa8af;
|
||||
}
|
||||
|
||||
/* 输入框聚焦态也落在同一套配色上 */
|
||||
.panel :deep(.el-input__wrapper) {
|
||||
--el-input-focus-border-color: var(--gold-deep);
|
||||
}
|
||||
|
||||
/* ============ 响应式 ============ */
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.login {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.brand {
|
||||
flex: 0 0 auto;
|
||||
padding: 36px 26px 32px;
|
||||
}
|
||||
|
||||
.brand-inner {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.brand-flow {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.brand-name {
|
||||
font-size: 30px;
|
||||
letter-spacing: 0.16em;
|
||||
}
|
||||
|
||||
.brand-rule {
|
||||
margin: 16px 0 12px;
|
||||
}
|
||||
|
||||
/* 窄屏上只留品牌识别,标语与环节行让位给表单 */
|
||||
.brand-hero,
|
||||
.brand-stages {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.panel {
|
||||
flex: 1;
|
||||
padding: 38px 26px 46px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 尊重系统的「减弱动效」偏好:保留终态,去掉过渡 */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.brand-flow__line {
|
||||
stroke-dashoffset: 0;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.panel-inner {
|
||||
opacity: 1;
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
25
frontend/tsconfig.json
Normal file
25
frontend/tsconfig.json
Normal file
@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"jsx": "preserve",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"isolatedModules": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"types": ["node", "vite/client"],
|
||||
"paths": {
|
||||
// 自 TypeScript 4.1 起 paths 不再需要 baseUrl —— 未指定时按
|
||||
// tsconfig.json 所在目录解析,结果与原先一致。
|
||||
// 保留 baseUrl 会被新版 TypeScript 标记为已弃用选项。
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.vue", "env.d.ts", "vite.config.ts"]
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user