chore: 初始化项目结构与登录鉴权模块

后端 FastAPI 六层结构(api/services/repositories/models/schemas/core),
统一响应信封、全局异常、日志脱敏;6 张表经 Alembic 迁移建库,SQL 留存
db/migration;登录鉴权含图形验证码(Redis GETDEL 防重放)、JWT、登录限流、
登出黑名单(TTL=令牌剩余有效期)、登录日志。

前端 Vue3 + TS + Element Plus 骨架,含登录页、路由守卫、请求封装与 Vite 代理。

工程配置:uv 锁定 Python 3.12、ruff 检查与格式化、editorconfig、gitattributes。

测试 184 项(单元 155 / 集成 29)全绿,浏览器端到端 8 项通过。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
liuwenxinandClaude Code committed 2026-10-09 18:46:36 +08:00
commit 273ebf4657
101 files changed
+8725

No files matched your search

+31
View File
@@ -0,0 +1,31 @@
# 后端应用配置模板——复制为 backend/.env 并填入真实值(.env 已 gitignore)
# 注意:MYSQL_PASSWORD / REDIS_PASSWORD 与 docker/.env 保持一致(改一处要改两处)
# ===== MySQL =====
MYSQL_HOST=127.0.0.1
MYSQL_PORT=3306
MYSQL_DATABASE=payroll
MYSQL_USER=payroll
MYSQL_PASSWORD=
# ===== Redis =====
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
REDIS_PASSWORD=
REDIS_DB=0
# ===== 应用 =====
APP_ENV=dev
APP_SECRET_KEY=
# JWT 有效期(分钟),规范要求 ≤120
JWT_EXPIRE_MINUTES=120
# 验证码有效期(秒),规范要求 300
CAPTCHA_EXPIRE_SECONDS=300
# 登录连续失败锁定:次数与锁定分钟
LOGIN_FAIL_LIMIT=5
LOGIN_LOCK_MINUTES=15
# ===== T+ 对接(待畅捷通确认后填写)=====
T_PLUS_BASE_URL=
T_PLUS_APP_KEY=
T_PLUS_APP_SECRET=
+1
View File
@@ -0,0 +1 @@
3.12
+129
View File
@@ -0,0 +1,129 @@
# 后端
> AI摘要:衍水集团营销人员工资管理系统的后端服务。FastAPI + SQLAlchemy(同步)+ MySQL + Redis,Python 3.12 由 uv 锁定。六层结构 `api / services / repositories / models / schemas / core`。当前实现登录鉴权与健康检查,业务模块待需求澄清后开发。
## 职责
- 提供 REST 接口(统一前缀 `/api`)
- 登录鉴权:图形验证码、JWT、登录限流、登出黑名单、登录日志
- 后续承担:基础数据、业务数据、绩效核算、报表统计、T+ 数据同步
## 技术栈与版本
| 组件 | 版本 | 说明 |
| --- | --- | --- |
| Python | 3.12 | uv 锁定。系统默认是 3.14,**不要用**——第三方库轮子覆盖不如 3.12 完备 |
| FastAPI / Starlette | 0.143 / 1.7 | `starlette` 显式限定 `<2`:FastAPI 对它的约束无上界,升级时可能漂移 |
| SQLAlchemy | 2.1 | **同步**引擎,路由写 `def` 由 FastAPI 放入线程池 |
| PyMySQL | 1.2 | **必须带 `[rsa]`**:MySQL 8.0 默认 `caching_sha2_password`,缺 `cryptography` 时首次连接抛 `RuntimeError`,且报错在建连阶段、极易误读为「密码错误」 |
| alembic | 1.20 | 迁移;运行期与迁移共用同一份 `mysql+pymysql` 连接串 |
| redis | 8.1 | **同步**客户端 |
| pwdlib | 0.3 | 密码哈希。**不用 passlib**(已停更且与 bcrypt ≥4.1 不兼容)。算法**显式指定** BcryptHasher,不用 `recommended()` |
| captcha | 0.7 | **仅用其内置字体文件**,渲染已自绘(见 `core/captcha_image.py`) |
## 分层结构与红线
```text
app/
├── api/ HTTP 层:路由、依赖注入、参数校验
│ ├── deps.py 公共依赖:get_db、get_current_user、get_raw_token
│ └── v1/ 版本化业务路由
├── services/ 业务编排 + **唯一事务边界**(commit/rollback 只在此层)
├── repositories/ 数据访问,**只 flush 不 commit**
├── models/ ORM 映射,不含业务逻辑
├── schemas/ 请求/响应模型,禁止把 ORM 对象直接当 response_model
└── core/ 横切基础设施:配置、安全、连接、异常、响应、日志
```
| 层 | 可以做 | **不可做** |
| --- | --- | --- |
| `api/` | 参数绑定、调 service、返回统一响应 | 写业务判断、直接拼 SQL |
| `services/` | 编排多个 repository、提交事务 | — |
| `repositories/` | 读写数据、flush | `commit()`、抛业务异常 |
| `models/` | 表映射 | 业务逻辑 |
| `core/` | 基础设施 | 业务规则 |
## 核心接口
| 方法 | 路径 | 认证 | 说明 |
| --- | --- | :---: | --- |
| GET | `/api/health` | 否 | 探活,上报 app/db/redis 三项状态 |
| GET | `/api/v1/auth/captcha` | 否 | 签发图形验证码,返回 id + 图片 base64 |
| POST | `/api/v1/auth/login` | 否 | 账号 + 密码 + 验证码 → JWT |
| POST | `/api/v1/auth/logout` | 是 | 令牌入黑名单,立即失效 |
| GET | `/api/v1/auth/me` | 是 | 当前用户与角色 |
统一响应 `{code, message, data}`,分页为 `list/total/pageNum/pageSize`。
状态码:400 参数错误 / 401 未登录 / 403 无权限 / 404 不存在 / 500 服务端异常。
**登录失败一律 400 或 403,绝不使用 401** —— 前端把 401 一律当作「清 token + 跳登录页」,
若登录接口自身返回 401,用户输错一次密码就会触发「清空 + 跳转 + 报错」的风暴。
## 常用命令
```bash
uv sync # 安装依赖
uv run uvicorn app.main:app --reload --port 8000 # 起服务(开发务必加 --reload)
uv run alembic upgrade head # 应用迁移
uv run alembic revision --autogenerate -m "说明" # 生成迁移
uv run python -m app.cli create-admin # 建管理员(幂等,已存在不覆盖)
uv run pytest tests/unit -q # 纯单元测试,无需容器
uv run pytest -q # 全部测试
uv run ruff check . # 代码检查
uv run ruff check --fix . # 代码检查并自动修复可修项
uv run ruff format . # 格式化代码
```
Python 版本由 `.python-version` 与 `pyproject.toml` 的 `requires-python` 双重锁定。
### 代码检查与格式化
配置在 `pyproject.toml` 的 `[tool.ruff]`。三处**刻意关闭**的规则,不要误以为是漏配:
| 规则 | 关闭原因 |
| --- | --- |
| `E501` 行宽 | 已由 formatter 控制,lint 重复报无意义;长中文注释极易触发 |
| `B008` 参数默认值调用 | FastAPI 依赖注入要求在参数默认值处写 `Depends()`,是框架约定而非缺陷 |
| `RUF001/002/003` 易混淆 Unicode | 该规则默认针对英文文本,会把中文的全角逗号、句号、括号全部误报(本项目实测 800+ 条)。中文里全角标点正是规范写法 |
另有 `known-first-party = ["app", "tests"]`:本项目 `[tool.uv] package = false`(不作为包安装),不显式声明的话 ruff 无法识别 `app` 是第一方包,会把第三方依赖排到它后面。
## 关键实现约定
### JWT 时间一律用 UTC
`exp` 是 UTC 时间戳,而库列存本地时间(容器 `TZ=Asia/Shanghai`)。混算会产生 8 小时偏差,
表现为「刚登录就过期」或「过期了还能用」。库列用 `datetime.now()`,JWT 用 `datetime.now(timezone.utc)`。
### 「离职即失效」用 `users.token_version`,不依赖 Redis
禁用 / 改密 / 强制下线只需 `UPDATE users SET token_version = token_version + 1`。
鉴权时比对 JWT 的 `ver` 声明与库中值,Redis 故障时依然生效。
Redis 黑名单只负责「用户主动登出」。
### Redis 键 TTL 由业务语义决定,不要套用固定值
| 键 | TTL | 依据 |
| --- | --- | --- |
| `ys:captcha:{id}` | 300s | 规范要求 |
| `ys:login:fail:{username}` | 900s | = 锁定时长,写死会导致锁定提前失效 |
| `ys:token:blacklist:{jti}` | 令牌剩余有效期 | 写死会导致「登出 N 分钟后令牌又可用」 |
### 验证码用 `GETDEL` 原子取出即删
写成「先 GET 再 DEL」存在竞态窗口,并发请求可用同一验证码重复提交。
### 密码按字节数校验,不按字符数
bcrypt 5.0 起对超过 72 字节的密码**直接抛 ValueError**(此前是静默截断)。
中文一个字符占 3 字节,24 个汉字即到顶——`max_length=20` 这类字符数校验拦不住。
## 常见问题
**起服务报 `APP_SECRET_KEY` 相关错误** —— `backend/.env` 未填或不足 32 字符。这是刻意的 fail-fast:空密钥签发 JWT 是安全问题,必须启动即失败。
**连数据库抛 `RuntimeError: 'cryptography' package is required`** —— 依赖被改回不带 `[rsa]` 的 `pymysql`。装回 `pymysql[rsa]`。
**`alembic upgrade head` 报表已存在** —— 开发库是 `init-dev.sql` 建的、`alembic_version` 未写入。手工补版本记录,或 `down -v` 重建。
**改了代码不生效** —— 启动时没加 `--reload`。
+149
View File
@@ -0,0 +1,149 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts.
# this is typically a path given in POSIX (e.g. forward slashes)
# format, relative to the token %(here)s which refers to the location of this
# ini file
script_location = %(here)s/alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# Or organize into date-based subdirectories (requires recursive_version_locations = true)
# file_template = %%(year)d/%%(month).2d/%%(day).2d_%%(hour).2d%%(minute).2d_%%(second).2d_%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory. for multiple paths, the path separator
# is defined by "path_separator" below.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the tzdata library which can be installed by adding
# `alembic[tz]` to the pip requirements.
# string value is passed to ZoneInfo()
# leave blank for localtime
# timezone =
# max length of characters to apply to the "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to <script_location>/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "path_separator"
# below.
# version_locations = %(here)s/bar:%(here)s/bat:%(here)s/alembic/versions
# path_separator; This indicates what character is used to split lists of file
# paths, including version_locations and prepend_sys_path within configparser
# files such as alembic.ini.
# The default rendered in new alembic.ini files is "os", which uses os.pathsep
# to provide os-dependent path splitting.
#
# Note that in order to support legacy alembic.ini files, this default does NOT
# take place if path_separator is not present in alembic.ini. If this
# option is omitted entirely, fallback logic is as follows:
#
# 1. Parsing of the version_locations option falls back to using the legacy
# "version_path_separator" key, which if absent then falls back to the legacy
# behavior of splitting on spaces and/or commas.
# 2. Parsing of the prepend_sys_path option falls back to the legacy
# behavior of splitting on spaces, commas, or colons.
#
# Valid values for path_separator are:
#
# path_separator = :
# path_separator = ;
# path_separator = space
# path_separator = newline
#
# Use os.pathsep. Default configuration used for new projects.
path_separator = os
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
# database URL. This is consumed by the user-maintained env.py script only.
# other means of configuring database URLs may be customized within the env.py
# file.
sqlalchemy.url = driver://user:pass@localhost/dbname
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the module runner, against the "ruff" module
# hooks = ruff
# ruff.type = module
# ruff.module = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Alternatively, use the exec runner to execute a binary found on your PATH
# hooks = ruff
# ruff.type = exec
# ruff.executable = ruff
# ruff.options = check --fix REVISION_SCRIPT_FILENAME
# Logging configuration. This is also consumed by the user-maintained
# env.py script only.
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARNING
handlers = console
qualname =
[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
+1
View File
@@ -0,0 +1 @@
Generic single-database configuration.
+74
View File
@@ -0,0 +1,74 @@
"""Alembic 迁移环境。
**连接串取自 backend/.env**,不写在 alembic.ini —— 避免密码进版本库,
也避免运行期配置与迁移配置两处漂移。运行期与迁移共用同一份同步连接串
(驱动 pymysql),不存在同步/异步两套配置。
"""
import sys
from logging.config import fileConfig
from pathlib import Path
from alembic import context
from sqlalchemy import engine_from_config, pool
# 使 `app` 包在 alembic 执行时可导入(alembic 从 backend/ 目录运行)
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from app.core.config import get_settings
from app.models import Base
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# autogenerate 的比对基准
target_metadata = Base.metadata
# 运行期把 .env 里的连接串注入配置,覆盖 alembic.ini 中的占位值
config.set_main_option(
"sqlalchemy.url",
get_settings().database_url.render_as_string(hide_password=False),
)
def run_migrations_offline() -> None:
"""离线模式:不连库,只输出 SQL。
用于导出 `db/migration/V{n}__*.sql` 留存脚本。
"""
context.configure(
url=config.get_main_option("sqlalchemy.url"),
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
# 类型变更也要被识别,否则改列类型不会生成迁移
compare_type=True,
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""在线模式:连库执行迁移。"""
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
# 迁移是一次性任务,用完即弃,不需要连接池
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata,
compare_type=True,
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
+28
View File
@@ -0,0 +1,28 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
"""Upgrade schema."""
${upgrades if upgrades else "pass"}
def downgrade() -> None:
"""Downgrade schema."""
${downgrades if downgrades else "pass"}
@@ -0,0 +1,353 @@
"""初始化 RBAC 与登录日志表
Revision ID: 6165691326d4
Revises:
Create Date: 2026-10-09 10:58:04.316902
"""
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision: str = "6165691326d4"
down_revision: str | Sequence[str] | None = None
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"permissions",
sa.Column(
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
),
sa.Column(
"perm_code",
sa.String(length=128),
nullable=False,
comment="权限编码,如 system:user:create",
),
sa.Column("perm_name", sa.String(length=64), nullable=False, comment="权限名称"),
sa.Column(
"perm_type",
mysql.TINYINT(),
server_default="1",
nullable=False,
comment="类型:1菜单 2按钮 3接口",
),
sa.Column(
"parent_id",
mysql.BIGINT(unsigned=True),
nullable=True,
comment="父权限ID(permissions.id),顶级为空",
),
sa.Column(
"sort_order", sa.Integer(), server_default="0", nullable=False, comment="显示排序"
),
sa.Column(
"status",
mysql.TINYINT(),
server_default="1",
nullable=False,
comment="状态:1启用 0禁用",
),
sa.Column(
"create_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP"),
nullable=False,
comment="创建时间",
),
sa.Column(
"update_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
nullable=False,
comment="更新时间",
),
sa.ForeignKeyConstraint(
["parent_id"],
["permissions.id"],
name=op.f("fk_permissions_parent_id"),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_permissions")),
sa.UniqueConstraint("perm_code", name=op.f("uk_permissions_perm_code")),
comment="权限表",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_general_ci",
mysql_engine="InnoDB",
)
op.create_table(
"roles",
sa.Column(
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
),
sa.Column(
"role_code",
sa.String(length=32),
nullable=False,
comment="角色编码:admin系统管理员 user普通用户",
),
sa.Column("role_name", sa.String(length=64), nullable=False, comment="角色名称"),
sa.Column("description", sa.String(length=255), nullable=True, comment="角色说明"),
sa.Column(
"sort_order", sa.Integer(), server_default="0", nullable=False, comment="显示排序"
),
sa.Column(
"status",
mysql.TINYINT(),
server_default="1",
nullable=False,
comment="状态:1启用 0禁用",
),
sa.Column(
"create_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP"),
nullable=False,
comment="创建时间",
),
sa.Column(
"update_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
nullable=False,
comment="更新时间",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_roles")),
sa.UniqueConstraint("role_code", name=op.f("uk_roles_role_code")),
comment="角色表",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_general_ci",
mysql_engine="InnoDB",
)
op.create_table(
"users",
sa.Column(
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
),
sa.Column("username", sa.String(length=64), nullable=False, comment="登录用户名"),
sa.Column(
"password",
sa.String(length=100),
nullable=False,
comment="密码哈希(BCrypt,固定60字符)",
),
sa.Column("real_name", sa.String(length=64), nullable=True, comment="真实姓名"),
sa.Column("phone", sa.String(length=20), nullable=True, comment="手机号"),
sa.Column("email", sa.String(length=128), nullable=True, comment="邮箱"),
sa.Column(
"status",
mysql.TINYINT(),
server_default="1",
nullable=False,
comment="状态:1启用 0禁用",
),
sa.Column(
"token_version",
sa.Integer(),
server_default="1",
nullable=False,
comment="令牌版本号:改密/禁用/强制下线时+1,使已签发Token立即失效",
),
sa.Column("last_login_time", sa.DateTime(), nullable=True, comment="最后登录时间"),
sa.Column("remark", sa.String(length=255), nullable=True, comment="备注"),
sa.Column(
"create_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP"),
nullable=False,
comment="创建时间",
),
sa.Column(
"update_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
nullable=False,
comment="更新时间",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_users")),
sa.UniqueConstraint("username", name=op.f("uk_users_username")),
comment="系统用户表",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_general_ci",
mysql_engine="InnoDB",
)
op.create_index(op.f("idx_users_phone"), "users", ["phone"], unique=False)
op.create_table(
"login_logs",
sa.Column(
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
),
sa.Column(
"user_id",
mysql.BIGINT(unsigned=True),
nullable=True,
comment="用户ID(账号不存在时为空)",
),
sa.Column(
"username", sa.String(length=64), nullable=False, comment="登录用户名(原样记录)"
),
sa.Column("ip", sa.String(length=45), nullable=True, comment="客户端IP(45位兼容IPv6)"),
sa.Column(
"user_agent",
sa.String(length=255),
nullable=True,
comment="客户端User-Agent(截断存储)",
),
sa.Column(
"success",
mysql.TINYINT(),
server_default="0",
nullable=False,
comment="是否成功:1成功 0失败",
),
sa.Column(
"fail_reason",
sa.String(length=64),
nullable=True,
comment="失败原因:验证码错误/用户名或密码错误/账号锁定/账号禁用",
),
sa.Column(
"create_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP"),
nullable=False,
comment="创建时间",
),
sa.ForeignKeyConstraint(
["user_id"], ["users.id"], name=op.f("fk_login_logs_user_id"), ondelete="SET NULL"
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_login_logs")),
comment="登录日志表",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_general_ci",
mysql_engine="InnoDB",
)
op.create_index("idx_login_logs_create_time", "login_logs", ["create_time"], unique=False)
op.create_index("idx_login_logs_username", "login_logs", ["username"], unique=False)
op.create_table(
"role_permissions",
sa.Column(
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
),
sa.Column(
"role_id", mysql.BIGINT(unsigned=True), nullable=False, comment="角色ID(roles.id)"
),
sa.Column(
"permission_id",
mysql.BIGINT(unsigned=True),
nullable=False,
comment="权限ID(permissions.id)",
),
sa.Column(
"create_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP"),
nullable=False,
comment="创建时间",
),
sa.ForeignKeyConstraint(
["permission_id"],
["permissions.id"],
name=op.f("fk_role_permissions_permission_id"),
ondelete="CASCADE",
),
sa.ForeignKeyConstraint(
["role_id"], ["roles.id"], name=op.f("fk_role_permissions_role_id"), ondelete="CASCADE"
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_role_permissions")),
sa.UniqueConstraint(
"role_id", "permission_id", name="uk_role_permissions_role_id_permission_id"
),
comment="角色权限关联表",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_general_ci",
mysql_engine="InnoDB",
)
op.create_index(
"idx_role_permissions_permission_id", "role_permissions", ["permission_id"], unique=False
)
op.create_table(
"user_roles",
sa.Column(
"id", mysql.BIGINT(unsigned=True), autoincrement=True, nullable=False, comment="主键ID"
),
sa.Column(
"user_id", mysql.BIGINT(unsigned=True), nullable=False, comment="用户ID(users.id)"
),
sa.Column(
"role_id", mysql.BIGINT(unsigned=True), nullable=False, comment="角色ID(roles.id)"
),
sa.Column(
"create_time",
sa.DateTime(),
server_default=sa.text("CURRENT_TIMESTAMP"),
nullable=False,
comment="创建时间",
),
sa.ForeignKeyConstraint(
["role_id"], ["roles.id"], name=op.f("fk_user_roles_role_id"), ondelete="CASCADE"
),
sa.ForeignKeyConstraint(
["user_id"], ["users.id"], name=op.f("fk_user_roles_user_id"), ondelete="CASCADE"
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_user_roles")),
sa.UniqueConstraint("user_id", "role_id", name="uk_user_roles_user_id_role_id"),
comment="用户角色关联表",
mysql_charset="utf8mb4",
mysql_collate="utf8mb4_general_ci",
mysql_engine="InnoDB",
)
op.create_index("idx_user_roles_role_id", "user_roles", ["role_id"], unique=False)
# ### end Alembic commands ###
# 角色为字典数据,随结构初始化 —— 放在迁移而非 init-dev.sql,
# 保证生产环境同样具备这两个基础角色(规范要求的两个角色,见功能需规 2.5.1)
op.bulk_insert(
sa.table(
"roles",
sa.column("role_code", sa.String),
sa.column("role_name", sa.String),
sa.column("description", sa.String),
sa.column("sort_order", sa.Integer),
),
[
{
"role_code": "admin",
"role_name": "系统管理员",
"description": "全部权限,含计提参数与系数表配置",
"sort_order": 1,
},
{
"role_code": "user",
"role_name": "普通用户",
"description": "业务操作",
"sort_order": 2,
},
],
)
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index("idx_user_roles_role_id", table_name="user_roles")
op.drop_table("user_roles")
op.drop_index("idx_role_permissions_permission_id", table_name="role_permissions")
op.drop_table("role_permissions")
op.drop_index("idx_login_logs_username", table_name="login_logs")
op.drop_index("idx_login_logs_create_time", table_name="login_logs")
op.drop_table("login_logs")
op.drop_index(op.f("idx_users_phone"), table_name="users")
op.drop_table("users")
op.drop_table("roles")
op.drop_table("permissions")
# ### end Alembic commands ###
+1
View File
@@ -0,0 +1 @@
"""衍水集团营销人员工资管理系统 —— 后端应用包。"""
+4
View File
@@ -0,0 +1,4 @@
"""HTTP 接口层:路由声明、依赖注入、参数校验、调用 service、返回统一响应。
禁止在此层写业务判断或直接拼 SQL。
"""
+73
View File
@@ -0,0 +1,73 @@
"""FastAPI 公共依赖注入。"""
from typing import Annotated
from fastapi import Depends
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.orm import Session
from app.core.db import get_db
from app.core.exceptions import AuthError
from app.core.security import decode_token
from app.models import User
from app.repositories import user_repository
from app.services import token_blacklist
# auto_error=False:令牌缺失时由我们自己抛 AuthError,
# 才能返回统一响应信封,而不是 FastAPI 默认的 {"detail": "Not authenticated"}
_bearer_scheme = HTTPBearer(auto_error=False, description="Bearer 访问令牌")
BearerCredentials = Annotated[HTTPAuthorizationCredentials | None, Depends(_bearer_scheme)]
DbSession = Annotated[Session, Depends(get_db)]
def get_raw_token(credentials: BearerCredentials) -> str:
"""取原始令牌字符串。登出时需要它来解析 jti。"""
if credentials is None or not credentials.credentials:
raise AuthError()
return credentials.credentials
def get_current_user(credentials: BearerCredentials, session: DbSession) -> User:
"""从 Bearer 令牌解析当前登录用户。
**三重校验,缺一不可**:
1. 签名与有效期(`decode_token`)
2. 是否已被登出拉黑(Redis `ys:token:blacklist:{jti}`)
3. **令牌版本与库中是否一致** —— 禁用 / 改密 / 强制下线会把
`users.token_version` 加一,使此前签发的所有令牌立即失效。
这一步**不依赖 Redis**,因此 Redis 故障或数据被清空时,
离职员工依然无法继续访问
Redis 不可用时第 2 步会抛异常而非放行 —— **有意的 fail-closed**:
对能查看全员薪酬的系统,宁可拒绝请求也不能放行状态不明的令牌。
:raises AuthError: 任一项校验未通过(401)
"""
if credentials is None or not credentials.credentials:
raise AuthError()
payload = decode_token(credentials.credentials)
jti = payload.get("jti")
if jti and token_blacklist.is_revoked(jti):
raise AuthError("登录状态已失效,请重新登录")
subject = payload.get("sub")
user = user_repository.get_by_id(session, int(subject)) if _is_digits(subject) else None
if user is None or user.status != 1:
raise AuthError()
if payload.get("ver") != user.token_version:
raise AuthError("登录状态已失效,请重新登录")
return user
def _is_digits(value: object) -> bool:
"""判断令牌中的 sub 是否为合法的主键字符串。
令牌内容不可信,直接 int() 会因非法输入抛 ValueError 而不是 401。
"""
return isinstance(value, str) and value.isdigit()
+52
View File
@@ -0,0 +1,52 @@
"""健康检查接口。
路由写 `def`(同步)而非 `async def`:FastAPI 会把同步路由放入线程池执行,
因此这里的数据库与 Redis 探测不会阻塞事件循环。
"""
import logging
from typing import Any
from fastapi import APIRouter
from sqlalchemy import text
from app.core.db import get_session_factory
from app.core.redis_client import get_redis
from app.core.response import ApiResponse, ok
logger = logging.getLogger(__name__)
router = APIRouter(tags=["系统"])
def _check_db() -> bool:
"""探测数据库连通性。失败只记日志并返回 False,不抛出。"""
try:
factory = get_session_factory()
with factory() as session:
session.execute(text("SELECT 1"))
return True
except Exception:
logger.warning("数据库健康检查未通过", exc_info=True)
return False
def _check_redis() -> bool:
"""探测 Redis 连通性。失败只记日志并返回 False,不抛出。"""
try:
return bool(get_redis().ping())
except Exception:
logger.warning("Redis 健康检查未通过", exc_info=True)
return False
@router.get("/health", response_model=ApiResponse[dict[str, Any]], summary="健康检查")
def health() -> ApiResponse[dict[str, Any]]:
"""探活接口:上报应用、数据库、Redis 三项状态。
即使依赖不可用,本接口本身仍返回 200 并在 data 中标记 false ——
这样运维能区分「服务没起来」与「服务起来了但依赖挂了」两种情况。
注意:生产部署应限制本接口仅内网可达,避免对外暴露依赖拓扑。
"""
return ok({"app": True, "db": _check_db(), "redis": _check_redis()})
+20
View File
@@ -0,0 +1,20 @@
"""API 路由汇总。
各业务模块的路由在此挂载。业务接口统一放在 `/api/v1/` 之下,
健康检查作为运维探针不带版本号,直接位于 `/api/health`。
"""
from fastapi import APIRouter
from app.api import health
from app.api.v1 import auth
api_router = APIRouter()
# 运维探针:/api/health
api_router.include_router(health.router)
# 业务接口:/api/v1/*
v1_router = APIRouter(prefix="/v1")
v1_router.include_router(auth.router)
api_router.include_router(v1_router)
+7
View File
@@ -0,0 +1,7 @@
"""API v1 版本化路由。
业务接口按模块拆分为独立文件挂载于此(如 `auth.py`);
跨模块共享的依赖(get_db、get_current_user 等)放在上一级 `api/deps.py`。
健康检查是运维探针,固定挂在 `/api/health`,不带版本号。
"""
+86
View File
@@ -0,0 +1,86 @@
"""认证接口:验证码、登录、登出、当前用户。"""
from typing import Annotated
from fastapi import APIRouter, Depends, Request
from app.api.deps import DbSession, get_current_user, get_raw_token
from app.core.response import ApiResponse, ok
from app.models import User
from app.repositories.user_repository import get_role_codes
from app.schemas.auth import CaptchaOut, CurrentUserOut, LoginIn, LoginOut
from app.services import auth_service, captcha_service
router = APIRouter(prefix="/auth", tags=["认证"])
def _client_ip(request: Request) -> str | None:
"""取客户端 IP。
当前取直连地址。生产环境经 Nginx 反代后直连地址会变成代理地址,
届时需改为读取 `X-Forwarded-For` 的**第一个**值 —— 且必须确保该头
由网关覆写,否则客户端可以伪造。此改动留到部署阶段一并处理。
"""
return request.client.host if request.client else None
@router.get("/captcha", response_model=ApiResponse[CaptchaOut], summary="获取图形验证码")
def get_captcha() -> ApiResponse[CaptchaOut]:
"""签发图形验证码。无需认证。
图片以 base64 内联返回而非单独 URL:保证 id 与图片成对产生,
避免前端分两次请求时拿到不匹配的组合。
"""
captcha_id, image_base64, expires_in = captcha_service.issue()
return ok(CaptchaOut(captcha_id=captcha_id, image_base64=image_base64, expires_in=expires_in))
@router.post("/login", response_model=ApiResponse[LoginOut], summary="登录")
def login(payload: LoginIn, request: Request, session: DbSession) -> ApiResponse[LoginOut]:
"""账号 + 密码 + 图形验证码登录。
失败一律返回 **400 或 403,绝不使用 401**:前端的 401 拦截器会清空令牌
并跳转登录页,若登录接口自身返回 401,用户输错一次密码就会触发
「清空 + 跳转 + 报错」的组合动作,登录页反复重挂载、验证码反复刷新。
另注意:验证码经 GETDEL 消费后即失效,**前端必须在登录失败后刷新验证码**,
否则第二次提交必然报「验证码错误」。
"""
result = auth_service.login(
session,
username=payload.username,
password=payload.password,
captcha_id=payload.captcha_id,
captcha_code=payload.captcha_code,
ip=_client_ip(request),
user_agent=request.headers.get("user-agent"),
)
return ok(result, message="登录成功")
@router.post("/logout", response_model=ApiResponse[None], summary="登出")
def logout(
token: Annotated[str, Depends(get_raw_token)],
_current_user: Annotated[User, Depends(get_current_user)],
) -> ApiResponse[None]:
"""登出:把当前令牌加入黑名单,使其立即失效。
同时依赖 `get_current_user` 做真实鉴权 —— 仅凭原始令牌字符串不能证明
它是有效的(可能已过期或已被拉黑)。
"""
auth_service.logout(token)
return ok(message="已退出登录")
@router.get("/me", response_model=ApiResponse[CurrentUserOut], summary="当前登录用户")
def me(current_user: Annotated[User, Depends(get_current_user)]) -> ApiResponse[CurrentUserOut]:
"""返回当前登录用户的信息与角色。前端刷新页面后用它恢复会话。"""
return ok(
CurrentUserOut(
id=current_user.id,
username=current_user.username,
real_name=current_user.real_name,
roles=get_role_codes(current_user),
last_login_time=current_user.last_login_time,
)
)
+108
View File
@@ -0,0 +1,108 @@
"""命令行管理工具。
用法(在 backend/ 目录下执行)::
uv run python -m app.cli create-admin --username admin
密码优先**交互式输入**而非命令行参数:命令行参数会进入 shell 历史,
在多用户机器上还能被 `ps` 看到。`--password` 仅为自动化脚本保留。
"""
import argparse
import getpass
import sys
from sqlalchemy import select
from app.core.constants import PASSWORD_MAX_BYTES, PASSWORD_MIN_BYTES, ROLE_ADMIN
from app.core.db import dispose_engine, get_session_factory
from app.core.security import hash_password
from app.models import Role, User, UserRole
def _prompt_password() -> str:
"""交互式读取并二次确认密码,循环直到符合策略。"""
while True:
password = getpass.getpass(
f"请输入密码({PASSWORD_MIN_BYTES}-{PASSWORD_MAX_BYTES} 字节): "
)
length = len(password.encode("utf-8"))
if length < PASSWORD_MIN_BYTES:
print(f" 密码不足 {PASSWORD_MIN_BYTES} 字节,请重新输入。")
continue
if length > PASSWORD_MAX_BYTES:
print(f" 密码超过 {PASSWORD_MAX_BYTES} 字节上限(中文一字占 3 字节),请重新输入。")
continue
if password != getpass.getpass("请再次输入以确认: "):
print(" 两次输入不一致,请重新输入。")
continue
return password
def _create_admin(args: argparse.Namespace) -> int:
"""幂等创建管理员账号。
已存在时**不覆盖密码** —— 意外重跑脚本不应把已有账号的密码改掉。
"""
factory = get_session_factory()
with factory() as session:
existing = session.execute(
select(User).where(User.username == args.username)
).scalar_one_or_none()
if existing is not None:
print(f"用户 {args.username!r} 已存在(id={existing.id}),未做任何修改。")
return 0
role = session.execute(
select(Role).where(Role.role_code == ROLE_ADMIN)
).scalar_one_or_none()
if role is None:
print(
"错误:未找到 admin 角色,请先执行数据库迁移(uv run alembic upgrade head)。",
file=sys.stderr,
)
return 1
password = args.password or _prompt_password()
user = User(
username=args.username,
password=hash_password(password),
real_name=args.real_name,
status=1,
)
session.add(user)
session.flush() # 取得自增 id 后才能建关联
session.add(UserRole(user_id=user.id, role_id=role.id))
session.commit()
print(f"已创建管理员 {args.username!r}(id={user.id}),角色:{role.role_name}。")
print("首次登录后请立即修改密码。")
return 0
def main() -> int:
"""命令行入口。"""
parser = argparse.ArgumentParser(
prog="app.cli", description="衍水集团营销人员工资管理系统 - 命令行工具"
)
sub = parser.add_subparsers(dest="command", required=True)
p_admin = sub.add_parser("create-admin", help="创建初始管理员账号(幂等)")
p_admin.add_argument("--username", default="admin", help="登录用户名,默认 admin")
p_admin.add_argument("--real-name", default="系统管理员", help="真实姓名")
p_admin.add_argument(
"--password",
default=None,
help="密码。不传则交互式输入(推荐)——命令行传参会留在 shell 历史中",
)
args = parser.parse_args()
try:
return _create_admin(args)
finally:
dispose_engine()
if __name__ == "__main__":
raise SystemExit(main())
+1
View File
@@ -0,0 +1 @@
"""横切基础设施:配置、安全、连接、异常、响应、日志。不含业务规则。"""
+160
View File
@@ -0,0 +1,160 @@
"""图形验证码渲染。
**为什么自绘而不用 captcha 库的默认渲染**:
- 该库默认输出随机彩色字符 + 重噪点 + 弧线,与界面视觉体系严重冲突
- 它最后更新于 2025-03,内部还引用了 Pillow 的旧别名(`PIL.ImageDraw.ImageDraw`)
自绘后配色与界面统一,而识别难度由**逐字旋转 + 垂直抖动 + 干扰曲线 + 噪点**
共同保证,强度与原实现相当且可调(见 `_draw_characters` 的注释)。
仅沿用该库内置的 DroidSansMono 字体文件:自带字体可避开
在 Linux 容器里找不到系统字体的经典问题。
"""
import base64
import io
import math
import random
import secrets
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
from app.core.constants import CAPTCHA_CHARSET, CAPTCHA_LENGTH
# 画布尺寸与前端展示槽位对齐(登录页为 116×40)
_WIDTH, _HEIGHT = 120, 40
_FONT_SIZE = 25
_PADDING = 8
# 与前端配色呼应:墨色字符、青灰干扰、素纸底
_INK = (15, 54, 70)
_INTERFERENCE = (126, 156, 170)
_SPECKLE = (168, 183, 192)
_PAPER = (247, 246, 242)
_SPECKLE_COUNT = 46
_INTERFERENCE_LINES = 2
# 备选字体路径:优先用 captcha 包内置字体(跨平台一致),其余为回退
_FONT_CANDIDATES = (
"C:/Windows/Fonts/consola.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf",
)
def _font_path() -> Path | None:
"""定位可用的等宽字体文件。"""
import captcha
package_file = getattr(captcha, "__file__", None)
if package_file:
bundled = Path(package_file).parent / "data" / "DroidSansMono.ttf"
if bundled.is_file():
return bundled
for candidate in _FONT_CANDIDATES:
path = Path(candidate)
if path.is_file():
return path
return None
def _load_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
"""加载字体。找不到任何字体文件时退回 PIL 内置位图字体(仍可用,只是较粗糙)。"""
path = _font_path()
if path is None:
return ImageFont.load_default(size)
return ImageFont.truetype(str(path), size)
def generate_text() -> str:
"""生成验证码文本。
用 `secrets` 而非 `random`:验证码是安全凭据,
`random` 的 Mersenne Twister 状态可被观测样本反推。
"""
return "".join(secrets.choice(CAPTCHA_CHARSET) for _ in range(CAPTCHA_LENGTH))
def _draw_speckles(draw: ImageDraw.ImageDraw, count: int) -> None:
"""撒噪点。
用单像素点与 2×2 方块,**不用 `ellipse`** —— 3×3 包围盒画出的椭圆
恰好填充中心与四个边中点,形状就是「+」,会形成一种规则的十字纹理,
既显眼又容易被整体滤波抹掉,起不到干扰作用。
"""
for _ in range(count):
x = random.randint(0, _WIDTH - 2)
y = random.randint(0, _HEIGHT - 2)
if random.random() < 0.75:
draw.point((x, y), fill=_SPECKLE)
else:
draw.rectangle((x, y, x + 1, y + 1), fill=_SPECKLE)
def _draw_interference(draw: ImageDraw.ImageDraw) -> None:
"""绘制干扰曲线。
用低对比度的正弦折线而非粗直线:粗直线会把字符主干割断、
显著提高正常用户的识错率,而低对比细线对分割算法的干扰并不更差。
"""
for _ in range(_INTERFERENCE_LINES):
amplitude = random.uniform(5.0, 11.0)
phase = random.uniform(0, math.tau)
base_y = random.uniform(_HEIGHT * 0.25, _HEIGHT * 0.75)
points = [
(x, base_y + amplitude * math.sin(phase + x / 34.0)) for x in range(0, _WIDTH + 1, 4)
]
draw.line(points, fill=_INTERFERENCE, width=1)
def _draw_characters(image: Image.Image, text: str) -> None:
"""逐字绘制。
**每个字符单独渲染后旋转再贴回**,而不是整行一起旋转:
整行旋转只是把同一个倾斜做一次仿射变换,对齐后即可还原;
逐字随机旋转会破坏字符间的版式一致性,模板匹配难以复用。
再叠加 ±2px 的垂直抖动,进一步打散基线。
"""
font = _load_font(_FONT_SIZE)
cell = (_WIDTH - 2 * _PADDING) / len(text)
for index, char in enumerate(text):
# 每个字形画在独立画布上以便单独旋转;留出余量避免旋转后切角
glyph = Image.new("RGBA", (int(cell) + 16, _HEIGHT), (0, 0, 0, 0))
ImageDraw.Draw(glyph).text(
(8, 3),
char,
font=font,
fill=(*_INK, 255),
# 加 1 像素描边:等宽细体在小字号下笔画过细,
# 而本系统的用户是营销人员、屏幕与年龄跨度都大,
# 验证码过难会直接变成支持负担。加粗的代价是连通像素略多,
# 但相对于"用户反复输错"的代价是划算的。
stroke_width=1,
stroke_fill=(*_INK, 255),
)
glyph = glyph.rotate(random.uniform(-14, 14), resample=Image.BICUBIC, expand=False)
image.paste(glyph, (int(_PADDING + index * cell) - 8, random.randint(-2, 2)), glyph)
def render_png_base64(text: str) -> str:
"""把验证码文本渲染为 PNG 并返回 base64(**不含** `data:` 前缀)。
不含前缀是为了让前端自行拼接;服务端返回纯数据,
浏览器 / 小程序 / 桌面端可各自决定呈现方式。
"""
image = Image.new("RGB", (_WIDTH, _HEIGHT), _PAPER)
draw = ImageDraw.Draw(image)
_draw_speckles(draw, _SPECKLE_COUNT // 2)
_draw_interference(draw)
_draw_characters(image, text)
# 字符之上再撒一层,干扰基于连通域的分割
_draw_speckles(draw, _SPECKLE_COUNT // 2)
buffer = io.BytesIO()
image.save(buffer, format="PNG")
return base64.b64encode(buffer.getvalue()).decode("ascii")
+103
View File
@@ -0,0 +1,103 @@
"""应用配置。
从 `backend/.env` 读取配置项,字段名与 `.env` 中的变量名一一对应
(pydantic-settings 默认大小写不敏感,`app_secret_key` ←→ `APP_SECRET_KEY`)。
设计取舍:
- `APP_SECRET_KEY` 无默认值 —— 缺失时启动即失败。空密钥签发 JWT 属安全问题
而非可用性问题,必须 fail-fast,不能容忍"后端起得来但令牌可被伪造"。
- 数据库/Redis 连接不在此处建立 —— 依赖不可用时仍应能启动应用、查看接口文档。
"""
from functools import lru_cache
from pathlib import Path
from pydantic import Field, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
from sqlalchemy import URL
# backend/ 目录:本文件位于 backend/app/core/config.py,向上三级
BACKEND_DIR = Path(__file__).resolve().parents[2]
ENV_FILE = BACKEND_DIR / ".env"
class Settings(BaseSettings):
"""应用配置项。"""
model_config = SettingsConfigDict(
env_file=str(ENV_FILE),
# Windows 默认编码为 cp936,不显式指定会导致中文值解析出错
env_file_encoding="utf-8",
case_sensitive=False,
# .env 中可能含本服务用不到的变量(如 docker 编排专用项),忽略而非报错
extra="ignore",
)
# ===== 应用 =====
app_env: str = "dev"
app_secret_key: str = Field(description="JWT 签名密钥,至少 32 字符")
# ===== 鉴权 =====
# 规范第 13 节:Token 有效期 ≤ 2 小时
jwt_expire_minutes: int = Field(default=120, ge=1, le=120)
# 规范第 6 节:验证码 300s
captcha_expire_seconds: int = Field(default=300, ge=60, le=300)
login_fail_limit: int = Field(default=5, ge=1)
login_lock_minutes: int = Field(default=15, ge=1)
# ===== MySQL =====
mysql_host: str = "127.0.0.1"
mysql_port: int = 3306
mysql_database: str = "payroll"
mysql_user: str = "payroll"
mysql_password: str = ""
# ===== Redis =====
redis_host: str = "127.0.0.1"
redis_port: int = 6379
redis_password: str = ""
redis_db: int = 0
@field_validator("app_secret_key")
@classmethod
def _check_secret_key(cls, v: str) -> str:
"""校验密钥强度:过短的密钥可被暴力破解,直接拒绝启动。"""
if len(v) < 32:
raise ValueError("APP_SECRET_KEY 长度不足 32 字符,请在 backend/.env 中重新生成")
return v
@property
def database_url(self) -> URL:
"""数据库连接 URL。运行期与 Alembic 迁移共用同一份。
用 SQLAlchemy 的 URL.create 而非手工拼接字符串 —— 密码含 @ : / 等
特殊字符时手工拼接会产生语法错误。
驱动为同步的 pymysql:本项目并发极低,异步无收益;而绩效核算与
pandas 报表都是同步阻塞代码,同步方案不会与之冲突。
"""
return URL.create(
drivername="mysql+pymysql",
username=self.mysql_user,
password=self.mysql_password,
host=self.mysql_host,
port=self.mysql_port,
database=self.mysql_database,
query={"charset": "utf8mb4"},
)
@property
def redis_url(self) -> str:
"""Redis 连接 URL。密码为空时不拼接认证段。"""
auth = f":{self.redis_password}@" if self.redis_password else ""
return f"redis://{auth}{self.redis_host}:{self.redis_port}/{self.redis_db}"
@lru_cache
def get_settings() -> Settings:
"""获取配置单例。
进程内只解析一次 .env;测试中如需覆盖,调用方应使用
`get_settings.cache_clear()` 配合环境变量注入。
"""
return Settings() # type: ignore[call-arg]
+38
View File
@@ -0,0 +1,38 @@
"""全局常量。
集中定义 Redis 键模板、响应码、角色编码等跨模块共享的字面量,
避免各处硬编码字符串导致拼写漂移。
"""
# ===== 响应码(与 HTTP 状态码保持一致,见 PROJECT_STANDARDS 第 9 节)=====
CODE_SUCCESS = 200
CODE_BAD_REQUEST = 400
CODE_UNAUTHORIZED = 401
CODE_FORBIDDEN = 403
CODE_NOT_FOUND = 404
CODE_SERVER_ERROR = 500
# ===== Redis 键模板 =====
# 规范第 6 节:键格式 {项目缩写}:{业务}:{标识},项目缩写为 ys
REDIS_KEY_CAPTCHA = "ys:captcha:{captcha_id}"
REDIS_KEY_LOGIN_FAIL = "ys:login:fail:{username}"
REDIS_KEY_TOKEN_BLACKLIST = "ys:token:blacklist:{jti}"
# ===== JWT =====
JWT_ALGORITHM = "HS256"
TOKEN_TYPE_BEARER = "Bearer"
# ===== 角色编码(与 db/migration 初始化数据保持一致)=====
ROLE_ADMIN = "admin"
ROLE_USER = "user"
# ===== 密码策略 =====
# bcrypt 5.0 起对超过 72 字节的密码由「静默截断」改为「抛 ValueError」,
# 而中文一个字符占 3 字节,因此必须按字节数校验,按字符数校验是错的。
PASSWORD_MIN_BYTES = 8
PASSWORD_MAX_BYTES = 72
# ===== 验证码 =====
# 剔除易混淆字符 0/O/1/I/l,降低人工识别错误率
CAPTCHA_CHARSET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"
CAPTCHA_LENGTH = 4
+64
View File
@@ -0,0 +1,64 @@
"""数据库连接管理。
**为什么用同步引擎**:本项目并发极低(内部系统,营销人员数十人),
异步拿不到吞吐收益;而重活——绩效批量核算、pandas 报表、Excel 导出——
全是同步阻塞代码,放在异步路由里会卡死事件循环,且这类问题在开发期
低并发下几乎不暴露。改用同步后,路由写 `def`,FastAPI 会**自动放入
线程池执行**,天然规避上述陷阱,也不会阻塞事件循环。
引擎采用**延迟创建**:应用启动时不连接 MySQL,
这样 Docker 未启动时仍能运行服务、查看接口文档。
"""
from collections.abc import Generator
from sqlalchemy import Engine, create_engine
from sqlalchemy.orm import Session, sessionmaker
from app.core.config import get_settings
_engine: Engine | None = None
_session_factory: sessionmaker[Session] | None = None
def get_engine() -> Engine:
"""获取同步引擎单例(首次调用时创建)。"""
global _engine, _session_factory
if _engine is None:
settings = get_settings()
_engine = create_engine(
settings.database_url,
# 连接池中的连接在使用前先探活,避免 MySQL 重启后取到死连接
pool_pre_ping=True,
pool_recycle=3600,
echo=False,
)
_session_factory = sessionmaker(_engine, expire_on_commit=False)
return _engine
def get_session_factory() -> sessionmaker[Session]:
"""获取会话工厂单例。"""
get_engine()
assert _session_factory is not None # get_engine 已确保初始化
return _session_factory
def get_db() -> Generator[Session, None, None]:
"""FastAPI 依赖:提供一个数据库会话。
只负责会话的创建与关闭,**事务边界由 service 层控制** ——
这样一次请求内的多个仓储调用可以共处同一事务。
"""
factory = get_session_factory()
with factory() as session:
yield session
def dispose_engine() -> None:
"""关闭连接池。应用退出时调用。"""
global _engine, _session_factory
if _engine is not None:
_engine.dispose()
_engine = None
_session_factory = None
+80
View File
@@ -0,0 +1,80 @@
"""全局异常处理器。
把各类异常统一转换为 `{code, message, data}` 响应,
避免 FastAPI 默认的 422 `detail` 数组结构泄漏到前端。
注意:参数校验失败按规范映射为 **400**(FastAPI 默认是 422),
因为规范第 9 节只定义了 400/401/403/404/500 五个状态码。
"""
import logging
from fastapi import FastAPI, Request
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from starlette.exceptions import HTTPException as StarletteHTTPException
from app.core.constants import CODE_BAD_REQUEST, CODE_SERVER_ERROR
from app.core.exceptions import AppException
from app.core.response import ApiResponse
logger = logging.getLogger(__name__)
def _build_response(code: int, message: str) -> JSONResponse:
"""按统一格式构造 JSON 响应。"""
body = ApiResponse[None](code=code, message=message, data=None)
return JSONResponse(status_code=code, content=body.model_dump())
def _format_validation_errors(exc: RequestValidationError) -> str:
"""把 pydantic 的校验错误压缩成一行中文提示。
只取首条错误的字段名与说明,避免把完整的错误数组抛给前端。
"""
errors = exc.errors()
if not errors:
return "参数错误"
first = errors[0]
# loc 形如 ("body", "username"),去掉来源前缀只留字段名
location = ".".join(
str(part) for part in first.get("loc", ()) if part not in ("body", "query", "path")
)
detail = first.get("msg", "参数错误")
return f"参数错误:{location} {detail}" if location else f"参数错误:{detail}"
async def handle_app_exception(_: Request, exc: AppException) -> JSONResponse:
"""处理业务异常,按其 code 返回。"""
return _build_response(exc.code, exc.message)
async def handle_validation_error(_: Request, exc: RequestValidationError) -> JSONResponse:
"""处理请求参数校验失败,映射为 400。"""
return _build_response(CODE_BAD_REQUEST, _format_validation_errors(exc))
async def handle_http_exception(_: Request, exc: StarletteHTTPException) -> JSONResponse:
"""处理框架级 HTTP 异常(404 路由不存在、405 方法不允许等)。"""
message = exc.detail if isinstance(exc.detail, str) else "请求处理失败"
return _build_response(exc.status_code, message)
async def handle_unexpected_exception(_: Request, exc: Exception) -> JSONResponse:
"""兜底处理未预期异常。
对外只返回通用文案,不泄漏堆栈;完整异常记录到日志供排查。
"""
logger.exception("未预期的服务端异常")
return _build_response(CODE_SERVER_ERROR, "服务端异常,请稍后重试")
def register_exception_handlers(app: FastAPI) -> None:
"""注册全部全局异常处理器。
注册顺序不影响匹配 —— FastAPI 按异常类型精确查找,越具体的类型优先。
"""
app.add_exception_handler(AppException, handle_app_exception) # type: ignore[arg-type]
app.add_exception_handler(RequestValidationError, handle_validation_error) # type: ignore[arg-type]
app.add_exception_handler(StarletteHTTPException, handle_http_exception) # type: ignore[arg-type]
app.add_exception_handler(Exception, handle_unexpected_exception)
+54
View File
@@ -0,0 +1,54 @@
"""业务异常定义。
`code` 直接作为 HTTP 状态码返回,使前端拦截器既能读 HTTP 状态也能读响应体的 code。
重要约定:**401 只表示"受保护接口的鉴权失败"**,登录接口自身的失败一律用 400/403。
否则前端"401 即跳登录页"的拦截器会在用户输错密码时触发跳转,导致登录页反复重挂载。
"""
from app.core.constants import (
CODE_BAD_REQUEST,
CODE_FORBIDDEN,
CODE_NOT_FOUND,
CODE_SERVER_ERROR,
CODE_UNAUTHORIZED,
)
class AppException(Exception):
"""业务异常基类。业务逻辑中主动抛出,由全局处理器转换为统一响应。"""
code: int = CODE_SERVER_ERROR
message: str = "服务端异常"
def __init__(self, message: str | None = None) -> None:
self.message = message or self.message
super().__init__(self.message)
class BadRequestError(AppException):
"""参数错误或业务规则校验不通过。"""
code = CODE_BAD_REQUEST
message = "参数错误"
class AuthError(AppException):
"""未认证:令牌缺失、无效、过期或已登出。"""
code = CODE_UNAUTHORIZED
message = "登录状态已失效,请重新登录"
class ForbiddenError(AppException):
"""已认证但无权限,或账号被禁用/锁定。"""
code = CODE_FORBIDDEN
message = "无权限"
class NotFoundError(AppException):
"""请求的资源不存在。"""
code = CODE_NOT_FOUND
message = "资源不存在"
+94
View File
@@ -0,0 +1,94 @@
"""日志配置。
规范要求(PROJECT_STANDARDS 第 8 节):生产 INFO 级别、按天+大小切分、
保留 30 天、禁止输出密码/手机号/身份证/Token。
Windows 默认编码为 cp936,日志文件 handler 必须显式指定 utf-8,
否则中文日志会变成乱码。
"""
import logging
import re
import sys
from logging.handlers import TimedRotatingFileHandler
from app.core.config import BACKEND_DIR
LOG_DIR = BACKEND_DIR / "logs"
# 文本脱敏规则。日志是最容易泄漏敏感信息的地方,且多半来自异常堆栈里的
# 请求体字符串,无法靠"调用方注意"来保证,所以在这里统一兜底。
_SENSITIVE_RULES: tuple[tuple[re.Pattern[str], str], ...] = (
# 含 password / passwd / pwd / secret / token 的键名,或 app_key / access_key 之类的短名
(
re.compile(
r'((?:\w*(?:password|passwd|pwd|secret|token)\w*|app_?key|access_?key)["\']?\s*[:=]\s*["\']?)([^"\'\s,}&]+)',
re.IGNORECASE,
),
r"\1***",
),
# Authorization: Bearer xxx
(re.compile(r"(Bearer\s+)[A-Za-z0-9\-._~+/]+=*"), r"\1***"),
# 手机号:138****8888
(re.compile(r"\b(1[3-9]\d)\d{4}(\d{4})\b"), r"\1****\2"),
# 18 位身份证:保留前 6 位与后 4 位
(re.compile(r"\b(\d{6})\d{8}(\d{3}[\dXx])\b"), r"\1********\2"),
)
def scrub(text: str) -> str:
"""对文本按规则脱敏。导出供单测直接验证。"""
for pattern, replacement in _SENSITIVE_RULES:
text = pattern.sub(replacement, text)
return text
class SensitiveFormatter(logging.Formatter):
"""脱敏格式化器。
脱敏必须在父类完成 %-格式化**之后**进行,而不是在 filter 阶段改写 ``record.msg``。
后者会把 ``"登录失败 password=%s"`` 里的占位符一并抹成 ``password=***``,
随后 logging 内部执行 ``msg % args`` 时因参数无处安放抛 TypeError,
导致整条日志丢失 —— 这比不脱敏更危险。
"""
def format(self, record: logging.LogRecord) -> str:
return scrub(super().format(record))
def setup_logging(level: str = "INFO", log_to_file: bool = True) -> None:
"""初始化根日志配置。
重复调用是安全的:先清空已有 handler,避免 uvicorn --reload
或测试中重复挂载导致日志重复输出。
:param level: 日志级别
:param log_to_file: 是否写文件;测试环境可关闭以避免产生日志文件
"""
root = logging.getLogger()
root.setLevel(level)
for handler in list(root.handlers):
root.removeHandler(handler)
formatter = SensitiveFormatter(
fmt="%(asctime)s | %(levelname)-8s | %(name)s | %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
# 控制台:Windows 终端默认 cp936,中文日志可能报编码错误,显式重配为 utf-8
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8") # type: ignore[union-attr]
console = logging.StreamHandler(sys.stdout)
console.setFormatter(formatter)
root.addHandler(console)
if log_to_file:
LOG_DIR.mkdir(parents=True, exist_ok=True)
file_handler = TimedRotatingFileHandler(
filename=LOG_DIR / "app.log",
when="midnight",
backupCount=30, # 规范要求保留 30 天
encoding="utf-8",
)
file_handler.setFormatter(formatter)
root.addHandler(file_handler)
+41
View File
@@ -0,0 +1,41 @@
"""Redis 连接管理。
承担三类职责:图形验证码、登录失败限流、JWT 登出黑名单。
键名模板统一在 `app.core.constants` 中定义,禁止在业务代码里拼接字符串。
与其他基础设施一致采用**同步客户端**:路由写 `def` 由 FastAPI 放入线程池执行。
若 DB 同步而 Redis 异步,同一请求里就会出现"要么路由必须写 async(于是同步 DB
阻塞事件循环)、要么把 Redis 调用包进 run_in_threadpool"的两难,两头都别扭。
"""
from redis import Redis
from app.core.config import get_settings
_client: Redis | None = None
def get_redis() -> Redis:
"""获取 Redis 客户端单例(首次调用时创建)。
延迟创建:Redis 未启动时不影响应用启动。
返回的是**同步**客户端,直接调用即可,不要 await。
"""
global _client
if _client is None:
settings = get_settings()
_client = Redis.from_url(
settings.redis_url,
encoding="utf-8",
decode_responses=True,
socket_connect_timeout=3,
)
return _client
def close_redis() -> None:
"""关闭连接池。应用退出时调用。"""
global _client
if _client is not None:
_client.close()
_client = None
+62
View File
@@ -0,0 +1,62 @@
"""统一响应模型。
所有接口响应体形如 `{code, message, data}`(PROJECT_STANDARDS 第 3 节)。
分页响应字段为 `list/total/pageNum/pageSize`(同规范第 14 节)。
字段名采用驼峰(规范约定)而 Python 属性用蛇形(语言惯例),
两者通过 pydantic 的 alias 映射,序列化时输出驼峰。
"""
from pydantic import BaseModel, ConfigDict, Field
from app.core.constants import CODE_SUCCESS
class ApiResponse[T](BaseModel):
"""统一响应包装。泛型参数为 `data` 的类型。"""
code: int = Field(default=CODE_SUCCESS, description="状态码,与 HTTP 状态码一致")
message: str = Field(default="success", description="提示信息")
data: T | None = Field(default=None, description="业务数据")
def ok[T](data: T | None = None, message: str = "success") -> ApiResponse[T]:
"""构造成功响应。
统一出口便于日后调整成功码或附加字段时只改一处。
"""
return ApiResponse(code=CODE_SUCCESS, message=message, data=data)
class PageResult[T](BaseModel):
"""分页结果。
Python 侧使用蛇形属性名,序列化后输出规范要求的驼峰字段名。
"""
model_config = ConfigDict(populate_by_name=True)
records: list[T] = Field(
default_factory=list, alias="list", serialization_alias="list", description="当前页数据"
)
total: int = Field(default=0, description="总记录数")
page_num: int = Field(
default=1, alias="pageNum", serialization_alias="pageNum", description="当前页码"
)
page_size: int = Field(
default=10, alias="pageSize", serialization_alias="pageSize", description="每页条数"
)
class PageQuery(BaseModel):
"""分页查询入参基类。业务查询参数继承本类扩展。"""
model_config = ConfigDict(populate_by_name=True)
page_num: int = Field(default=1, ge=1, alias="pageNum", description="页码,从 1 开始")
page_size: int = Field(default=10, ge=1, le=200, alias="pageSize", description="每页条数")
@property
def offset(self) -> int:
"""转换为 SQL OFFSET。"""
return (self.page_num - 1) * self.page_size
+102
View File
@@ -0,0 +1,102 @@
"""密码哈希与 JWT 签发校验。
**为什么不用 passlib**:passlib 1.7.4 自 2020 年起停止维护,且会读取
bcrypt 4.1 起已删除的 `bcrypt.__about__` 属性(表现为 "(trapped) error
reading bcrypt version"),与 bcrypt 5.x 组合没有任何官方支持。
FastAPI 官方文档亦已改用 pwdlib。
**BCrypt 的 72 字节硬限制**:bcrypt 5.0 起对超过 72 字节的密码**直接抛
ValueError**(此前是静默截断)。中文一个字符占 3 字节,24 个汉字即到顶 ——
因此必须按字节数校验,按字符数校验是错的。
本模块做防御性兜底,调用方(schemas 层)应更早拦截以给出友好提示。
"""
import uuid
from datetime import UTC, datetime, timedelta
from typing import Any
import jwt
from pwdlib import PasswordHash
from pwdlib.hashers.bcrypt import BcryptHasher
from app.core.config import get_settings
from app.core.constants import JWT_ALGORITHM, PASSWORD_MAX_BYTES
from app.core.exceptions import AuthError
# **显式指定 bcrypt,不用 `PasswordHash.recommended()`**:
# 后者在 pwdlib 0.3.x 推荐的是 Argon2,且会随库版本变化 —— 结果是
# 同一批密码用不同算法哈希、与规范要求的「密码 BCrypt」不符。
# 哈希算法属于会被写入数据、影响长期兼容性的决策,必须钉死。
_password_hasher = PasswordHash((BcryptHasher(),))
def hash_password(plain: str) -> str:
"""计算密码哈希。返回值为 BCrypt 格式,定长 60 字符。
:raises ValueError: 密码超过 72 字节(bcrypt 算法上限)
"""
if len(plain.encode("utf-8")) > PASSWORD_MAX_BYTES:
raise ValueError(f"密码超过 {PASSWORD_MAX_BYTES} 字节上限,无法哈希")
return _password_hasher.hash(plain)
def verify_password(plain: str, hashed: str) -> bool:
"""校验密码是否匹配哈希。
超长密码直接判为不匹配而不抛异常 —— 校验路径不应因输入长度而报错,
否则会给攻击者提供区分"密码过长"与"密码错误"的侧信道。
"""
if len(plain.encode("utf-8")) > PASSWORD_MAX_BYTES:
return False
try:
return _password_hasher.verify(plain, hashed)
except Exception:
# 哈希串损坏或格式不符时视为校验失败,不向上抛
return False
# ==================== JWT ====================
def create_access_token(
*, user_id: int, token_version: int, roles: list[str]
) -> tuple[str, str, int]:
"""签发访问令牌。
载荷含 `ver`(对应用户的 `token_version`)—— 鉴权时与库中值比对,
使「禁用账号 / 改密 / 强制下线」能立即让已签发令牌失效,**不依赖 Redis**。
:returns: ``(token, jti, 有效期秒数)``。jti 供登出时拉黑使用。
"""
settings = get_settings()
now = datetime.now(UTC)
expires_in = settings.jwt_expire_minutes * 60
jti = uuid.uuid4().hex
payload = {
"sub": str(user_id), # PyJWT 要求 sub 为字符串
"jti": jti,
"ver": token_version,
"roles": roles, # 仅供前端展示菜单,鉴权时以数据库为准
"iat": now,
"exp": now + timedelta(seconds=expires_in),
}
token = jwt.encode(payload, settings.app_secret_key, algorithm=JWT_ALGORITHM)
return token, jti, expires_in
def decode_token(token: str) -> dict[str, Any]:
"""解析并校验令牌的签名与有效期。
时间一律使用 **UTC 感知时间**:JWT 的 exp 是 UTC 时间戳,若与本地时间
(容器 TZ=Asia/Shanghai)比较会出现 8 小时偏差,表现为「刚登录就过期」
或「过期了还能用」。
:raises AuthError: 签名无效、格式错误或已过期
"""
try:
return jwt.decode(token, get_settings().app_secret_key, algorithms=[JWT_ALGORITHM])
except jwt.ExpiredSignatureError as exc:
raise AuthError("登录已过期,请重新登录") from exc
except jwt.PyJWTError as exc:
raise AuthError("登录凭证无效,请重新登录") from exc
+66
View File
@@ -0,0 +1,66 @@
"""应用入口。
启动:``uv run uvicorn app.main:app --reload --port 8000``
"""
import logging
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from fastapi import FastAPI
from app.api.router import api_router
from app.core.config import get_settings
from app.core.db import dispose_engine
from app.core.exception_handlers import register_exception_handlers
from app.core.logging import setup_logging
from app.core.redis_client import close_redis
logger = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(_: FastAPI) -> AsyncGenerator[None, None]:
"""应用生命周期管理。
启动阶段不建立数据库/Redis 连接(延迟到首次使用),
但退出时必须显式释放连接池,否则 uvicorn 热重载会残留连接。
"""
settings = get_settings()
logger.info(
"应用启动 | 环境=%s | 数据库=%s/%s",
settings.app_env,
settings.mysql_host,
settings.mysql_database,
)
yield
dispose_engine()
close_redis()
logger.info("应用已停止,连接池已释放")
def create_app() -> FastAPI:
"""构造并配置 FastAPI 应用实例。
独立成函数便于测试中构造互不干扰的实例。
"""
settings = get_settings()
setup_logging(level="DEBUG" if settings.app_env == "dev" else "INFO")
app = FastAPI(
title="衍水集团营销人员工资管理系统",
description="营销人员绩效工资核算系统 —— 后端服务",
version="0.1.0",
# 接口文档挂在 /api 下,开发期可通过 Vite 代理从同一个端口访问
docs_url="/api/docs",
redoc_url=None,
openapi_url="/api/openapi.json",
lifespan=lifespan,
)
register_exception_handlers(app)
app.include_router(api_router, prefix="/api")
return app
app = create_app()
+26
View File
@@ -0,0 +1,26 @@
"""ORM 模型层:只做表结构映射。
分层约束:
- 不含业务逻辑
- 不得被 `api/` 层直接使用 —— ORM 对象必须经 `schemas/` 转换后才能出参,
否则表结构变更会直接泄漏到接口契约上
**本模块的导入列表必须保持完整**:Alembic autogenerate 依赖
`Base.metadata` 包含全部表,漏掉一张就会生成「删除该表」的迁移。
"""
from app.models.base import Base, TimestampMixin
from app.models.login_log import LoginLog
from app.models.role import Permission, Role, RolePermission
from app.models.user import User, UserRole
__all__ = [
"Base",
"LoginLog",
"Permission",
"Role",
"RolePermission",
"TimestampMixin",
"User",
"UserRole",
]
+83
View File
@@ -0,0 +1,83 @@
"""ORM 基类、命名约定与公共列。
命名约定按 PROJECT_STANDARDS 红线设置,使 Alembic autogenerate 产出的
约束名自动符合规范(唯一键 `uk_表_字段`、索引 `idx_表_字段`),
无需在每张表上手工指定 —— 手工指定迟早会漏。
"""
from datetime import datetime
from sqlalchemy import DateTime, MetaData, text
from sqlalchemy.dialects.mysql import BIGINT, TINYINT
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
# 约束命名模板。`column_0_name` 取约束涉及的第一列,
# 对单列唯一键/索引即为字段名,符合规范的 uk_表_字段 / idx_表_字段
NAMING_CONVENTION = {
"ix": "idx_%(table_name)s_%(column_0_name)s",
"uq": "uk_%(table_name)s_%(column_0_name)s",
"ck": "ck_%(table_name)s_%(constraint_name)s",
"fk": "fk_%(table_name)s_%(column_0_name)s",
"pk": "pk_%(table_name)s",
}
# 主键与外键统一使用无符号 BIGINT(规范模板为 BIGINT UNSIGNED)。
# 两边类型必须完全一致,否则 MySQL 拒绝建立外键约束。
BIGINT_UNSIGNED = BIGINT(unsigned=True)
# 状态类字段统一用 TINYINT(0/1 或少量枚举值)
TINYINT_TYPE = TINYINT
__all__ = ["BIGINT_UNSIGNED", "TINYINT_TYPE", "Base", "TimestampMixin", "mysql_table_args"]
class Base(DeclarativeBase):
"""全部 ORM 模型的基类。"""
metadata = MetaData(naming_convention=NAMING_CONVENTION)
class TimestampMixin:
"""创建时间 / 更新时间公共列。
取默认值交给数据库而非应用层:即使有人直接用 SQL 改数据,时间戳依然准确;
应用层赋值则会被绕过。
`update_time` 必须用 `text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP")`
而非 `server_onupdate=func.current_timestamp()` —— 后者只是告诉 ORM
「这个值由服务端维护」,**不会生成 ON UPDATE 子句**,结果是更新时间永远不刷新。
"""
create_time: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,
server_default=text("CURRENT_TIMESTAMP"),
comment="创建时间",
)
update_time: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,
server_default=text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP"),
comment="更新时间",
)
def mysql_table_args(comment: str, *extra: object) -> tuple[object, ...]:
"""构造 MySQL 表的公共参数,返回可直接赋给 `__table_args__` 的元组。
显式指定引擎、字符集与排序规则:compose 已把服务端排序规则覆盖为
`utf8mb4_general_ci`,若某处按 MySQL 8.0 默认的 `utf8mb4_0900_ai_ci` 建表,
跨表 JOIN 会报 `Illegal mix of collations`。
:param comment: 表注释(规范红线要求中文 COMMENT)
:param extra: 额外的约束对象(UniqueConstraint / Index 等)
"""
return (
*extra,
{
"mysql_engine": "InnoDB",
"mysql_charset": "utf8mb4",
"mysql_collate": "utf8mb4_general_ci",
"comment": comment,
},
)
+53
View File
@@ -0,0 +1,53 @@
"""登录日志模型。"""
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Index, String, text
from sqlalchemy.orm import Mapped, mapped_column
from app.models.base import BIGINT_UNSIGNED, TINYINT_TYPE, Base, mysql_table_args
class LoginLog(Base):
"""登录日志。需求 2.5.1 要求记录用户登录行为。
不继承 TimestampMixin:日志是追加写、不可变的,没有"更新时间"语义,
也不使用 update_time 的 ON UPDATE 子句。
记录原则:
- **只记登录尝试,绝不记录密码**(明文或哈希都不记)
- 用户名原样记录而非仅存 user_id —— 账号不存在时也要能追溯
- 写入使用独立事务,失败不阻断登录(审计可丢,业务不可断)
"""
__tablename__ = "login_logs"
__table_args__ = mysql_table_args(
"登录日志表",
Index("idx_login_logs_username", "username"),
Index("idx_login_logs_create_time", "create_time"),
)
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
)
user_id: Mapped[int | None] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("users.id", ondelete="SET NULL"),
comment="用户ID(账号不存在时为空)",
)
username: Mapped[str] = mapped_column(
String(64), nullable=False, comment="登录用户名(原样记录)"
)
ip: Mapped[str | None] = mapped_column(String(45), comment="客户端IP(45位兼容IPv6)")
user_agent: Mapped[str | None] = mapped_column(
String(255), comment="客户端User-Agent(截断存储)"
)
success: Mapped[int] = mapped_column(
TINYINT_TYPE, nullable=False, server_default="0", comment="是否成功:1成功 0失败"
)
fail_reason: Mapped[str | None] = mapped_column(
String(64), comment="失败原因:验证码错误/用户名或密码错误/账号锁定/账号禁用"
)
create_time: Mapped[datetime] = mapped_column(
DateTime, nullable=False, server_default=text("CURRENT_TIMESTAMP"), comment="创建时间"
)
+105
View File
@@ -0,0 +1,105 @@
"""角色、权限及其关联模型(RBAC 标准五表的后四张)。
权限与角色是多对多:一个权限(如「计提参数配置」)可授予多个角色,
因此关联必须独立成表,不能把 role_id 塞进 permissions 表。
"""
from datetime import datetime
from typing import TYPE_CHECKING
from sqlalchemy import DateTime, ForeignKey, Index, Integer, String, UniqueConstraint, text
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import BIGINT_UNSIGNED, TINYINT_TYPE, Base, TimestampMixin, mysql_table_args
if TYPE_CHECKING:
from app.models.user import UserRole
class Role(Base, TimestampMixin):
"""角色表。本期仅 admin / user 两个角色,数据随迁移脚本初始化。"""
__tablename__ = "roles"
__table_args__ = mysql_table_args("角色表")
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
)
role_code: Mapped[str] = mapped_column(
String(32), nullable=False, unique=True, comment="角色编码:admin系统管理员 user普通用户"
)
role_name: Mapped[str] = mapped_column(String(64), nullable=False, comment="角色名称")
description: Mapped[str | None] = mapped_column(String(255), comment="角色说明")
sort_order: Mapped[int] = mapped_column(
Integer, nullable=False, server_default="0", comment="显示排序"
)
status: Mapped[int] = mapped_column(
TINYINT_TYPE, nullable=False, server_default="1", comment="状态:1启用 0禁用"
)
user_roles: Mapped[list["UserRole"]] = relationship(back_populates="role")
class Permission(Base, TimestampMixin):
"""权限表。
perm_type 区分菜单/按钮/接口三类,parent_id 支撑树形结构。
本期只建表不填数据 —— 具体权限清单待需求澄清后确定。
"""
__tablename__ = "permissions"
__table_args__ = mysql_table_args("权限表")
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
)
perm_code: Mapped[str] = mapped_column(
String(128), nullable=False, unique=True, comment="权限编码,如 system:user:create"
)
perm_name: Mapped[str] = mapped_column(String(64), nullable=False, comment="权限名称")
perm_type: Mapped[int] = mapped_column(
TINYINT_TYPE, nullable=False, server_default="1", comment="类型:1菜单 2按钮 3接口"
)
parent_id: Mapped[int | None] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("permissions.id", ondelete="CASCADE"),
comment="父权限ID(permissions.id),顶级为空",
)
sort_order: Mapped[int] = mapped_column(
Integer, nullable=False, server_default="0", comment="显示排序"
)
status: Mapped[int] = mapped_column(
TINYINT_TYPE, nullable=False, server_default="1", comment="状态:1启用 0禁用"
)
class RolePermission(Base):
"""角色-权限关联表。"""
__tablename__ = "role_permissions"
__table_args__ = mysql_table_args(
"角色权限关联表",
UniqueConstraint(
"role_id", "permission_id", name="uk_role_permissions_role_id_permission_id"
),
Index("idx_role_permissions_permission_id", "permission_id"),
)
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
)
role_id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("roles.id", ondelete="CASCADE"),
nullable=False,
comment="角色ID(roles.id)",
)
permission_id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("permissions.id", ondelete="CASCADE"),
nullable=False,
comment="权限ID(permissions.id)",
)
create_time: Mapped[datetime] = mapped_column(
DateTime, nullable=False, server_default=text("CURRENT_TIMESTAMP"), comment="创建时间"
)
+102
View File
@@ -0,0 +1,102 @@
"""系统用户与用户-角色关联模型。"""
from datetime import datetime
from typing import TYPE_CHECKING
from sqlalchemy import (
DateTime,
ForeignKey,
Index,
Integer,
String,
UniqueConstraint,
text,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import BIGINT_UNSIGNED, TINYINT_TYPE, Base, TimestampMixin, mysql_table_args
if TYPE_CHECKING: # 仅用于类型标注,避免运行期循环导入
from app.models.role import Role
class User(Base, TimestampMixin):
"""系统登录账号。
与营销人员业务数据**严格分离**:营销人员是核算对象,数据来自 T+ 同步,
其中多数不需要登录系统(内勤、离职人员、仅参与核算的业务员)。
合并成一张表会让 T+ 全量同步覆盖掉 password,导致全员无法登录。
采用「删除即禁用」(status=0)而非软删除列:软删除与
`uk_users_username` 唯一键会冲突(删除后的用户名无法重新启用)。
"""
__tablename__ = "users"
__table_args__ = mysql_table_args("系统用户表")
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
)
username: Mapped[str] = mapped_column(
String(64), nullable=False, unique=True, comment="登录用户名"
)
password: Mapped[str] = mapped_column(
String(100), nullable=False, comment="密码哈希(BCrypt,固定60字符)"
)
real_name: Mapped[str | None] = mapped_column(String(64), comment="真实姓名")
phone: Mapped[str | None] = mapped_column(String(20), index=True, comment="手机号")
email: Mapped[str | None] = mapped_column(String(128), comment="邮箱")
status: Mapped[int] = mapped_column(
TINYINT_TYPE, nullable=False, server_default="1", comment="状态:1启用 0禁用"
)
# 「离职即失效」的核心:每次鉴权比对 JWT 的 ver 声明与本列。
# 相比只在 Redis 维护黑名单,这种方式在 Redis 不可用时依然有效
token_version: Mapped[int] = mapped_column(
Integer,
nullable=False,
server_default="1",
comment="令牌版本号:改密/禁用/强制下线时+1,使已签发Token立即失效",
)
last_login_time: Mapped[datetime | None] = mapped_column(DateTime, comment="最后登录时间")
remark: Mapped[str | None] = mapped_column(String(255), comment="备注")
user_roles: Mapped[list["UserRole"]] = relationship(
back_populates="user", cascade="all, delete-orphan", lazy="selectin"
)
class UserRole(Base):
"""用户-角色关联表。
不继承 TimestampMixin(关联关系不会被"更新",只有建立与删除),
但保留 create_time 以便审计「角色何时授予」。
"""
__tablename__ = "user_roles"
__table_args__ = mysql_table_args(
"用户角色关联表",
UniqueConstraint("user_id", "role_id", name="uk_user_roles_user_id_role_id"),
Index("idx_user_roles_role_id", "role_id"),
)
id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED, primary_key=True, autoincrement=True, comment="主键ID"
)
user_id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
comment="用户ID(users.id)",
)
role_id: Mapped[int] = mapped_column(
BIGINT_UNSIGNED,
ForeignKey("roles.id", ondelete="CASCADE"),
nullable=False,
comment="角色ID(roles.id)",
)
create_time: Mapped[datetime] = mapped_column(
DateTime, nullable=False, server_default=text("CURRENT_TIMESTAMP"), comment="创建时间"
)
user: Mapped[User] = relationship(back_populates="user_roles")
role: Mapped["Role"] = relationship(back_populates="user_roles", lazy="selectin")
+7
View File
@@ -0,0 +1,7 @@
"""数据访问层:只负责数据读写。
分层约束:
- **只 flush,不 commit** —— 事务边界由 `services/` 层控制
- 不抛业务异常;查不到数据返回 None 或空集合,由 service 决定如何处置
- 不在此层做权限判断或业务规则校验
"""
@@ -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()
@@ -0,0 +1,36 @@
"""用户与角色数据访问。
本层只 flush 不 commit —— 事务边界由 services 层控制。
"""
from datetime import datetime
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models import User
def get_by_username(session: Session, username: str) -> User | None:
"""按用户名查询用户。不存在返回 None。"""
return session.execute(select(User).where(User.username == username)).scalar_one_or_none()
def get_by_id(session: Session, user_id: int) -> User | None:
"""按主键查询用户。不存在返回 None。"""
return session.get(User, user_id)
def get_role_codes(user: User) -> list[str]:
"""取用户的角色编码列表。
依赖 `User.user_roles` 的 `lazy="selectin"` 预加载(见模型定义),
故此处遍历不会触发 N+1 查询。
"""
return [link.role.role_code for link in user.user_roles if link.role is not None]
def touch_last_login(session: Session, user: User, moment: datetime) -> None:
"""更新最后登录时间。只 flush,提交由 service 决定。"""
user.last_login_time = moment
session.flush()
+9
View File
@@ -0,0 +1,9 @@
"""请求/响应模型层(Pydantic)。
分层约束:
- 禁止把 ORM 对象直接作为 `response_model` 返回,必须在本层完成转换
- 入参校验(含长度、格式、取值范围)在此层声明,业务校验放 `services/`
本项目特有要求:密码字段必须按**字节数**校验而非字符数 ——
bcrypt 5.0 起对超过 72 字节的密码直接抛 ValueError,而中文一个字符占 3 字节。
"""
+73
View File
@@ -0,0 +1,73 @@
"""认证相关的请求与响应模型。"""
from datetime import datetime
from pydantic import BaseModel, ConfigDict, Field, field_validator
from app.core.constants import CAPTCHA_LENGTH, PASSWORD_MAX_BYTES
class CaptchaOut(BaseModel):
"""验证码响应。
图片以 base64 内联返回而非单独的 URL:让 id 与图片在同一响应中成对产生,
避免前端分两次请求时拿到不匹配的组合。
"""
captcha_id: str = Field(description="验证码标识,登录时需回传")
image_base64: str = Field(description="PNG 图片的 base64,不含 data: 前缀")
expires_in: int = Field(description="有效期(秒)")
class LoginIn(BaseModel):
"""登录请求。"""
model_config = ConfigDict(str_strip_whitespace=True)
username: str = Field(min_length=1, max_length=64, description="登录用户名")
password: str = Field(min_length=1, description="密码")
captcha_id: str = Field(min_length=1, max_length=64, description="验证码标识")
captcha_code: str = Field(
min_length=CAPTCHA_LENGTH,
max_length=CAPTCHA_LENGTH,
description="用户填写的验证码",
)
@field_validator("password")
@classmethod
def _check_password_bytes(cls, value: str) -> str:
"""按**字节数**校验密码长度。
必须在这里拦下:bcrypt 5.0 起对超过 72 字节的密码直接抛 ValueError,
而中文一个字符占 3 字节——按字符数校验(如 max_length=20)根本拦不住,
24 个汉字就会让哈希函数抛异常、登录接口 500。
"""
if len(value.encode("utf-8")) > PASSWORD_MAX_BYTES:
raise ValueError(f"密码超过 {PASSWORD_MAX_BYTES} 字节上限")
return value
class UserBrief(BaseModel):
"""用户简要信息。"""
model_config = ConfigDict(from_attributes=True)
id: int
username: str
real_name: str | None = None
roles: list[str] = Field(default_factory=list, description="角色编码列表")
class LoginOut(BaseModel):
"""登录成功响应。"""
access_token: str
token_type: str = "Bearer"
expires_in: int = Field(description="有效期(秒)")
user: UserBrief
class CurrentUserOut(UserBrief):
"""当前登录用户信息。"""
last_login_time: datetime | None = None
+8
View File
@@ -0,0 +1,8 @@
"""业务服务层:业务编排与事务边界。
分层约束:
- **唯一允许出现 commit/rollback 的层**。repository 只 flush,由本层决定何时提交,
这样一次请求内的多次数据写入能共处同一事务
- 可调用多个 repository 组合业务逻辑
- 业务规则不满足时抛 `AppException` 子类,由全局异常处理器转换为统一响应
"""
+167
View File
@@ -0,0 +1,167 @@
"""认证业务编排。
**事务边界在本层**:一次登录的「更新最后登录时间 + 写登录日志」在同一事务内提交。
**失败日志为什么单独提交**:登录失败时抛出的异常会让请求事务回滚,
若日志与业务同事务,最需要留痕的失败记录反而被丢掉。故失败路径先提交日志再抛。
"""
import logging
from datetime import UTC, datetime
from sqlalchemy.orm import Session
from app.core.exceptions import AuthError, BadRequestError, ForbiddenError
from app.core.security import create_access_token, decode_token, verify_password
from app.repositories import login_log_repository, user_repository
from app.schemas.auth import LoginOut, UserBrief
from app.services import captcha_service, login_limiter, token_blacklist
logger = logging.getLogger(__name__)
# 统一凭据错误文案:不区分「用户不存在」与「密码错误」,防止账号枚举。
# 攻击者据此无法判断某个用户名是否真实存在。
INVALID_CREDENTIALS = "用户名或密码错误"
def _record(
session: Session,
*,
user_id: int | None,
username: str,
ip: str | None,
user_agent: str | None,
success: bool,
fail_reason: str | None = None,
) -> None:
"""写登录日志并立即提交。单独提交是刻意为之,见模块 docstring。"""
login_log_repository.add(
session,
user_id=user_id,
username=username,
ip=ip,
user_agent=user_agent,
success=success,
fail_reason=fail_reason,
)
session.commit()
def login(
session: Session,
*,
username: str,
password: str,
captcha_id: str,
captcha_code: str,
ip: str | None,
user_agent: str | None,
) -> LoginOut:
"""执行登录,返回访问令牌与用户信息。
判定顺序是有意设计的,每一步对应一类攻击面:
1. **锁定检查放在最前** —— 否则攻击者无需通过验证码即可消耗失败计数
2. **验证码校验并消费** —— 阻断自动化撞库
3. **凭据校验** —— 用户不存在与密码错误共用同一文案
4. **状态检查放在最后** —— 若提前到密码校验之前,会向未持有正确密码者
暴露「该账号存在但已禁用」
:raises ForbiddenError: 账号锁定或已禁用(403)
:raises BadRequestError: 验证码错误或凭据错误(400)
"""
# ---- 1. 锁定检查 ----
if login_limiter.is_locked(username):
remaining = login_limiter.remaining_lock_seconds(username)
minutes = max(1, (remaining + 59) // 60) # 向上取整,避免显示"0 分钟"
_record(
session,
user_id=None,
username=username,
ip=ip,
user_agent=user_agent,
success=False,
fail_reason="账号锁定",
)
raise ForbiddenError(f"账号已锁定,请 {minutes} 分钟后重试")
# ---- 2. 验证码校验(无论成败,该验证码都已失效)----
if not captcha_service.verify_and_consume(captcha_id, captcha_code):
_record(
session,
user_id=None,
username=username,
ip=ip,
user_agent=user_agent,
success=False,
fail_reason="验证码错误",
)
raise BadRequestError("验证码错误或已过期,请刷新验证码")
# ---- 3. 凭据校验 ----
user = user_repository.get_by_username(session, username)
if user is None or not verify_password(password, user.password):
login_limiter.record_failure(username)
_record(
session,
user_id=user.id if user else None,
username=username,
ip=ip,
user_agent=user_agent,
success=False,
fail_reason=INVALID_CREDENTIALS,
)
raise BadRequestError(INVALID_CREDENTIALS)
# ---- 4. 状态检查 ----
if user.status != 1:
_record(
session,
user_id=user.id,
username=username,
ip=ip,
user_agent=user_agent,
success=False,
fail_reason="账号禁用",
)
raise ForbiddenError("账号已被禁用,请联系管理员")
# ---- 成功 ----
login_limiter.clear(username)
role_codes = user_repository.get_role_codes(user)
# 库列存本地时间(容器 TZ=Asia/Shanghai);JWT 内部另用 UTC,两者不混算
user_repository.touch_last_login(session, user, datetime.now())
token, _, expires_in = create_access_token(
user_id=user.id, token_version=user.token_version, roles=role_codes
)
_record(session, user_id=user.id, username=username, ip=ip, user_agent=user_agent, success=True)
logger.info("用户登录成功 | username=%s | ip=%s", username, ip)
return LoginOut(
access_token=token,
expires_in=expires_in,
user=UserBrief(
id=user.id, username=user.username, real_name=user.real_name, roles=role_codes
),
)
def logout(access_token: str) -> None:
"""登出:把当前令牌加入黑名单。
令牌已自然过期或格式非法时**静默返回** —— 此时没有需要撤销的凭证,
对调用方而言登出都是"已达成";返回 401 只会让前端多弹一次无意义的报错。
"""
try:
payload = decode_token(access_token)
except AuthError:
return
jti = payload.get("jti")
exp = payload.get("exp")
if not jti or not exp:
return
# 黑名单 TTL 必须等于令牌**剩余**有效期,写死固定值会导致"登出后又活了"
remaining = int(exp - datetime.now(UTC).timestamp())
token_blacklist.revoke(jti, remaining)
+48
View File
@@ -0,0 +1,48 @@
"""图形验证码的签发与校验。
校验使用 Redis `GETDEL`——**原子取出即删**,同一验证码用两次必然失败,防重放。
若写成「先 GET 再 DEL」,两条命令之间存在竞态窗口,并发请求可用同一验证码
重复提交。
这是设计代价而非缺陷:用户输错一次密码后验证码已被消费,必须换一张。
**前端必须在登录失败后自动刷新验证码**,否则第二次提交必然报「验证码错误」。
"""
import uuid
from app.core.captcha_image import generate_text, render_png_base64
from app.core.config import get_settings
from app.core.constants import REDIS_KEY_CAPTCHA
from app.core.redis_client import get_redis
def issue() -> tuple[str, str, int]:
"""签发验证码。
:returns: ``(captcha_id, PNG 的 base64, 有效期秒数)``
图片与 ID 在同一次调用中成对产生,避免前端分两次请求导致错配。
"""
settings = get_settings()
captcha_id = uuid.uuid4().hex
text = generate_text()
# 用 set(ex=…) 而非 setex(…):后者在 redis-py 8 已弃用(规范禁用弃用 API)
get_redis().set(
REDIS_KEY_CAPTCHA.format(captcha_id=captcha_id),
text.upper(),
ex=settings.captcha_expire_seconds,
)
return captcha_id, render_png_base64(text), settings.captcha_expire_seconds
def verify_and_consume(captcha_id: str, code: str) -> bool:
"""校验验证码并消费它。
**无论校验成败,该验证码都会失效** —— 通过 GETDEL 取出后键已被删除。
这阻断了「用一个验证码反复试密码」的路径。
:returns: 校验是否通过。验证码不存在(过期或已被使用)时返回 False。
"""
expected = get_redis().getdel(REDIS_KEY_CAPTCHA.format(captcha_id=captcha_id))
if expected is None:
return False
return expected == code.strip().upper()
+54
View File
@@ -0,0 +1,54 @@
"""登录失败限流。
键 `ys:login:fail:{username}` 记录连续失败次数,TTL 等于锁定时长。
达到阈值后即使密码正确也拒绝登录,须等锁定期结束。
**为什么按用户名而非 IP**:本系统用户是确定的几十个营销人员,
按用户名限流才能防住针对性撞库;按 IP 会被内网 NAT 和攻击者换 IP 绕过。
代价是攻击者可故意把他人账号锁 15 分钟 —— 该风险在内部系统中可接受,
且管理员可通过 `clear()` 手动解锁。
"""
from app.core.config import get_settings
from app.core.constants import REDIS_KEY_LOGIN_FAIL
from app.core.redis_client import get_redis
def _key(username: str) -> str:
return REDIS_KEY_LOGIN_FAIL.format(username=username)
def failure_count(username: str) -> int:
"""当前连续失败次数。键不存在时为 0。"""
value = get_redis().get(_key(username))
return int(value) if value else 0
def is_locked(username: str) -> bool:
"""账号当前是否处于锁定期。"""
return failure_count(username) >= get_settings().login_fail_limit
def remaining_lock_seconds(username: str) -> int:
"""锁定期剩余秒数。未锁定时为 0。"""
ttl = get_redis().ttl(_key(username))
return ttl if ttl and ttl > 0 else 0
def record_failure(username: str) -> int:
"""记录一次登录失败,返回累计次数。
每次失败都刷新 TTL,形成**滑动窗口**:任意 15 分钟区间内累计 5 次即锁定。
若不刷新,则"4 次失败 + 等 14 分钟 + 1 次失败"就不会触发锁定,
正好给撞库留出缝隙。
"""
key = _key(username)
redis = get_redis()
count = int(redis.incr(key))
redis.expire(key, get_settings().login_lock_minutes * 60)
return count
def clear(username: str) -> None:
"""登录成功后清除失败计数。"""
get_redis().delete(_key(username))
+39
View File
@@ -0,0 +1,39 @@
"""JWT 登出黑名单。
**TTL 必须等于令牌剩余有效期**:写死固定值会导致「登出 N 分钟后又活了」;
不设过期则 Redis 会随登出次数无限增长。两者都是错的。
**范围限定**:本模块只解决「用户主动登出」。
「禁用账号 / 改密 / 强制下线」由 `users.token_version` 承担 ——
那种场景按用户维度写入 N 个黑名单键既低效,又会在 Redis 故障时完全失效
(那时离职员工仍能用旧令牌查看全员薪酬)。鉴权时二者都要检查。
"""
from app.core.constants import REDIS_KEY_TOKEN_BLACKLIST
from app.core.redis_client import get_redis
def _key(jti: str) -> str:
return REDIS_KEY_TOKEN_BLACKLIST.format(jti=jti)
def revoke(jti: str, remaining_seconds: int) -> None:
"""把令牌加入黑名单。
:param jti: 令牌唯一标识
:param remaining_seconds: 令牌剩余有效期;**必须传真实剩余值**。
非正数表示令牌已自然过期,无需拉黑,本函数直接忽略。
"""
if remaining_seconds <= 0:
return
# 用 set(ex=…) 而非 setex(…):后者在 redis-py 8 已弃用(规范禁用弃用 API)
get_redis().set(_key(jti), "1", ex=remaining_seconds)
def is_revoked(jti: str) -> bool:
"""令牌是否已被拉黑。
Redis 不可用时本函数会抛异常 —— 这是**有意的 fail-closed**:
对能查看全员薪酬的系统,宁可拒绝请求也不能放行无法确定状态的令牌。
"""
return bool(get_redis().exists(_key(jti)))
+107
View File
@@ -0,0 +1,107 @@
[project]
name = "payroll-backend"
version = "0.1.0"
description = "衍水集团营销人员工资管理系统 —— 后端服务"
readme = "README.md"
# 锁定 3.12:系统默认是 3.14,第三方库轮子覆盖不如 3.12 完备
requires-python = ">=3.12,<3.13"
dependencies = [
"fastapi>=0.115",
# starlette 已进入 1.x(当前 1.7.0)。FastAPI 对它的约束是 `starlette>=0.46.0`,
# **没有上界** —— uv lock --upgrade 时可能漂移到未验证的大版本。
# 我们的异常处理器与 TestClient 直接依赖其 API,故显式限定主版本。
# 当前组合(fastapi 0.143 + starlette 1.7.0)已实测通过全部用例。
"starlette>=1.7,<2",
"uvicorn[standard]>=0.32",
"pydantic>=2.9",
"pydantic-settings>=2.6",
# ORM 与驱动:采用同步 —— 本项目并发极低,异步无吞吐收益;
# 而重活(绩效批量核算、pandas 报表、Excel 导出)全是同步阻塞代码,
# 在异步路由里跑会卡死事件循环。同步路由由 FastAPI 自动放入线程池执行。
"sqlalchemy>=2.0.36",
# 必须带 [rsa]:MySQL 8.0 默认 caching_sha2_password,容器重启后首次连接走
# 全量认证需要 cryptography 做 RSA 交换,缺失时 PyMySQL 直接抛 RuntimeError
# (报错出现在建连阶段,极易被误读成"密码错误")
"pymysql[rsa]>=1.2.3",
"alembic>=1.14",
# 验证码、登录限流、Token 黑名单
"redis[hiredis]>=5.2",
# 鉴权与密码哈希
# 注意:不用 passlib —— 它与 bcrypt>=4.1 存在 __about__ 属性缺失的不兼容问题
"pyjwt>=2.10",
"pwdlib[bcrypt]>=0.2",
# 图形验证码(纯 Python,依赖 Pillow)
"captcha>=0.6",
"pillow>=11.0",
]
[dependency-groups]
dev = [
"pytest>=8.3",
"pytest-cov>=7",
"httpx>=0.28,<1.0",
# 用假 Redis 做单测,无需起容器即可覆盖 GETDEL 一次性语义
"fakeredis>=2.30",
# lint + format 二合一,替代 flake8 + isort + black
"ruff>=0.14",
]
# 本项目按应用而非库来管理,不打包安装自身
[tool.uv]
package = false
[tool.pytest.ini_options]
testpaths = ["tests"]
markers = [
"integration: 需要 MySQL/Redis 容器或完整应用实例的测试",
]
# ==================== 代码检查与格式化 ====================
[tool.ruff]
# 与项目根 .editorconfig 保持一致:Python 用 4 空格(缩进由 formatter 控制)
target-version = "py312"
line-length = 100
src = ["app", "tests"]
[tool.ruff.lint]
select = [
"E", # pycodestyle 错误
"W", # pycodestyle 警告
"F", # pyflakes:未使用的导入、未定义的名称等
"I", # isort:导入顺序
"B", # flake8-bugbear:常见陷阱(可变默认参数、循环引用等)
"C4", # flake8-comprehensions:推导式可简化处
"UP", # pyupgrade:按目标 Python 版本升级旧写法
"SIM", # flake8-simplify:可简化的分支与判断
"RUF", # ruff 自有规则
]
ignore = [
# 行宽已由 formatter 控制,lint 不再重复报——长中文注释很容易触发
"E501",
# FastAPI 的依赖注入要求在参数默认值处调用 Depends(),是框架约定而非缺陷。
# 项目内已尽量改用 Annotated 形式(见 api/deps.py),此处兜底。
"B008",
# RUF001/002/003 检查「易混淆的 Unicode 字符」,分别针对字符串字面量、
# 文档字符串、注释 —— 这套规则的默认前提是英文文本。
# 对中文会把全角逗号、句号、括号、冒号全部判定为混淆字符(本项目实测 800+ 条),
# 而中文里全角标点正是规范写法,故一并关闭。
"RUF001",
"RUF002",
"RUF003",
]
[tool.ruff.lint.isort]
# 必须显式声明:本项目 `[tool.uv] package = false`(不作为包安装),
# ruff 无法从项目布局推断出 `app` 是第一方包,否则会把第三方依赖
# 排到 `app.*` 之后(按字母序把两者当成同一组)。
known-first-party = ["app", "tests"]
[tool.ruff.lint.per-file-ignores]
# Alembic 自动生成的迁移脚本不按手写代码的标准要求。
# 这些文件由 `alembic revision --autogenerate` 产出,手工修 lint 会在下次生成时被覆盖。
"alembic/versions/*.py" = ["E501", "I001", "W291"]
[tool.ruff.format]
quote-style = "double"
+98
View File
@@ -0,0 +1,98 @@
# 后端测试
> AI摘要:本项目后端的测试用例清单与运行记录。按「基础设施层随实现即时测、业务模块整模块完成时统一测」分层组织。当前 184 个用例全绿(单元 155 / 集成 29)。
## 一、测试约定
### 编写时机(分层对待)
- `core/` 等**基础设施**随实现即时补测 —— 它们是被业务复用多次的地基,越早锁定越省事
- **业务模块**(系统管理、基础数据管理、业务数据管理、绩效工资核算、报表统计)在**整个模块功能完成时**统一编写用例并执行,不在模块做到一半时零散补测
### 目录二分
| 目录 | 依赖 | 用途 |
| --- | --- | --- |
| `tests/unit/` | 无(不碰 Docker、不连库) | 纯函数与元数据验证,秒级跑完,开发中可高频执行 |
| `tests/integration/` | 完整应用实例或 MySQL/Redis 容器 | 接口级、跨产物一致性验证 |
单元测试里的 Redis 依赖通过 `fake_redis` 夹具(fakeredis)替换,因此验证码 GETDEL、限流计数、黑名单 TTL 这些逻辑无需起容器即可覆盖。
### 注释要求
- 测试类用中文 docstring 说明「这组用例守的是什么」——即**如果不测会出什么问题**
- 用例名描述场景与预期(`test_中文按字节而非字符计算边界`)
- 关键断言补注释说明为何该断言重要,尤其是防回归的用例
### 运行方式
```bash
cd backend
uv run pytest tests/unit -q # 纯单元,无需容器,秒级
uv run pytest -q # 全部(集成部分需容器在跑)
uv run pytest tests/integration -q # 仅集成
```
## 二、用例清单
### 基础设施层(core)
| 测试文件 | 守什么 | 用例数 | 关键覆盖点 |
| --- | --- | :---: | --- |
| `unit/test_models.py` | 建表规范红线 | 49 | 表名小写蛇形复数;每列中文注释**且确实输出到 DDL**;`uk_`/`idx_`/`fk_` 命名前缀;ENGINE/CHARSET 显式指定;`ON UPDATE CURRENT_TIMESTAMP` 存在(`server_onupdate` 不会生成该子句,是 SQLAlchemy 的行为陷阱);日志表反向断言不含该子句 |
| `unit/test_logging.py` | 日志脱敏与格式化 | 16 | 密码/令牌/Bearer/手机号/身份证五类脱敏;**脱敏必须后置于 %-格式化**(早期实现在 filter 阶段改写 `record.msg`,抹掉 `%s` 占位符导致 logging 抛 TypeError、整条日志丢失);setup_logging 幂等 |
| `unit/test_config.py` | 配置加载与安全边界 | 14 | 密钥强度 fail-fast;JWT ≤120 分钟、验证码 ≤300 秒的硬约束;密码含 `@ : / #` 时 URL 构造不破坏;渲染 URL 默认隐藏密码 |
| `unit/test_security.py` | 密码哈希 | 11 | BCrypt 格式与定长 60;**72 字节边界**(24 个汉字正好、25 个超限);超长密码校验时返回 False 而非抛异常(防侧信道);哈希串损坏不炸 |
| `unit/test_response.py` | 统一响应契约 | 11 | 字段固定为 `code/message/data`;分页序列化为规范要求的驼峰 `list/total/pageNum/pageSize` |
| `unit/test_exception_handlers.py` | 异常转统一信封 | 5 | 校验错误 422→400 并压成单行中文;HTTP 状态码与 body 的 `code` 必须一致 |
### 系统管理 · 登录鉴权
| 测试文件 | 守什么 | 用例数 | 关键覆盖点 |
| --- | --- | :---: | --- |
| `unit/test_jwt.py` | 令牌有效期语义与版本机制 | 12 | `exp` 基于 **UTC** 而非本地时间(混算会产生 8 小时偏差,表现为「刚登录就过期」或「过期了还能用」);过期/篡改签名/**alg=none 攻击**/畸形字符串一律收敛为 401 而非 500;jti 唯一 |
| `unit/test_captcha.py` | 验证码**一次性语义** | 12 | GETDEL 后二次使用必然失败(防重放);**校验失败也消费**(否则可用一个验证码反复试密码);字符集剔除 `0O1Il`;每次渲染含随机干扰不可比对;TTL 300 秒 |
| `unit/test_login_limiter.py` | 阈值与**滑动窗口** | 9 | 达阈值即锁;每次失败刷新 TTL(否则「4 次失败 + 等 14 分钟 + 1 次失败」可绕过锁定);按用户名隔离,一个账号被攻击不牵连他人;登录成功清除计数 |
| `unit/test_token_blacklist.py` | 黑名单 **TTL 语义** | 7 | TTL 等于令牌剩余有效期(写死固定值会导致「登出 N 分钟后又活了」,不设过期则 Redis 无限增长);已过期令牌不写入;jti 互不串号 |
| `unit/test_auth_schemas.py` | 密码**按字节**校验 | 9 | 24 个汉字 = 72 字节正好、25 个被拒 —— 按字符数校验(如 `max_length=20`)根本拦不住,会让登录接口 500 |
| `integration/test_auth_flow.py` | 整条链路**串联正确性** | 16 | 验证码→登录→持令牌访问→登出失效;验证码重放被拒;失败原因如实落库且**日志表无任何密码字段**;账号不存在与密码错误文案完全相同(防枚举);禁用账号与 `token_version` 提升均使旧令牌立即失效 |
### 应用骨架与交付产物
| 测试文件 | 守什么 | 用例数 | 关键覆盖点 |
| --- | --- | :---: | --- |
| `integration/test_health.py` | 统一信封与全局异常 | 7 | `/api/health` 始终 200 且依赖状态如实上报;404/405 返回统一信封而非框架默认 `detail` 数组;`/api/docs` 可访问 |
| `integration/test_schema_sync.py` | DDL 不漂移 | 6 | `db/migration/V1__*.sql` 与 `db/init/init-dev.sql` 的**表集合与列集合逐表比对**——两份 DDL 手工同步迟早漂移,且漂移后果隐蔽(开发库结构与生产不一致,上线才发现) |
## 三、运行记录
| 日期 | 范围 | 用例数 | 结果 | 备注 |
| --- | --- | :---: | --- | --- |
| 2026-10-09 | 全部 | 120 | ✅ 通过(4.99s) | 阶段 1 骨架 + 阶段 2 数据库完成后首次全量运行 |
| 2026-10-09 | 全部 | 119 | ✅ 通过(5.01s) | ORM 由异步改回同步后回归;净减 1 例(`sync_database_url` 独立配置已合并) |
| 2026-10-09 | 全部 | 184 | ✅ 通过(25.46s) | **登录鉴权模块完成**后统一编写并执行该模块测试套件(新增 65 例)。集成用例需容器在跑 |
## 四、测试发现并修复的缺陷
| 日期 | 缺陷 | 影响 | 处理 |
| --- | --- | --- | --- |
| 2026-10-09 | 日志脱敏在 `logging.Filter` 阶段改写 `record.msg` | 抹掉 `%s` 占位符 → logging 内部 `msg % args` 抛 TypeError → **整条日志丢失**(比不脱敏更危险) | 改为 `SensitiveFormatter` 在格式化**之后**脱敏;补回归用例 |
| 2026-10-09 | `PasswordHash.recommended()` 在 pwdlib 0.3.x 推荐 Argon2 而非 BCrypt | 密码算法与规范不符,且随库版本变化——同一批密码可能混用两种算法 | 改为显式 `PasswordHash((BcryptHasher(),))` 钉死算法 |
| 2026-10-09 | 模型未设置表级 `comment`,`update_time` 用 `server_onupdate` | 违反规范红线;`server_onupdate` 只是 ORM 标记,**不生成 `ON UPDATE CURRENT_TIMESTAMP` 子句**,更新时间永不刷新 | 补表注释;改用 `text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP")` |
| 2026-10-09 | `pymysql` 未带 `[rsa]` extra,`cryptography` 缺失 | MySQL 8.0 默认 `caching_sha2_password`,容器重启后首次连接走全量认证时抛 `RuntimeError`,且报错在建连阶段易被误读为「密码错误」 | 依赖改为 `pymysql[rsa]` |
| 2026-10-09 | `setex` 在 redis-py 8 已弃用 | 规范明确**禁用弃用 API**;弃用接口在未来大版本会被移除 | 验证码与黑名单改用 `set(key, value, ex=…)` |
| 2026-10-09 | 测试中 TTL 断言写死等值 | 属**测试自身缺陷**:Redis 的 TTL 以毫秒精度存绝对过期时刻、返回时向下取整,刚写入就查询必然可能少 1 秒——会变成偶发失败的闪烁测试 | 断言改为容忍 1 秒偏差,并注明原因 |
| 2026-10-09 | 验证码沿用 `captcha` 库默认渲染 | 随机彩色字符 + 重噪点,与界面视觉体系冲突;该库最后更新于 2025-03,内部还引用了 Pillow 的旧别名 | 改为自绘(`core/captcha_image.py`):配色与界面统一,难度由逐字旋转、抖动、干扰曲线与噪点保证;仅沿用其内置字体文件 |
| 2026-10-09 | 噪点用 `ellipse` 绘制 | 3×3 包围盒画出的椭圆恰好填充中心与四个边中点,形状就是「+」,形成规则的十字纹理——既显眼又容易被整体滤波抹掉,起不到干扰作用 | 改用单像素点与 2×2 方块 |
| 2026-10-09 | `tests/unit/test_jwt.py` 两处赋值了未使用的 `settings`(ruff F841) | 变量未被引用,属无效代码 | 删除赋值;该文件的 `get_settings` 导入仍被其他用例使用 |
## 五、待补用例
| 模块 | 状态 | 说明 |
| --- | --- | --- |
| 系统管理 · 用户与角色管理 | 待编写 | 需求 2.5.1 的用户增删改查、密码重置、启用/禁用、角色分配 — 该模块功能尚未实现 |
| 系统管理 · 操作日志 | 待编写 | 除登录日志外的操作审计(当前仅登录日志落库) |
| 基础数据管理 | 未开始 | 依赖 T+ 对接方式确认 |
| 业务数据管理 | 未开始 | 依赖需求澄清(回款天数口径等 10 项) |
| 绩效工资核算 | 未开始 | 依赖需求澄清;金额精度需专项用例(`Fraction` 与 1/90 日斜率) |
| 报表统计 | 未开始 | 依赖报表口径确认 |
+1
View File
@@ -0,0 +1 @@
"""后端测试包。"""
+38
View File
@@ -0,0 +1,38 @@
"""测试共用夹具。"""
import pytest
from fastapi.testclient import TestClient
@pytest.fixture(scope="session")
def client() -> TestClient:
"""构造应用测试客户端(会话级复用,避免重复构建应用)。
注意:测试环境未必有 MySQL/Redis 在跑,健康检查会如实返回 false,
因此断言应聚焦于响应结构而非依赖的连通性。
"""
from app.main import app
with TestClient(app) as test_client:
yield test_client
@pytest.fixture
def fake_redis(monkeypatch: pytest.MonkeyPatch):
"""把 Redis 客户端替换为进程内假实现。
验证码的 GETDEL、限流的 INCR/EXPIRE/TTL、黑名单的 SETEX——都只用到
基础字符串命令,fakeredis 能完整覆盖,因此这些逻辑无需起容器即可测试,
单测得以保持秒级。
替换的是 `app.core.redis_client` 的模块级单例,各 service 通过
`get_redis()` 取用,故一处替换即全局生效。
"""
import fakeredis
from app.core import redis_client
fake = fakeredis.FakeRedis(decode_responses=True)
monkeypatch.setattr(redis_client, "_client", fake)
yield fake
fake.close()
+5
View File
@@ -0,0 +1,5 @@
"""需要构造完整应用或依赖外部服务(MySQL/Redis)的测试。
`tests/unit/` 下的用例必须能在没有 Docker 的机器上秒跑;
凡是要连容器或用例起整个 FastAPI 应用的,归入本目录。
"""
+271
View File
@@ -0,0 +1,271 @@
"""登录链路端到端测试(需要 MySQL 与 Redis)。
守的是整条链路的**串联正确性**:验证码 → 登录 → 持令牌访问 → 登出失效。
各环节细节已由单元测试覆盖,这里验证它们接起来是否真的可用 ——
尤其是「判定顺序」「失败时日志是否真的落库」「禁用与提版能否立即生效」
这类只有串联运行才会暴露的问题。
运行前置:``docker compose -f docker/docker-compose.dev.yml up -d``
"""
import uuid
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import delete, select
from app.core.constants import REDIS_KEY_CAPTCHA, REDIS_KEY_LOGIN_FAIL
from app.core.db import get_session_factory
from app.core.redis_client import get_redis
from app.core.security import hash_password
from app.models import LoginLog, User
pytestmark = pytest.mark.integration
TEST_PASSWORD = "TestPwd@2026"
@pytest.fixture
def test_user():
"""创建独立测试账号,用后即删。
不复用 admin:测试会触发账号锁定、提升 token_version 等操作,
污染真实账号会让后续手工验证产生困惑。
"""
factory = get_session_factory()
username = f"t_{uuid.uuid4().hex[:8]}"
with factory() as session:
user = User(
username=username,
password=hash_password(TEST_PASSWORD),
real_name="测试账号",
status=1,
)
session.add(user)
session.commit()
user_id = user.id
yield {"id": user_id, "username": username}
with factory() as session:
session.execute(delete(LoginLog).where(LoginLog.username == username))
session.execute(delete(User).where(User.id == user_id)) # user_roles 级联删除
session.commit()
get_redis().delete(REDIS_KEY_LOGIN_FAIL.format(username=username))
def _fetch_captcha(client: TestClient) -> tuple[str, str]:
"""取验证码及其明文答案。
直接从 Redis 读取答案 —— **仅测试可用**,生产环境当然不能如此,
这里是为了让自动化用例不必做图像识别。
"""
data = client.get("/api/v1/auth/captcha").json()["data"]
captcha_id = data["captcha_id"]
answer = get_redis().get(REDIS_KEY_CAPTCHA.format(captcha_id=captcha_id))
return captcha_id, answer
def _login(client: TestClient, username: str, password: str, **overrides):
"""发起一次登录,默认使用新鲜验证码。"""
captcha_id, code = _fetch_captcha(client)
payload = {
"username": username,
"password": password,
"captcha_id": captcha_id,
"captcha_code": code,
}
payload.update(overrides)
return client.post("/api/v1/auth/login", json=payload)
def _bearer(token: str) -> dict[str, str]:
return {"Authorization": f"Bearer {token}"}
class TestHappyPath:
"""正常链路。"""
def test_验证码到登出的完整链路(self, client: TestClient, test_user: dict) -> None:
captcha_id, code = _fetch_captcha(client)
login = client.post(
"/api/v1/auth/login",
json={
"username": test_user["username"],
"password": TEST_PASSWORD,
"captcha_id": captcha_id,
"captcha_code": code,
},
)
assert login.status_code == 200
body = login.json()
assert body["code"] == 200
token = body["data"]["access_token"]
assert body["data"]["token_type"] == "Bearer"
assert body["data"]["user"]["username"] == test_user["username"]
me = client.get("/api/v1/auth/me", headers=_bearer(token))
assert me.json()["data"]["username"] == test_user["username"]
assert client.post("/api/v1/auth/logout", headers=_bearer(token)).json()["code"] == 200
after = client.get("/api/v1/auth/me", headers=_bearer(token))
assert after.status_code == 401
assert after.json()["code"] == 401
def test_登录成功后清除失败计数(self, client: TestClient, test_user: dict) -> None:
username = test_user["username"]
for _ in range(3):
_login(client, username, "WrongPwd@1")
assert get_redis().exists(REDIS_KEY_LOGIN_FAIL.format(username=username)) == 1
assert _login(client, username, TEST_PASSWORD).status_code == 200
assert get_redis().exists(REDIS_KEY_LOGIN_FAIL.format(username=username)) == 0
class TestCaptcha:
"""验证码环节。"""
def test_同一验证码不可重放(self, client: TestClient, test_user: dict) -> None:
captcha_id, code = _fetch_captcha(client)
payload = {
"username": test_user["username"],
"password": TEST_PASSWORD,
"captcha_id": captcha_id,
"captcha_code": code,
}
assert client.post("/api/v1/auth/login", json=payload).status_code == 200
replay = client.post("/api/v1/auth/login", json=payload)
assert replay.status_code == 400
assert "验证码" in replay.json()["message"]
def test_验证码错误时拒绝登录(self, client: TestClient, test_user: dict) -> None:
response = _login(client, test_user["username"], TEST_PASSWORD, captcha_code="ZZZZ")
assert response.status_code == 400
assert "验证码" in response.json()["message"]
def test_验证码错误不消耗失败次数(self, client: TestClient, test_user: dict) -> None:
"""验证码与密码是两类失败,混在一起计数会让用户被误锁。"""
username = test_user["username"]
for _ in range(5):
_login(client, username, TEST_PASSWORD, captcha_code="ZZZZ")
# 之后用正确验证码仍可正常登录
assert _login(client, username, TEST_PASSWORD).status_code == 200
class TestCredentials:
"""凭据校验。"""
def test_密码错误被拒(self, client: TestClient, test_user: dict) -> None:
response = _login(client, test_user["username"], "WrongPwd@1")
assert response.status_code == 400
def test_账号不存在与密码错误的提示完全相同(self, client: TestClient) -> None:
"""文案不一致会形成账号枚举信道,攻击者可据此筛出真实用户名。"""
missing = _login(client, f"nobody_{uuid.uuid4().hex[:8]}", "Whatever@1")
assert missing.status_code == 400
assert missing.json()["message"] == "用户名或密码错误"
def test_无令牌访问受保护接口返回401(self, client: TestClient) -> None:
assert client.get("/api/v1/auth/me").status_code == 401
def test_伪造令牌返回401(self, client: TestClient) -> None:
response = client.get("/api/v1/auth/me", headers=_bearer("not.a.real.token"))
assert response.status_code == 401
assert response.json()["code"] == 401
class TestAccountState:
"""账号状态与令牌失效。"""
def test_禁用账号无法登录(self, client: TestClient, test_user: dict) -> None:
with get_session_factory()() as session:
user = session.get(User, test_user["id"])
user.status = 0
session.commit()
response = _login(client, test_user["username"], TEST_PASSWORD)
assert response.status_code == 403
assert "禁用" in response.json()["message"]
def test_禁用后已签发的令牌立即失效(self, client: TestClient, test_user: dict) -> None:
token = _login(client, test_user["username"], TEST_PASSWORD).json()["data"]["access_token"]
assert client.get("/api/v1/auth/me", headers=_bearer(token)).status_code == 200
with get_session_factory()() as session:
user = session.get(User, test_user["id"])
user.status = 0
session.commit()
assert client.get("/api/v1/auth/me", headers=_bearer(token)).status_code == 401
def test_提升token_version使旧令牌立即失效(self, client: TestClient, test_user: dict) -> None:
"""这是「离职即失效」的实现手段,且**不依赖 Redis**。"""
token = _login(client, test_user["username"], TEST_PASSWORD).json()["data"]["access_token"]
assert client.get("/api/v1/auth/me", headers=_bearer(token)).status_code == 200
with get_session_factory()() as session:
user = session.get(User, test_user["id"])
user.token_version += 1 # 相当于改密 / 强制下线
session.commit()
assert client.get("/api/v1/auth/me", headers=_bearer(token)).status_code == 401
class TestRateLimit:
"""登录限流。"""
def test_连续失败达阈值后正确密码也被拒(self, client: TestClient, test_user: dict) -> None:
username = test_user["username"]
for _ in range(5):
_login(client, username, "WrongPwd@1")
response = _login(client, username, TEST_PASSWORD)
assert response.status_code == 403
assert "锁定" in response.json()["message"]
def test_锁定期内失败计数未被继续放大(self, client: TestClient, test_user: dict) -> None:
"""锁定后仍不断尝试不应无限刷新 TTL——否则会被用来永久锁死他人账号。"""
username = test_user["username"]
for _ in range(5):
_login(client, username, "WrongPwd@1")
ttl_before = get_redis().ttl(REDIS_KEY_LOGIN_FAIL.format(username=username))
for _ in range(3):
_login(client, username, TEST_PASSWORD)
ttl_after = get_redis().ttl(REDIS_KEY_LOGIN_FAIL.format(username=username))
assert ttl_after <= ttl_before
class TestLoginLogs:
"""登录日志落库。"""
def _logs(self, username: str) -> list[LoginLog]:
with get_session_factory()() as session:
return list(
session.execute(
select(LoginLog).where(LoginLog.username == username).order_by(LoginLog.id)
).scalars()
)
def test_成功与失败均落库且记录失败原因(self, client: TestClient, test_user: dict) -> None:
username = test_user["username"]
_login(client, username, TEST_PASSWORD)
_login(client, username, "WrongPwd@1")
_login(client, username, TEST_PASSWORD, captcha_code="ZZZZ")
reasons = [(log.success, log.fail_reason) for log in self._logs(username)]
assert (1, None) in reasons
assert (0, "用户名或密码错误") in reasons
assert (0, "验证码错误") in reasons
def test_日志不含任何密码字段(self, client: TestClient, test_user: dict) -> None:
"""日志是最容易泄漏凭据的地方,这条断言防止日后有人「顺手」加字段。"""
username = test_user["username"]
_login(client, username, TEST_PASSWORD)
_login(client, username, "WrongPwd@1")
columns = {column.name for column in LoginLog.__table__.columns}
assert not (columns & {"password", "passwd", "pwd", "credential"})
for log in self._logs(username):
assert TEST_PASSWORD not in str(log.__dict__)
+55
View File
@@ -0,0 +1,55 @@
"""应用级 HTTP 测试:统一响应格式与全局异常处理。
归在 integration 下是因为需要构造完整 FastAPI 应用;
但用例本身不要求 MySQL/Redis 在跑 —— 健康检查会把依赖状态如实体现在
data 中,接口始终返回 200。
"""
from fastapi.testclient import TestClient
class TestHealthEndpoint:
"""健康检查接口。"""
def test_返回统一响应格式(self, client: TestClient) -> None:
response = client.get("/api/health")
assert response.status_code == 200
body = response.json()
assert body["code"] == 200
assert body["message"] == "success"
assert body["data"]["app"] is True
def test_上报数据库与Redis状态为布尔值(self, client: TestClient) -> None:
data = client.get("/api/health").json()["data"]
assert isinstance(data["db"], bool)
assert isinstance(data["redis"], bool)
def test_不要求认证(self, client: TestClient) -> None:
assert client.get("/api/health").status_code == 200
class TestGlobalExceptionHandling:
"""全局异常处理器:不得把框架默认的 detail 数组泄漏给前端。"""
def test_未知路由返回统一格式(self, client: TestClient) -> None:
response = client.get("/api/definitely-not-exist")
assert response.status_code == 404
body = response.json()
assert body["code"] == 404
assert "detail" not in body
def test_方法不允许返回统一格式(self, client: TestClient) -> None:
response = client.post("/api/health")
assert response.status_code == 405
assert response.json()["code"] == 405
class TestApiDocs:
"""接口文档(规范第 10 节要求提供)。文档挂在 /api 下,便于经 Vite 代理访问。"""
def test_swagger文档可访问(self, client: TestClient) -> None:
assert client.get("/api/docs").status_code == 200
def test_openapi结构可访问(self, client: TestClient) -> None:
schema = client.get("/api/openapi.json").json()
assert "/api/health" in schema["paths"]
@@ -0,0 +1,89 @@
"""DDL 漂移检测。
结构定义同时存在于两处:
- `db/migration/V1__*.sql` —— 事实来源,生产迁移用
- `db/init/init-dev.sql` —— 开发环境重置脚本,Docker 首次启动唯一会自动执行的位置
两份 DDL 手工同步迟早会漂移,且后果隐蔽:开发库的表结构与迁移脚本不一致,
新人克隆下来建出的库和生产不一样,等到上线才发现。
本测试把「靠自觉」变成「可自动拦截」,成本极低。
注:本文件不需要容器,放在 integration 下是因为它校验的是交付产物而非应用代码。
"""
import re
from pathlib import Path
import pytest
PROJECT_ROOT = Path(__file__).resolve().parents[3]
MIGRATION_FILE = PROJECT_ROOT / "db" / "migration" / "V1__init_rbac_and_login_logs.sql"
INIT_DEV_FILE = PROJECT_ROOT / "db" / "init" / "init-dev.sql"
_CREATE_TABLE = re.compile(r"CREATE TABLE\s+`?(\w+)`?\s*\((.*?)\n\)", re.DOTALL)
_COLUMN_LINE = re.compile(r"^\s{4}`?(\w+)`?\s", re.MULTILINE)
# 这些开头的是约束定义,不是列定义
_NON_COLUMN_KEYWORDS = {"CONSTRAINT", "PRIMARY", "UNIQUE", "FOREIGN", "KEY", "INDEX"}
def _parse_tables(sql: str) -> dict[str, set[str]]:
"""从 SQL 文本中提取 {表名: {列名集合}}。"""
tables: dict[str, set[str]] = {}
for table_name, body in _CREATE_TABLE.findall(sql):
tables[table_name] = {
column
for column in _COLUMN_LINE.findall(body)
if column.upper() not in _NON_COLUMN_KEYWORDS
}
return tables
@pytest.fixture(scope="module")
def migration_tables() -> dict[str, set[str]]:
return _parse_tables(MIGRATION_FILE.read_text(encoding="utf-8"))
@pytest.fixture(scope="module")
def init_dev_tables() -> dict[str, set[str]]:
return _parse_tables(INIT_DEV_FILE.read_text(encoding="utf-8"))
class TestSchemaSync:
"""两份 DDL 的结构一致性。"""
def test_文件存在(self) -> None:
assert MIGRATION_FILE.is_file(), f"缺少迁移脚本:{MIGRATION_FILE}"
assert INIT_DEV_FILE.is_file(), f"缺少开发初始化脚本:{INIT_DEV_FILE}"
def test_表集合一致(self, migration_tables: dict, init_dev_tables: dict) -> None:
assert set(init_dev_tables) == set(migration_tables), (
"两份 DDL 的表集合不一致 —— 表结构变更时需同时维护 "
"db/migration/V{n}__*.sql 与 db/init/init-dev.sql"
)
def test_每张表的列集合一致(self, migration_tables: dict, init_dev_tables: dict) -> None:
mismatched = {
table: {
"仅迁移脚本有": sorted(migration_tables[table] - init_dev_tables[table]),
"仅初始化脚本有": sorted(init_dev_tables[table] - migration_tables[table]),
}
for table in set(migration_tables) & set(init_dev_tables)
if migration_tables[table] != init_dev_tables[table]
}
assert not mismatched, f"以下表的列不一致:{mismatched}"
class TestSeedData:
"""字典数据在两处应保持同步。"""
@pytest.mark.parametrize("role_code", ["admin", "user"])
def test_角色初始化数据在两处都存在(self, role_code: str) -> None:
migration_sql = MIGRATION_FILE.read_text(encoding="utf-8")
init_sql = INIT_DEV_FILE.read_text(encoding="utf-8")
assert f"'{role_code}'" in migration_sql
assert f"'{role_code}'" in init_sql
def test_开发脚本标记了迁移版本(self) -> None:
"""不写 alembic_version 的话,在开发库上执行 upgrade head 会重复建表而失败。"""
init_sql = INIT_DEV_FILE.read_text(encoding="utf-8")
assert "INSERT INTO alembic_version" in init_sql
+1
View File
@@ -0,0 +1 @@
"""不依赖外部服务的单元测试。"""
+71
View File
@@ -0,0 +1,71 @@
"""认证请求模型单元测试。
守的是:**密码按字节数校验**。
bcrypt 5.0 起对超过 72 字节的密码直接抛 ValueError(此前是静默截断),
而中文一个字符占 3 字节 —— 只按字符数校验(如 max_length=20)根本拦不住,
24 个汉字就会让哈希函数抛异常、登录接口返回 500。
"""
import pytest
from pydantic import ValidationError
from app.core.constants import CAPTCHA_LENGTH, PASSWORD_MAX_BYTES
from app.schemas.auth import LoginIn
def _payload(**overrides: object) -> dict[str, object]:
base: dict[str, object] = {
"username": "admin",
"password": "Pwd@12345",
"captcha_id": "a" * 32,
"captcha_code": "A" * CAPTCHA_LENGTH,
}
base.update(overrides)
return base
class TestPasswordByteLimit:
"""72 字节边界 —— 与 bcrypt 算法上限严格对齐。"""
def test_合法密码通过(self) -> None:
assert LoginIn(**_payload()).password == "Pwd@12345"
def test_恰好72字节ASCII通过(self) -> None:
password = "a" * PASSWORD_MAX_BYTES
assert LoginIn(**_payload(password=password)).password == password
def test_超过72字节ASCII被拒(self) -> None:
with pytest.raises(ValidationError):
LoginIn(**_payload(password="a" * (PASSWORD_MAX_BYTES + 1)))
def test_24个汉字恰好72字节通过(self) -> None:
"""一个汉字 3 字节:24 个正好等于上限。"""
password = "中" * 24
assert len(password.encode("utf-8")) == PASSWORD_MAX_BYTES
LoginIn(**_payload(password=password))
def test_25个汉字被拒(self) -> None:
"""若只按字符数校验,25 个字符远低于常见的 max_length=128,
会一路放行到哈希函数才炸成 500。"""
with pytest.raises(ValidationError):
LoginIn(**_payload(password="中" * 25))
class TestFieldConstraints:
"""其余字段的边界。"""
def test_验证码位数不符被拒(self) -> None:
with pytest.raises(ValidationError):
LoginIn(**_payload(captcha_code="AB"))
def test_用户名为空被拒(self) -> None:
with pytest.raises(ValidationError):
LoginIn(**_payload(username=""))
def test_用户名超长被拒(self) -> None:
with pytest.raises(ValidationError):
LoginIn(**_payload(username="a" * 65))
def test_首尾空白被自动去除(self) -> None:
"""用户复制粘贴账号时常带空格,自动去除比报错友好。"""
assert LoginIn(**_payload(username=" admin ")).username == "admin"
+91
View File
@@ -0,0 +1,91 @@
"""图形验证码单元测试。
守的是:**验证码的一次性语义**。若 GETDEL 退化为「先 GET 再 DEL」,
两条命令之间会出现竞态窗口,攻击者可用同一验证码并发重复提交 ——
而这个问题在正常使用下完全看不出来。
"""
import base64
from app.core.captcha_image import generate_text, render_png_base64
from app.core.constants import CAPTCHA_CHARSET, CAPTCHA_LENGTH, REDIS_KEY_CAPTCHA
from app.services import captcha_service
PNG_MAGIC = b"\x89PNG"
class TestCaptchaText:
"""验证码文本生成。"""
def test_长度固定(self) -> None:
assert len(generate_text()) == CAPTCHA_LENGTH
def test_字符集不含易混淆字符(self) -> None:
"""0/O、1/I/l 在图片上难以区分,会显著提高用户输错率。"""
text = "".join(generate_text() for _ in range(200))
assert set(text) <= set(CAPTCHA_CHARSET)
assert not (set(text) & set("0O1Il"))
def test_具备随机性(self) -> None:
assert len({generate_text() for _ in range(50)}) > 40
class TestCaptchaImage:
"""图片渲染。"""
def test_输出为合法PNG的base64(self) -> None:
raw = base64.b64decode(render_png_base64("AB3D"))
assert raw.startswith(PNG_MAGIC)
def test_不含data前缀(self) -> None:
"""返回纯 base64,由前端自行拼接 data:image/png;base64, ——
服务端不替调用方决定呈现方式。"""
assert not render_png_base64("AB3D").startswith("data:")
def test_同一文本两次渲染结果不同(self) -> None:
"""每次绘制含随机干扰线与噪点,防止通过图片比对反推答案。"""
assert render_png_base64("AB3D") != render_png_base64("AB3D")
class TestCaptchaService:
"""签发与校验(用假 Redis)。"""
def test_签发后可用正确验证码通过(self, fake_redis) -> None:
captcha_id, image, expires_in = captcha_service.issue()
assert expires_in == 300
assert base64.b64decode(image).startswith(PNG_MAGIC)
answer = fake_redis.get(REDIS_KEY_CAPTCHA.format(captcha_id=captcha_id))
assert captcha_service.verify_and_consume(captcha_id, answer) is True
def test_同一验证码二次使用必然失败(self, fake_redis) -> None:
"""GETDEL 一次性语义的核心断言 —— 防重放。"""
captcha_id, _, _ = captcha_service.issue()
answer = fake_redis.get(REDIS_KEY_CAPTCHA.format(captcha_id=captcha_id))
assert captcha_service.verify_and_consume(captcha_id, answer) is True
assert captcha_service.verify_and_consume(captcha_id, answer) is False
def test_校验失败后验证码同样被消费(self, fake_redis) -> None:
"""失败也要消费:否则攻击者可拿一个验证码反复试密码。"""
captcha_id, _, _ = captcha_service.issue()
answer = fake_redis.get(REDIS_KEY_CAPTCHA.format(captcha_id=captcha_id))
assert captcha_service.verify_and_consume(captcha_id, "XXXX") is False
assert captcha_service.verify_and_consume(captcha_id, answer) is False
def test_不存在的验证码返回False(self, fake_redis) -> None:
assert captcha_service.verify_and_consume("nonexistent-id", "AB3D") is False
def test_校验时忽略大小写与首尾空格(self, fake_redis) -> None:
"""用户从图片抄写时容易带上空格;答案统一按大写存储。"""
captcha_id, _, _ = captcha_service.issue()
answer = fake_redis.get(REDIS_KEY_CAPTCHA.format(captcha_id=captcha_id))
assert captcha_service.verify_and_consume(captcha_id, f" {answer.lower()} ") is True
def test_有效期300秒符合规范(self, fake_redis) -> None:
"""规范第 6 节要求验证码 300 秒。
容忍 1 秒偏差:Redis 的 TTL 以毫秒精度存绝对过期时刻,
命令返回时向下取整,刚写入就查询必然是 N 或 N-1。
"""
captcha_id, _, _ = captcha_service.issue()
ttl = fake_redis.ttl(REDIS_KEY_CAPTCHA.format(captcha_id=captcha_id))
assert ttl in (300, 299)
+83
View File
@@ -0,0 +1,83 @@
"""配置模块单元测试。
用 `_env_file=None` 绕过 backend/.env,使断言不受开发者本地配置影响。
"""
import pytest
from pydantic import ValidationError
from app.core.config import Settings
VALID_SECRET = "x" * 32
def _settings(**overrides: object) -> Settings:
"""构造一个不读取 .env 的配置实例。"""
params: dict[str, object] = {"_env_file": None, "app_secret_key": VALID_SECRET}
params.update(overrides)
return Settings(**params) # type: ignore[arg-type]
class TestSecretKey:
"""密钥强度校验。"""
def test_缺失密钥时拒绝构造(self) -> None:
with pytest.raises(ValidationError):
Settings(_env_file=None) # type: ignore[call-arg]
@pytest.mark.parametrize("length", [0, 1, 31])
def test_密钥短于32字符时拒绝(self, length: int) -> None:
with pytest.raises(ValidationError):
_settings(app_secret_key="x" * length)
def test_密钥恰好32字符时通过(self) -> None:
assert _settings(app_secret_key="x" * 32).app_secret_key == "x" * 32
class TestSecurityBounds:
"""安全边界:规范要求 JWT ≤120 分钟、验证码 300 秒。"""
def test_jwt有效期超过120分钟时拒绝(self) -> None:
with pytest.raises(ValidationError):
_settings(jwt_expire_minutes=121)
def test_jwt有效期为120分钟时通过(self) -> None:
assert _settings(jwt_expire_minutes=120).jwt_expire_minutes == 120
def test_验证码有效期超过300秒时拒绝(self) -> None:
with pytest.raises(ValidationError):
_settings(captcha_expire_seconds=301)
def test_验证码有效期为300秒时通过(self) -> None:
assert _settings(captcha_expire_seconds=300).captcha_expire_seconds == 300
class TestConnectionUrl:
"""连接 URL 构造:密码含特殊字符时不可手工拼接。"""
def test_数据库URL使用同步驱动(self) -> None:
"""运行期与迁移共用同步连接串,不存在同步/异步两套配置。"""
url = _settings().database_url
assert url.drivername == "mysql+pymysql"
assert url.database == "payroll"
assert url.query.get("charset") == "utf8mb4"
def test_密码含特殊字符时被原样保存于URL对象(self) -> None:
url = _settings(mysql_password="p@ss:w/ord#1").database_url
# URL.create 以结构化方式保存各组件,不做编码 —— 编码发生在驱动建连时,
# 这正是手工拼接字符串会出错(如把 : 误认为端口分隔符)而它不会的原因
assert url.password == "p@ss:w/ord#1"
assert url.username == "payroll"
assert url.port == 3306
def test_渲染URL时默认隐藏密码(self) -> None:
"""异常或日志中打印 URL 时不得泄露密码。"""
url = _settings(mysql_password="p@ss:w/ord#1").database_url
assert "p@ss:w/ord#1" not in url.render_as_string()
def test_Redis无密码时不拼接认证段(self) -> None:
assert _settings(redis_password="").redis_url == "redis://127.0.0.1:6379/0"
def test_Redis有密码时拼接认证段(self) -> None:
url = _settings(redis_password="secret").redis_url
assert url == "redis://:secret@127.0.0.1:6379/0"
@@ -0,0 +1,61 @@
"""全局异常处理器单元测试。
重点验证校验错误的文案压缩逻辑:FastAPI 默认返回 422 与 detail 数组,
本项目需映射为 400 与单行中文提示。
"""
from fastapi.exceptions import RequestValidationError
from app.core.constants import CODE_BAD_REQUEST
from app.core.exception_handlers import _build_response, _format_validation_errors
class TestFormatValidationErrors:
"""校验错误文案压缩。"""
def test_包含字段名与原始说明(self) -> None:
exc = RequestValidationError(
[{"loc": ("body", "username"), "msg": "Field required", "type": "missing"}]
)
assert _format_validation_errors(exc) == "参数错误:username Field required"
def test_去掉来源前缀只保留字段路径(self) -> None:
exc = RequestValidationError(
[
{
"loc": ("query", "pageNum"),
"msg": "Input should be greater than 0",
"type": "greater_than",
}
]
)
assert "query" not in _format_validation_errors(exc)
assert "pageNum" in _format_validation_errors(exc)
def test_多个错误时只取第一条(self) -> None:
exc = RequestValidationError(
[
{"loc": ("body", "a"), "msg": "错误甲", "type": "missing"},
{"loc": ("body", "b"), "msg": "错误乙", "type": "missing"},
]
)
message = _format_validation_errors(exc)
assert "错误甲" in message
assert "错误乙" not in message
def test_错误列表为空时兜底(self) -> None:
assert _format_validation_errors(RequestValidationError([])) == "参数错误"
class TestBuildResponse:
"""统一响应构造:HTTP 状态码与响应体 code 必须一致。"""
def test_状态码与响应体code一致(self) -> None:
response = _build_response(CODE_BAD_REQUEST, "参数错误")
assert response.status_code == CODE_BAD_REQUEST
import json
body = json.loads(response.body)
assert body["code"] == CODE_BAD_REQUEST
assert body["message"] == "参数错误"
assert body["data"] is None
+112
View File
@@ -0,0 +1,112 @@
"""JWT 签发与校验单元测试。
守的是:令牌的**有效期语义**与**版本号机制**。
这两点出错都不会在开发期暴露,但后果严重 ——
有效期算错 8 小时会表现为「刚登录就过期」或「过期了还能用」;
版本号机制失效则意味着禁用账号后旧令牌仍可访问全员薪酬。
"""
import uuid
from datetime import UTC, datetime, timedelta
import jwt
import pytest
from app.core.config import get_settings
from app.core.constants import JWT_ALGORITHM
from app.core.exceptions import AuthError
from app.core.security import create_access_token, decode_token
class TestCreateAccessToken:
"""令牌签发。"""
def test_返回令牌_jti与有效期(self) -> None:
token, jti, expires_in = create_access_token(user_id=1, token_version=1, roles=["admin"])
assert isinstance(token, str) and token.count(".") == 2 # header.payload.signature
assert len(jti) == 32
assert expires_in == get_settings().jwt_expire_minutes * 60
def test_载荷包含鉴定所需的全部字段(self) -> None:
token, jti, _ = create_access_token(user_id=7, token_version=3, roles=["admin", "user"])
payload = decode_token(token)
assert payload["sub"] == "7" # PyJWT 要求 sub 为字符串
assert payload["jti"] == jti
assert payload["ver"] == 3
assert payload["roles"] == ["admin", "user"]
def test_每次签发的jti都不同(self) -> None:
"""jti 是登出拉黑的唯一键,重复会导致误拉黑他人令牌。"""
jtis = set()
for _ in range(20):
_, jti, _ = create_access_token(user_id=1, token_version=1, roles=[])
jtis.add(jti)
assert len(jtis) == 20
def test_有效期基于UTC而非本地时间(self) -> None:
"""库列存本地时间、JWT 用 UTC,两者混算会产生 8 小时偏差。
这里断言 exp 与 UTC 当前时间的差值确实等于配置值 ——
若误用 datetime.now()(本地时间)生成,差值会差 8 小时。
"""
token, _, expires_in = create_access_token(user_id=1, token_version=1, roles=[])
exp = decode_token(token)["exp"]
drift = exp - datetime.now(UTC).timestamp()
assert abs(drift - expires_in) < 5 # 允许几秒执行耗时
class TestDecodeToken:
"""令牌解析与校验。"""
def test_正常令牌可解析(self) -> None:
token, _, _ = create_access_token(user_id=1, token_version=1, roles=["admin"])
assert decode_token(token)["sub"] == "1"
def test_已过期令牌抛AuthError(self) -> None:
"""直接构造过期令牌 —— create_access_token 的最小有效期为 1 分钟,
无法用它验证过期分支。"""
settings = get_settings()
past = datetime.now(UTC) - timedelta(hours=1)
expired = jwt.encode(
{
"sub": "1",
"jti": uuid.uuid4().hex,
"ver": 1,
"iat": past,
"exp": past + timedelta(seconds=60),
},
settings.app_secret_key,
algorithm=JWT_ALGORITHM,
)
with pytest.raises(AuthError):
decode_token(expired)
def test_签名被篡改时抛AuthError(self) -> None:
forged = jwt.encode(
{
"sub": "1",
"jti": uuid.uuid4().hex,
"ver": 1,
"exp": datetime.now(UTC) + timedelta(hours=1),
},
"wrong-secret-key-that-is-long-enough-to-pass-validation",
algorithm=JWT_ALGORITHM,
)
with pytest.raises(AuthError):
decode_token(forged)
@pytest.mark.parametrize("garbage", ["", "not.a.token", "a.b", "...."])
def test_格式非法的令牌抛AuthError而非其他异常(self, garbage: str) -> None:
"""令牌内容不可信,任何畸形输入都必须收敛为 401 而不是 500。"""
with pytest.raises(AuthError):
decode_token(garbage)
def test_算法被改为none时拒绝(self) -> None:
"""经典的 JWT 算法混淆攻击:把 alg 改成 none 伪造无签名令牌。"""
unsigned = jwt.encode(
{"sub": "1", "exp": datetime.now(UTC) + timedelta(hours=1)},
key="",
algorithm="none",
)
with pytest.raises(AuthError):
decode_token(unsigned)
+111
View File
@@ -0,0 +1,111 @@
"""日志脱敏单元测试。
规范第 8 节禁止日志输出密码、手机号、身份证、Token。
脱敏是最容易在重构中悄悄失效的一环,故用参数化用例覆盖各条规则。
"""
import logging
import pytest
from app.core.logging import SensitiveFormatter, scrub
class TestScrub:
"""文本脱敏规则。"""
@pytest.mark.parametrize(
("raw", "leaked"),
[
('{"password": "Admin@123"}', "Admin@123"),
("password=Secret123", "Secret123"),
("pwd: hunter2xyz", "hunter2xyz"),
('{"app_secret_key": "abcdef123456"}', "abcdef123456"),
("token=eyJhbGciOiJIUzI1NiJ9", "eyJhbGciOiJIUzI1NiJ9"),
],
)
def test_凭据值被脱敏(self, raw: str, leaked: str) -> None:
assert leaked not in scrub(raw)
def test_Bearer令牌被脱敏(self) -> None:
result = scrub("Authorization: Bearer eyJhbGciOi.J9.abc-_123")
assert "eyJhbGciOi.J9.abc-_123" not in result
assert "Bearer ***" in result
def test_手机号保留前三后四(self) -> None:
assert scrub("联系电话 13812345678") == "联系电话 138****5678"
def test_身份证保留前六后四(self) -> None:
assert scrub("证件号 110101199003071234") == "证件号 110101********1234"
def test_普通文本不被误伤(self) -> None:
text = "用户 admin 登录成功,耗时 120ms"
assert scrub(text) == text
def _format(msg: str, args: object = None) -> str:
"""用 SensitiveFormatter 渲染一条日志,返回最终文本。"""
record = logging.LogRecord(
name="t",
level=logging.INFO,
pathname="",
lineno=0,
msg=msg,
args=args,
exc_info=None,
)
return SensitiveFormatter("%(message)s").format(record)
class TestSensitiveFormatter:
"""脱敏必须在 %-格式化之后进行。"""
def test_脱敏位置参数中的敏感值(self) -> None:
# 回归用例:早期实现在 filter 阶段改写 record.msg,把 %s 占位符一并抹掉,
# 导致 logging 内部 msg % args 抛 TypeError,整条日志丢失
result = _format("登录失败 password=%s", ("Secret123",))
assert "Secret123" not in result
assert "登录失败" in result
def test_脱敏字典参数中的敏感值(self) -> None:
# LogRecord 要求 args 为元组;字典参数须包成单元素元组,
# 这正是 logger.info(msg, {...}) 的内部形态
result = _format("token=%(token)s", ({"token": "abc123xyz"},))
assert "abc123xyz" not in result
def test_脱敏消息模板内联的敏感值(self) -> None:
"""敏感值直接写在消息模板里(无占位符)时同样要脱敏。"""
result = _format('请求体 {"password": "Admin@123"}')
assert "Admin@123" not in result
def test_普通日志不被改动(self) -> None:
assert (
_format("用户 admin 登录成功,耗时 %d ms", (120,)) == "用户 admin 登录成功,耗时 120 ms"
)
def test_无参数日志正常通过(self) -> None:
assert _format("服务已启动") == "服务已启动"
class TestSetupLogging:
"""setup_logging 必须挂上脱敏格式化器,否则规则形同虚设。"""
def test_所有handler均使用脱敏格式化器(self) -> None:
import logging as _logging
from app.core.logging import setup_logging
setup_logging(level="INFO", log_to_file=False)
handlers = _logging.getLogger().handlers
assert handlers, "根日志器应至少有一个 handler"
assert all(isinstance(h.formatter, SensitiveFormatter) for h in handlers)
def test_重复调用不会累积handler(self) -> None:
import logging as _logging
from app.core.logging import setup_logging
setup_logging(log_to_file=False)
first = len(_logging.getLogger().handlers)
setup_logging(log_to_file=False)
assert len(_logging.getLogger().handlers) == first
+82
View File
@@ -0,0 +1,82 @@
"""登录失败限流单元测试。
守的是:锁定阈值与**滑动窗口**语义。若记录失败时不刷新 TTL,
「4 次失败 + 等 14 分钟 + 1 次失败」就不会触发锁定,
正好给慢速撞库留出缝隙。
"""
from app.core.config import get_settings
from app.core.constants import REDIS_KEY_LOGIN_FAIL
from app.services import login_limiter
def _ttl(username: str) -> int:
from app.core.redis_client import get_redis
return get_redis().ttl(REDIS_KEY_LOGIN_FAIL.format(username=username))
class TestThreshold:
"""阈值判定。"""
def test_初始状态未锁定且计数为零(self, fake_redis) -> None:
assert login_limiter.failure_count("u") == 0
assert login_limiter.is_locked("u") is False
def test_未达阈值时不锁定(self, fake_redis) -> None:
for _ in range(get_settings().login_fail_limit - 1):
login_limiter.record_failure("u")
assert login_limiter.is_locked("u") is False
def test_达到阈值即锁定(self, fake_redis) -> None:
for _ in range(get_settings().login_fail_limit):
login_limiter.record_failure("u")
assert login_limiter.is_locked("u") is True
def test_不同用户之间互不影响(self, fake_redis) -> None:
"""按用户名隔离,避免一个账号被攻击导致全员被锁。"""
for _ in range(get_settings().login_fail_limit):
login_limiter.record_failure("victim")
assert login_limiter.is_locked("victim") is True
assert login_limiter.is_locked("others") is False
class TestSlidingWindow:
"""滑动窗口:每次失败都刷新过期时间。"""
def test_锁定时长与配置一致(self, fake_redis) -> None:
"""容忍 1 秒偏差:Redis 的 TTL 向下取整,刚写入就查询可能少 1 秒。"""
login_limiter.record_failure("u")
expected = get_settings().login_lock_minutes * 60
assert _ttl("u") in (expected, expected - 1)
def test_再次失败会刷新过期时间(self, fake_redis) -> None:
"""把 TTL 改小模拟「距上次失败已过 14 分钟」,再失败一次应重新计满。
若不刷新,此处 TTL 仍是改小后的值,"慢速撞库"就能绕过锁定。
"""
login_limiter.record_failure("u")
fake_redis.expire(REDIS_KEY_LOGIN_FAIL.format(username="u"), 10)
login_limiter.record_failure("u")
assert _ttl("u") > 600
class TestClearAndRemaining:
"""解锁与剩余时长。"""
def test_登录成功清除失败计数(self, fake_redis) -> None:
for _ in range(get_settings().login_fail_limit):
login_limiter.record_failure("u")
login_limiter.clear("u")
assert login_limiter.is_locked("u") is False
assert login_limiter.failure_count("u") == 0
def test_未锁定时剩余秒数为零(self, fake_redis) -> None:
assert login_limiter.remaining_lock_seconds("u") == 0
def test_锁定后剩余秒数为正且不超过锁定时长(self, fake_redis) -> None:
for _ in range(get_settings().login_fail_limit):
login_limiter.record_failure("u")
remaining = login_limiter.remaining_lock_seconds("u")
assert 0 < remaining <= get_settings().login_lock_minutes * 60
+127
View File
@@ -0,0 +1,127 @@
"""ORM 模型元数据测试。
建表规范(表小写蛇形复数、字段中文 COMMENT、主键 id、索引 uk_/idx_ 前缀)
属于「红线」,靠人工 review 迟早会漏,这里用测试把它固定下来。
"""
import re
import pytest
from sqlalchemy.dialects import mysql
from app.models import Base
EXPECTED_TABLES = {"users", "roles", "user_roles", "permissions", "role_permissions", "login_logs"}
def _ddl(table_name: str) -> str:
"""返回某张表的建表 DDL 文本。"""
from sqlalchemy.schema import CreateTable
table = Base.metadata.tables[table_name]
return str(CreateTable(table).compile(dialect=mysql.dialect()))
class TestTableSet:
"""表集合必须完整 —— 漏一张就会让 alembic 生成「删除该表」的迁移。"""
def test_表集合与预期一致(self) -> None:
assert set(Base.metadata.tables) == EXPECTED_TABLES
def test_所有表名均为小写蛇形复数(self) -> None:
for name in Base.metadata.tables:
assert re.fullmatch(r"[a-z][a-z0-9_]*", name), f"表名 {name} 不符合小写蛇形"
assert name.endswith("s"), f"表名 {name} 应为复数"
class TestNamingConvention:
"""约束命名:规范要求 uk_表_字段 / idx_表_字段。"""
@pytest.mark.parametrize("table_name", sorted(EXPECTED_TABLES))
def test_主键命名为pk_表名(self, table_name: str) -> None:
pk = Base.metadata.tables[table_name].primary_key
assert pk.name == f"pk_{table_name}"
def test_唯一键以uk_开头(self) -> None:
for table in Base.metadata.tables.values():
for constraint in table.constraints:
if constraint.__class__.__name__ == "UniqueConstraint":
assert constraint.name.startswith(f"uk_{table.name}_"), constraint.name
def test_索引以idx_开头(self) -> None:
for table in Base.metadata.tables.values():
for index in table.indexes:
assert index.name.startswith(f"idx_{table.name}_"), index.name
def test_外键以fk_开头(self) -> None:
for table in Base.metadata.tables.values():
for fk in table.foreign_keys:
assert fk.constraint.name.startswith(f"fk_{table.name}_"), fk.constraint.name
class TestComments:
"""规范红线要求中文 COMMENT。"""
@pytest.mark.parametrize("table_name", sorted(EXPECTED_TABLES))
def test_表级中文注释存在(self, table_name: str) -> None:
assert Base.metadata.tables[table_name].comment, f"{table_name} 缺少表注释"
@pytest.mark.parametrize("table_name", sorted(EXPECTED_TABLES))
def test_每列都有中文注释(self, table_name: str) -> None:
table = Base.metadata.tables[table_name]
missing = [c.name for c in table.columns if not c.comment]
assert not missing, f"{table_name} 缺注释的列:{missing}"
@pytest.mark.parametrize("table_name", sorted(EXPECTED_TABLES))
def test_注释确实输出到DDL(self, table_name: str) -> None:
ddl = _ddl(table_name)
assert "COMMENT '" in ddl
assert f"COMMENT='{Base.metadata.tables[table_name].comment}'" in ddl
class TestMysqlTableOptions:
"""引擎与字符集:不显式指定则跟随服务端,跨环境可能不一致。"""
@pytest.mark.parametrize("table_name", sorted(EXPECTED_TABLES))
def test_显式指定引擎与字符集(self, table_name: str) -> None:
ddl = _ddl(table_name)
assert "ENGINE=InnoDB" in ddl
assert "CHARSET=utf8mb4" in ddl
assert "utf8mb4_general_ci" in ddl
@pytest.mark.parametrize("table_name", sorted(EXPECTED_TABLES))
def test_主键为无符号BIGINT(self, table_name: str) -> None:
assert "id BIGINT UNSIGNED NOT NULL" in _ddl(table_name)
class TestTimestampColumns:
"""更新时间必须由数据库维护 —— 这是 server_onupdate 做不到的。"""
@pytest.mark.parametrize("table_name", ["users", "roles", "permissions"])
def test_带时间戳的表含ON_UPDATE子句(self, table_name: str) -> None:
assert "ON UPDATE CURRENT_TIMESTAMP" in _ddl(table_name)
@pytest.mark.parametrize("table_name", ["login_logs"])
def test_仅追加的日志表不含ON_UPDATE子句(self, table_name: str) -> None:
"""日志是不可变记录,没有"更新时间"语义。"""
assert "ON UPDATE CURRENT_TIMESTAMP" not in _ddl(table_name)
def test_关联表保留create_time便于审计(self) -> None:
for table_name in ("user_roles", "role_permissions"):
assert "create_time" in Base.metadata.tables[table_name].columns
class TestEnumColumns:
"""状态类字段的默认值。"""
def test_用户默认启用且令牌版本为1(self) -> None:
users = Base.metadata.tables["users"]
assert users.columns["status"].server_default.arg == "1"
assert users.columns["token_version"].server_default.arg == "1"
def test_角色编码唯一(self) -> None:
ddl = _ddl("roles")
assert "uk_roles_role_code UNIQUE (role_code)" in ddl
def test_用户名唯一(self) -> None:
assert "uk_users_username UNIQUE (username)" in _ddl("users")
+67
View File
@@ -0,0 +1,67 @@
"""统一响应模型单元测试。"""
import pytest
from pydantic import ValidationError
from app.core.response import ApiResponse, PageQuery, PageResult, ok
class TestApiResponse:
"""响应信封结构。"""
def test_默认值符合规范(self) -> None:
resp = ApiResponse[None]()
assert resp.code == 200
assert resp.message == "success"
assert resp.data is None
def test_序列化字段固定为code_message_data(self) -> None:
keys = set(ApiResponse[None]().model_dump().keys())
assert keys == {"code", "message", "data"}
class TestOkHelper:
"""成功响应构造助手。"""
def test_携带业务数据(self) -> None:
resp = ok({"id": 1})
assert resp.code == 200
assert resp.data == {"id": 1}
def test_可自定义提示语(self) -> None:
assert ok(message="登录成功").message == "登录成功"
class TestPageResult:
"""分页结果:Python 蛇形属性名,序列化为规范要求的驼峰字段。"""
def test_序列化输出驼峰字段(self) -> None:
dumped = PageResult[int](records=[1, 2], total=2, page_num=1, page_size=10).model_dump(
by_alias=True
)
assert dumped == {"list": [1, 2], "total": 2, "pageNum": 1, "pageSize": 10}
def test_空结果时list为空数组而非null(self) -> None:
dumped = PageResult[int]().model_dump(by_alias=True)
assert dumped["list"] == []
class TestPageQuery:
"""分页入参。"""
def test_offset按页码换算(self) -> None:
assert PageQuery(page_num=1, page_size=10).offset == 0
assert PageQuery(page_num=3, page_size=20).offset == 40
@pytest.mark.parametrize("bad_page", [0, -1])
def test_页码小于1时拒绝(self, bad_page: int) -> None:
with pytest.raises(ValidationError):
PageQuery(page_num=bad_page)
def test_每页条数超上限时拒绝(self) -> None:
with pytest.raises(ValidationError):
PageQuery(page_size=201)
def test_接受驼峰输入(self) -> None:
query = PageQuery.model_validate({"pageNum": 2, "pageSize": 5})
assert (query.page_num, query.page_size) == (2, 5)
+66
View File
@@ -0,0 +1,66 @@
"""密码哈希单元测试。
重点覆盖 bcrypt 5.0 的 72 字节硬限制 —— 此前是静默截断,现在是抛 ValueError,
若不在边界上把关,用户设一个长密码就会在登录时炸出 500。
"""
import pytest
from app.core.constants import PASSWORD_MAX_BYTES
from app.core.security import hash_password, verify_password
class TestHashPassword:
"""哈希生成。"""
def test_输出为BCrypt格式且定长60字符(self) -> None:
hashed = hash_password("Admin@2026")
assert hashed.startswith("$2")
assert len(hashed) == 60
def test_相同明文两次哈希结果不同(self) -> None:
"""BCrypt 内含随机盐,相同明文每次结果不同 —— 这是防彩虹表的基础。"""
assert hash_password("SamePwd@1") != hash_password("SamePwd@1")
def test_恰好72字节时允许(self) -> None:
password = "a" * PASSWORD_MAX_BYTES
assert len(password.encode("utf-8")) == PASSWORD_MAX_BYTES
assert len(hash_password(password)) == 60
def test_超过72字节时抛ValueError(self) -> None:
with pytest.raises(ValueError):
hash_password("a" * (PASSWORD_MAX_BYTES + 1))
@pytest.mark.parametrize(
("text", "count", "should_pass"),
[("中", 24, True), ("中", 25, False)],
)
def test_中文按字节而非字符计算边界(self, text: str, count: int, should_pass: bool) -> None:
"""一个汉字 3 字节:24 个正好 72 字节,25 个即 75 字节超限。"""
password = text * count
if should_pass:
assert len(hash_password(password)) == 60
else:
with pytest.raises(ValueError):
hash_password(password)
class TestVerifyPassword:
"""哈希校验。"""
def test_正确密码校验通过(self) -> None:
assert verify_password("Admin@2026", hash_password("Admin@2026")) is True
def test_错误密码校验失败(self) -> None:
assert verify_password("WrongPwd@1", hash_password("Admin@2026")) is False
def test_超长密码返回False而不抛异常(self) -> None:
"""校验路径不得因输入长度报错 —— 否则会形成「密码过长」与「密码错误」
可区分的侧信道,且会让恶意长输入直接打出 500。"""
assert verify_password("a" * 200, hash_password("Admin@2026")) is False
def test_损坏的哈希串返回False而不抛异常(self) -> None:
assert verify_password("Admin@2026", "not-a-bcrypt-hash") is False
def test_空哈希串返回False(self) -> None:
assert verify_password("Admin@2026", "") is False
@@ -0,0 +1,60 @@
"""登出黑名单单元测试。
守的是:黑名单的 **TTL 必须等于令牌剩余有效期**。
写死固定值会出现「登出 N 分钟后令牌又活了」;
完全不设过期则 Redis 会随登出次数无限增长。两者都是真实事故。
"""
from app.core.constants import REDIS_KEY_TOKEN_BLACKLIST
from app.services import token_blacklist
def _ttl(jti: str) -> int:
from app.core.redis_client import get_redis
return get_redis().ttl(REDIS_KEY_TOKEN_BLACKLIST.format(jti=jti))
class TestRevoke:
"""拉黑。"""
def test_拉黑后可检出(self, fake_redis) -> None:
token_blacklist.revoke("jti-1", 600)
assert token_blacklist.is_revoked("jti-1") is True
def test_TTL等于传入的剩余秒数(self, fake_redis) -> None:
"""容忍 1 秒偏差:Redis 的 TTL 以毫秒精度存绝对过期时刻,
命令返回时向下取整,刚写入就查询必然是 N 或 N-1。
断言死等会变成偶发失败的"闪烁测试"。"""
token_blacklist.revoke("jti-1", 1234)
assert _ttl("jti-1") in (1234, 1233)
def test_键带过期时间而非永久存在(self, fake_redis) -> None:
"""永久存储会让 Redis 随登出次数无限增长。"""
token_blacklist.revoke("jti-1", 600)
assert _ttl("jti-1") > 0
def test_已过期令牌不写入(self, fake_redis) -> None:
"""剩余有效期为 0 或负数时无凭证可撤销,写入只会浪费内存。"""
token_blacklist.revoke("jti-expired", 0)
token_blacklist.revoke("jti-expired2", -100)
assert token_blacklist.is_revoked("jti-expired") is False
assert token_blacklist.is_revoked("jti-expired2") is False
def test_重复拉黑同一jti不报错且TTL更新(self, fake_redis) -> None:
token_blacklist.revoke("jti-1", 600)
token_blacklist.revoke("jti-1", 300)
assert _ttl("jti-1") in (300, 299)
class TestIsRevoked:
"""检测。"""
def test_未拉黑的令牌返回False(self, fake_redis) -> None:
assert token_blacklist.is_revoked("never-revoked") is False
def test_不同jti之间互不影响(self, fake_redis) -> None:
"""jti 是唯一键,串号会导致误踢其他用户的登录态。"""
token_blacklist.revoke("jti-1", 600)
assert token_blacklist.is_revoked("jti-1") is True
assert token_blacklist.is_revoked("jti-2") is False
+847
View File
@@ -0,0 +1,847 @@
version = 1
revision = 3
requires-python = "==3.12.*"
[[package]]
name = "alembic"
version = "1.20.0"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
dependencies = [
{ name = "mako" },
{ name = "sqlalchemy" },
{ name = "typing-extensions" },
]
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ed/aa/02910bdb8e2f1444f6654d5b296cd827d126f82209050ee7b1000f92ac4b/alembic-1.20.0.tar.gz", hash = "sha256:db505480647bc60386c5369402f4a57a506b7539c9e9ef5e270d45cbbe4939bf", size = 2093272, upload-time = "2026-09-11T19:09:11.126Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/3f/27/78a89b55b0904d222183164e079b4ca56208e94eff1d35ad1f1ad5be9b06/alembic-1.20.0-py3-none-any.whl", hash = "sha256:77eb101048d95f982c0353e9233404889dcd7a6fc244c107836c0e2fc9cf7d9d", size = 268719, upload-time = "2026-09-11T19:09:12.88Z" },
]
[[package]]
name = "annotated-doc"
version = "0.0.5"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758, upload-time = "2026-07-28T13:50:58.129Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302, upload-time = "2026-07-28T13:50:57.239Z" },
]
[[package]]
name = "annotated-types"
version = "0.8.0"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" },
]
[[package]]
name = "anyio"
version = "4.15.1"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
dependencies = [
{ name = "idna" },
{ name = "typing-extensions" },
]
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" },
]
[[package]]
name = "bcrypt"
version = "5.0.0"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d4/36/3329e2518d70ad8e2e5817d5a4cac6bba05a47767ec416c7d020a965f408/bcrypt-5.0.0.tar.gz", hash = "sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd", size = 25386, upload-time = "2025-09-25T19:50:47.829Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/84/29/6237f151fbfe295fe3e074ecc6d44228faa1e842a81f6d34a02937ee1736/bcrypt-5.0.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:fc746432b951e92b58317af8e0ca746efe93e66555f1b40888865ef5bf56446b", size = 494553, upload-time = "2025-09-25T19:49:49.006Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/45/b6/4c1205dde5e464ea3bd88e8742e19f899c16fa8916fb8510a851fae985b5/bcrypt-5.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c2388ca94ffee269b6038d48747f4ce8df0ffbea43f31abfa18ac72f0218effb", size = 275009, upload-time = "2025-09-25T19:49:50.581Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/3b/71/427945e6ead72ccffe77894b2655b695ccf14ae1866cd977e185d606dd2f/bcrypt-5.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:560ddb6ec730386e7b3b26b8b4c88197aaed924430e7b74666a586ac997249ef", size = 278029, upload-time = "2025-09-25T19:49:52.533Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/17/72/c344825e3b83c5389a369c8a8e58ffe1480b8a699f46c127c34580c4666b/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d79e5c65dcc9af213594d6f7f1fa2c98ad3fc10431e7aa53c176b441943efbdd", size = 275907, upload-time = "2025-09-25T19:49:54.709Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/0b/7e/d4e47d2df1641a36d1212e5c0514f5291e1a956a7749f1e595c07a972038/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2b732e7d388fa22d48920baa267ba5d97cca38070b69c0e2d37087b381c681fd", size = 296500, upload-time = "2025-09-25T19:49:56.013Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/0f/c3/0ae57a68be2039287ec28bc463b82e4b8dc23f9d12c0be331f4782e19108/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0c8e093ea2532601a6f686edbc2c6b2ec24131ff5c52f7610dd64fa4553b5464", size = 278412, upload-time = "2025-09-25T19:49:57.356Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/45/2b/77424511adb11e6a99e3a00dcc7745034bee89036ad7d7e255a7e47be7d8/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5b1589f4839a0899c146e8892efe320c0fa096568abd9b95593efac50a87cb75", size = 275486, upload-time = "2025-09-25T19:49:59.116Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/43/0a/405c753f6158e0f3f14b00b462d8bca31296f7ecfc8fc8bc7919c0c7d73a/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:89042e61b5e808b67daf24a434d89bab164d4de1746b37a8d173b6b14f3db9ff", size = 277940, upload-time = "2025-09-25T19:50:00.869Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/62/83/b3efc285d4aadc1fa83db385ec64dcfa1707e890eb42f03b127d66ac1b7b/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e3cf5b2560c7b5a142286f69bde914494b6d8f901aaa71e453078388a50881c4", size = 310776, upload-time = "2025-09-25T19:50:02.393Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/95/7d/47ee337dacecde6d234890fe929936cb03ebc4c3a7460854bbd9c97780b8/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f632fd56fc4e61564f78b46a2269153122db34988e78b6be8b32d28507b7eaeb", size = 312922, upload-time = "2025-09-25T19:50:04.232Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/d6/3a/43d494dfb728f55f4e1cf8fd435d50c16a2d75493225b54c8d06122523c6/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:801cad5ccb6b87d1b430f183269b94c24f248dddbbc5c1f78b6ed231743e001c", size = 341367, upload-time = "2025-09-25T19:50:05.559Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/55/ab/a0727a4547e383e2e22a630e0f908113db37904f58719dc48d4622139b5c/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3cf67a804fc66fc217e6914a5635000259fbbbb12e78a99488e4d5ba445a71eb", size = 359187, upload-time = "2025-09-25T19:50:06.916Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/1b/bb/461f352fdca663524b4643d8b09e8435b4990f17fbf4fea6bc2a90aa0cc7/bcrypt-5.0.0-cp38-abi3-win32.whl", hash = "sha256:3abeb543874b2c0524ff40c57a4e14e5d3a66ff33fb423529c88f180fd756538", size = 153752, upload-time = "2025-09-25T19:50:08.515Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/41/aa/4190e60921927b7056820291f56fc57d00d04757c8b316b2d3c0d1d6da2c/bcrypt-5.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:35a77ec55b541e5e583eb3436ffbbf53b0ffa1fa16ca6782279daf95d146dcd9", size = 150881, upload-time = "2025-09-25T19:50:09.742Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/54/12/cd77221719d0b39ac0b55dbd39358db1cd1246e0282e104366ebbfb8266a/bcrypt-5.0.0-cp38-abi3-win_arm64.whl", hash = "sha256:cde08734f12c6a4e28dc6755cd11d3bdfea608d93d958fffbe95a7026ebe4980", size = 144931, upload-time = "2025-09-25T19:50:11.016Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/5d/ba/2af136406e1c3839aea9ecadc2f6be2bcd1eff255bd451dd39bcf302c47a/bcrypt-5.0.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0c418ca99fd47e9c59a301744d63328f17798b5947b0f791e9af3c1c499c2d0a", size = 495313, upload-time = "2025-09-25T19:50:12.309Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/ac/ee/2f4985dbad090ace5ad1f7dd8ff94477fe089b5fab2040bd784a3d5f187b/bcrypt-5.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddb4e1500f6efdd402218ffe34d040a1196c072e07929b9820f363a1fd1f4191", size = 275290, upload-time = "2025-09-25T19:50:13.673Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/e4/6e/b77ade812672d15cf50842e167eead80ac3514f3beacac8902915417f8b7/bcrypt-5.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7aeef54b60ceddb6f30ee3db090351ecf0d40ec6e2abf41430997407a46d2254", size = 278253, upload-time = "2025-09-25T19:50:15.089Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/36/c4/ed00ed32f1040f7990dac7115f82273e3c03da1e1a1587a778d8cea496d8/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f0ce778135f60799d89c9693b9b398819d15f1921ba15fe719acb3178215a7db", size = 276084, upload-time = "2025-09-25T19:50:16.699Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/e7/c4/fa6e16145e145e87f1fa351bbd54b429354fd72145cd3d4e0c5157cf4c70/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a71f70ee269671460b37a449f5ff26982a6f2ba493b3eabdd687b4bf35f875ac", size = 297185, upload-time = "2025-09-25T19:50:18.525Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/24/b4/11f8a31d8b67cca3371e046db49baa7c0594d71eb40ac8121e2fc0888db0/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8429e1c410b4073944f03bd778a9e066e7fad723564a52ff91841d278dfc822", size = 278656, upload-time = "2025-09-25T19:50:19.809Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/ac/31/79f11865f8078e192847d2cb526e3fa27c200933c982c5b2869720fa5fce/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:edfcdcedd0d0f05850c52ba3127b1fce70b9f89e0fe5ff16517df7e81fa3cbb8", size = 275662, upload-time = "2025-09-25T19:50:21.567Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/d4/8d/5e43d9584b3b3591a6f9b68f755a4da879a59712981ef5ad2a0ac1379f7a/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a", size = 278240, upload-time = "2025-09-25T19:50:23.305Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/89/48/44590e3fc158620f680a978aafe8f87a4c4320da81ed11552f0323aa9a57/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:db99dca3b1fdc3db87d7c57eac0c82281242d1eabf19dcb8a6b10eb29a2e72d1", size = 311152, upload-time = "2025-09-25T19:50:24.597Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/5f/85/e4fbfc46f14f47b0d20493669a625da5827d07e8a88ee460af6cd9768b44/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:5feebf85a9cefda32966d8171f5db7e3ba964b77fdfe31919622256f80f9cf42", size = 313284, upload-time = "2025-09-25T19:50:26.268Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/25/ae/479f81d3f4594456a01ea2f05b132a519eff9ab5768a70430fa1132384b1/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3ca8a166b1140436e058298a34d88032ab62f15aae1c598580333dc21d27ef10", size = 341643, upload-time = "2025-09-25T19:50:28.02Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/df/d2/36a086dee1473b14276cd6ea7f61aef3b2648710b5d7f1c9e032c29b859f/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:61afc381250c3182d9078551e3ac3a41da14154fbff647ddf52a769f588c4172", size = 359698, upload-time = "2025-09-25T19:50:31.347Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/c0/f6/688d2cd64bfd0b14d805ddb8a565e11ca1fb0fd6817175d58b10052b6d88/bcrypt-5.0.0-cp39-abi3-win32.whl", hash = "sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683", size = 153725, upload-time = "2025-09-25T19:50:34.384Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/9f/b9/9d9a641194a730bda138b3dfe53f584d61c58cd5230e37566e83ec2ffa0d/bcrypt-5.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2", size = 150912, upload-time = "2025-09-25T19:50:35.69Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/27/44/d2ef5e87509158ad2187f4dd0852df80695bb1ee0cfe0a684727b01a69e0/bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927", size = 144953, upload-time = "2025-09-25T19:50:37.32Z" },
]
[[package]]
name = "captcha"
version = "0.7.1"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
dependencies = [
{ name = "pillow" },
]
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b4/65/8e186bb798f33ba390eab897c995b0fcee92bc030e0f40cb8ea01f34dd07/captcha-0.7.1.tar.gz", hash = "sha256:a1b462bcc633a64d8db5efa7754548a877c698d98f87716c620a707364cabd6b", size = 226561, upload-time = "2025-03-01T05:00:13.395Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/da/ff/3f0982ecd37c2d6a7266c22e7ea2e47d0773fe449984184c5316459d2776/captcha-0.7.1-py3-none-any.whl", hash = "sha256:8b73b5aba841ad1e5bdb856205bf5f09560b728ee890eb9dae42901219c8c599", size = 147606, upload-time = "2025-03-01T05:00:10.433Z" },
]
[[package]]
name = "certifi"
version = "2026.7.22"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" },
]
[[package]]
name = "cffi"
version = "2.1.1"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
dependencies = [
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
]
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" },
]
[[package]]
name = "click"
version = "8.5.0"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "coverage"
version = "7.16.2"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/2f/55/d1eaf3e73781174340a00dc1ba2aee8a65f82fadb18e2797b192b6b3925b/coverage-7.16.2.tar.gz", hash = "sha256:ca64d9f1f384f151b9511bec01126072acd2f313439f8ed015a22d8790aab6fa", size = 971999, upload-time = "2026-09-27T12:29:01.118Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/5e/2c/f8296c63c5d542f3d21aed685e56b7031a419037d155bb3382fc0940d249/coverage-7.16.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:218d742afca2b5ad5ca759e93eddedfbcc6eadf8322f080dcefc40b7bd4e2d48", size = 223969, upload-time = "2026-09-27T12:26:16.753Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/90/23/6f3dcb1423a0d43216e402ea1746e4a7c7c44f38896b97dd573790f56a40/coverage-7.16.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a9a638be322a8d76a41cdb17781c7f82aaee6a66493d8ffb7e2c09ee22423d99", size = 224328, upload-time = "2026-09-27T12:26:18.15Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/ac/7d/8f3b6dc920e3fc6732f7678785a2091db439f186afbec30dbf2214d9b1f7/coverage-7.16.2-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:724bd0f1e81856b35e59fc98cf7b4e544a3cb662e4e0864dca73d4326ee9d808", size = 255832, upload-time = "2026-09-27T12:26:19.799Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/d1/36/6c45f15be4eca4ac1062c6a55a323286494c99726a7e58951fe85967ac08/coverage-7.16.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5375ebd99038021b35e99dc88255022912c06565d316212f4a576e4b08d30f5d", size = 258571, upload-time = "2026-09-27T12:26:21.199Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/34/fb/b54cbeba3ad89082c2e441278681859e538322cc34b84b2af7ebff00080f/coverage-7.16.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a076277ca9f5750cc230f0f578ebd2620cec60255b25707361699fef6fb465c", size = 259680, upload-time = "2026-09-27T12:26:22.822Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/6e/a2/0dc65ec3d61930e1e4c2e371763b15eb4290896eb343a12d5d3091308116/coverage-7.16.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:58d4a54c6ea672afef66d49be922a2c69826c5ae1a42a9cd94f0c9c2bacdf800", size = 261945, upload-time = "2026-09-27T12:26:24.336Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/d6/93/5fad7a61f2c14e08e98946fc31c1c7ffc1195061bf3fdc351db3be77a863/coverage-7.16.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0dcbcfcc059117284c603ff8cb61a65872512882f84a8cf0339241f7f7c2f148", size = 256191, upload-time = "2026-09-27T12:26:25.89Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/2d/47/74e5de9227b939ece9f64e729645ddc4296bea10dbfa98721c1333c8be2e/coverage-7.16.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:afdf43b72ef3876c1fe66423b91466e37877c9e81e8cec70542b7e8525b9d1b7", size = 257610, upload-time = "2026-09-27T12:26:27.35Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/13/fe/2cf28d40b43645d1b72388fe3ee7f7c747533a6a9557bb8c24a7ae74fe1a/coverage-7.16.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:9acc7f7ec4a1b5f89bd929fde5b8a714f6fafdc6cc18725413d510aa082b47ad", size = 255757, upload-time = "2026-09-27T12:26:28.949Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/d7/3d/7c149fd99fc8bbc39c80db5e688d1d39fd040be2ecb78b8335a51a55b9c0/coverage-7.16.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:80d3f7b48d43ee8fc5e8707a8adb43d743a5a1a85256c25a24f9d6d0e2238fa6", size = 259822, upload-time = "2026-09-27T12:26:30.515Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/e6/3f/b283fce09d5995e227bd8e513358dd7471bedc0f78abc85a925ebdb0a2f6/coverage-7.16.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:126d1af8804d7224421fe991ff65d3ce649081560df7a98b1a5ffff07f9923bd", size = 255325, upload-time = "2026-09-27T12:26:32.037Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/bf/91/f3325edf0c4223fb1fe1532b8dbef2a1d2f729459a9a7d1a44d073bae534/coverage-7.16.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c19cd6d025c1673f22afcd22c7df8a662d779e05d8e3fa6820c22afb895b0206", size = 257191, upload-time = "2026-09-27T12:26:33.525Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/c4/89/21eb5e83ecf2eed523c4eb3d65ae513cd082c8fd1b6deb34c4cb6c332f97/coverage-7.16.2-cp312-cp312-win32.whl", hash = "sha256:152877cdc8a07264882cfcd503ba56a3ef6cba56a70e8c70f6eb8ffd7384789a", size = 226034, upload-time = "2026-09-27T12:26:35.021Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/db/de/e3ad6d864c0833624b4f1f9b53f9e58e116c945e5e965c3f1e172c5e84cd/coverage-7.16.2-cp312-cp312-win_amd64.whl", hash = "sha256:e6c52d3307824ff93b39efd99e4185d557db40bd841452abfb32e5d9151ca162", size = 226569, upload-time = "2026-09-27T12:26:36.604Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/3e/c1/bccc58ebe5489cc70628f635c1932fd371f5d7da850dbcf960f95f4c4afc/coverage-7.16.2-cp312-cp312-win_arm64.whl", hash = "sha256:a678c0b6b22086ec2427359d22e37445d4a792f5fdbbc744112c7dade65cad02", size = 226372, upload-time = "2026-09-27T12:26:38.406Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/3f/0c/7a64e1ac90541a8edf50daef0914848011fb057a5bf55284a4811e21939a/coverage-7.16.2-py3-none-any.whl", hash = "sha256:11d28e9123a9156cb405d8d27b44256c9a58fb5decc2073a8f17862057e3aa0f", size = 215754, upload-time = "2026-09-27T12:28:59.075Z" },
]
[[package]]
name = "cryptography"
version = "50.0.2"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
dependencies = [
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/9d/af/182eb91b0df3fe75c4d9f26fe70684569566745f6ba7e5c9c73a862c5252/cryptography-50.0.2.tar.gz", hash = "sha256:7b46165bb56eb4704e2eaaf86f3c940d19154535d9b0ca7d6d590b04060e00d5", size = 880623, upload-time = "2026-09-30T15:30:04.884Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/e5/56/d194340cc4a57535e82e1bee9e89667ac4b7c13b5d3f59686deae3094dd5/cryptography-50.0.2-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:fa8f5efb344d6908a1ce62f4a24e2e5780f825d6f53f5f50ec5ffacac72936cb", size = 3914904, upload-time = "2026-09-30T14:43:44.339Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/d9/69/c9bd862c3bf43d6399c433caf002df16e2dffd4be49bdf515cda38038711/cryptography-50.0.2-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:79def8d059362e7831389ed3be0ecdf58a89386e1271e35dd9f5af84e81bffd0", size = 4731146, upload-time = "2026-09-30T14:43:47.113Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/21/69/64cef1f702bf6657e0cc186ed1a2891d50d29fb41586b254e1c07adea261/cryptography-50.0.2-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:630ebfea3bf689d075f82316324ff7433dc447fe6bc1bfc76524b74b4a9567d2", size = 4719841, upload-time = "2026-09-30T14:43:49.01Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/38/6b/61a3f8d8c5e1e49a6cddccafc4015cc1c0021360ab0acb4080e7a423644a/cryptography-50.0.2-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f9f6143a8c75945eb960d9eb98905a441394abfa24afaae239d514ffb2586480", size = 4738340, upload-time = "2026-09-30T14:43:50.932Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/7b/2e/7212ca32fd43dc91f2f41db20160b268098874b4c9a0e7be94d6835f5b2e/cryptography-50.0.2-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:a582ab2ae1d34f67112cadc86702774c9ea4374df6bca6afe672817203c99134", size = 5367029, upload-time = "2026-09-30T14:43:52.911Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/1a/f1/b474e930c4d910328780e3940da76f5aa5cbc48ce1fc14e44d239d9ea9db/cryptography-50.0.2-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:4061c0079120205fb760c58acab6443e217307dcf05e3702cf970e0689972856", size = 4753050, upload-time = "2026-09-30T14:43:55.272Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/7c/52/9af10e80ac16b0fcc2123f9cbd5e7afbd0fd5075bb7a607c592258a39cda/cryptography-50.0.2-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:ac9ed99d81760c62fe89d5f0815cdfa1ba9a35141cf30f1c2d044f04b4803d2e", size = 4376724, upload-time = "2026-09-30T14:43:57.24Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/71/37/6202e488cc1eb625ea110c292c6bda92823176e023f427d8d5660ce8d632/cryptography-50.0.2-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:87e9ce85beb6b328ba370cc6e6aea483c92617b4c95b1d33a49297eb662bfb04", size = 4737859, upload-time = "2026-09-30T14:43:59.541Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/8f/30/e86d7d518489b0ae2497091a35287abcb1a2ce4037837a34afbe9b1d6964/cryptography-50.0.2-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f265528741e048bce55c3463ed721fb0aa45a5888d8add8cfeccb3035451bbdc", size = 5324103, upload-time = "2026-09-30T14:44:01.901Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/d3/69/2c833a049475e0a3444e94c7d0aca0aa51d166374a449b09e92ac98138de/cryptography-50.0.2-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dab55f57c74c3cad24c323bacbbd04be4705ba6eb0d92e920b1fc4837ed5079", size = 4752576, upload-time = "2026-09-30T14:44:04.545Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/6c/5d/906970b83bbfc1f5bbfb677a143c181f2801f23b6a7204a3b47c42c97e65/cryptography-50.0.2-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:25784ce8b9621c90c643efb9e1e2162ab3b0224cae446ad5e70e7fcb1ce18b51", size = 4870819, upload-time = "2026-09-30T14:44:06.884Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/68/e3/f2298d3bb55e0c4a91841ec4d01b3f020ba8c5fbf15ccdcc6dcf03f97025/cryptography-50.0.2-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:85d0d9a31b9098e98534226d5686b47264b95e62ce459dc2e62fdfc809f9fe93", size = 5030152, upload-time = "2026-09-30T14:44:09.443Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/9a/4f/adfc442765721292fff86d314ce385d3249d22db42295c0dd057727b60f3/cryptography-50.0.2-cp311-abi3-win_amd64.whl", hash = "sha256:7afa5a6602a9f29af1f3a2965f831bae7c9d5d597b7cbb716d41ab3b7d89879c", size = 3824692, upload-time = "2026-09-30T14:44:11.671Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/2d/49/93f6a6e7a87c9aa68d44d3e1cdb5fe8f60c90d5d2f46acae9a56892816b8/cryptography-50.0.2-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:edc3342adf8f697fc5f59c887a304356f147b397809440ed64e2fa6af2f50f37", size = 4133708, upload-time = "2026-09-30T14:44:41.807Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/8c/75/32ac2a56243d778805c16ca6a32b8f74fb757df7e28d7ecb560afafb59cf/cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d370b8d1dfcdf7130178137f6fbee6140774a1acc6cacefc4b42643ec11d0a3a", size = 4956267, upload-time = "2026-09-30T14:44:43.693Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/aa/a4/2c8d734e43d97f0842ee9f1b7b4bfb3d0cf5e19edebf43c2afe6675c2320/cryptography-50.0.2-cp315-abi3.abi3t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f2f9bd7f90c64fe89253f0a2c05e3c4856072660429ce8831b4235bf29403a67", size = 4966465, upload-time = "2026-09-30T14:44:45.769Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/c2/58/ee288c829a6f41f6235ae9dd33d82fd19b45442b65b4c8a3da36963d9f7a/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_aarch64.whl", hash = "sha256:e275096ea1e60cc595cda2836fd4a6c725d1125108b868be17f53684d164e2cc", size = 4959356, upload-time = "2026-09-30T14:44:48.211Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/92/20/9ded6d51ddd9897f6b6e81fb9ebea7951d7cc5d6c890b0ed8abf77a51a80/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_ppc64le.whl", hash = "sha256:b13478603dcd0a2479ff8e87e2c19a7d525734686fe3c49542472293a204212d", size = 5548822, upload-time = "2026-09-30T14:44:50.86Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/02/a8/8df951850d6b31d2a00218f19e2b3f999523437ed7a819df7fa427942fca/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_28_x86_64.whl", hash = "sha256:58a0c478eeca76fe5e07993c5a0703def34a6dc6a0cda4f5564639b33112ffe7", size = 5001199, upload-time = "2026-09-30T14:44:53.379Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/8b/f9/36b3022218ce75b7cdf068fb95f809f9bd0d820e4955ef43b90c255cc7ac/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_31_armv7l.whl", hash = "sha256:d38cdff612d06fa6a32840d5e1b1f7a27cee4a349aa9085d94a67789d6bfd408", size = 4629333, upload-time = "2026-09-30T14:44:55.635Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/8c/72/20f99a219f6af47cdd1cbd978c243b92d71496e168a746138af44ded4f29/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_aarch64.whl", hash = "sha256:fdd28f912fccfec1846a94e2e1e8f9b0012f557f0c46fe4f3eb0d7a87afcf90b", size = 4958822, upload-time = "2026-09-30T14:44:59.639Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/f2/20/196f112617fb08eb4d608a2a6c422373d46f9cc2857f38fc0667033c0899/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_ppc64le.whl", hash = "sha256:cbc8738fd8526d80f35cb3a40d41f41a2e7030bb3b18b09a6778ef63d291c2fd", size = 5506351, upload-time = "2026-09-30T14:45:02.267Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/24/95/83378121ef3eaaaf71d4b781577ff794acb39b9e1b87a3f156898c8497ed/cryptography-50.0.2-cp315-abi3.abi3t-manylinux_2_34_x86_64.whl", hash = "sha256:e105ab60406787da31fccc883fc0f733af1efd78f0136a4599692c4083a73d0c", size = 5000859, upload-time = "2026-09-30T14:45:05.009Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/22/f7/70fd7ae4d1dbfa7ba29b02e1b9068771519a86027756510b700ce81086a8/cryptography-50.0.2-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:6f8700550aa1474a91e5dc07049c46f98b423b5b1ddd0483e0b51362eeeaf5be", size = 5092151, upload-time = "2026-09-30T15:29:15.932Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/d4/be/688367b74de86984bd58d8efacfc7c9e68b89a6a22ced0fb4f38db50254a/cryptography-50.0.2-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:c71be1cbfa5cd9a41ee452acf1eccd82b2c05950358b106ec8ceb83411d1a020", size = 5286120, upload-time = "2026-09-30T15:29:18.309Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/39/d1/55f8a3f2ef5d1529e16835ef10cf0fe3d559ce237b46dddc440c0bba3649/cryptography-50.0.2-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:c423ab384a46c4dff7217b2ea5ba2e11cffdeab6441acd04cf65a369caf0366c", size = 4111557, upload-time = "2026-09-30T15:29:20.155Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/23/ad/ac987755d00e1e64273760228d2635ae38dae2be83e3c6e0d3289d91dec3/cryptography-50.0.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:0ec5f09541743261e66e291b4a0cbf0fb2997aeaab6d9e9c740b9dba1b58d1c2", size = 3943588, upload-time = "2026-09-30T15:29:22.265Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/d5/8d/6d585339bedf85d45044c85d8412dac53f2bb6f918e8b7777efba1787844/cryptography-50.0.2-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c5e67125c7dca78d199ec4e116aa93dbb83494808ecbb8211a2cb09b1bf41dbd", size = 4756166, upload-time = "2026-09-30T15:29:24.58Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/bf/f1/1c1f6874e8550cfddd4b688ceb38cefb6ed15ceed224d56f133f3d88c214/cryptography-50.0.2-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ee247f5c245c9a2fe7c8e2214e295918838e44e00a45a6718451e4004219e767", size = 4749145, upload-time = "2026-09-30T15:29:26.807Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/c1/63/61b15dc1a8de03fe0adbe3fd7608b3ad5c73bf50993bbcb1faaa930afe33/cryptography-50.0.2-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:dfe9763530994147d9af1def057a5b9658b00e8f8fe8743d144d1e0911c2e454", size = 4763638, upload-time = "2026-09-30T15:29:28.588Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/fc/35/b345bdfa40c9126df1a9d33236aa98418367931b8725f84fc3ae2b98dc59/cryptography-50.0.2-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:58ddb5a8e3179d12f19e4ea34d2d32e9d63a4baa142c875c1eb59f41b7243acd", size = 5382217, upload-time = "2026-09-30T15:29:30.589Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/4f/87/ef344a9e616871f2519c22d6afcda79ddd5d35e9592d95eb6e677608d055/cryptography-50.0.2-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f21e8a22c8605750c7af886bab299a363721264061b4ac0a30efb73cfd58efc5", size = 4781387, upload-time = "2026-09-30T15:29:32.605Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/90/5b/f2fdb13cd0b96f6f932c8627bb292a45f11c64d21620a8e120aee9a3b848/cryptography-50.0.2-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:9c8402a82ea0dc4ceeab793db05f0fafa8ca139ca34fcde5df0f596103c74107", size = 4403790, upload-time = "2026-09-30T15:29:34.374Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/bc/ce/7e4f662b1e3c393513569e402cfc85ac7da0bd3d5435e122a3140219eb2d/cryptography-50.0.2-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:0ddc924c04591c2811ca024d62ecad4f7f6f08af8939c211438f48a16bd23602", size = 4764319, upload-time = "2026-09-30T15:29:36.149Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/3c/3f/86ff33ce34cc0de6847fb96e035a1a760d81652e38643f617c02ad32ef7a/cryptography-50.0.2-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a6557e5f38e065ca9fbdaf7cfc7435ecb1d113aa81a022d1b51921ee7432e227", size = 5338560, upload-time = "2026-09-30T15:29:39.053Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/40/cf/6b5c8e2fd9202d98988ab7cb5cc5c991704c4ad55f492ff408e4969f83f1/cryptography-50.0.2-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:1981f1db4630889b9ef7803fadef12b056f428cb6b85c27ba57b774793b6093c", size = 4780973, upload-time = "2026-09-30T15:29:41.251Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/10/bf/8d6ebc7dded797bd0f0160d52188021211f011a2b164ef0ae1dac4587465/cryptography-50.0.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7a8701d6b584d76e909e3d305b7d126b41439876a5aaf76cddc67fc230eafa2e", size = 4897738, upload-time = "2026-09-30T15:29:43.106Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/d4/aa/f3f6e0de7e6253b8baa8b2d8fb9d50924fa75cee3d4624bd4bc1208ee923/cryptography-50.0.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ce47f66801c20ec6c6632453bb5960fe38939e9306970b48b3a5a26de7745d94", size = 5058280, upload-time = "2026-09-30T15:29:44.827Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/f6/b6/a1faf3a27ae9405fb34b1713cc73b2d8a26b04d5c561578fa2e6ef3e5bb9/cryptography-50.0.2-cp39-abi3-win_amd64.whl", hash = "sha256:4e81d95e5bafc2d6e34e4bed780e53e4d5b9a2f928573428aa4d35fbec1eb0de", size = 3854095, upload-time = "2026-09-30T15:29:46.782Z" },
]
[[package]]
name = "fakeredis"
version = "2.39.0"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
dependencies = [
{ name = "redis" },
{ name = "sortedcontainers" },
]
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/2f/27/3ed3eee5e5a929345c37024b814a70f6e2452ffdab77a2680c2ebba3614a/fakeredis-2.39.0.tar.gz", hash = "sha256:e89c3410f290330042638ff5cca3e22788fa267dcaf28a64b4f483e14577208d", size = 301722, upload-time = "2026-10-01T12:35:19.404Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/35/ca/8bf657139922808196e6480ec6ed94008897e23d603abd5b27538cfdf811/fakeredis-2.39.0-py3-none-any.whl", hash = "sha256:acd1450575259634db2942d5bae93e383aac32bb9968aab29fe7b0c2ab880bb8", size = 186508, upload-time = "2026-10-01T12:35:17.899Z" },
]
[[package]]
name = "fastapi"
version = "0.143.0"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
dependencies = [
{ name = "annotated-doc" },
{ name = "opentelemetry-api" },
{ name = "pydantic" },
{ name = "starlette" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/0b/d7/6a8753ab6c1d432dc53703c3e1b92974a94531b7d047c32bbaae461ea844/fastapi-0.143.0.tar.gz", hash = "sha256:1acffe48206a80917cf7dac21992b5c44b25384e8902bf745c1fd9dabcf6c51f", size = 468391, upload-time = "2026-10-08T12:29:46.54Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/bd/f4/27e386913417ad32aae42bba48b0c0cce40e9ff2fba1a871ca2702c37324/fastapi-0.143.0-py3-none-any.whl", hash = "sha256:3e9395fd35276425b61b516a31fdd7c77fe2af83e41b4da22e30696fb1304c5d", size = 144665, upload-time = "2026-10-08T12:29:44.853Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
]
[[package]]
name = "hiredis"
version = "3.4.2"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/38/da/41b341ebed1eb6f1074112936af98bb52880724737887ae9bade9d7ce107/hiredis-3.4.2.tar.gz", hash = "sha256:9a566dc70e9dd84be3550babc56a8e109bb65cafcac635aea027fa425196a7d7", size = 138058, upload-time = "2026-09-22T12:39:20.363Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/f4/fb/aee5f09ba3b483700b0fb4556f9c09e752791d255bb677310485e76a3e37/hiredis-3.4.2-cp312-cp312-macosx_10_15_universal2.whl", hash = "sha256:eb98b46a781a960bc9044050cc166e38c19b327a7a8c62afee9c78d72d80dd18", size = 140912, upload-time = "2026-09-22T12:37:48.554Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/41/0c/d29b76ac581200ebf0e0194edda8c7aef51dd497079d556aabfd44336de7/hiredis-3.4.2-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:05d06f3edcdeb484aa47610fd520c07d637a763d4ab1cd7793550829afe27ccb", size = 75186, upload-time = "2026-09-22T12:37:50.066Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/d5/6f/9092acfd69d9a76fecce4723bba624a36d321447f92e88f80296038dfaee/hiredis-3.4.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ddfdd5006d1cbe2ee961852b90f89d676b44dd8e0eb2f032dc2383c16a54bfc9", size = 71987, upload-time = "2026-09-22T12:37:51.09Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/3c/29/65e823bc79be70322dfab5b7bf46bdbac2d029b848950fed9621adac7445/hiredis-3.4.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4cf7924e86c5f9d4e212d9643a99e607008628941e771df015c72cd6dc4d15e", size = 307130, upload-time = "2026-09-22T12:37:52.538Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/69/51/f8b21afd788b8da4be6368cec3777b44151c166b054d3b6dd38349b4323b/hiredis-3.4.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:258741a87fb551e58e5e008ffc989e1bc980b26e2156be365a12b7088b2c48c9", size = 340169, upload-time = "2026-09-22T12:37:54.192Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/cc/2c/0f535418703886f755fb8edba8c4ae174e02663ec61b1029d248afa7d835/hiredis-3.4.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:aa9fef272956109d72a46016f2ca8431d8af36fcf9cd155da53aeba642d201e7", size = 351929, upload-time = "2026-09-22T12:37:55.735Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/b1/4c/d4d16acb0c9d4d4741d4d8c8bd72e7b7e881c9a41867a6b7d24748099a57/hiredis-3.4.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:018fdee902038f74b21e18a6d2fe7819bb63bdaec878d9d5f27280005b778ad7", size = 313508, upload-time = "2026-09-22T12:37:56.879Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/98/b7/b7ceb4f6975a91e8100da63d53b41ff075a706472f095e442c8e998bd521/hiredis-3.4.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2d7282fba5602013d11c068c0f6218c28b67c4c80064f0b3882ffaf0290bbfa9", size = 301438, upload-time = "2026-09-22T12:37:57.994Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/00/dc/1ae6dca5684631595685482a8478179503e54d3be40097b3e345f2aa4e93/hiredis-3.4.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:254c880fbd087527c326ec7672562dde4ac9dfe1c38b2ce923a387858c7a2618", size = 331982, upload-time = "2026-09-22T12:37:59.266Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/da/7c/767f89bdded81ba7be1a185f0718d8662b8e8eee1009ab88e9e1decc6bb4/hiredis-3.4.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:12f05180d1dbc11647a11c967984873dd8baa7f4cdfc4f1b3eff42983fa80d4a", size = 333346, upload-time = "2026-09-22T12:38:00.337Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/3c/38/5715f89fa8d6ca724ae073d92474628525c9751864eda7a3811033a846fa/hiredis-3.4.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc446964ce1ae16ca7689b27991dfb769094531e69f3972e2eaaf03f19037a1e", size = 312252, upload-time = "2026-09-22T12:38:01.518Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/0c/c0/3f1f58df82e59f740d3b3eb76c14144917e9cc7345695b184e88669a60a6/hiredis-3.4.2-cp312-cp312-win32.whl", hash = "sha256:cdd19191555763455d34d63697becfe480a5bb907a33fe90e5505fadfd7bc9ae", size = 38728, upload-time = "2026-09-22T12:38:02.697Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/0b/e2/de4c556ca70124b3f45396ffe2f339a35d80639e1595abc67c3aa09fba4c/hiredis-3.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:51add939c00482b855b9ef6ea1354d4ea942f0c281f32aec514a94f07c3e2148", size = 40530, upload-time = "2026-09-22T12:38:03.656Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/83/c2/cd2deae4d071718303449c376e29ca3b5000489ca7c6e19d1230e4c7c641/hiredis-3.4.2-cp312-cp312-win_arm64.whl", hash = "sha256:9f298b8a2c2af3166a7381c3d9b6a80c3bf2cf38785dbe06bf030882584eb4f8", size = 36929, upload-time = "2026-09-22T12:38:04.553Z" },
]
[[package]]
name = "httpcore"
version = "1.0.9"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
]
[[package]]
name = "httptools"
version = "0.8.0"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/43/e5/d471fcb0e14523fe1c3f4ba58ca52480e7bd70ad7109a3846bc75892f7fb/httptools-0.8.0.tar.gz", hash = "sha256:6b2a32f18d97e16e90827d7a819ffa8dbd8cc245fc4e1fa9d1095b54ef4bd999", size = 271342, upload-time = "2026-05-25T22:17:48.841Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/14/88/1d21a36da8f5cb0fa49eafd4b169eba5608d57e75bbcf61845cbc6243216/httptools-0.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:880490234c10f70a9830743097e8958d6e4b9f5a0ffc24515023afeef984054d", size = 208247, upload-time = "2026-05-25T22:17:07.843Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/a5/42/cc4feea2945cb3051038f090c9b36bd5b8a9d7f5a894a506a8983e33fd1c/httptools-0.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5931891fb7b441b8a3853cf1b85c82c903defce084dd5f6771ca46e31bf862c5", size = 113064, upload-time = "2026-05-25T22:17:09.136Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/e3/a6/febbb8b8db0f58b38e44ad6cb946e6a255ae49b55f2e8543408fb7501ccd/httptools-0.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b15fc622b0f869d19207c4089a501d9bcc63ca5e071ffdd2f03f922df882dcb2", size = 523851, upload-time = "2026-05-25T22:17:10.106Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/b7/e4/f90a0df0b83beff265b7e3b65f2a4cefd95792d4be0ac3e16049f2acd3c2/httptools-0.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:425f83884fd6343828d8c565f046cb72b6d19063f6924093e11bcd8e1548cd09", size = 518842, upload-time = "2026-05-25T22:17:11.218Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/9e/2d/0c9ac76dd2c893841fbf6498d6acec4f2442e1b7067f6e3e316a80e494e8/httptools-0.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ef7c3c97f4311c7be57e2986629df89d49cb434dbff78eafcd48c2bff986b15a", size = 501238, upload-time = "2026-05-25T22:17:12.728Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/ca/42/906adc91ae3a5fa9c59c0a2f21c139725bd7e5b41ae6acd485cd14123ebf/httptools-0.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a1afd7c9fbff0d9f5d489c4ce2768bd09c84a46ddefc7161e6aa82ae35c85745", size = 509567, upload-time = "2026-05-25T22:17:13.842Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/05/0b/4240efeb672751ee5b9b380cb0e3fdc050bc05f68adc7a8aefc4fcd9a69a/httptools-0.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:cd96f29b4bab1d42fa6e3d008711c75e0f79e94e06827330160e3a304227f150", size = 90918, upload-time = "2026-05-25T22:17:15.155Z" },
]
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
]
[[package]]
name = "idna"
version = "3.20"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.1"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/01/e1/2069291243c926a2ff1cd706c7f3eeb9b62144bf60f77c9fb9ff2fb26bd3/iniconfig-2.3.1.tar.gz", hash = "sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960", size = 21209, upload-time = "2026-10-06T22:48:38.076Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7", size = 7552, upload-time = "2026-10-06T22:48:36.959Z" },
]
[[package]]
name = "mako"
version = "1.4.3"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/5a/09/e07c4b5579a79f4b16f8d4f29f6c54514ac787c4ad506b8c4f28a0e6b0bf/mako-1.4.3.tar.gz", hash = "sha256:cd6537fe88d5fec315c55c2f8529bc4ce7a9a352ad7db3eeaa6a66e2dd4ec37a", size = 412799, upload-time = "2026-09-22T20:54:31.509Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/6d/a0/053d6af3e8f871e0073b4a36732d9e65be77a72e5434c31b94f6af78a6bb/mako-1.4.3-py3-none-any.whl", hash = "sha256:723296007c870bfd6b3f0c3230dba7198096e5269297ebf5e4eff9e7ffa39d4f", size = 80164, upload-time = "2026-09-22T20:54:33.128Z" },
]
[[package]]
name = "markupsafe"
version = "3.0.4"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/38/9b/e422a865e1d5d57d0e509b4e0bf1c1a70a7f6382c29a5aa428df994c8bc8/markupsafe-3.0.4.tar.gz", hash = "sha256:2e9ad7dd851bf45fab9f75cbff4cb493fee9979e8d8c7c9c3ee119022518edd6", size = 153777, upload-time = "2026-10-02T23:07:22.29Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/81/09/4c59d56b8461ae8eb0d8ba34bb25b7e618547044679d58a82ef9b2479fc1/markupsafe-3.0.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:61631e08084be9e21a8967ec3139c7616ed7c5e9368e05c86d1b39562c8a57b6", size = 11658, upload-time = "2026-10-02T23:04:51.876Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/a2/f0/d6613774d86fbf6d145751d43c59875e47a6f9f17daee0aef173bd36d90e/markupsafe-3.0.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0930db9bdc62d22944e10b066448bb65dc9abe9112880c7cab8da54db4284d5f", size = 12050, upload-time = "2026-10-02T23:04:52.931Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/0d/f2/8f18e0b806eb13c1f8d07d917a720831ead54253a6dec011fbc78098a6f8/markupsafe-3.0.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6a45c3d514f2436064db00d7fc8778d888f0236ebfed649b53d13a59e69ad51b", size = 24363, upload-time = "2026-10-02T23:04:53.895Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/60/ce/fa07dbe8a5675558fa36dea033e19995bc783de2dec5f540ccb9030b06aa/markupsafe-3.0.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:1e1451fab512d1bcc3dc26988ec1edb0b82c2db909132872cd9356070a6b63df", size = 28525, upload-time = "2026-10-02T23:04:54.905Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/85/40/be87c01f3868ec217f8a2015089d71c22c8c5a75324822e5ed1cdd87210d/markupsafe-3.0.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bd3ce56ae2cbae3ba82b683bc425cd7e48d2ed8b10f3e818186b6f5646d9271c", size = 24733, upload-time = "2026-10-02T23:04:56.229Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/4f/a7/aeedb5140afa41fc74c225e9184ab96723a6e873b6ee1c9fede7283456d8/markupsafe-3.0.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e124f974786f831d6043728e38296969d3579db8896fe004682f5758e613581", size = 22985, upload-time = "2026-10-02T23:04:57.521Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/c3/fc/e91352bb08c6a59da3ef0909d457bf95a5f5908fbf151b30a06d9dbcfbb4/markupsafe-3.0.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c02e8f18bdedba082cef725942ac823b9b60656db07f7e265cb31618dfd00d77", size = 22001, upload-time = "2026-10-02T23:04:58.597Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/5d/f8/bffee5e7d2a3deb59748a797650a48af7e672025cf641a79344a771ad106/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9f098115c247e11d138ab83a28fa0323c77015007ea2df73ba5fd714dfefd67c", size = 23793, upload-time = "2026-10-02T23:04:59.686Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/ed/59/b853d6628ecb4d658e1d637224846d5e9bb4adf4f8df97f3be9f29dce2ec/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d5f93ebbeb8032d47e349328ec8662d973d9b05a70b3c35df1f91fe419b84749", size = 22640, upload-time = "2026-10-02T23:05:00.768Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/09/b2/1506df394f0f075797c418d0301498f49e43be194e3ffcb49e6fe6ccf022/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:64511c54db4e4987aef4c41923235927428729e8174c5dba488429be70a998ed", size = 24074, upload-time = "2026-10-02T23:05:01.813Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/c7/81/5ed69cda630ac69ef60d06c09ba5a7f84ff66a2e28cf986fd5614ab3c6e6/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e1a622f13970d81f95d0c72f9dc090dce9085fccfa4c9f2174377ee32bd15786", size = 21563, upload-time = "2026-10-02T23:05:03.239Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/0c/fe/fb1e79be0fea60aa32602ebefc9c35a82bb42b4df157285ab7dfec12341a/markupsafe-3.0.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c9a7f43c0b202b334cc9184af09bb8f21d3a209e038efaf106936fb69e6b026e", size = 23048, upload-time = "2026-10-02T23:05:04.479Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/c8/52/7632a53360671a9b750cdbabaf9cdd89f18b42248b8e4cb42c0b0296e459/markupsafe-3.0.4-cp312-cp312-win32.whl", hash = "sha256:f0ec3b750b59375eab5b0fb2b9254810c00a3375be6d789899f1055a1d556237", size = 14108, upload-time = "2026-10-02T23:05:05.513Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/3f/bf/62495e180b7000aaf30000fff849e933f74264638057176cf46852500adc/markupsafe-3.0.4-cp312-cp312-win_amd64.whl", hash = "sha256:11935df9bf455ed0c04eb87bcd720f02b1fe5e02128a9430f23aed6f93336fc7", size = 14303, upload-time = "2026-10-02T23:05:06.538Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/c5/8e/4c24208776a65878d656996945aacfbfe010d3720d1a98fc0eb8491fc03b/markupsafe-3.0.4-cp312-cp312-win_arm64.whl", hash = "sha256:a4bbd2d87dd233b9fc5812160c3d0ffbe42edc22a26ce0469f58479ede633fe9", size = 14174, upload-time = "2026-10-02T23:05:07.617Z" },
]
[[package]]
name = "opentelemetry-api"
version = "1.45.1"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/2e/02/6e0ae9cc61bd3169d401077b507b3ebc344745171e1051ab430be012dcd9/opentelemetry_api-1.45.1.tar.gz", hash = "sha256:aa38ed19bcc084ba42782a73255b3582283eced7ad6dddbd6695189e69adfb75", size = 72804, upload-time = "2026-10-06T17:32:58.133Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/1e/41/f7dcf80b81ee8e71c1a2b59f14208bc723edbd89ed027a73b175abf6348e/opentelemetry_api-1.45.1-py3-none-any.whl", hash = "sha256:b31553efa588ae44bc306f863c785c5333a9ecc091248c6ee68b4b6c87fdedfb", size = 60256, upload-time = "2026-10-06T17:32:33.506Z" },
]
[[package]]
name = "packaging"
version = "26.3"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
]
[[package]]
name = "payroll-backend"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "alembic" },
{ name = "captcha" },
{ name = "fastapi" },
{ name = "pillow" },
{ name = "pwdlib", extra = ["bcrypt"] },
{ name = "pydantic" },
{ name = "pydantic-settings" },
{ name = "pyjwt" },
{ name = "pymysql", extra = ["rsa"] },
{ name = "redis", extra = ["hiredis"] },
{ name = "sqlalchemy" },
{ name = "starlette" },
{ name = "uvicorn", extra = ["standard"] },
]
[package.dev-dependencies]
dev = [
{ name = "fakeredis" },
{ name = "httpx" },
{ name = "pytest" },
{ name = "pytest-cov" },
{ name = "ruff" },
]
[package.metadata]
requires-dist = [
{ name = "alembic", specifier = ">=1.14" },
{ name = "captcha", specifier = ">=0.6" },
{ name = "fastapi", specifier = ">=0.115" },
{ name = "pillow", specifier = ">=11.0" },
{ name = "pwdlib", extras = ["bcrypt"], specifier = ">=0.2" },
{ name = "pydantic", specifier = ">=2.9" },
{ name = "pydantic-settings", specifier = ">=2.6" },
{ name = "pyjwt", specifier = ">=2.10" },
{ name = "pymysql", extras = ["rsa"], specifier = ">=1.2.3" },
{ name = "redis", extras = ["hiredis"], specifier = ">=5.2" },
{ name = "sqlalchemy", specifier = ">=2.0.36" },
{ name = "starlette", specifier = ">=1.7,<2" },
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.32" },
]
[package.metadata.requires-dev]
dev = [
{ name = "fakeredis", specifier = ">=2.30" },
{ name = "httpx", specifier = ">=0.28,<1.0" },
{ name = "pytest", specifier = ">=8.3" },
{ name = "pytest-cov", specifier = ">=7" },
{ name = "ruff", specifier = ">=0.14" },
]
[[package]]
name = "pillow"
version = "12.3.0"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" },
]
[[package]]
name = "pluggy"
version = "1.6.0"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pwdlib"
version = "0.3.1"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/78/a6/644f28a21cb68920d06b264e72b864ac9bff2377f4757dd4648b5110b3ad/pwdlib-0.3.1.tar.gz", hash = "sha256:2ffd1955c92190d5bfbdc5071bf7d422568b83f943453ebd3d43d031ccba37b1", size = 216951, upload-time = "2026-08-12T08:38:38.383Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/6b/2e/51979293b2337f508597d13ee20db1ac1d43e48086b2ed7de6da213ed46b/pwdlib-0.3.1-py3-none-any.whl", hash = "sha256:944fa439198bed8ac3ea63dac76ea4ae43e2a35f4f21e9e725e82d3c2b97d6a5", size = 9272, upload-time = "2026-08-12T08:38:37.483Z" },
]
[package.optional-dependencies]
bcrypt = [
{ name = "bcrypt" },
]
[[package]]
name = "pycparser"
version = "3.1"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/ac/d3/eb1d3bc30dda12f7e69640ae2ac8cb10240b71fb73024ad528b7d2ae73da/pycparser-3.1.tar.gz", hash = "sha256:b3fc6dec06a8b2fefa0ed4ff92285306a5e3be9987bc5603c9edbdc4e492418f", size = 105946, upload-time = "2026-10-08T12:26:34.349Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/99/ce/b3ae9ee0324d991c860187be2a6ee436d27a3d02397eaddbb101ec901f3d/pycparser-3.1-py3-none-any.whl", hash = "sha256:f09d358c840bd147b79e55f2bc494f18ea869dc897f5852a8f5766b74f787882", size = 48709, upload-time = "2026-10-08T12:26:32.97Z" },
]
[[package]]
name = "pydantic"
version = "2.14.0"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
dependencies = [
{ name = "annotated-types" },
{ name = "pydantic-core" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/6b/fb/6e44b63b26efea1cec48c26d8362313310202ef5ed6e7a52f1669e64e2cd/pydantic-2.14.0.tar.gz", hash = "sha256:8a51a7aaddd60f55566d1f07bdd87b92b463903f39a8f26b71a06314cd1548ae", size = 898613, upload-time = "2026-10-08T14:34:48.341Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/2d/eb/9146591cc819d040475bf7f2be786710c7f7eb8083693bf859728da2ca9c/pydantic-2.14.0-py3-none-any.whl", hash = "sha256:15fab1bea6f1dc5003b54fc2ecab230c1fd1dbade2acd4addc52d81e32416d4b", size = 487109, upload-time = "2026-10-08T14:34:46.864Z" },
]
[[package]]
name = "pydantic-core"
version = "2.50.0"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e6/6d/196e8c819e0e934f35a1a33b3530396feadb0af4ca38fe9f995249e55794/pydantic_core-2.50.0.tar.gz", hash = "sha256:84d2d38f7d163c4dec292f379e9de1960c661795442aca6c90d706436cb3749e", size = 513355, upload-time = "2026-10-08T14:30:58.245Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/e3/d8/e0fe374bc0082dfd337ca319505c9302ce383aeeeacc33d93383edd159e5/pydantic_core-2.50.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:79e8fc9c135ef628c45cb8aa5deef8d21de13d33bb4c59a859fa73d335ceb40a", size = 2222860, upload-time = "2026-10-08T14:26:39.575Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/ef/7d/0a2f829e3e1d809393faab907e3d9307245cd6043ebf54fad439f72f0003/pydantic_core-2.50.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0abe1b44d361b948404b6b2ed80be2583e0077340572e071afa6e0eda4e1de30", size = 2025752, upload-time = "2026-10-08T14:26:41.786Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/67/d4/e2808af12de4809dd6a4b532e3877baa5f1000df51bd8ebb8756450c6c92/pydantic_core-2.50.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ae45853d25a23fba56681d2f9ed41f3e3f12f0a3b2393fefa08ff6406320a1f5", size = 2048432, upload-time = "2026-10-08T14:26:43.593Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/18/58/46ad42a321051585d89f4e563b115015332caf79ba4e96f2785715c7f52d/pydantic_core-2.50.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd841dcf394ff261c26763a9d7be754176d4f1e6d26cb2a5d5331e91b6b56a5f", size = 2173565, upload-time = "2026-10-08T14:26:45.299Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/92/69/541206e657ecea865e5f6d49a4a0d8062586d05da482d9d7ed77fdcdfc86/pydantic_core-2.50.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c2e97985641fe53ad7824d1b5bfeb7990a5ff788c4559ee44d7522642560ddc2", size = 2361974, upload-time = "2026-10-08T14:26:47.024Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/f6/c0/cf4850441d0d3d3736abe5e8b6fcb49a50166aa0651ed8444172f81f4b4f/pydantic_core-2.50.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7987866a2569396e6765c54d643fd6a7b3b234e89ee8d45a3729a7b4b2726145", size = 2440817, upload-time = "2026-10-08T14:26:48.937Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/ca/e2/2f793fa2f1b338aa03124efd4362956c84d7bf1b331dc86050bc0151e67d/pydantic_core-2.50.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f187030fc3d62c668feb0f09e92852e0eb414d7fcefc4748f2e67d245aade37e", size = 2160358, upload-time = "2026-10-08T14:26:50.74Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/db/00/35e314c08e721ec1f070408a4e41926b90347ecd2863c08c78daad5f8a16/pydantic_core-2.50.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d187f43d1c5b844adc871c5b8c22b4aa12a116aaca4e9bd1521bc9ce479aae1f", size = 2231416, upload-time = "2026-10-08T14:26:52.733Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/0f/3a/6f7c36afe35a9eca24e5f74f33ca641ac8cf308aae4ac7af4e585d464a40/pydantic_core-2.50.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:cb4fcaabb28cabf21396a9b816b9afe091f8c776805fdf03e2cbc606b64dfa7c", size = 2242496, upload-time = "2026-10-08T14:26:54.485Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/8d/af/77adf30285836c25f6a927e170df45f8dd4af713c89135b22abcbf3c6d67/pydantic_core-2.50.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:1751e92d56fbb623b937d73985e500b1a2b1053e56f718a6c564660d99bb9cb0", size = 2279176, upload-time = "2026-10-08T14:26:56.432Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/08/37/4e2a05247f82b59337bf45e9bb14aed85e2d83aecd21e49ad6cedf91882b/pydantic_core-2.50.0-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:70df7ff903aea05383298715ea53551c8f27c8f70cbe54ba7f05606cd822e7e4", size = 2450488, upload-time = "2026-10-08T14:26:58.218Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/85/e6/75f25906212ecb94d69a0c8ed16f5c4ac312dc9961c2e93ff2a7fdf9a07a/pydantic_core-2.50.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:24302bf47319a64e5c5c7c29e971d2b7a20a190c64e59035a3df9582dec636fa", size = 2476664, upload-time = "2026-10-08T14:26:59.98Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/75/72/ebb97b3becd0c0f2c722f698d34dec951ab1aa6086edf535f8769b545e3e/pydantic_core-2.50.0-cp312-cp312-win32.whl", hash = "sha256:5dbf9f18c8af11db719e67633be0af556d7d765bee0ca9419bd706fe4b7ed9fe", size = 2102409, upload-time = "2026-10-08T14:27:01.846Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/3f/7b/5ebf3e62f5d0f6e3503ffdfffd7d0c2f8d45690afd37fa0cfdcb1786d7d6/pydantic_core-2.50.0-cp312-cp312-win_amd64.whl", hash = "sha256:1541c334af5d42cb9eb03862a9b4d2cfbfc670fd05172ec51f3ce02d704550f1", size = 2171536, upload-time = "2026-10-08T14:27:03.797Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/58/1c/879ee9d5b63c60e5a077bab74ad93a4a48476090ca9c97534ab4263d0bae/pydantic_core-2.50.0-cp312-cp312-win_arm64.whl", hash = "sha256:b1399f918aea8fb76ffa99b474c9b768accea1f079fde407ee538bec89f20fa7", size = 2162946, upload-time = "2026-10-08T14:27:05.876Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/a9/c8/45e2cbac6379b8cec8cffd7e6aed50754fce13d93f0cff28bbbfe18c1d1c/pydantic_core-2.50.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:b916ff828d604d4311a5639b7b3da51eaea3923833ec3e300a5ee35eade99691", size = 2217049, upload-time = "2026-10-08T14:30:23.532Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/61/8c/b81c4139bff6305e7a98fa7845a99e05df1461d3c33d0fef417e02114ae7/pydantic_core-2.50.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:732efbb50977ab7cf18f01d4c255834aeb14cb2425459f7bff681a1ba3a4ffa1", size = 2039121, upload-time = "2026-10-08T14:30:26.423Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/0f/49/679293741809cf835290bccf80216aebfcf5d94cb84a34709f18b21abfd5/pydantic_core-2.50.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad9185366893714cd4514ae5e1a098227704fa395cb41a7855d75968ecedc826", size = 2109280, upload-time = "2026-10-08T14:30:29.216Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/f5/49/76f167aec9b9d77a82e38131539ca6c6fcf69d720965f76eda20b87b454d/pydantic_core-2.50.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0def1dc09802a790e4a1b3cbc4401f0b53c58f273ce3671df9867f7bdf1fbe20", size = 2269313, upload-time = "2026-10-08T14:30:32.096Z" },
]
[[package]]
name = "pydantic-settings"
version = "2.15.0"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
dependencies = [
{ name = "pydantic" },
{ name = "python-dotenv" },
{ name = "typing-inspection" },
]
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/68/ca/31c57507b13119d7d3cfa1576dad2911a4861e3be07b579395f4e9d393f9/pydantic_settings-2.15.0.tar.gz", hash = "sha256:694b793e84f766ba76a90ebdefc01d0a9a045dab0382bee70393da93712ad117", size = 261253, upload-time = "2026-08-07T09:24:57.419Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/30/a4/2bffa9f8e804325a09867f0e9d30795c80ea9f8d62560bd1b6ad6220eb2f/pydantic_settings-2.15.0-py3-none-any.whl", hash = "sha256:0ba092c291c94baceb5eff768aa0d56400a457585bc0175925a5a5510303da42", size = 69413, upload-time = "2026-08-07T09:24:55.839Z" },
]
[[package]]
name = "pygments"
version = "2.21.0"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
]
[[package]]
name = "pyjwt"
version = "2.15.1"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/43/ea/5194e52748b0da83d71e082d75496eaec6e58f419f5e184786ded517e6a9/pyjwt-2.15.1.tar.gz", hash = "sha256:4f259e80cdfb6b3fc18a7de51fd1ef9ec79652f25019bae68975ca2468a34df8", size = 121252, upload-time = "2026-09-28T18:40:42.598Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/50/ca/44de4e75f8aadc457f0634be3b542815078ded46dca30efb960edeecad6e/pyjwt-2.15.1-py3-none-any.whl", hash = "sha256:42d59d631f7768a1028a64c7ff581a9bf7519804daf91fc5b6c56e30eec5e193", size = 33860, upload-time = "2026-09-28T18:40:41.429Z" },
]
[[package]]
name = "pymysql"
version = "1.2.3"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b1/d4/c15b459e25a23767d2f4065ef40968920320f04e302889574310c21c96a3/pymysql-1.2.3.tar.gz", hash = "sha256:d5b288529782e536ae171866df3ca9dc4f6cbfb3cc2f18e6f837fbb90dbc262b", size = 50629, upload-time = "2026-09-17T12:22:49.146Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/a9/4b/0a906d8184f011ff8dbd4722743783867589b33269d2c5fff238d636fdcb/pymysql-1.2.3-py3-none-any.whl", hash = "sha256:14f1c68e2ed859243ae5ca41ffbe677027fc46bc136a9f0be8a4e928e5e7415a", size = 46740, upload-time = "2026-09-17T12:22:47.826Z" },
]
[package.optional-dependencies]
rsa = [
{ name = "cryptography" },
]
[[package]]
name = "pytest"
version = "9.1.1"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
]
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
]
[[package]]
name = "pytest-cov"
version = "7.1.0"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
dependencies = [
{ name = "coverage" },
{ name = "pluggy" },
{ name = "pytest" },
]
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" },
]
[[package]]
name = "python-dotenv"
version = "1.2.4"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/74/26/2fbeedb218a787a5eea551c7532cac4e009f83d689dd2faa0d0353473f86/python_dotenv-1.2.4.tar.gz", hash = "sha256:f0d53e69935a851c0dcc78f3ab7aaccd8cabef0b92382b576b824212902873c0", size = 60824, upload-time = "2026-10-01T05:36:10Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/60/d1/38f3a3405989a89ac18390803e70c6ad7c7760da4f9b83cbeca0c44a0c72/python_dotenv-1.2.4-py3-none-any.whl", hash = "sha256:42269a8a5b3fd54ffa6f3d84b18abed50064717576b4ecf03dc4a55d8aa04fdc", size = 23266, upload-time = "2026-10-01T05:36:08.633Z" },
]
[[package]]
name = "pyyaml"
version = "6.0.3"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
]
[[package]]
name = "redis"
version = "8.1.0"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a8/99/604f0b666d4c616d891cf77ebb9db6bb21601344c051aebf1b72b9ff915f/redis-8.1.0.tar.gz", hash = "sha256:6e1a19beef9225c83efd689c7e6b7da2d5215b1f42cd13b7fc3714d0a09c7b25", size = 5254356, upload-time = "2026-07-30T08:51:00.269Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/66/9d/c5731f6e3608663d4d3656fd8d3aecee8b509c3082818f5a13eae925baea/redis-8.1.0-py3-none-any.whl", hash = "sha256:a4fe1aac3d3b3cc791d4b3d5931c5a956045dc951ee74d1c913ee3ac4d2ee9fb", size = 560618, upload-time = "2026-07-30T08:50:58.497Z" },
]
[package.optional-dependencies]
hiredis = [
{ name = "hiredis" },
]
[[package]]
name = "ruff"
version = "0.16.10"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c4/49/23802c45f093eb14bde54b141d2b2f058edfa63a06db7beed047308cc08f/ruff-0.16.10.tar.gz", hash = "sha256:eff4728c4eaae93f0955cd264d24b2ab348e74bf59986ccf282ba6dc16b3b017", size = 4958724, upload-time = "2026-10-01T18:03:21.697Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/2f/21/ebce22e1d90cdb2cd691026b9c6e9bec6499f0b396089481755b5d49efff/ruff-0.16.10-py3-none-linux_armv6l.whl", hash = "sha256:488b0fe3f3574210e5cf80d9f59b9e3ab17a127a8155de3f307b392589cfb511", size = 10095557, upload-time = "2026-10-01T18:02:36.072Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/cb/98/a54de85876a8b2612bfa0d84c7b9abfb39c6a3354aee7800b09c1649b9e3/ruff-0.16.10-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e748ff95c934c4e978783b8e687bc174e7bd84e8ad24e3243e1ecfcda5e0282d", size = 10340577, upload-time = "2026-10-01T18:02:39.258Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/9f/16/1a5a4a2657effe4806110f2b907313802f1367fcdbb29e8122766e407fab/ruff-0.16.10-py3-none-macosx_11_0_arm64.whl", hash = "sha256:3031a4a2e8e7b8a46f70be45f198c35a11ece509a94b80334d8d397a33c67550", size = 9774282, upload-time = "2026-10-01T18:02:41.79Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/6e/fb/470085af734da396e68cd80fb0a3e7c109a459716ae59588c0f6fab8a17d/ruff-0.16.10-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:494401c86df4c4c25f69b9419605d944467ee98c42fb6ad405ef4fa40b8fb67d", size = 9920895, upload-time = "2026-10-01T18:02:44.458Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/57/de/f10cffe4f88a37ea6615ec460f01bf76f0bc1477737a35d9ee61a630a78b/ruff-0.16.10-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d203abc0ff2b773ee33d00ab8df0bb67046fbc7c07b119332f08b9b345cf8221", size = 9902707, upload-time = "2026-10-01T18:02:47.041Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/ff/44/3fdcedf83ae60ef837dd239170e480dce606a9142cfa2db911afdf855fd9/ruff-0.16.10-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bd83d1235a5258d318477bc5b576303974cbdef5df0c01a1bff14efcc23bd12a", size = 10619287, upload-time = "2026-10-01T18:02:49.485Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/c1/62/02e76a5574002153618eafbb70e159468addc72a5d2c488e65cbb4639d2d/ruff-0.16.10-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bc2610fb269fa56dd8a68669ae470fa6272902668c0fc2ebc3aa112b2633d5b8", size = 11339942, upload-time = "2026-10-01T18:02:52.008Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/6b/c4/cde27d47ad8d4126c587e608c67c46e7feac11763f606d261a1bc8a489e6/ruff-0.16.10-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3e70175e29cc94c26ea296c80e470180b744b7419026898e58f520c6ab32578e", size = 10934316, upload-time = "2026-10-01T18:02:54.811Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/e4/03/17234145f302a645a123e8c3bb2411ecf4669fbc350de3b0d803230a1729/ruff-0.16.10-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f33f43a864a8483eebd160e713336c8bab02c934feaff0a33cf5ccb41546d09a", size = 10387968, upload-time = "2026-10-01T18:02:57.497Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/71/29/2493af60240ee7644b38a4b821f5f9c3b5a4fa3178770fe1d0c685217395/ruff-0.16.10-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:1dfc6f0088149fb6a362c1c446bcbb3fd2157b3852fe2fa68409276eab9ad9b3", size = 10537906, upload-time = "2026-10-01T18:03:00.006Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/2e/9a/f56b28f3b143bb9e518c34fb89ab191b626bca8b70dbf8af0d2e2d473572/ruff-0.16.10-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6553498afc35f580f036030795810b9e6bcea31604b0fd9e8d352795473042e3", size = 10012938, upload-time = "2026-10-01T18:03:03.006Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/c2/c4/fca37362848ea4d4d80712df13632e645e7c7cfb1bdedf140699ac7a090b/ruff-0.16.10-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a3b8471dea115d37f123882be852bed13403746d3a76c11de4a19ec5f9ff5a03", size = 9897945, upload-time = "2026-10-01T18:03:05.818Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/7d/c7/e0bc57664d6e0c61fd22f620af260fe9662d7e1ddb320ea7f160e76ef165/ruff-0.16.10-py3-none-musllinux_1_2_i686.whl", hash = "sha256:92e59a70bcbd9d3a5483656da906ec28edfdacfce00afd99edb8b4e9d15644be", size = 10333134, upload-time = "2026-10-01T18:03:08.25Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/21/aa/5c9f3b68737c0e4a33a1db5dfab44f7b786d96ca91a7233112ddab1e6dc2/ruff-0.16.10-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:7ae7375f803b5520dc9f546bed7e3a0acb70b91812e9bb4b19927de22f25b77d", size = 10741702, upload-time = "2026-10-01T18:03:10.775Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/78/fa/0f9c2020dc316c53d983be011720b8157cc052b97e3f4dfa7db6d0f880a6/ruff-0.16.10-py3-none-win32.whl", hash = "sha256:2a12e01cb9156c10c466f63b46eaae5ecea28dfbd21b5836353ae498e7d1349a", size = 10139176, upload-time = "2026-10-01T18:03:13.267Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/99/29/cfb0df9448d4d4ad48c2de029ada9ebd71baa6da983a6c77ee6c6cd0fe82/ruff-0.16.10-py3-none-win_amd64.whl", hash = "sha256:97f2015c92aa97105b0eab19eb5d224884399281cfc5da86a92db4ab5e7fb2ca", size = 10584734, upload-time = "2026-10-01T18:03:16.006Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/fc/05/c16957eb287c3fc062e032619a25868d93d408a844b725bcf514f7a378ff/ruff-0.16.10-py3-none-win_arm64.whl", hash = "sha256:25a65fe998c4e6861ec079ada5826a2fc605e6cbccbe9dcd7fac1f54e791621b", size = 10366440, upload-time = "2026-10-01T18:03:19.04Z" },
]
[[package]]
name = "sortedcontainers"
version = "2.4.0"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" },
]
[[package]]
name = "sqlalchemy"
version = "2.1.4"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/1f/44/311bac6b6ef81e4dfd0287d04900108b1f5c00c9761dd3c0a2b7b9d0f86b/sqlalchemy-2.1.4.tar.gz", hash = "sha256:7bd7ad604487daa7eab8716471c29a7185f17b5287ce73bb7bc79fea050d8cfd", size = 10544216, upload-time = "2026-10-07T17:33:59.116Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/49/5e/cb5b078e007340661b010fa8bd31ce27468f88e09b35266544df4e0c52ca/sqlalchemy-2.1.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f953be9ba26039a24a5205c65d33518b608ce6f4f0f4e9b9c14eaf42a10dfc52", size = 2466469, upload-time = "2026-10-07T18:17:24.049Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/b1/98/44e2fdc5bc053dae559bf4f4eb7967ceecbad162299ecfc8de2edc3fcbe7/sqlalchemy-2.1.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ac64fce94c5b389062d2e3806db5dc780447591e0dfd5ead218c884f0703f2e", size = 4668213, upload-time = "2026-10-07T18:37:42.294Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/08/25/ed2262f964687b06f10c2c98b2dc9c9ed211f7cc11702879969a9ac217e4/sqlalchemy-2.1.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3e5045fb6aadbb0f978ab9b9d8822f7b7a97d2281814e7d13d791155664eace3", size = 4720857, upload-time = "2026-10-07T18:24:46.842Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/4d/d4/fab64c61d5d22ddbb077afd1e6b29b498bdacdf6406a03f53566e7e01686/sqlalchemy-2.1.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e3a026436c51f296aa1d01243909a3b76490950e927824b10899a083cc26e7c3", size = 4369351, upload-time = "2026-10-07T18:59:45.483Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/d9/e4/33413f0fafbcf3b332320aac2c1e40f3b4f17e56359a9474cb10de4bee8b/sqlalchemy-2.1.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:71040390ef01c85e9d26e5c83cb0c5942dcc8725c49186430af160ce2f54234d", size = 4591496, upload-time = "2026-10-07T18:37:44.433Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/bb/65/19821440cbd5c93da053d627b3e402eff11ff252bfae37700645b3c155a4/sqlalchemy-2.1.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:07c60abaffb980b7382f2c75be8a5279c2b5df2626a0f5d751dd942799bf3b5c", size = 4362379, upload-time = "2026-10-07T18:59:48.278Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/01/e3/168a0f93efd6ec40f59645a7e45ab08918e0bc8ecf07656e4ca09acdcc30/sqlalchemy-2.1.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a577e2127e52b0fe2bc54c73abb375a20ffe6f59fbc5568ccafc233f5bfcf8ef", size = 4667997, upload-time = "2026-10-07T18:24:48.72Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/54/79/0a852ef65864acd8d577d7aa6f67146167382bd6faee7a7586b9e6e28275/sqlalchemy-2.1.4-cp312-cp312-win32.whl", hash = "sha256:6c79e0c824d51c586757ecd342160bbdede9010df04bb71b9bbfffd5c7b6ee29", size = 2376664, upload-time = "2026-10-07T18:25:00.637Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/27/b9/a5934263bb1d712f743289ca224ab3b87e3570ac157802291e37ab85d365/sqlalchemy-2.1.4-cp312-cp312-win_amd64.whl", hash = "sha256:dffa69d2f3ba1933c1c1882dbef8fb3231b33eb19263e8b8c5cea24995071f06", size = 2429357, upload-time = "2026-10-07T18:25:02.565Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/a5/fa/a2323d81384ff214aa189057b7455b63623e66f28208b982e86c3cb042f5/sqlalchemy-2.1.4-cp312-cp312-win_arm64.whl", hash = "sha256:e30524ae24e31d83e1b5f734862882c442f4158e3566f2c5f5e9bd3c659bb517", size = 2388756, upload-time = "2026-10-07T18:22:36.025Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/f7/62/dbf11a262f6fbb41390cab2d8e47a30ec0961018b68201607b599dd489f5/sqlalchemy-2.1.4-py3-none-any.whl", hash = "sha256:0b96edcc2cd60fe1e35f67a46f4eb076e57297841b9eae949ac5f196593f00a7", size = 2054935, upload-time = "2026-10-07T18:01:16.403Z" },
]
[[package]]
name = "starlette"
version = "1.7.0"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
dependencies = [
{ name = "anyio" },
{ name = "typing-extensions" },
]
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/7b/2b/3850dc6bf7ef71b088962eba31dafc6cffd2f96e577ebb0bb316df96da3e/starlette-1.7.0.tar.gz", hash = "sha256:c79f74ea63cff761804fbbfb182f1e0b440c2d07b164d24700c5a1bab5d6ff5d", size = 2736246, upload-time = "2026-09-23T07:30:26.35Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/4e/d6/1ec1b290f9e0fb067899b61e1d37a30c923068bad260b216dbe37a7d2967/starlette-1.7.0-py3-none-any.whl", hash = "sha256:67f8e99895493dd2911a03f11314af6ceebeae4e704bb9f43dfc6a9db151c93e", size = 78980, upload-time = "2026-09-23T07:30:24.567Z" },
]
[[package]]
name = "typing-extensions"
version = "4.16.0"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
]
[[package]]
name = "typing-inspection"
version = "0.4.4"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" },
]
[[package]]
name = "uvicorn"
version = "0.54.0"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
dependencies = [
{ name = "click" },
{ name = "h11" },
]
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/da/34/30e9280707135d2cfc589dfff3cb796bd07a3aeb1a3e415ba09dd89d7bb4/uvicorn-0.54.0.tar.gz", hash = "sha256:a2e33cbfaa0306f8e6b0c13e0cb89d7d7a2da3e62b90c66e18c33d9807b28620", size = 112283, upload-time = "2026-09-25T06:52:37.601Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/38/0c/b54a4fdd7f90a3af8b02ebc9ce6712c2c208b7926a2f7bad95c33ebbe943/uvicorn-0.54.0-py3-none-any.whl", hash = "sha256:505bdb0f318731d45f1f712071fc781a8981f6847a31c902c9f5e652d4f67faf", size = 87427, upload-time = "2026-09-25T06:52:35.829Z" },
]
[package.optional-dependencies]
standard = [
{ name = "httptools" },
{ name = "python-dotenv" },
{ name = "pyyaml" },
{ name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" },
{ name = "watchfiles" },
{ name = "websockets" },
]
[[package]]
name = "uvloop"
version = "0.23.0"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/fa/42/02c739ce85fb2ee8d99212c61417da8140c6b87e9d97c430bea520d76044/uvloop-0.23.0.tar.gz", hash = "sha256:28d160f51ab4da3b187063652e643dea6831072add4adc1e6d62afbe73b6be27", size = 2559185, upload-time = "2026-10-01T03:17:04.4Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/05/98/04e766a6de99e6f7f955ecb7829e8d5a557de3427cb85be2236de54dda0c/uvloop-0.23.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:93935ab27b6eaef4c3e5489aebc84284f0644592f7ab516df60ee1b27eaf5eb3", size = 1393055, upload-time = "2026-10-01T03:15:42.526Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/33/8a/499e7b863a848ede009539bce39806b66205da5f8779354228e785601144/uvloop-0.23.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4448e9124537620f9c25d004c227bb5104440b58955c19bbd312d910af919a63", size = 768909, upload-time = "2026-10-01T03:15:43.974Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/3d/95/a880f8ce3b87ac5b307c354e8ee480be4658d24bf01f87921d57e3530b4a/uvloop-0.23.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7548ede3ee908cfabc0d068106e303a9a2d811af959cdf6ab85676344cedcda", size = 4419106, upload-time = "2026-10-01T03:15:45.551Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/51/27/c1d2f9fa977f8f42ea294604166df10e0027e6dc6cd17f85ede386c9bf36/uvloop-0.23.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:090865d8ce7a03986755a3ce711b7dd0d4b44eb14ab74368b717f3fad1180208", size = 4532597, upload-time = "2026-10-01T03:15:47.258Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/42/dd/2cb6a2c8a30ca55c07a882dd4ae4ceae0fa7d8c15b25b3b7cb9a4b6cf4ca/uvloop-0.23.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bd6f2f81c7b9da99d301c0b16b82044e76fe887086e42e1590ecf520b94dbdac", size = 4230048, upload-time = "2026-10-01T03:15:49.119Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/f4/52/29989cbaa4022dc4ef35c1dd60a4ab989e4c2065f341ed483ae71d2bd950/uvloop-0.23.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a6ac96da66c35bf789bdcde78a88dc7d56b7907d8379648c54adc1c61594575d", size = 4394152, upload-time = "2026-10-01T03:15:50.829Z" },
]
[[package]]
name = "watchfiles"
version = "1.3.0"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
dependencies = [
{ name = "anyio" },
]
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b3/68/e6aa0b77d217b31f8f486ec0cdfe5e00e6e38dc0be657e7d85819b9faf0a/watchfiles-1.3.0.tar.gz", hash = "sha256:99aee4a07847c06820765fd7b1b49ceac4f3f711ccb7d104655a33231de1c207", size = 108119, upload-time = "2026-09-21T09:08:55.664Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/68/fa/c0b840d5d8bafe640925408ce11095948c8945bb61be1e67d0ab629b872a/watchfiles-1.3.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:000b9688fc8133037a8b075c8ebf98f32844ff8964dda61e85c1db547dafc441", size = 399703, upload-time = "2026-09-21T09:08:13.518Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/c7/8a/894799b485fe9473ad10422a0d9668e53fb78e3a2b5cc6061159572844ad/watchfiles-1.3.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:bbc1198edfdc90fda0600f825aa94150f428dfcbf8138746f55998e0e660d64c", size = 397647, upload-time = "2026-09-21T09:08:15.16Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/5a/61/f277cd5ead05f5d37b98b41fbdd0c943ee0c04d49738f62e902cb291cf43/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4af9c464d410e8c44b58ddf1cbec7c3a02c1cb2c6c80df9c74659ba2fbda0ab5", size = 454994, upload-time = "2026-09-21T09:08:16.587Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/46/66/ea4c01382975e53b1ac1b0a0ea1b353416b8127fb6e04f5c1c313119aab6/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb94e40b9a0db19636b5e3fed0a9803452ecc7381df13b385272abb779ee83ca", size = 460105, upload-time = "2026-09-21T09:08:17.961Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/31/c3/1a501b597817096c9831c255f6270c124ce0e2faeb74124be24dcc93b86d/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7398930a2b76b9dc67a4e6b8bfc7baf30946892a86f417a3920c07bdd6febc30", size = 493761, upload-time = "2026-09-21T09:08:19.327Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/5a/16/77352d05e152c7786a016e9f1decdb4507e8aa5bcf3343c72142a0413d14/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:025b108f2d5cc2799cb941f79380df3e8b22bfb589c218e801767fe78d598c95", size = 577500, upload-time = "2026-09-21T09:08:20.723Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/95/fe/81001c55b24466fe9911135e50269fd3a29f43e446627a92adfab54553b6/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:be9ef3cd403d756a304a0a08a112163c63da7ec72c8c9d083c98818291ff8f29", size = 469209, upload-time = "2026-09-21T09:08:22.346Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/8a/cf/e3ad894ed5909a7c44b0707e89bc25d19a3d78b3a7aec846119443b6ae56/watchfiles-1.3.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b5768b49e426fd5b550b012c866db347cdf15c398ef98dc557b6e6b72fa74cd1", size = 458353, upload-time = "2026-09-21T09:08:23.7Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/7f/f5/2f106829e6e2bc91ad74614730f6df484202b9a4d20d927dbf2f08d37d44/watchfiles-1.3.0-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:d36c72fcdc08f143d87316a4c4c3b064e340da2c7383d39803950740ed418935", size = 468707, upload-time = "2026-09-21T09:08:25.167Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/15/37/2741ab776b978669c1f3bba1e573f1a184f727ab8b4d3a6d9c61090800ed/watchfiles-1.3.0-cp310-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:f0c9865240e065a2247f4b5248528ae3d01e0e8ae071462c250c884dc16e3ee3", size = 632510, upload-time = "2026-09-21T09:08:26.532Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/48/64/db4a4684275fe9b696ee5118c194705c1423b3d42181c3f8ee70194d5c73/watchfiles-1.3.0-cp310-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:380f513d26cc2e598266b88d66456e07d67ef4be8f0f435a1154d9c44b43d509", size = 661723, upload-time = "2026-09-21T09:08:28.004Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/52/80/95ddb24f6a2f595c031c24432ab7b8aa7d6d1ada0c6d63a552166e06e8d1/watchfiles-1.3.0-cp310-abi3-win32.whl", hash = "sha256:509d9f74d2bec5c1f4cd868ddcfe0c9601f7ea53066dfcb19d8ede742c4de661", size = 277761, upload-time = "2026-09-21T09:08:29.335Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/48/a1/1c4a1b3c8030cd03206232612be8a6e8799093490c220b0fcb935d02b75a/watchfiles-1.3.0-cp310-abi3-win_amd64.whl", hash = "sha256:1acabde19b67e673274e89a04d613b7fb1f122e2c4b8ec9a581e59c149264b61", size = 290643, upload-time = "2026-09-21T09:08:30.45Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/82/e0/d2db577c632d26d08078c4390dd870b8555c4e558df25d22270f0166c71f/watchfiles-1.3.0-cp310-abi3-win_arm64.whl", hash = "sha256:d978cc1dd7ba44f5590d7de74f7a9c8be7262d9e469c2c0efb8bbfc2574321dc", size = 285510, upload-time = "2026-09-21T09:08:31.561Z" },
]
[[package]]
name = "websockets"
version = "17.2"
source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/01/89/3f825ab71c242fffb62ea8fe638741c290f62f8d7aadf8125ff897747af3/websockets-17.2.tar.gz", hash = "sha256:36c2fb94c990cc2545143b12690e2de6c16300f9dbe5b4f33fa300cf57dc8792", size = 188355, upload-time = "2026-10-03T14:56:53.5Z" }
wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/bc/de/87854af9b38fe4738fd85f7f21c5b49558ae20aec898880894e435f33375/websockets-17.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:916ebdfd82e7fc68041d36b2b5f60361b9abce1e087454da15f8bd004839e090", size = 217757, upload-time = "2026-10-03T14:53:23.029Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/3a/2e/1e80b5efa41544f626d56bd15ccb53dbfc56bf28bf80ab9cd6f82c4b1d20/websockets-17.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3621f3686397708b8eeabfd0a9d75267c1f29a7537d2fe31e65d099e71587fa4", size = 215439, upload-time = "2026-10-03T14:53:24.531Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/3b/6e/82c78b595aee05be76a7ee78539323da1593c1848e4fef51c704c696568f/websockets-17.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a81e19710d48da88653473b6b9c366d47e99fe4f58e37ce415be47966748f31f", size = 215703, upload-time = "2026-10-03T14:53:26.226Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/f8/c4/905ef6aa80423c03dba99e1e26fc0acf63a2a9a6a2d9e8c0e6a63caaf952/websockets-17.2-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:f2731f9067976c8c4127212c0d2f2ada42d497d935e470419e029802365b12bb", size = 225023, upload-time = "2026-10-03T14:53:27.744Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/03/c0/a6d8be9c43e4456fb9597fdf8b5e0ce1f0a5df41503acce6d869536e4e23/websockets-17.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6627b913b8586b1c06db9516b31dd0dfbc621de3bb9312616d92a7e44f268a5b", size = 225299, upload-time = "2026-10-03T14:53:29.171Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/2f/d4/976d34b5491258b0a86c2ce9b9aabb9fdd68919ffd7fe65999c14a502a98/websockets-17.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0198c4ec6a3406a2f7557c032967de426474c2c995c81076585e09d29a9f407b", size = 226540, upload-time = "2026-10-03T14:53:31.635Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/83/2f/c4cfd42f53c697a8ed123fd82b8f85fcd13b6360d47f9f1d1d45d6ec6627/websockets-17.2-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:88c6a42c2632ff469e84155e44f6ed92cb15ccb047bf5fcb59225ae5a12fd33d", size = 229371, upload-time = "2026-10-03T14:53:33.061Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/e7/55/9a221b29c6232ff9282eecb2fc102402cb9e42a3479264db0e5fc4fe6835/websockets-17.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:eb0023e6cdb4b8ece0b33875188dd16104ad8c335361d396a98394f99e30ff7a", size = 227173, upload-time = "2026-10-03T14:53:34.502Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/8f/07/125e6d010c56c253d3d2b93cabaea0f96d33898151a16b49066a594acecf/websockets-17.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c1c09d5d4646eb96bda2cfb97493bcea21a0956a981de116e6b1f4a9de07f3fd", size = 225929, upload-time = "2026-10-03T14:53:36.071Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/23/a8/aad3bd902aee84e1b261ad6ab83b405e4a564af43101b8ad1dc0293ff4f4/websockets-17.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0360c4dc13ac569cc245e0efa2f4d4b1e4733d24c47b8ab3f3747227b1356348", size = 223167, upload-time = "2026-10-03T14:53:37.528Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/1f/f4/ec8ab9be1a5310b4fea829f088c7aa2b7a58b61d34bce1b2a9338635ff12/websockets-17.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:76693a16dead737946b651375ee3109d7db7ad9569a1c55c60aaed3ef85cfcc6", size = 225974, upload-time = "2026-10-03T14:53:38.959Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/65/45/ba6503f8257d3f98b0f07ebaad0fd099c9023eae744fd5b775416743597e/websockets-17.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:77a42cc507993ec5471b5283f7eef869239173b6000031543e3938a86d1af0fd", size = 224581, upload-time = "2026-10-03T14:53:40.496Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/d0/45/05cca59a876c6776727d96fc7ba59e0b6f9aa496afbf13e7e04ad0b63678/websockets-17.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:3bbc5543e39ee025d524077c5c15c2d67bc11c9f6676afe5b531839e24d701f6", size = 225347, upload-time = "2026-10-03T14:53:42.061Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/1c/00/cf0e43292ae949b13f67535be84317102891d69fd1986ec2bf2ead42747b/websockets-17.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8da58558bfb0ca6ccac2419773521f1111e40654038b1afabdfc69c02cb82614", size = 226457, upload-time = "2026-10-03T14:53:43.575Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/79/0d/9a5c61a18f0cc9876d94c70ccb3daf7614a9fee56abbb37c0e64e757fb96/websockets-17.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:01420cb1cb47433e8e7075d32cb8017ad3ffed0654bd1e48c0251b865920dec3", size = 224011, upload-time = "2026-10-03T14:53:45.077Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/34/ed/991c1ab80ab2ce40e1c939fef6fa8f971c3ef3b21caf988a7a107e0ad27d/websockets-17.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:c49c9edd47d0e44d360299e2d8865e2950d2fcf1b4098782c9d7dcd070919e5a", size = 224990, upload-time = "2026-10-03T14:53:46.8Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/e7/7a/363c835d17923e967fb66376188e67b9a261c85d826a0cd5e4dd3471221d/websockets-17.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:96f6c8d0fe21930d1f982bfce2382789d2e8d005d2ab63d21280660f95ef8fe1", size = 225265, upload-time = "2026-10-03T14:53:48.382Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/c8/90/6c51f6d78636bd1cd6781fae8ea5ea7bf1d5b4059354f3c1f5f8de793338/websockets-17.2-cp312-cp312-win32.whl", hash = "sha256:b25659ab2d655d742701487d5591e3f98e8f8b329fc999e05e3d59691ab344a1", size = 218228, upload-time = "2026-10-03T14:53:49.867Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/c6/2a/90008411c652dcfae34345a2169f4becd066a4ba71eebfa8dd801e0445e1/websockets-17.2-cp312-cp312-win_amd64.whl", hash = "sha256:faa763b677e96f1beccc6b4d7e8c079dfeed2f249f57a19debc321b519ee64ec", size = 218528, upload-time = "2026-10-03T14:53:51.486Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/1f/a1/b8ad6c17f8e75ba2215422fffe0d7f0c4b690dcff1c47c0473db0d253d51/websockets-17.2-cp312-cp312-win_arm64.whl", hash = "sha256:63499fc49efe48bccc2fca40723bc7adb198866cbe159093dd979905316994b6", size = 218457, upload-time = "2026-10-03T14:53:52.938Z" },
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/8a/58/835cd51934d6780fa586f275b5d9901eead6d81569b4343b3767cdbaae4c/websockets-17.2-py3-none-any.whl", hash = "sha256:6aa59f0ef92e796b2db6f5f26550c4713c0e4036899fadf02f55e2ed4db0b7ae", size = 211883, upload-time = "2026-10-03T14:56:51.898Z" },
]