From 273ebf465759b72d93227c82d8cb2d0d20c7e9e9 Mon Sep 17 00:00:00 2001 From: liuwenxin <1009266660@qq.com> Date: Fri, 9 Oct 2026 18:46:36 +0800 Subject: [PATCH] =?UTF-8?q?chore:=20=E5=88=9D=E5=A7=8B=E5=8C=96=E9=A1=B9?= =?UTF-8?q?=E7=9B=AE=E7=BB=93=E6=9E=84=E4=B8=8E=E7=99=BB=E5=BD=95=E9=89=B4?= =?UTF-8?q?=E6=9D=83=E6=A8=A1=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端 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 --- .editorconfig | 28 + .gitattributes | 31 + .gitignore | 56 + .markdownlint.json | 4 + README.md | 135 ++ backend/.env.example | 31 + backend/.python-version | 1 + backend/README.md | 129 ++ backend/alembic.ini | 149 ++ backend/alembic/README | 1 + backend/alembic/env.py | 74 + backend/alembic/script.py.mako | 28 + .../6165691326d4_init_rbac_and_login_logs.py | 353 ++++ backend/app/__init__.py | 1 + backend/app/api/__init__.py | 4 + backend/app/api/deps.py | 73 + backend/app/api/health.py | 52 + backend/app/api/router.py | 20 + backend/app/api/v1/__init__.py | 7 + backend/app/api/v1/auth.py | 86 + backend/app/cli.py | 108 ++ backend/app/core/__init__.py | 1 + backend/app/core/captcha_image.py | 160 ++ backend/app/core/config.py | 103 ++ backend/app/core/constants.py | 38 + backend/app/core/db.py | 64 + backend/app/core/exception_handlers.py | 80 + backend/app/core/exceptions.py | 54 + backend/app/core/logging.py | 94 + backend/app/core/redis_client.py | 41 + backend/app/core/response.py | 62 + backend/app/core/security.py | 102 ++ backend/app/main.py | 66 + backend/app/models/__init__.py | 26 + backend/app/models/base.py | 83 + backend/app/models/login_log.py | 53 + backend/app/models/role.py | 105 ++ backend/app/models/user.py | 102 ++ backend/app/repositories/__init__.py | 7 + .../app/repositories/login_log_repository.py | 38 + backend/app/repositories/user_repository.py | 36 + backend/app/schemas/__init__.py | 9 + backend/app/schemas/auth.py | 73 + backend/app/services/__init__.py | 8 + backend/app/services/auth_service.py | 167 ++ backend/app/services/captcha_service.py | 48 + backend/app/services/login_limiter.py | 54 + backend/app/services/token_blacklist.py | 39 + backend/pyproject.toml | 107 ++ backend/tests/README.md | 98 ++ backend/tests/__init__.py | 1 + backend/tests/conftest.py | 38 + backend/tests/integration/__init__.py | 5 + backend/tests/integration/test_auth_flow.py | 271 +++ backend/tests/integration/test_health.py | 55 + backend/tests/integration/test_schema_sync.py | 89 + backend/tests/unit/__init__.py | 1 + backend/tests/unit/test_auth_schemas.py | 71 + backend/tests/unit/test_captcha.py | 91 + backend/tests/unit/test_config.py | 83 + backend/tests/unit/test_exception_handlers.py | 61 + backend/tests/unit/test_jwt.py | 112 ++ backend/tests/unit/test_logging.py | 111 ++ backend/tests/unit/test_login_limiter.py | 82 + backend/tests/unit/test_models.py | 127 ++ backend/tests/unit/test_response.py | 67 + backend/tests/unit/test_security.py | 66 + backend/tests/unit/test_token_blacklist.py | 60 + backend/uv.lock | 847 +++++++++ db/init/init-dev.sql | 152 ++ db/migration/V1__init_rbac_and_login_logs.sql | 103 ++ db/queries/.gitkeep | 0 docker/.env.example | 14 + docker/docker-compose.dev.yml | 55 + docs/2026.9.8修改方案.docx | Bin 0 -> 15815 bytes docs/业务确认问题清单.docx | Bin 0 -> 11571 bytes .../衍水绩效管理系统功能需规.docx | Bin 0 -> 44851 bytes frontend/.env.development | 9 + frontend/.env.production | 8 + frontend/README.md | 122 ++ frontend/e2e/login_flow.py | 190 ++ frontend/env.d.ts | 18 + frontend/index.html | 12 + frontend/package.json | 28 + frontend/pnpm-lock.yaml | 1523 +++++++++++++++++ frontend/pnpm-workspace.yaml | 2 + frontend/src/App.vue | 7 + frontend/src/api/auth.ts | 51 + frontend/src/api/request.ts | 88 + frontend/src/assets/styles/index.css | 22 + frontend/src/components/.gitkeep | 0 frontend/src/composables/.gitkeep | 0 frontend/src/main.ts | 12 + frontend/src/router/index.ts | 65 + frontend/src/store/index.ts | 5 + frontend/src/store/user.ts | 53 + frontend/src/utils/storage.ts | 21 + frontend/src/views/dashboard/index.vue | 98 ++ frontend/src/views/login/index.vue | 478 ++++++ frontend/tsconfig.json | 25 + frontend/vite.config.ts | 37 + 101 files changed, 8725 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 .markdownlint.json create mode 100644 README.md create mode 100644 backend/.env.example create mode 100644 backend/.python-version create mode 100644 backend/README.md create mode 100644 backend/alembic.ini create mode 100644 backend/alembic/README create mode 100644 backend/alembic/env.py create mode 100644 backend/alembic/script.py.mako create mode 100644 backend/alembic/versions/6165691326d4_init_rbac_and_login_logs.py create mode 100644 backend/app/__init__.py create mode 100644 backend/app/api/__init__.py create mode 100644 backend/app/api/deps.py create mode 100644 backend/app/api/health.py create mode 100644 backend/app/api/router.py create mode 100644 backend/app/api/v1/__init__.py create mode 100644 backend/app/api/v1/auth.py create mode 100644 backend/app/cli.py create mode 100644 backend/app/core/__init__.py create mode 100644 backend/app/core/captcha_image.py create mode 100644 backend/app/core/config.py create mode 100644 backend/app/core/constants.py create mode 100644 backend/app/core/db.py create mode 100644 backend/app/core/exception_handlers.py create mode 100644 backend/app/core/exceptions.py create mode 100644 backend/app/core/logging.py create mode 100644 backend/app/core/redis_client.py create mode 100644 backend/app/core/response.py create mode 100644 backend/app/core/security.py create mode 100644 backend/app/main.py create mode 100644 backend/app/models/__init__.py create mode 100644 backend/app/models/base.py create mode 100644 backend/app/models/login_log.py create mode 100644 backend/app/models/role.py create mode 100644 backend/app/models/user.py create mode 100644 backend/app/repositories/__init__.py create mode 100644 backend/app/repositories/login_log_repository.py create mode 100644 backend/app/repositories/user_repository.py create mode 100644 backend/app/schemas/__init__.py create mode 100644 backend/app/schemas/auth.py create mode 100644 backend/app/services/__init__.py create mode 100644 backend/app/services/auth_service.py create mode 100644 backend/app/services/captcha_service.py create mode 100644 backend/app/services/login_limiter.py create mode 100644 backend/app/services/token_blacklist.py create mode 100644 backend/pyproject.toml create mode 100644 backend/tests/README.md create mode 100644 backend/tests/__init__.py create mode 100644 backend/tests/conftest.py create mode 100644 backend/tests/integration/__init__.py create mode 100644 backend/tests/integration/test_auth_flow.py create mode 100644 backend/tests/integration/test_health.py create mode 100644 backend/tests/integration/test_schema_sync.py create mode 100644 backend/tests/unit/__init__.py create mode 100644 backend/tests/unit/test_auth_schemas.py create mode 100644 backend/tests/unit/test_captcha.py create mode 100644 backend/tests/unit/test_config.py create mode 100644 backend/tests/unit/test_exception_handlers.py create mode 100644 backend/tests/unit/test_jwt.py create mode 100644 backend/tests/unit/test_logging.py create mode 100644 backend/tests/unit/test_login_limiter.py create mode 100644 backend/tests/unit/test_models.py create mode 100644 backend/tests/unit/test_response.py create mode 100644 backend/tests/unit/test_security.py create mode 100644 backend/tests/unit/test_token_blacklist.py create mode 100644 backend/uv.lock create mode 100644 db/init/init-dev.sql create mode 100644 db/migration/V1__init_rbac_and_login_logs.sql create mode 100644 db/queries/.gitkeep create mode 100644 docker/.env.example create mode 100644 docker/docker-compose.dev.yml create mode 100644 docs/2026.9.8修改方案.docx create mode 100644 docs/业务确认问题清单.docx create mode 100644 docs/衍水绩效管理系统功能需规.docx create mode 100644 frontend/.env.development create mode 100644 frontend/.env.production create mode 100644 frontend/README.md create mode 100644 frontend/e2e/login_flow.py create mode 100644 frontend/env.d.ts create mode 100644 frontend/index.html create mode 100644 frontend/package.json create mode 100644 frontend/pnpm-lock.yaml create mode 100644 frontend/pnpm-workspace.yaml create mode 100644 frontend/src/App.vue create mode 100644 frontend/src/api/auth.ts create mode 100644 frontend/src/api/request.ts create mode 100644 frontend/src/assets/styles/index.css create mode 100644 frontend/src/components/.gitkeep create mode 100644 frontend/src/composables/.gitkeep create mode 100644 frontend/src/main.ts create mode 100644 frontend/src/router/index.ts create mode 100644 frontend/src/store/index.ts create mode 100644 frontend/src/store/user.ts create mode 100644 frontend/src/utils/storage.ts create mode 100644 frontend/src/views/dashboard/index.vue create mode 100644 frontend/src/views/login/index.vue create mode 100644 frontend/tsconfig.json create mode 100644 frontend/vite.config.ts diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..319aefd --- /dev/null +++ b/.editorconfig @@ -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 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..4848d88 --- /dev/null +++ b/.gitattributes @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6f27c66 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/.markdownlint.json b/.markdownlint.json new file mode 100644 index 0000000..d500419 --- /dev/null +++ b/.markdownlint.json @@ -0,0 +1,4 @@ +{ + "$schema": "https://raw.githubusercontent.com/DavidAnson/markdownlint/main/schema/markdownlint-config-schema.json", + "MD013": false +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..dd5d4df --- /dev/null +++ b/README.md @@ -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 +``` + +打开 即可登录。接口文档在 。 + +> **务必加 `--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/` | 需求文档与工资算法方案 | diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..863150f --- /dev/null +++ b/backend/.env.example @@ -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= diff --git a/backend/.python-version b/backend/.python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/backend/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..4300b80 --- /dev/null +++ b/backend/README.md @@ -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`。 diff --git a/backend/alembic.ini b/backend/alembic.ini new file mode 100644 index 0000000..807ded2 --- /dev/null +++ b/backend/alembic.ini @@ -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 /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 diff --git a/backend/alembic/README b/backend/alembic/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/backend/alembic/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/backend/alembic/env.py b/backend/alembic/env.py new file mode 100644 index 0000000..05e8978 --- /dev/null +++ b/backend/alembic/env.py @@ -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() diff --git a/backend/alembic/script.py.mako b/backend/alembic/script.py.mako new file mode 100644 index 0000000..1101630 --- /dev/null +++ b/backend/alembic/script.py.mako @@ -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"} diff --git a/backend/alembic/versions/6165691326d4_init_rbac_and_login_logs.py b/backend/alembic/versions/6165691326d4_init_rbac_and_login_logs.py new file mode 100644 index 0000000..44db76a --- /dev/null +++ b/backend/alembic/versions/6165691326d4_init_rbac_and_login_logs.py @@ -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 ### diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..6d08118 --- /dev/null +++ b/backend/app/__init__.py @@ -0,0 +1 @@ +"""衍水集团营销人员工资管理系统 —— 后端应用包。""" diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..ee3fca1 --- /dev/null +++ b/backend/app/api/__init__.py @@ -0,0 +1,4 @@ +"""HTTP 接口层:路由声明、依赖注入、参数校验、调用 service、返回统一响应。 + +禁止在此层写业务判断或直接拼 SQL。 +""" diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py new file mode 100644 index 0000000..6115b33 --- /dev/null +++ b/backend/app/api/deps.py @@ -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() diff --git a/backend/app/api/health.py b/backend/app/api/health.py new file mode 100644 index 0000000..0fad20a --- /dev/null +++ b/backend/app/api/health.py @@ -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()}) diff --git a/backend/app/api/router.py b/backend/app/api/router.py new file mode 100644 index 0000000..692fa8a --- /dev/null +++ b/backend/app/api/router.py @@ -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) diff --git a/backend/app/api/v1/__init__.py b/backend/app/api/v1/__init__.py new file mode 100644 index 0000000..e4a5c55 --- /dev/null +++ b/backend/app/api/v1/__init__.py @@ -0,0 +1,7 @@ +"""API v1 版本化路由。 + +业务接口按模块拆分为独立文件挂载于此(如 `auth.py`); +跨模块共享的依赖(get_db、get_current_user 等)放在上一级 `api/deps.py`。 + +健康检查是运维探针,固定挂在 `/api/health`,不带版本号。 +""" diff --git a/backend/app/api/v1/auth.py b/backend/app/api/v1/auth.py new file mode 100644 index 0000000..62150c4 --- /dev/null +++ b/backend/app/api/v1/auth.py @@ -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, + ) + ) diff --git a/backend/app/cli.py b/backend/app/cli.py new file mode 100644 index 0000000..f0689d7 --- /dev/null +++ b/backend/app/cli.py @@ -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()) diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py new file mode 100644 index 0000000..085752d --- /dev/null +++ b/backend/app/core/__init__.py @@ -0,0 +1 @@ +"""横切基础设施:配置、安全、连接、异常、响应、日志。不含业务规则。""" diff --git a/backend/app/core/captcha_image.py b/backend/app/core/captcha_image.py new file mode 100644 index 0000000..eb7c631 --- /dev/null +++ b/backend/app/core/captcha_image.py @@ -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") diff --git a/backend/app/core/config.py b/backend/app/core/config.py new file mode 100644 index 0000000..9a930d7 --- /dev/null +++ b/backend/app/core/config.py @@ -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] diff --git a/backend/app/core/constants.py b/backend/app/core/constants.py new file mode 100644 index 0000000..5074c0d --- /dev/null +++ b/backend/app/core/constants.py @@ -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 diff --git a/backend/app/core/db.py b/backend/app/core/db.py new file mode 100644 index 0000000..43f7940 --- /dev/null +++ b/backend/app/core/db.py @@ -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 diff --git a/backend/app/core/exception_handlers.py b/backend/app/core/exception_handlers.py new file mode 100644 index 0000000..78fc7bb --- /dev/null +++ b/backend/app/core/exception_handlers.py @@ -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) diff --git a/backend/app/core/exceptions.py b/backend/app/core/exceptions.py new file mode 100644 index 0000000..cf353e7 --- /dev/null +++ b/backend/app/core/exceptions.py @@ -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 = "资源不存在" diff --git a/backend/app/core/logging.py b/backend/app/core/logging.py new file mode 100644 index 0000000..c26f18b --- /dev/null +++ b/backend/app/core/logging.py @@ -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) diff --git a/backend/app/core/redis_client.py b/backend/app/core/redis_client.py new file mode 100644 index 0000000..58e9403 --- /dev/null +++ b/backend/app/core/redis_client.py @@ -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 diff --git a/backend/app/core/response.py b/backend/app/core/response.py new file mode 100644 index 0000000..bbae9e0 --- /dev/null +++ b/backend/app/core/response.py @@ -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 diff --git a/backend/app/core/security.py b/backend/app/core/security.py new file mode 100644 index 0000000..8bc5c30 --- /dev/null +++ b/backend/app/core/security.py @@ -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 diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..a7a8a72 --- /dev/null +++ b/backend/app/main.py @@ -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() diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..821725f --- /dev/null +++ b/backend/app/models/__init__.py @@ -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", +] diff --git a/backend/app/models/base.py b/backend/app/models/base.py new file mode 100644 index 0000000..59f932e --- /dev/null +++ b/backend/app/models/base.py @@ -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, + }, + ) diff --git a/backend/app/models/login_log.py b/backend/app/models/login_log.py new file mode 100644 index 0000000..ecb93da --- /dev/null +++ b/backend/app/models/login_log.py @@ -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="创建时间" + ) diff --git a/backend/app/models/role.py b/backend/app/models/role.py new file mode 100644 index 0000000..efe49d3 --- /dev/null +++ b/backend/app/models/role.py @@ -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="创建时间" + ) diff --git a/backend/app/models/user.py b/backend/app/models/user.py new file mode 100644 index 0000000..211ccd1 --- /dev/null +++ b/backend/app/models/user.py @@ -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") diff --git a/backend/app/repositories/__init__.py b/backend/app/repositories/__init__.py new file mode 100644 index 0000000..b29d467 --- /dev/null +++ b/backend/app/repositories/__init__.py @@ -0,0 +1,7 @@ +"""数据访问层:只负责数据读写。 + +分层约束: +- **只 flush,不 commit** —— 事务边界由 `services/` 层控制 +- 不抛业务异常;查不到数据返回 None 或空集合,由 service 决定如何处置 +- 不在此层做权限判断或业务规则校验 +""" diff --git a/backend/app/repositories/login_log_repository.py b/backend/app/repositories/login_log_repository.py new file mode 100644 index 0000000..5c230bc --- /dev/null +++ b/backend/app/repositories/login_log_repository.py @@ -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() diff --git a/backend/app/repositories/user_repository.py b/backend/app/repositories/user_repository.py new file mode 100644 index 0000000..314968b --- /dev/null +++ b/backend/app/repositories/user_repository.py @@ -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() diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..2c7ed2d --- /dev/null +++ b/backend/app/schemas/__init__.py @@ -0,0 +1,9 @@ +"""请求/响应模型层(Pydantic)。 + +分层约束: +- 禁止把 ORM 对象直接作为 `response_model` 返回,必须在本层完成转换 +- 入参校验(含长度、格式、取值范围)在此层声明,业务校验放 `services/` + +本项目特有要求:密码字段必须按**字节数**校验而非字符数 —— +bcrypt 5.0 起对超过 72 字节的密码直接抛 ValueError,而中文一个字符占 3 字节。 +""" diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py new file mode 100644 index 0000000..c258d32 --- /dev/null +++ b/backend/app/schemas/auth.py @@ -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 diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..bbb1926 --- /dev/null +++ b/backend/app/services/__init__.py @@ -0,0 +1,8 @@ +"""业务服务层:业务编排与事务边界。 + +分层约束: +- **唯一允许出现 commit/rollback 的层**。repository 只 flush,由本层决定何时提交, + 这样一次请求内的多次数据写入能共处同一事务 +- 可调用多个 repository 组合业务逻辑 +- 业务规则不满足时抛 `AppException` 子类,由全局异常处理器转换为统一响应 +""" diff --git a/backend/app/services/auth_service.py b/backend/app/services/auth_service.py new file mode 100644 index 0000000..cc51f2a --- /dev/null +++ b/backend/app/services/auth_service.py @@ -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) diff --git a/backend/app/services/captcha_service.py b/backend/app/services/captcha_service.py new file mode 100644 index 0000000..41ce91b --- /dev/null +++ b/backend/app/services/captcha_service.py @@ -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() diff --git a/backend/app/services/login_limiter.py b/backend/app/services/login_limiter.py new file mode 100644 index 0000000..6aec497 --- /dev/null +++ b/backend/app/services/login_limiter.py @@ -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)) diff --git a/backend/app/services/token_blacklist.py b/backend/app/services/token_blacklist.py new file mode 100644 index 0000000..8415ebf --- /dev/null +++ b/backend/app/services/token_blacklist.py @@ -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))) diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..0a7dce5 --- /dev/null +++ b/backend/pyproject.toml @@ -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" diff --git a/backend/tests/README.md b/backend/tests/README.md new file mode 100644 index 0000000..cc75c6a --- /dev/null +++ b/backend/tests/README.md @@ -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 日斜率) | +| 报表统计 | 未开始 | 依赖报表口径确认 | diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..f493e35 --- /dev/null +++ b/backend/tests/__init__.py @@ -0,0 +1 @@ +"""后端测试包。""" diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..936b326 --- /dev/null +++ b/backend/tests/conftest.py @@ -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() diff --git a/backend/tests/integration/__init__.py b/backend/tests/integration/__init__.py new file mode 100644 index 0000000..2502bd2 --- /dev/null +++ b/backend/tests/integration/__init__.py @@ -0,0 +1,5 @@ +"""需要构造完整应用或依赖外部服务(MySQL/Redis)的测试。 + +`tests/unit/` 下的用例必须能在没有 Docker 的机器上秒跑; +凡是要连容器或用例起整个 FastAPI 应用的,归入本目录。 +""" diff --git a/backend/tests/integration/test_auth_flow.py b/backend/tests/integration/test_auth_flow.py new file mode 100644 index 0000000..d7e88ce --- /dev/null +++ b/backend/tests/integration/test_auth_flow.py @@ -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__) diff --git a/backend/tests/integration/test_health.py b/backend/tests/integration/test_health.py new file mode 100644 index 0000000..471b201 --- /dev/null +++ b/backend/tests/integration/test_health.py @@ -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"] diff --git a/backend/tests/integration/test_schema_sync.py b/backend/tests/integration/test_schema_sync.py new file mode 100644 index 0000000..0634e11 --- /dev/null +++ b/backend/tests/integration/test_schema_sync.py @@ -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 diff --git a/backend/tests/unit/__init__.py b/backend/tests/unit/__init__.py new file mode 100644 index 0000000..f26acfd --- /dev/null +++ b/backend/tests/unit/__init__.py @@ -0,0 +1 @@ +"""不依赖外部服务的单元测试。""" diff --git a/backend/tests/unit/test_auth_schemas.py b/backend/tests/unit/test_auth_schemas.py new file mode 100644 index 0000000..c13e594 --- /dev/null +++ b/backend/tests/unit/test_auth_schemas.py @@ -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" diff --git a/backend/tests/unit/test_captcha.py b/backend/tests/unit/test_captcha.py new file mode 100644 index 0000000..b260faa --- /dev/null +++ b/backend/tests/unit/test_captcha.py @@ -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) diff --git a/backend/tests/unit/test_config.py b/backend/tests/unit/test_config.py new file mode 100644 index 0000000..dc85672 --- /dev/null +++ b/backend/tests/unit/test_config.py @@ -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" diff --git a/backend/tests/unit/test_exception_handlers.py b/backend/tests/unit/test_exception_handlers.py new file mode 100644 index 0000000..ee56fb2 --- /dev/null +++ b/backend/tests/unit/test_exception_handlers.py @@ -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 diff --git a/backend/tests/unit/test_jwt.py b/backend/tests/unit/test_jwt.py new file mode 100644 index 0000000..62fd1ae --- /dev/null +++ b/backend/tests/unit/test_jwt.py @@ -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) diff --git a/backend/tests/unit/test_logging.py b/backend/tests/unit/test_logging.py new file mode 100644 index 0000000..3af3860 --- /dev/null +++ b/backend/tests/unit/test_logging.py @@ -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 diff --git a/backend/tests/unit/test_login_limiter.py b/backend/tests/unit/test_login_limiter.py new file mode 100644 index 0000000..4887dd0 --- /dev/null +++ b/backend/tests/unit/test_login_limiter.py @@ -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 diff --git a/backend/tests/unit/test_models.py b/backend/tests/unit/test_models.py new file mode 100644 index 0000000..139bf13 --- /dev/null +++ b/backend/tests/unit/test_models.py @@ -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") diff --git a/backend/tests/unit/test_response.py b/backend/tests/unit/test_response.py new file mode 100644 index 0000000..d89b108 --- /dev/null +++ b/backend/tests/unit/test_response.py @@ -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) diff --git a/backend/tests/unit/test_security.py b/backend/tests/unit/test_security.py new file mode 100644 index 0000000..0908667 --- /dev/null +++ b/backend/tests/unit/test_security.py @@ -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 diff --git a/backend/tests/unit/test_token_blacklist.py b/backend/tests/unit/test_token_blacklist.py new file mode 100644 index 0000000..d157ffb --- /dev/null +++ b/backend/tests/unit/test_token_blacklist.py @@ -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 diff --git a/backend/uv.lock b/backend/uv.lock new file mode 100644 index 0000000..d8159fe --- /dev/null +++ b/backend/uv.lock @@ -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" }, +] diff --git a/db/init/init-dev.sql b/db/init/init-dev.sql new file mode 100644 index 0000000..51f9c85 --- /dev/null +++ b/db/init/init-dev.sql @@ -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'); diff --git a/db/migration/V1__init_rbac_and_login_logs.sql b/db/migration/V1__init_rbac_and_login_logs.sql new file mode 100644 index 0000000..64879d0 --- /dev/null +++ b/db/migration/V1__init_rbac_and_login_logs.sql @@ -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'); + diff --git a/db/queries/.gitkeep b/db/queries/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docker/.env.example b/docker/.env.example new file mode 100644 index 0000000..ca5377d --- /dev/null +++ b/docker/.env.example @@ -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= diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml new file mode 100644 index 0000000..7cf69c3 --- /dev/null +++ b/docker/docker-compose.dev.yml @@ -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: diff --git a/docs/2026.9.8修改方案.docx b/docs/2026.9.8修改方案.docx new file mode 100644 index 0000000000000000000000000000000000000000..6b96bda11ea804ea33e102bf5010924152c968d3 GIT binary patch literal 15815 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 G^#1`pd}!7H literal 0 HcmV?d00001 diff --git a/docs/业务确认问题清单.docx b/docs/业务确认问题清单.docx new file mode 100644 index 0000000000000000000000000000000000000000..5b0d35c0c1eb8bb4a1212377b8210f5c9351d08f GIT binary patch literal 11571 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 literal 0 HcmV?d00001 diff --git a/frontend/.env.development b/frontend/.env.development new file mode 100644 index 0000000..3cf63d0 --- /dev/null +++ b/frontend/.env.development @@ -0,0 +1,9 @@ +# 开发环境配置(纳入版本管理 —— 不含任何敏感信息) +# +# VITE_ 前缀是 Vite 的约定:只有该前缀的变量会被注入客户端代码。 +# 因此**绝不要**在此类文件中放密钥 —— 它们会被打包进浏览器可见的产物。 + +VITE_APP_TITLE=衍水集团营销人员工资管理系统 +# 走 Vite 代理,指向相对路径而非后端绝对地址(规范禁止前端硬编码后端 URL) +VITE_API_BASE_URL=/api +VITE_PORT=5173 diff --git a/frontend/.env.production b/frontend/.env.production new file mode 100644 index 0000000..667e1a6 --- /dev/null +++ b/frontend/.env.production @@ -0,0 +1,8 @@ +# 生产环境配置(纳入版本管理 —— 不含任何敏感信息) +# +# 保持相对路径 /api:生产由 Nginx 反向代理到后端。 +# 若写成后端绝对地址,一是违反「前端禁硬编码后端 URL」的规范, +# 二是换个部署域名就要重新打包。 + +VITE_APP_TITLE=衍水集团营销人员工资管理系统 +VITE_API_BASE_URL=/api diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..c86e097 --- /dev/null +++ b/frontend/README.md @@ -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` 同权重,会受样式注入顺序影响而不稳定 +- 中文可在任意字间断行,**关键文案的断行位置要显式控制**(如主标语拆成两个 ``),交给浏览器自动换行会出现单字独占一行的难看结果 +- 新页面需响应式到移动端、保留键盘焦点可见、并尊重 `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)。 diff --git a/frontend/e2e/login_flow.py b/frontend/e2e/login_flow.py new file mode 100644 index 0000000..8fbfa89 --- /dev/null +++ b/frontend/e2e/login_flow.py @@ -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()) diff --git a/frontend/env.d.ts b/frontend/env.d.ts new file mode 100644 index 0000000..649ff1f --- /dev/null +++ b/frontend/env.d.ts @@ -0,0 +1,18 @@ +/// + +declare module '*.vue' { + import type { DefineComponent } from 'vue' + const component: DefineComponent, Record, 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 +} diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..a90b6a4 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,12 @@ + + + + + + 衍水集团营销人员工资管理系统 + + +
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..0e6d432 --- /dev/null +++ b/frontend/package.json @@ -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" + } +} diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml new file mode 100644 index 0000000..4e7a896 --- /dev/null +++ b/frontend/pnpm-lock.yaml @@ -0,0 +1,1523 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@vue/devtools-api': + specifier: ^8.1.5 + version: 8.2.1 + axios: + specifier: ^1.20.0 + version: 1.20.0(debug@4.4.3) + element-plus: + specifier: ^2.14.7 + version: 2.14.7(vue@3.5.43(typescript@5.9.3)) + pinia: + specifier: ^4.0.3 + version: 4.0.3(@vue/devtools-api@8.2.1)(typescript@5.9.3)(vue@3.5.43(typescript@5.9.3)) + vue: + specifier: ^3.5.43 + version: 3.5.43(typescript@5.9.3) + vue-router: + specifier: ^5.4.0 + version: 5.4.0(@vue/compiler-sfc@3.5.43)(pinia@4.0.3(@vue/devtools-api@8.2.1)(typescript@5.9.3)(vue@3.5.43(typescript@5.9.3)))(rolldown@1.2.13)(vite@8.3.4(@types/node@24.19.1))(vue@3.5.43(typescript@5.9.3)) + devDependencies: + '@types/node': + specifier: ^24.0.0 + version: 24.19.1 + '@vitejs/plugin-vue': + specifier: ^6.0.9 + version: 6.0.9(vite@8.3.4(@types/node@24.19.1))(vue@3.5.43(typescript@5.9.3)) + typescript: + specifier: ~5.9.3 + version: 5.9.3 + vite: + specifier: ^8.3.4 + version: 8.3.4(@types/node@24.19.1) + vue-tsc: + specifier: ^3.3.12 + version: 3.3.12(typescript@5.9.3) + +packages: + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-string-parser@8.0.6': + resolution: {integrity: sha512-+6HYEBnsDRZHJtUTAEfMQd0zMDqdFHS7C+5vAsEZqBUr3vEMWOWlHJ0Jz1gS8jJIlCvLsuA6ivmeoqzH936YcA==} + engines: {node: ^22.18.0 || >=24.11.0} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@8.0.6': + resolution: {integrity: sha512-HYx0YmuchRxRSpnjvcNffnPS8DCauKNmnumIuHG3jnQAVewRA7kfJGwF13ayEpSZs7s4R/oBH4eppuXcAQd5JQ==} + engines: {node: ^22.18.0 || >=24.11.0} + + '@babel/parser@7.29.9': + resolution: {integrity: sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/parser@8.0.7': + resolution: {integrity: sha512-UkDwEbPO1px2JQSrdh4qIgaH1JgG7GO/pFeJkpxhDxIETkT/QvGpnigXIw75ViHYPtIgKITmVUJqLjiO+JuWjA==} + engines: {node: ^22.18.0 || >=24.11.0} + hasBin: true + + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + + '@babel/types@8.0.6': + resolution: {integrity: sha512-c+xgSWboV2pdwXP67BUWDVRHi2BO5Q3KwxyFVs7taVTY+fsK47/L51ZXQoV9rozHJDNHvJ4v4WEsI/IlxV2l2A==} + engines: {node: ^22.18.0 || >=24.11.0} + + '@ctrl/tinycolor@4.2.1': + resolution: {integrity: sha512-Vh5uy9Y1JBMId6J1f68TMXnZrTCvNdo9Fu2k0d1YnJngR+sM5kWTKl441c/3TFs+cG0NWOjRWgQ/iaWhbYx+7A==} + engines: {node: '>=14'} + + '@element-plus/icons-vue@2.3.2': + resolution: {integrity: sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==} + peerDependencies: + vue: ^3.2.0 + + '@floating-ui/core@1.8.0': + resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==} + + '@floating-ui/dom@1.8.0': + resolution: {integrity: sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==} + + '@floating-ui/utils@0.2.12': + resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@oxc-project/types@0.153.0': + resolution: {integrity: sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw==} + + '@rolldown/binding-android-arm-eabi@1.2.13': + resolution: {integrity: sha512-Vu/NICrgdRvUOeaJZIyB2FP63s6fO/4ud5l8dXmxDx9yDyn+mN0y+gsvsVviSli+T361Aqo0h46EuMoobrwawA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@rolldown/binding-android-arm64@1.2.13': + resolution: {integrity: sha512-6LEoIrGy5vvCd2boNnv2soPSUZj/PRvhp8R/t9KlX+mIZxThIE/ZqxrGXxmuNX0FuvQFvLdZr7S4HrYpYBVhEQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.13': + resolution: {integrity: sha512-CaJSRzlQ6Q+zkDMVA9OkVxBjVoPRCDEE3v0FSJ9kIQBgRjFApA3Si4ijhbNVUji7ZJt+PfO0e46qy4nPInHUPA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.13': + resolution: {integrity: sha512-e4r340I5hxSzJkD3YJBxgzq/rJ3wwxwdLLb2FPt1Zpyem20nrQPT11DDEEkazwxaHumH8wpglsp/Ozgj/wT6mw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.13': + resolution: {integrity: sha512-hoBWDZ4/K/mJKVMbsgUd1jfpA2DiOrJ54mpegObU9TBHBBrtX4FLIXJJQRDPZ/2Dn91GvPob+Z2hwKJzf4E5SQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.13': + resolution: {integrity: sha512-oF9fZu3vTWnTiVAkDFxU9PJLt1nh20K9RRpuHsR7ZnE2L7XookBbDBVLojCVSLGbvtSlxeUNnjAj6DbNsKyzbg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.13': + resolution: {integrity: sha512-DTpb/+jMiqWMPgphn4ksW1zW+XLJ6dJJ9FMvnIk9I291LhCctMPhMYJrP5t9p6C9qa3KXadDdUEZHBaPh3kNxQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.2.13': + resolution: {integrity: sha512-xU+Kvv9N2A3SY35zFh1FWWNjxbadE/BKeqnkXe7vScU4GZsw757BlVmDLITZEq8DPpk6m7uoDYkK7P+x/uU5Ow==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.2.13': + resolution: {integrity: sha512-XMk/hnhh25oQ756Lclb1Q11h4ll0xC0Esa5w4Vzp+mAE2S3AGJgeM0SonrIgY8fHhjarb6hcoTuwoFG0VVP2ig==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.2.13': + resolution: {integrity: sha512-U1BoTHYmlywE2n3HUWjzZ+8EQsKr+OwpxYuDWaubx42tUL8QSL7nOqRz/NVHanibbFP3M1Rxuprz/wNcbMKjqw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.2.13': + resolution: {integrity: sha512-J4cm80Pc6wEwV5AZ42o62sIQgDk+Y7R2s646jIwuvO+4V46QB58N4vVuMPWCBaNgursXLpmUWJV//WQAkPvHBQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.2.13': + resolution: {integrity: sha512-W0euZS40U4V6JkmEnVCIX9mcsL6cbXQ4X8c2w+MMOIjFg68TXUW+u3RT+2e1t1pv9mg/EP3pDtK3POXdQ7Rxyw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.2.13': + resolution: {integrity: sha512-do/AF/4n9MRshia3a5aXoy8xM2c6mgfT6D7H/dY6my5z+hfCIR4ab7mk4IRj++nsEQpKy39kd0DQnDq+r692tw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-win32-arm64-msvc@1.2.13': + resolution: {integrity: sha512-6FtUf2cJZobutJdxnGYX8z0vtbgqU0b2v8vfBhkXb9wp2MppMFtkunE+IaOntyi01rkIQoPW2GHABtfzsC0BRQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.13': + resolution: {integrity: sha512-UKhFaZTeTcbKrb3iU82m17jVcPEAoqDkCN5MBqgPyZSwp/cQmib21crTS0DdH7T1B7wZRWEI89YQAOJMet5yhg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@sxzz/popperjs-es@2.11.8': + resolution: {integrity: sha512-wOwESXvvED3S8xBmcPWHs2dUuzrE4XiZeFu7e1hROIJkm02a49N120pmOXxY33sBb6hArItm5W5tcg1cBtV+HQ==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/lodash-es@4.17.12': + resolution: {integrity: sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==} + + '@types/lodash@4.17.25': + resolution: {integrity: sha512-+K1NIO8I+F9/wNulfVvu23QYd0Pe9/OCqRrim4NoYIf1VoEDL90Ve4ClzpyqBLc7NpGGWRvYNCKZ1BE/Jpf8dQ==} + + '@types/node@24.19.1': + resolution: {integrity: sha512-aS3/DG0oM05K0RIXXP+hKjinGG5IgSSVGzswZxW3O0sS3pH4/fycXundUC9XsszgKCk4gHXylTEK6hyFxVxnoQ==} + + '@types/web-bluetooth@0.0.21': + resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==} + + '@vitejs/plugin-vue@6.0.9': + resolution: {integrity: sha512-rD/MORlhaZMlXWW0rEn4FB4wMinWC0z/D7Ye160S5+Rs1mC8ZDAdcDp4SjKfUhG5t8q1ktcPVw4xuTkPRlzrSA==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + vue: ^3.2.25 + + '@volar/language-core@2.4.28': + resolution: {integrity: sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==} + + '@volar/source-map@2.4.28': + resolution: {integrity: sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==} + + '@volar/typescript@2.4.28': + resolution: {integrity: sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@vue-macros/common@3.1.4': + resolution: {integrity: sha512-/5Fv+6DgIcM9ajY05ZmKBv+LMX1M9A0X+IUwDRVdt67ciw8OV9bvG2r34p3RiEadlsQybjhKPRKNXDC8Bp23cw==} + engines: {node: '>=20.19.0'} + peerDependencies: + vue: ^2.7.0 || ^3.2.25 + peerDependenciesMeta: + vue: + optional: true + + '@vue/compiler-core@3.5.43': + resolution: {integrity: sha512-zdiLhnbe1QQqgDT8xZMpNmyqZ3qlI+/Q/FHQco57Kwl/b05HhCzN6eVGN9QU9rbga4CrS0H5SYY8VGHZCt/1Hg==} + + '@vue/compiler-dom@3.5.43': + resolution: {integrity: sha512-PEZoAk3NQmsn/ejMzSOCyTYqwGqczrWm70PuhBKjjv1+TCoQAaO/zOqNwjV+honlNstT5ILxtc+8r8UUfj+iEQ==} + + '@vue/compiler-sfc@3.5.43': + resolution: {integrity: sha512-FCbrG3XNCRl+js3huuKx4IVHBLTvMkJhVepjbxSPu1gn4yWLaYtGNQdjJGZaMytXB6qb76qQDDmDSLy/vkmleQ==} + + '@vue/compiler-ssr@3.5.43': + resolution: {integrity: sha512-GF62orf7KiJX9RqrHNGrYBudsQGD0OhJ5nDs90O8UiDuS40+YMYomiXu6w6EuvtXuRDc3MSNis3EaaSKAVSWpg==} + + '@vue/devtools-api@8.2.1': + resolution: {integrity: sha512-6u4vXBlIBAC1wMplIZgpyPn7uh/s4Bf6F5bMzvLv+EdJ0aHs/+4B7Ygv864EStQSjRbsRzTko/kUG1A1IejQ3A==} + + '@vue/devtools-kit@8.2.1': + resolution: {integrity: sha512-FIGIuq3AWReEpbAHY/cRGeHDfI0qOb8OCQ3YjbEAX04uaxIDbGc9rhkbVcG7rnfHPXE3RsU5KrWOu9V/okd8AQ==} + + '@vue/devtools-shared@8.2.1': + resolution: {integrity: sha512-Fkac7lUdGReh6pVOi3AYPRGe82LQqRmAfThW7RRligOAP0ZA/Z1z9XLHDM9dv34pV2HRc79DK8uKPeG2fLnA/g==} + + '@vue/language-core@3.3.12': + resolution: {integrity: sha512-k7uIKR0n4CBidk233y7cac9osnDQ5WRidTl6nsrNvpZ3nf8cx9iJxRVsRzVsoPRHbowT/iKgGMTS9RGov3jeIA==} + + '@vue/reactivity@3.5.43': + resolution: {integrity: sha512-G/c9GyOZNI2jVaaS6OX1EF1SSFSv7H0ERqNTl4+DTFMlZmB5eVAB53aLQNam/7NL2NPtaDD7RdVrzf8uJzMuOA==} + + '@vue/runtime-core@3.5.43': + resolution: {integrity: sha512-hU6U6VnVhBGQDpvlnnDlIB8ZGJBiOcgk2lh/0InltHiz3D8oSkluvuvY+do1G2H3+udeKFsmaBlgVYP7gXQzEw==} + + '@vue/runtime-dom@3.5.43': + resolution: {integrity: sha512-Bb2Jc0YjjJdMt1SJmb9b2L/IWd3I8lIT9x9eS/xvvP9CiVgna0ffua74xKRmt4/uSJ+0r4iN8ex1jrqkhQGWQw==} + + '@vue/server-renderer@3.5.43': + resolution: {integrity: sha512-l2Ygjv9NehV94PSBxNWsAHC0j/eIIKbn92mBuWXAPGLnn6HfJ6MH5ubsd+Nk0YoZ5FRuxWI1P2VSoh+dbPQhCQ==} + + '@vue/shared@3.5.43': + resolution: {integrity: sha512-uksS7YGMR5NZyr4JNq0Rp+QyLns0ueaz20KwzIPW9R0LH1Vnt4E+XUM29PNseEbf1www2gOuhuDi5AKOIXag9Q==} + + '@vueuse/core@15.0.0': + resolution: {integrity: sha512-Zv67h5mrk7YHDJsRObx+DXaCBz6RKBUpxqLesJ+3xZNPG6yuQs7ja7kg3veFrrouYLWAqydQ7CwGEbLVxB43Ng==} + engines: {node: '>=22'} + peerDependencies: + vue: ^3.5.0 + + '@vueuse/metadata@15.0.0': + resolution: {integrity: sha512-sfWE5LADAKJ0KnjtGliMdSN8Ij/L7uP6rXIZlgPloAI4zvvefbw6H5Lt7KJA6RqQOYgVqWF5Guj50HHMIzygBg==} + engines: {node: '>=22'} + + '@vueuse/shared@15.0.0': + resolution: {integrity: sha512-vbNXhGrbV53xPOMSyOzMHQRvhMgZf7oNDw2RChUetVxwTIEkInQ1joZ9JbT0G3z2n2AJNjvmCWeM9ySYrnvogA==} + engines: {node: '>=22'} + peerDependencies: + vue: ^3.5.0 + + acorn@8.19.0: + resolution: {integrity: sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==} + engines: {node: '>=0.4.0'} + hasBin: true + + agent-base@6.0.2: + resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} + engines: {node: '>= 6.0.0'} + + alien-signals@3.2.1: + resolution: {integrity: sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g==} + + ast-kit@2.2.0: + resolution: {integrity: sha512-m1Q/RaVOnTp9JxPX+F+Zn7IcLYMzM8kZofDImfsKZd8MbR+ikdOzTeztStWqfrqIxZnYWryyI9ePm3NGjnZgGw==} + engines: {node: '>=20.19.0'} + + ast-kit@3.0.0: + resolution: {integrity: sha512-8OG92q3R35qjC/4i6BLBMg8IB+fClWu/1PEwg2Z9Rn+BuNaiEgJzpzn+pxWOdHJWDCAwu2JP0wCDTozAM4QirQ==} + engines: {node: ^22.18.0 || >=24.11.0} + + ast-walker-scope@0.10.0: + resolution: {integrity: sha512-KzvoxDfXNz2Gm8KYLh47K3qcY3Zza3WKOuh2xoqa7LBu6MDKWAZLovJtU9DuAd/Er+rhQHCRn619lHS5U+qmhw==} + engines: {node: ^22.18.0 || >=24.11.0} + + async-validator@4.2.5: + resolution: {integrity: sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==} + + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + axios@1.20.0: + resolution: {integrity: sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==} + + birpc@2.9.0: + resolution: {integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + chokidar@5.0.0: + resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} + engines: {node: '>= 20.19.0'} + + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + + confbox@0.3.1: + resolution: {integrity: sha512-cKUSoKa8YxFZZSmraVi7onONx3amu77ngK3kGpsYHDH7drPwCRkQE1RYMPlLRrMtnciRj274XNRxcHxnKmDSnA==} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + dayjs@1.11.23: + resolution: {integrity: sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + element-plus@2.14.7: + resolution: {integrity: sha512-lrG7pZyCS+mpCNVpzxxyu5r35+T6V0hzkAeIwn7HA6to772XSTHKuYCZG51OfzlqaxGbNegLxs650zAF1j43gA==} + peerDependencies: + vue: ^3.3.7 + + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + exsolve@1.1.3: + resolution: {integrity: sha512-kuzL9zherLQoqDeQaS55AiiIg1kb4KYXaSusTtgZTPlvKP2gGCI82LNmBNh5RjDVUXIcmgx+/Mx7vPwRzBbhJA==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + follow-redirects@1.16.1: + resolution: {integrity: sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + + form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} + engines: {node: '>= 6'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + + hookable@5.5.3: + resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} + + https-proxy-agent@5.0.1: + resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} + engines: {node: '>= 6'} + + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + + local-pkg@1.2.1: + resolution: {integrity: sha512-++gUqRDEvcnN6Zhqrr+y/CkVEHhlrR96vZn3nZZPYzMcBUyBtTKzB9NadClFIsIVSsu+3i9tfk/erqy9kAmt7Q==} + engines: {node: '>=14'} + + lodash-es@4.18.1: + resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==} + + lodash-unified@1.0.3: + resolution: {integrity: sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==} + peerDependencies: + '@types/lodash-es': '*' + lodash: '*' + lodash-es: '*' + + lodash@4.18.1: + resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==} + + magic-string-ast@1.0.3: + resolution: {integrity: sha512-CvkkH1i81zl7mmb94DsRiFeG9V2fR2JeuK8yDgS8oiZSFa++wWLEgZ5ufEOyLHbvSbD1gTRKv9NdX69Rnvr9JA==} + engines: {node: '>=20.19.0'} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + magic-string@1.4.3: + resolution: {integrity: sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + memoize-one@6.0.0: + resolution: {integrity: sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==} + + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + + mlly@1.8.2: + resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + muggle-string@0.4.1: + resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} + + nanoid@3.3.20: + resolution: {integrity: sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + normalize-wheel-es@1.2.0: + resolution: {integrity: sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==} + + nostics@1.3.0: + resolution: {integrity: sha512-wzRsrJwNlFbqIRQt8aCeL2WE8USip/uuvGSOZV1D02CxX8ABTMbw8IywKjH7yWHETMXbWJI4OBpihg9rmz2Vyg==} + + path-browserify@1.0.1: + resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + perfect-debounce@2.1.0: + resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + + pinia@4.0.3: + resolution: {integrity: sha512-XMQqpvjgG7LMqVhhFUzKLT4KEbsYbfZZ0CZU9PgdFm1O2VKBmIkykL8+SfNhOaT7sR0wT6BEgKT0YNDYWgmVRA==} + peerDependencies: + '@vue/devtools-api': ^8.1.5 + typescript: '>=5.6.0' + vue: ^3.5.11 + peerDependenciesMeta: + typescript: + optional: true + + pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + + pkg-types@2.3.3: + resolution: {integrity: sha512-j/lCFdcppV0JxWpCEITdbDltBxPP6cHT+yNJ6Go2OgoSA9518X847X9z0p6LtA4Nc16+eQzCZjRrWanTGvHJ5w==} + + postcss@8.5.29: + resolution: {integrity: sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==} + engines: {node: ^10 || ^12 || >=14} + + proxy-from-env@2.1.0: + resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} + engines: {node: '>=10'} + + quansync@0.2.11: + resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} + + readdirp@5.1.1: + resolution: {integrity: sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==} + engines: {node: '>= 20.19.0'} + + rolldown@1.2.13: + resolution: {integrity: sha512-/5apOkwWxtACcQtZ5WlrAsKS4JwXVThQPJZ3RqIruRY9UG9M8e0VlXN068xum/JRKA4aIUoJ13ghe/kGCvidiQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + scule@1.3.0: + resolution: {integrity: sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==} + + source-map-js@1.2.2: + resolution: {integrity: sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==} + engines: {node: '>=0.10.0'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + ufo@1.6.4: + resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} + + undici-types@7.24.6: + resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + + unplugin-utils@0.3.2: + resolution: {integrity: sha512-xVToRh2CTmLk2HnEG7ac4rl1MJTT3RFkpS8B++/SnB0kXvuaavD+n3m/vrzyWQOdJNSZQACnbz01pnppbwV5BA==} + engines: {node: '>=20.19.0'} + + unplugin@3.4.0: + resolution: {integrity: sha512-9skdIFlCsPdFV7wUfZxNsFInlW+7nJmGu2gkTu0OUhF56aXGsHab9x52/QhdJ4lC7ZDPWTxbiC4ANqVlsuaW3w==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@farmfe/core': '*' + '@rsbuild/core': '*' + '@rspack/core': '*' + bun-types-no-globals: '*' + esbuild: '*' + rolldown: '*' + rollup: '*' + unloader: '*' + vite: '*' + webpack: '*' + peerDependenciesMeta: + '@farmfe/core': + optional: true + '@rsbuild/core': + optional: true + '@rspack/core': + optional: true + bun-types-no-globals: + optional: true + esbuild: + optional: true + rolldown: + optional: true + rollup: + optional: true + unloader: + optional: true + vite: + optional: true + webpack: + optional: true + + vite@8.3.4: + resolution: {integrity: sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.7.1 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vscode-uri@3.2.0: + resolution: {integrity: sha512-m2gXo3bn0G1kT9InzMf07fTbqMbGtyckj3bH5ktLO+1Ssv+yiATZ4dhwaQv9UZWxJh6E9IFGnQyjgWVDWVBDrg==} + + vue-component-type-helpers@3.3.12: + resolution: {integrity: sha512-HwCqmgTfLFskQc4P5TRnbduCMyPat2Ezu0elLWOGn0dD/6BKHP15B5bxce6aDi+E4kblPtru8amIBQzu0Di3qQ==} + + vue-router@5.4.0: + resolution: {integrity: sha512-pFvcYcxinDHjZUlf7AzFBrT8r8Xqvv4uMHoJnjc+EqPawXQvHTkF8S4tfSmQLpriVHUGfkaKlI7Et0KRjTMTdw==} + peerDependencies: + '@pinia/colada': '>=0.21.2' + '@vue/compiler-sfc': ^3.5.34 || ^4.0.0 + pinia: ^3.0.4 || ^4.0.2 + vite: ^7.3.0 || ^8.0.0 + vue: ^3.5.34 || ^4.0.0 + peerDependenciesMeta: + '@pinia/colada': + optional: true + '@vue/compiler-sfc': + optional: true + pinia: + optional: true + vite: + optional: true + + vue-tsc@3.3.12: + resolution: {integrity: sha512-kbpaz18O9jPVkXVHD1BEjpUXM6WhXSu+62L1sGGr6Wbzd8GbvkBQWjoj3KZ/C0aq2MZllce7UdtWzwCIYsBMGA==} + hasBin: true + peerDependencies: + typescript: '>=5.0.0' + + vue@3.5.43: + resolution: {integrity: sha512-o5qZoksdnjIKvW1srZ3ab7pcDNYAerBjRe54D0LBLfRdCYFrSgBHVXokMas35czQc0//lmx4/tuY4ZNQ+Rf2Ng==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + webpack-virtual-modules@0.6.2: + resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} + +snapshots: + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-string-parser@8.0.6': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/helper-validator-identifier@8.0.6': {} + + '@babel/parser@7.29.9': + dependencies: + '@babel/types': 7.29.8 + + '@babel/parser@8.0.7': + dependencies: + '@babel/types': 8.0.6 + + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@babel/types@8.0.6': + dependencies: + '@babel/helper-string-parser': 8.0.6 + '@babel/helper-validator-identifier': 8.0.6 + + '@ctrl/tinycolor@4.2.1': {} + + '@element-plus/icons-vue@2.3.2(vue@3.5.43(typescript@5.9.3))': + dependencies: + vue: 3.5.43(typescript@5.9.3) + + '@floating-ui/core@1.8.0': + dependencies: + '@floating-ui/utils': 0.2.12 + + '@floating-ui/dom@1.8.0': + dependencies: + '@floating-ui/core': 1.8.0 + '@floating-ui/utils': 0.2.12 + + '@floating-ui/utils@0.2.12': {} + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.6.0': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.6.0 + + '@oxc-project/types@0.153.0': {} + + '@rolldown/binding-android-arm-eabi@1.2.13': + optional: true + + '@rolldown/binding-android-arm64@1.2.13': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.13': + optional: true + + '@rolldown/binding-darwin-x64@1.2.13': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.13': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.13': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.13': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.13': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.13': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.13': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.13': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.13': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.13': + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.13': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.13': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@sxzz/popperjs-es@2.11.8': {} + + '@types/estree@1.0.9': {} + + '@types/lodash-es@4.17.12': + dependencies: + '@types/lodash': 4.17.25 + + '@types/lodash@4.17.25': {} + + '@types/node@24.19.1': + dependencies: + undici-types: 7.24.6 + + '@types/web-bluetooth@0.0.21': {} + + '@vitejs/plugin-vue@6.0.9(vite@8.3.4(@types/node@24.19.1))(vue@3.5.43(typescript@5.9.3))': + dependencies: + '@rolldown/pluginutils': 1.0.1 + vite: 8.3.4(@types/node@24.19.1) + vue: 3.5.43(typescript@5.9.3) + + '@volar/language-core@2.4.28': + dependencies: + '@volar/source-map': 2.4.28 + + '@volar/source-map@2.4.28': {} + + '@volar/typescript@2.4.28(typescript@5.9.3)': + dependencies: + '@volar/language-core': 2.4.28 + path-browserify: 1.0.1 + vscode-uri: 3.2.0 + optionalDependencies: + typescript: 5.9.3 + + '@vue-macros/common@3.1.4(vue@3.5.43(typescript@5.9.3))': + dependencies: + '@vue/compiler-sfc': 3.5.43 + ast-kit: 2.2.0 + local-pkg: 1.2.1 + magic-string-ast: 1.0.3 + unplugin-utils: 0.3.2 + optionalDependencies: + vue: 3.5.43(typescript@5.9.3) + + '@vue/compiler-core@3.5.43': + dependencies: + '@babel/parser': 7.29.9 + '@vue/shared': 3.5.43 + entities: 7.0.1 + estree-walker: 2.0.2 + source-map-js: 1.2.2 + + '@vue/compiler-dom@3.5.43': + dependencies: + '@vue/compiler-core': 3.5.43 + '@vue/shared': 3.5.43 + + '@vue/compiler-sfc@3.5.43': + dependencies: + '@babel/parser': 7.29.9 + '@vue/compiler-core': 3.5.43 + '@vue/compiler-dom': 3.5.43 + '@vue/compiler-ssr': 3.5.43 + '@vue/shared': 3.5.43 + estree-walker: 2.0.2 + magic-string: 0.30.21 + postcss: 8.5.29 + source-map-js: 1.2.2 + + '@vue/compiler-ssr@3.5.43': + dependencies: + '@vue/compiler-dom': 3.5.43 + '@vue/shared': 3.5.43 + + '@vue/devtools-api@8.2.1': + dependencies: + '@vue/devtools-kit': 8.2.1 + + '@vue/devtools-kit@8.2.1': + dependencies: + '@vue/devtools-shared': 8.2.1 + birpc: 2.9.0 + hookable: 5.5.3 + perfect-debounce: 2.1.0 + + '@vue/devtools-shared@8.2.1': {} + + '@vue/language-core@3.3.12': + dependencies: + '@volar/language-core': 2.4.28 + '@vue/compiler-dom': 3.5.43 + '@vue/shared': 3.5.43 + alien-signals: 3.2.1 + muggle-string: 0.4.1 + path-browserify: 1.0.1 + picomatch: 4.0.7 + + '@vue/reactivity@3.5.43': + dependencies: + '@vue/shared': 3.5.43 + + '@vue/runtime-core@3.5.43': + dependencies: + '@vue/reactivity': 3.5.43 + '@vue/shared': 3.5.43 + + '@vue/runtime-dom@3.5.43': + dependencies: + '@vue/reactivity': 3.5.43 + '@vue/runtime-core': 3.5.43 + '@vue/shared': 3.5.43 + csstype: 3.2.3 + + '@vue/server-renderer@3.5.43': + dependencies: + '@vue/compiler-ssr': 3.5.43 + '@vue/runtime-dom': 3.5.43 + '@vue/shared': 3.5.43 + + '@vue/shared@3.5.43': {} + + '@vueuse/core@15.0.0(vue@3.5.43(typescript@5.9.3))': + dependencies: + '@types/web-bluetooth': 0.0.21 + '@vueuse/metadata': 15.0.0 + '@vueuse/shared': 15.0.0(vue@3.5.43(typescript@5.9.3)) + vue: 3.5.43(typescript@5.9.3) + + '@vueuse/metadata@15.0.0': {} + + '@vueuse/shared@15.0.0(vue@3.5.43(typescript@5.9.3))': + dependencies: + vue: 3.5.43(typescript@5.9.3) + + acorn@8.19.0: {} + + agent-base@6.0.2: + dependencies: + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + alien-signals@3.2.1: {} + + ast-kit@2.2.0: + dependencies: + '@babel/parser': 7.29.9 + pathe: 2.0.3 + + ast-kit@3.0.0: + dependencies: + '@babel/parser': 8.0.7 + estree-walker: 3.0.3 + pathe: 2.0.3 + + ast-walker-scope@0.10.0: + dependencies: + '@babel/parser': 8.0.7 + '@babel/types': 8.0.6 + ast-kit: 3.0.0 + + async-validator@4.2.5: {} + + asynckit@0.4.0: {} + + axios@1.20.0(debug@4.4.3): + dependencies: + follow-redirects: 1.16.1(debug@4.4.3) + form-data: 4.0.6 + https-proxy-agent: 5.0.1 + proxy-from-env: 2.1.0 + transitivePeerDependencies: + - debug + - supports-color + + birpc@2.9.0: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + chokidar@5.0.0: + dependencies: + readdirp: 5.1.1 + + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + + confbox@0.1.8: {} + + confbox@0.3.1: {} + + csstype@3.2.3: {} + + dayjs@1.11.23: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + delayed-stream@1.0.0: {} + + detect-libc@2.1.2: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + element-plus@2.14.7(vue@3.5.43(typescript@5.9.3)): + dependencies: + '@ctrl/tinycolor': 4.2.1 + '@element-plus/icons-vue': 2.3.2(vue@3.5.43(typescript@5.9.3)) + '@floating-ui/dom': 1.8.0 + '@popperjs/core': '@sxzz/popperjs-es@2.11.8' + '@types/lodash': 4.17.25 + '@types/lodash-es': 4.17.12 + '@vueuse/core': 15.0.0(vue@3.5.43(typescript@5.9.3)) + async-validator: 4.2.5 + dayjs: 1.11.23 + lodash: 4.18.1 + lodash-es: 4.18.1 + lodash-unified: 1.0.3(@types/lodash-es@4.17.12)(lodash-es@4.18.1)(lodash@4.18.1) + memoize-one: 6.0.0 + normalize-wheel-es: 1.2.0 + vue: 3.5.43(typescript@5.9.3) + vue-component-type-helpers: 3.3.12 + + entities@7.0.1: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + + estree-walker@2.0.2: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + exsolve@1.1.3: {} + + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + + follow-redirects@1.16.1(debug@4.4.3): + optionalDependencies: + debug: 4.4.3 + + form-data@4.0.6: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.4 + mime-types: 2.1.35 + + fsevents@2.3.3: + optional: true + + function-bind@1.1.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.4 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + gopd@1.2.0: {} + + has-symbols@1.1.0: {} + + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + + hookable@5.5.3: {} + + https-proxy-agent@5.0.1: + dependencies: + agent-base: 6.0.2 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + + local-pkg@1.2.1: + dependencies: + mlly: 1.8.2 + pkg-types: 2.3.3 + quansync: 0.2.11 + + lodash-es@4.18.1: {} + + lodash-unified@1.0.3(@types/lodash-es@4.17.12)(lodash-es@4.18.1)(lodash@4.18.1): + dependencies: + '@types/lodash-es': 4.17.12 + lodash: 4.18.1 + lodash-es: 4.18.1 + + lodash@4.18.1: {} + + magic-string-ast@1.0.3: + dependencies: + magic-string: 0.30.21 + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + + magic-string@1.4.3: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + + math-intrinsics@1.1.0: {} + + memoize-one@6.0.0: {} + + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + + mlly@1.8.2: + dependencies: + acorn: 8.19.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.4 + + ms@2.1.3: {} + + muggle-string@0.4.1: {} + + nanoid@3.3.20: {} + + normalize-wheel-es@1.2.0: {} + + nostics@1.3.0: {} + + path-browserify@1.0.1: {} + + pathe@2.0.3: {} + + perfect-debounce@2.1.0: {} + + picocolors@1.1.1: {} + + picomatch@4.0.7: {} + + pinia@4.0.3(@vue/devtools-api@8.2.1)(typescript@5.9.3)(vue@3.5.43(typescript@5.9.3)): + dependencies: + '@vue/devtools-api': 8.2.1 + nostics: 1.3.0 + vue: 3.5.43(typescript@5.9.3) + optionalDependencies: + typescript: 5.9.3 + + pkg-types@1.3.1: + dependencies: + confbox: 0.1.8 + mlly: 1.8.2 + pathe: 2.0.3 + + pkg-types@2.3.3: + dependencies: + confbox: 0.3.1 + exsolve: 1.1.3 + pathe: 2.0.3 + + postcss@8.5.29: + dependencies: + nanoid: 3.3.20 + picocolors: 1.1.1 + source-map-js: 1.2.2 + + proxy-from-env@2.1.0: {} + + quansync@0.2.11: {} + + readdirp@5.1.1: {} + + rolldown@1.2.13: + dependencies: + '@oxc-project/types': 0.153.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm-eabi': 1.2.13 + '@rolldown/binding-android-arm64': 1.2.13 + '@rolldown/binding-darwin-arm64': 1.2.13 + '@rolldown/binding-darwin-x64': 1.2.13 + '@rolldown/binding-freebsd-x64': 1.2.13 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.13 + '@rolldown/binding-linux-arm64-gnu': 1.2.13 + '@rolldown/binding-linux-arm64-musl': 1.2.13 + '@rolldown/binding-linux-ppc64-gnu': 1.2.13 + '@rolldown/binding-linux-s390x-gnu': 1.2.13 + '@rolldown/binding-linux-x64-gnu': 1.2.13 + '@rolldown/binding-linux-x64-musl': 1.2.13 + '@rolldown/binding-openharmony-arm64': 1.2.13 + '@rolldown/binding-win32-arm64-msvc': 1.2.13 + '@rolldown/binding-win32-x64-msvc': 1.2.13 + + scule@1.3.0: {} + + source-map-js@1.2.2: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + typescript@5.9.3: {} + + ufo@1.6.4: {} + + undici-types@7.24.6: {} + + unplugin-utils@0.3.2: + dependencies: + pathe: 2.0.3 + picomatch: 4.0.7 + + unplugin@3.4.0(rolldown@1.2.13)(vite@8.3.4(@types/node@24.19.1)): + dependencies: + '@jridgewell/remapping': 2.3.5 + picomatch: 4.0.7 + webpack-virtual-modules: 0.6.2 + optionalDependencies: + rolldown: 1.2.13 + vite: 8.3.4(@types/node@24.19.1) + + vite@8.3.4(@types/node@24.19.1): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.7 + postcss: 8.5.29 + rolldown: 1.2.13 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 24.19.1 + fsevents: 2.3.3 + + vscode-uri@3.2.0: {} + + vue-component-type-helpers@3.3.12: {} + + vue-router@5.4.0(@vue/compiler-sfc@3.5.43)(pinia@4.0.3(@vue/devtools-api@8.2.1)(typescript@5.9.3)(vue@3.5.43(typescript@5.9.3)))(rolldown@1.2.13)(vite@8.3.4(@types/node@24.19.1))(vue@3.5.43(typescript@5.9.3)): + dependencies: + '@vue-macros/common': 3.1.4(vue@3.5.43(typescript@5.9.3)) + '@vue/devtools-api': 8.2.1 + ast-walker-scope: 0.10.0 + chokidar: 5.0.0 + confbox: 0.3.1 + local-pkg: 1.2.1 + magic-string: 1.4.3 + mlly: 1.8.2 + muggle-string: 0.4.1 + nostics: 1.3.0 + pathe: 2.0.3 + picomatch: 4.0.7 + scule: 1.3.0 + tinyglobby: 0.2.17 + unplugin: 3.4.0(rolldown@1.2.13)(vite@8.3.4(@types/node@24.19.1)) + unplugin-utils: 0.3.2 + vue: 3.5.43(typescript@5.9.3) + optionalDependencies: + '@vue/compiler-sfc': 3.5.43 + pinia: 4.0.3(@vue/devtools-api@8.2.1)(typescript@5.9.3)(vue@3.5.43(typescript@5.9.3)) + vite: 8.3.4(@types/node@24.19.1) + transitivePeerDependencies: + - '@farmfe/core' + - '@rsbuild/core' + - '@rspack/core' + - bun-types-no-globals + - esbuild + - rolldown + - rollup + - unloader + - webpack + + vue-tsc@3.3.12(typescript@5.9.3): + dependencies: + '@volar/typescript': 2.4.28(typescript@5.9.3) + '@vue/language-core': 3.3.12 + typescript: 5.9.3 + + vue@3.5.43(typescript@5.9.3): + dependencies: + '@vue/compiler-dom': 3.5.43 + '@vue/compiler-sfc': 3.5.43 + '@vue/runtime-dom': 3.5.43 + '@vue/server-renderer': 3.5.43 + '@vue/shared': 3.5.43 + optionalDependencies: + typescript: 5.9.3 + + webpack-virtual-modules@0.6.2: {} diff --git a/frontend/pnpm-workspace.yaml b/frontend/pnpm-workspace.yaml new file mode 100644 index 0000000..9776cf9 --- /dev/null +++ b/frontend/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +minimumReleaseAgeExclude: + - vite@8.3.4 diff --git a/frontend/src/App.vue b/frontend/src/App.vue new file mode 100644 index 0000000..82c4abe --- /dev/null +++ b/frontend/src/App.vue @@ -0,0 +1,7 @@ + + + diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts new file mode 100644 index 0000000..ddfef52 --- /dev/null +++ b/frontend/src/api/auth.ts @@ -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 { + return request({ url: '/v1/auth/captcha', method: 'get' }) +} + +/** 登录 */ +export function login(data: LoginParams): Promise { + return request({ url: '/v1/auth/login', method: 'post', data }) +} + +/** 登出 */ +export function logout(): Promise { + return request({ url: '/v1/auth/logout', method: 'post' }) +} + +/** 获取当前登录用户(刷新页面后用它恢复会话) */ +export function getCurrentUser(): Promise { + return request({ url: '/v1/auth/me', method: 'get' }) +} diff --git a/frontend/src/api/request.ts b/frontend/src/api/request.ts new file mode 100644 index 0000000..7f6ff39 --- /dev/null +++ b/frontend/src/api/request.ts @@ -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 { + 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) => { + 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) => { + 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(config: AxiosRequestConfig): Promise { + const response = await service.request>(config) + return response.data.data +} + +export default service diff --git a/frontend/src/assets/styles/index.css b/frontend/src/assets/styles/index.css new file mode 100644 index 0000000..d3510fa --- /dev/null +++ b/frontend/src/assets/styles/index.css @@ -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; +} diff --git a/frontend/src/components/.gitkeep b/frontend/src/components/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/composables/.gitkeep b/frontend/src/composables/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/main.ts b/frontend/src/main.ts new file mode 100644 index 0000000..48fda14 --- /dev/null +++ b/frontend/src/main.ts @@ -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') diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts new file mode 100644 index 0000000..856d6dc --- /dev/null +++ b/frontend/src/router/index.ts @@ -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 diff --git a/frontend/src/store/index.ts b/frontend/src/store/index.ts new file mode 100644 index 0000000..b275c96 --- /dev/null +++ b/frontend/src/store/index.ts @@ -0,0 +1,5 @@ +import { createPinia } from 'pinia' + +export const pinia = createPinia() + +export default pinia diff --git a/frontend/src/store/user.ts b/frontend/src/store/user.ts new file mode 100644 index 0000000..5c4fe25 --- /dev/null +++ b/frontend/src/store/user.ts @@ -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(getToken()) + const userInfo = ref(null) + + const roles = computed(() => userInfo.value?.roles ?? []) + const displayName = computed(() => userInfo.value?.real_name || userInfo.value?.username || '') + + /** 登录并保存令牌 */ + async function login(params: authApi.LoginParams): Promise { + const result = await authApi.login(params) + token.value = result.access_token + setToken(result.access_token) + userInfo.value = result.user + } + + /** 拉取当前用户信息 —— 路由守卫在刷新页面后用它恢复会话 */ + async function fetchUserInfo(): Promise { + userInfo.value = await authApi.getCurrentUser() + } + + /** 登出:无论后端是否成功,本地状态都要清干净 */ + async function logout(): Promise { + 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 } +}) diff --git a/frontend/src/utils/storage.ts b/frontend/src/utils/storage.ts new file mode 100644 index 0000000..47e5bf6 --- /dev/null +++ b/frontend/src/utils/storage.ts @@ -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) +} diff --git a/frontend/src/views/dashboard/index.vue b/frontend/src/views/dashboard/index.vue new file mode 100644 index 0000000..101396d --- /dev/null +++ b/frontend/src/views/dashboard/index.vue @@ -0,0 +1,98 @@ + + + + + diff --git a/frontend/src/views/login/index.vue b/frontend/src/views/login/index.vue new file mode 100644 index 0000000..1da5400 --- /dev/null +++ b/frontend/src/views/login/index.vue @@ -0,0 +1,478 @@ + + + + + diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..9c3df3f --- /dev/null +++ b/frontend/tsconfig.json @@ -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"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..9bfed84 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,37 @@ +import { fileURLToPath, URL } from 'node:url' + +import vue from '@vitejs/plugin-vue' +import { defineConfig, loadEnv } from 'vite' + +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, process.cwd(), '') + + return { + plugins: [vue()], + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)), + }, + }, + server: { + port: Number(env.VITE_PORT ?? 5173), + // 显式绑定 IPv4 回环。不指定时 Vite 在本机只监听 [::1]:5173, + // 于是 localhost:5173 能开、127.0.0.1:5173 却连不上 —— + // 这种"换个写法就坏"的差异排查起来很费时间。 + host: '127.0.0.1', + proxy: { + // 后端路由本身带 /api 前缀,因此不做 rewrite + // + // 目标地址用 127.0.0.1 而非 localhost:Node 17+ 对 localhost 优先解析 + // IPv6 的 ::1,而 uvicorn 默认只监听 127.0.0.1,用 localhost 会连不上。 + // + // 开发期走代理而非后端 CORS,符合规范「跨域在网关统一处理」; + // 副作用是 /api/docs(Swagger)也能从 5173 端口打开,联调期只用一个域名。 + '/api': { + target: 'http://127.0.0.1:8000', + changeOrigin: true, + }, + }, + }, + } +})