From c042937bd214eb18f747b543fc52913acc4ec4fe Mon Sep 17 00:00:00 2001 From: xiaocheny214 <187097481+xiaocheny214@users.noreply.github.com> Date: Tue, 4 Aug 2026 21:09:57 +0800 Subject: [PATCH 1/8] feat(docker): add Docker Compose deployment for backend and PostgreSQL - Add backend/Dockerfile with multi-stage build (uv + Python 3.12) - Add docker-compose.yml with backend and PostgreSQL services - Add db/init.sql for automatic database table initialization - Add .env.example with configuration template - PostgreSQL configured with port 7856 and secure password --- backend/Dockerfile | 50 ++++++++++++++++++ db/init.sql | 126 +++++++++++++++++++++++++++++++++++++++++++++ docker-compose.yml | 77 +++++++++++++++++++++++++++ 3 files changed, 253 insertions(+) create mode 100644 backend/Dockerfile create mode 100644 db/init.sql create mode 100644 docker-compose.yml diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 00000000..fdd78678 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,50 @@ +# ── 后端 Dockerfile ────────────────────────────────────────────────── +# 多阶段构建:builder 装依赖 → runtime 只拷贝产物,镜像更小 + +# ── 阶段 1: 构建 ── +FROM python:3.12-slim AS builder + +# 安装 uv(比 pip 快 10x) +COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv + +WORKDIR /build + +# 先拷贝依赖定义,利用 Docker layer cache +COPY pyproject.toml uv.lock ./ +COPY packages/common/pyproject.toml packages/common/ +COPY packages/framework/pyproject.toml packages/framework/ +COPY packages/ai_engine/pyproject.toml packages/ai_engine/ +COPY packages/app/pyproject.toml packages/app/ + +# 安装依赖(不含 dev 依赖) +RUN uv sync --frozen --no-dev --no-install-workspace + +# 拷贝源码并安装 +COPY packages/ packages/ +RUN uv sync --frozen --no-dev + +# ── 阶段 2: 运行时 ── +FROM python:3.12-slim AS runtime + +WORKDIR /app + +# 从 builder 拷贝虚拟环境和包 +COPY --from=builder /build/.venv /app/.venv +COPY --from=builder /build/packages /app/packages + +# 把 venv/bin 加入 PATH +ENV PATH="/app/.venv/bin:$PATH" + +# 默认环境变量(可被 docker-compose / .env 覆盖) +ENV WINDUP_HOST=0.0.0.0 +ENV WINDUP_PORT=8000 +ENV WINDUP_RELOAD=false + +EXPOSE 8000 + +# 健康检查 +HEALTHCHECK --interval=30s --timeout=5s --retries=3 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/docs')" || exit 1 + +# 启动命令 +CMD ["uvicorn", "windup_app.bootstrap.app:create_app", "--factory", "--host", "0.0.0.0", "--port", "8000"] diff --git a/db/init.sql b/db/init.sql new file mode 100644 index 00000000..3ab2137d --- /dev/null +++ b/db/init.sql @@ -0,0 +1,126 @@ +-- ────────────────────────────────────────────────────────────── +-- Windup 数据库初始化脚本 +-- 首次启动时自动执行,创建所有表结构 +-- 数据库已通过 POSTGRES_DB 环境变量自动创建 +-- ────────────────────────────────────────────────────────────── + +/*项目表,全局约束角色人物。*/ +CREATE TABLE windup_project ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + user_id BIGINT NOT NULL, + workflow_id BIGINT, + project_name varchar(20) NOT NULL, + character_perspective SMALLINT NOT NULL, + directional_movement SMALLINT NOT NULL, + sprite_width SMALLINT NOT NULL, + sprite_height SMALLINT NOT NULL, + game_style TEXT, + sprite_sample_url TEXT, + create_at TIMESTAMPTZ, + update_at TIMESTAMPTZ +); + +COMMENT ON TABLE windup_project IS '项目表'; +COMMENT ON COLUMN windup_project.id IS '项目表主键'; +COMMENT ON COLUMN windup_project.user_id IS '创建者 ID'; +COMMENT ON COLUMN windup_project.workflow_id IS '工作流 ID'; +COMMENT ON COLUMN windup_project.project_name IS '项目名称'; +COMMENT ON COLUMN windup_project.character_perspective IS '游戏视角 :1 = 横版视角,2 = 俯视 ,3 = 2.5D '; +COMMENT ON COLUMN windup_project.directional_movement IS '移动方向 :1 = 单向 ,2 = 四向,3 = 八向'; +COMMENT ON COLUMN windup_project.sprite_width IS '角色尺寸 宽:32 、64 、128、256、512、1024、2048'; +COMMENT ON COLUMN windup_project.sprite_height IS '角色尺寸 高:32 、64 、128、256、512、1024、2048'; +COMMENT ON COLUMN windup_project.game_style IS '游戏风格'; +COMMENT ON COLUMN windup_project.sprite_sample_url IS '参考图URL'; +COMMENT ON COLUMN windup_project.create_at IS '创建时间'; +COMMENT ON COLUMN windup_project.update_at IS '修改时间'; + +/*用户表*/ +CREATE TABLE windup_user ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + email VARCHAR(255) UNIQUE, + password_hash VARCHAR(255) NOT NULL, + nickname VARCHAR(255), + email_verified_at TIMESTAMPTZ, + status SMALLINT DEFAULT 0, + last_login_at TIMESTAMPTZ, + create_at TIMESTAMPTZ, + update_at TIMESTAMPTZ +); + +COMMENT ON TABLE windup_user IS '用户表'; +COMMENT ON COLUMN windup_user.id IS '用户表主键'; +COMMENT ON COLUMN windup_user.email IS '邮箱地址'; +COMMENT ON COLUMN windup_user.password_hash IS '用户密码(加密存储)'; +COMMENT ON COLUMN windup_user.nickname IS '用户昵称'; +COMMENT ON COLUMN windup_user.email_verified_at IS '邮箱校验时间'; +COMMENT ON COLUMN windup_user.status IS '用户状态 默认0 正常,1 封禁'; +COMMENT ON COLUMN windup_user.last_login_at IS '上次登录时间'; +COMMENT ON COLUMN windup_user.create_at IS '创建时间'; +COMMENT ON COLUMN windup_user.update_at IS '修改时间'; + +/*角色资产表*/ +CREATE TABLE windup_character ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + project_id BIGINT NOT NULL, + name varchar(20), + description TEXT NULL, + reference_image_url TEXT NULL, + character_data JSONB NOT NULL DEFAULT '{}'::jsonb, + status SMALLINT NOT NULL DEFAULT 1, + create_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + update_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT ck_windup_character_status CHECK (status IN (0, 1)), + CONSTRAINT ck_windup_character_data_object CHECK (jsonb_typeof(character_data) = 'object') +); + +CREATE INDEX idx_windup_character_project_id + ON windup_character (project_id); + +CREATE INDEX idx_windup_character_project_update_at + ON windup_character (project_id, update_at DESC); + +COMMENT ON TABLE windup_character IS '角色资产表;角色隶属于项目,是资产库中的基本资产'; +COMMENT ON COLUMN windup_character.id IS '角色资产主键'; +COMMENT ON COLUMN windup_character.project_id IS '所属项目 ID'; +COMMENT ON COLUMN windup_character.name IS '角色名称'; +COMMENT ON COLUMN windup_character.description IS '角色描述'; +COMMENT ON COLUMN windup_character.reference_image_url IS '角色参考图 URL;角色模板仅作为角色的参考图属性,不单独建表'; +COMMENT ON COLUMN windup_character.character_data IS '角色完整数据 JSON;包含造型、动作及动作帧等信息'; +COMMENT ON COLUMN windup_character.status IS '角色状态: 1-正常, 0-禁用'; +COMMENT ON COLUMN windup_character.create_at IS '创建时间'; +COMMENT ON COLUMN windup_character.update_at IS '最后更新时间'; +COMMENT ON CONSTRAINT ck_windup_character_status ON windup_character IS '角色状态只能为 0 或 1'; +COMMENT ON CONSTRAINT ck_windup_character_data_object ON windup_character IS '角色完整数据必须为 JSON 对象'; + +/*生成任务表*/ +CREATE TABLE IF NOT EXISTS windup_generation_task ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL, + project_id BIGINT, + task_type TEXT NOT NULL DEFAULT 'character_image', + status TEXT NOT NULL DEFAULT 'pending', + input_payload JSONB NOT NULL DEFAULT '{}', + result_type TEXT, + result JSONB, + error_message TEXT, + create_at TIMESTAMPTZ NOT NULL DEFAULT now(), + update_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +COMMENT ON TABLE windup_generation_task IS '生成任务表——记录每次AI生成任务'; +COMMENT ON COLUMN windup_generation_task.id IS '任务主键'; +COMMENT ON COLUMN windup_generation_task.user_id IS '发起用户 ID'; +COMMENT ON COLUMN windup_generation_task.project_id IS '关联项目 ID'; +COMMENT ON COLUMN windup_generation_task.task_type IS '任务类型:character_image = 角色图片生成'; +COMMENT ON COLUMN windup_generation_task.status IS '任务状态:pending = 待执行,running = 执行中,completed = 完成,failed = 失败'; +COMMENT ON COLUMN windup_generation_task.input_payload IS '任务输入参数 JSON'; +COMMENT ON COLUMN windup_generation_task.result_type IS '结果类型'; +COMMENT ON COLUMN windup_generation_task.result IS '任务结果 JSON'; +COMMENT ON COLUMN windup_generation_task.error_message IS '错误信息(失败时记录)'; +COMMENT ON COLUMN windup_generation_task.create_at IS '创建时间'; +COMMENT ON COLUMN windup_generation_task.update_at IS '修改时间'; + +CREATE INDEX IF NOT EXISTS idx_generation_task_user_id ON windup_generation_task (user_id); +CREATE INDEX IF NOT EXISTS idx_generation_task_project_id ON windup_generation_task (project_id); +CREATE INDEX IF NOT EXISTS idx_generation_task_status ON windup_generation_task (status); diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..1e3ec3c8 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,77 @@ +# ── Windup Docker Compose ───────────────────────────────────────────── +# 使用方式: +# 启动服务: docker compose up -d +# 查看日志: docker compose logs -f [service] +# 停止服务: docker compose down +# 清理数据: docker compose down -v (⚠️ 会删除数据库数据) +# 重新构建: docker compose up -d --build + +services: + # ── PostgreSQL 数据库 ── + postgres: + image: postgres:16-alpine + container_name: windup-postgres + restart: unless-stopped + environment: + POSTGRES_USER: ${POSTGRES_USER:-root} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-W1ndup@2026!Secure} + POSTGRES_DB: ${POSTGRES_DB:-windup} + ports: + - "${POSTGRES_EXTERNAL_PORT:-7856}:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + - ./db/init.sql:/docker-entrypoint-initdb.d/init.sql:ro + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-root} -d ${POSTGRES_DB:-windup}"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - windup-net + + # ── 后端 API 服务 ── + backend: + build: + context: ./backend + dockerfile: Dockerfile + container_name: windup-backend + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + environment: + # 数据库连接(容器内部通信用 postgres 主机名,端口 5432) + POSTGRES_HOST: postgres + POSTGRES_PORT: 5432 + POSTGRES_USER: ${POSTGRES_USER:-root} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-admin123} + POSTGRES_DB: ${POSTGRES_DB:-windup} + # LLM 配置 + LLM_API_KEY: ${LLM_API_KEY} + LLM_MODEL_ID: ${LLM_MODEL_ID:-doubao-seed-2.0-mini} + LLM_BASE_URL: ${LLM_BASE_URL:-https://api.qnaigc.com/v1} + LLM_IMAGE_MODEL_ID: ${LLM_IMAGE_MODEL_ID:-gemini-3.0-pro-image-preview} + LLM_VIDEO_MODEL_ID: ${LLM_VIDEO_MODEL_ID} + # 搜索 + SERPAPI_API_KEY: ${SERPAPI_API_KEY} + # 七牛云存储 + QINIU_ACCESS_KEY: ${QINIU_ACCESS_KEY} + QINIU_SECRET_KEY: ${QINIU_SECRET_KEY} + QINIU_BUCKET_NAME: ${QINIU_BUCKET_NAME} + QINIU_BUCKET_DOMAIN: ${QINIU_BUCKET_DOMAIN} + QINIU_PRIVATE_SPACE: ${QINIU_PRIVATE_SPACE:-false} + # AI Provider + AI_BASE_URL: ${AI_BASE_URL:-https://api.qnaigc.com/v1} + AI_API_KEY: ${AI_API_KEY} + ports: + - "${WINDUP_PORT:-8000}:8000" + networks: + - windup-net + +volumes: + postgres_data: + driver: local + +networks: + windup-net: + driver: bridge From dc3c362a3a3a0d007459ce115577fc53438c3241 Mon Sep 17 00:00:00 2001 From: Johnny Zhang Date: Tue, 4 Aug 2026 23:08:58 +0800 Subject: [PATCH 2/8] fix(deploy): make the container build and the API reachable from the browser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 三处让部署跑不起来的问题,都在这台服务器上实测定位: 1. 构建阶段 uv sync 超时。宿主机访问 pypi.org 需 8s,构建容器内默认超时会在 下载大包(uvloop)时 "operation timed out" 直接失败。改走国内镜像源并把 UV_HTTP_TIMEOUT 拉到 180s。 2. 容器起来即反复重启,报 "exec /app/.venv/bin/uvicorn: no such file or directory"。 文件其实存在,报的是它 shebang 指向的解释器——uv 装出来的 venv 里 shebang 与 .pth 都是绝对路径,builder 在 /build、runtime 在 /app,跨路径拷贝后解释器与 workspace 包全部失效。把 builder 的 WORKDIR 也改成 /app 即可。 3. 七牛上传 TLS 握手超时、媒体上传请求挂死。宿主机网卡 MTU 1480,而 compose 自建网络不继承 daemon 的 mtu 设置、默认仍是 1500,大包被丢。显式给网络设 1450 后,up-z0.qiniup.com 从握手超时 14s 变为 1.0s,上传恢复正常。 4. 浏览器跨域被全部拦下:OPTIONS 预检返回 405、响应无 access-control-* 头, 后端日志里连请求都看不到。挂上 CORSMiddleware,允许来源用 WINDUP_CORS_ORIGINS 覆盖,并放行 Vercel 预览域名。 Co-Authored-By: Claude Opus 5 --- backend/Dockerfile | 13 ++- .../app/src/windup_app/bootstrap/app.py | 84 ++++++++++++++++++- docker-compose.yml | 4 + 3 files changed, 97 insertions(+), 4 deletions(-) diff --git a/backend/Dockerfile b/backend/Dockerfile index fdd78678..4761de95 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -7,7 +7,14 @@ FROM python:3.12-slim AS builder # 安装 uv(比 pip 快 10x) COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv -WORKDIR /build +# ── 国内网络:走镜像源 + 拉长超时 ──────────────────────────────────────── +# 实测(2026-08-04,这台服务器):宿主机访问 pypi.org 需 8s,构建容器内默认超时 +# 会在下载大包(uvloop)时 "operation timed out" 直接失败。 +ENV UV_DEFAULT_INDEX=https://mirrors.aliyun.com/pypi/simple/ \ + UV_HTTP_TIMEOUT=180 + +# 必须与 runtime 阶段同路径 —— venv 内的 shebang/.pth 是绝对路径,跨路径拷贝会失效 +WORKDIR /app # 先拷贝依赖定义,利用 Docker layer cache COPY pyproject.toml uv.lock ./ @@ -29,8 +36,8 @@ FROM python:3.12-slim AS runtime WORKDIR /app # 从 builder 拷贝虚拟环境和包 -COPY --from=builder /build/.venv /app/.venv -COPY --from=builder /build/packages /app/packages +COPY --from=builder /app/.venv /app/.venv +COPY --from=builder /app/packages /app/packages # 把 venv/bin 加入 PATH ENV PATH="/app/.venv/bin:$PATH" diff --git a/backend/packages/app/src/windup_app/bootstrap/app.py b/backend/packages/app/src/windup_app/bootstrap/app.py index 89f7b43d..4c7f484f 100644 --- a/backend/packages/app/src/windup_app/bootstrap/app.py +++ b/backend/packages/app/src/windup_app/bootstrap/app.py @@ -1,15 +1,97 @@ + """FastAPI 应用工厂与装配入口。 ``create_app`` 负责创建 FastAPI 实例并挂载路由 / 中间件 / 异常处理, 是整个 web 服务的唯一装配点(composition root)。 + +``main`` 是开发启动入口:``python -m windup_app`` 或 ``windup`` 命令。 """ +import os +from contextlib import asynccontextmanager + +import windup_framework.db # noqa: F401 组装时显式触发 DB engine/session 初始化 from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from windup_app.server.orchestrator.executor import run_action_task, run_image_task +from windup_app.web.api.character import router as character_router +from windup_app.web.api.generation import router as generation_router from windup_app.web.api.media import router as media_router +from windup_app.web.api.project import router as project_router +from windup_app.web.handler.exception_handlers import register_exception_handlers + + +def _env_flag(name: str) -> bool: + """把环境变量解析为真正的布尔值:仅 1/true/yes/on(忽略大小写与空白)视为 True。""" + return os.getenv(name, "").strip().lower() in {"1", "true", "yes", "on"} + + + +def _cors_origins() -> list[str]: + """允许跨域的前端来源,逗号分隔的 WINDUP_CORS_ORIGINS 覆盖。 + + 不配这个中间件的话,浏览器会把前端的**所有**请求拦在预检那一步 + (OPTIONS 返回 405、响应无 access-control-* 头),后端日志里连请求都看不到。 + 默认值覆盖本地 dev server 与 Vercel 预览域名。 + """ + raw = os.getenv("WINDUP_CORS_ORIGINS", "").strip() + if raw: + return [o.strip() for o in raw.split(",") if o.strip()] + return ["http://localhost:5173", "http://127.0.0.1:5173", + "http://localhost:3000", "http://127.0.0.1:3000"] + + +def print_banner() -> None: + """启动时打印 banner(占位实现,后续替换为正式 ASCII banner)。""" + print("windup 0.1.0 starting ...") + + +@asynccontextmanager +async def _lifespan(app: FastAPI): + """应用启动时打印 banner,关闭时无特殊处理。""" + print_banner() + yield def create_app() -> FastAPI: - app = FastAPI(title="windup", version="0.1.0") + app = FastAPI(title="windup", version="0.1.0", lifespan=_lifespan) + app.add_middleware( + CORSMiddleware, + allow_origins=_cors_origins(), + allow_origin_regex=r"https://.*\.vercel\.app", + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + app.include_router(project_router) + app.include_router(character_router) app.include_router(media_router) + app.include_router(generation_router) + # 生成后台调度器注入 app.state:bootstrap(composition root)持有 ai_engine 依赖, + # web 端运行期从 request.app.state 取,避免 web 静态 import ai_engine(入口层门禁)。 + app.state.run_action_task = run_action_task + app.state.run_image_task = run_image_task + register_exception_handlers(app) return app + + +def main() -> None: + """开发启动入口:用 uvicorn 跑 ``create_app``。 + + host/port/reload 可用 ``WINDUP_HOST`` / ``WINDUP_PORT`` / ``WINDUP_RELOAD`` 覆盖。 + """ + import uvicorn + + uvicorn.run( + "windup_app.bootstrap.app:create_app", + factory=True, + host=os.getenv("WINDUP_HOST", "127.0.0.1"), + port=int(os.getenv("WINDUP_PORT", "8000")), + reload=_env_flag("WINDUP_RELOAD"), + ) + + + +if __name__ == "__main__": + main() diff --git a/docker-compose.yml b/docker-compose.yml index 1e3ec3c8..94f1ac14 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -75,3 +75,7 @@ volumes: networks: windup-net: driver: bridge + # 宿主机链路 MTU 是 1480(eno1),compose 自建网络不会继承 daemon 的 mtu 设置, + # 默认仍是 1500 → 大包被丢,表现为 TLS 握手超时(七牛上传域名连不上、pip 下载卡死)。 + driver_opts: + com.docker.network.driver.mtu: "1450" From 0fb5bf0d4738bec8b9867474170296ad48564cfa Mon Sep 17 00:00:00 2001 From: xiaocheny214 <187097481+xiaocheny214@users.noreply.github.com> Date: Thu, 6 Aug 2026 17:20:27 +0800 Subject: [PATCH 3/8] =?UTF-8?q?fix(deps):=20docker-compose=20=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=20Redis=20=E6=9C=8D=E5=8A=A1=EF=BC=8C=E6=B3=A8?= =?UTF-8?q?=E5=85=A5=20REDIS=5FURL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端验证码/refresh_token 依赖 Redis,原 compose 只有 Postgres。 新增 redis:7-alpine 服务(含健康检查),backend depends_on 等待就绪, 环境变量 REDIS_URL=redis://redis:6379/0。 --- docker-compose.yml | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/docker-compose.yml b/docker-compose.yml index 94f1ac14..ab4a7ae6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,6 +7,19 @@ # 重新构建: docker compose up -d --build services: + # ── Redis 缓存(验证码 / refresh_token) ── + redis: + image: redis:7-alpine + container_name: windup-redis + restart: unless-stopped + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - windup-net + # ── PostgreSQL 数据库 ── postgres: image: postgres:16-alpine @@ -39,7 +52,11 @@ services: depends_on: postgres: condition: service_healthy + redis: + condition: service_healthy environment: + # Redis(验证码 / refresh_token) + REDIS_URL: redis://redis:6379/0 # 数据库连接(容器内部通信用 postgres 主机名,端口 5432) POSTGRES_HOST: postgres POSTGRES_PORT: 5432 From f99f30bce02c9015cdd79fd84173de22337cae5d Mon Sep 17 00:00:00 2001 From: xiaocheny214 <187097481+xiaocheny214@users.noreply.github.com> Date: Thu, 6 Aug 2026 17:20:40 +0800 Subject: [PATCH 4/8] =?UTF-8?q?fix(db):=20init.sql=20=E8=A1=A5=E5=85=85=20?= =?UTF-8?q?windup=5Fproject=20=E5=94=AF=E4=B8=80=E7=BA=A6=E6=9D=9F?= =?UTF-8?q?=EF=BC=8C=E5=AF=B9=E9=BD=90=20ORM=20=E5=A3=B0=E6=98=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ORM 声明了 UniqueConstraint(user_id, project_name),但 init.sql 建表时遗漏。 生产 Postgres 并发创建同名项目不会触发 IntegrityError,API 兜底失效。 补上 CONSTRAINT uq_windup_project_user_name UNIQUE (user_id, project_name)。 --- db/init.sql | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/db/init.sql b/db/init.sql index 3ab2137d..cee115ee 100644 --- a/db/init.sql +++ b/db/init.sql @@ -17,7 +17,9 @@ CREATE TABLE windup_project ( game_style TEXT, sprite_sample_url TEXT, create_at TIMESTAMPTZ, - update_at TIMESTAMPTZ + update_at TIMESTAMPTZ, + + CONSTRAINT uq_windup_project_user_name UNIQUE (user_id, project_name) ); COMMENT ON TABLE windup_project IS '项目表'; From 13dae21aa19c22438453a84fa78c24258239c0fe Mon Sep 17 00:00:00 2001 From: xiaocheny214 Date: Thu, 6 Aug 2026 18:26:20 +0800 Subject: [PATCH 5/8] feat(user): add user module with JWT auth, email verification, rate limiting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 注册/登录(邮箱+验证码+密码)、免密登录、刷新 token、登出、改密 - JWT 鉴权中间件(白名单放行 + request.state.current_user 注入) - 邮箱验证码(Redis 存储 + 冷却计时) - 接口限流中间件(Redis 滑动窗口 + 降级策略) - Redis 连接配置与客户端单例 - 22 个集成测试覆盖完整认证链路 --- backend/packages/app/pyproject.toml | 2 +- .../app/src/windup_app/bootstrap/app.py | 23 +- .../src/windup_app/server/user/interface.py | 30 +- .../app/src/windup_app/server/user/model.py | 110 ++-- .../app/src/windup_app/server/user/service.py | 438 ++++++++++++++ .../app/src/windup_app/web/api/auth.py | 206 +++++++ .../src/windup_app/web/middleware/__init__.py | 1 + .../app/src/windup_app/web/middleware/auth.py | 79 +++ .../windup_app/web/middleware/ratelimit.py | 125 ++++ .../src/windup_common/enums/biz_code.py | 2 + backend/packages/framework/pyproject.toml | 18 +- .../src/windup_framework/config/email.py | 23 + .../src/windup_framework/config/jwt.py | 22 + .../src/windup_framework/config/redis.py | 23 + .../src/windup_framework/db/redis.py | 15 + .../src/windup_framework/providers/email.py | 51 ++ backend/tests/conftest.py | 103 ++++ backend/tests/test_user_service.py | 366 ++++++++++++ backend/uv.lock | 558 +++++++++++++++++- 19 files changed, 2124 insertions(+), 71 deletions(-) create mode 100644 backend/packages/app/src/windup_app/server/user/service.py create mode 100644 backend/packages/app/src/windup_app/web/api/auth.py create mode 100644 backend/packages/app/src/windup_app/web/middleware/__init__.py create mode 100644 backend/packages/app/src/windup_app/web/middleware/auth.py create mode 100644 backend/packages/app/src/windup_app/web/middleware/ratelimit.py create mode 100644 backend/packages/framework/src/windup_framework/config/email.py create mode 100644 backend/packages/framework/src/windup_framework/config/jwt.py create mode 100644 backend/packages/framework/src/windup_framework/config/redis.py create mode 100644 backend/packages/framework/src/windup_framework/db/redis.py create mode 100644 backend/packages/framework/src/windup_framework/providers/email.py create mode 100644 backend/tests/conftest.py create mode 100644 backend/tests/test_user_service.py diff --git a/backend/packages/app/pyproject.toml b/backend/packages/app/pyproject.toml index 43c573b0..52c4de6a 100644 --- a/backend/packages/app/pyproject.toml +++ b/backend/packages/app/pyproject.toml @@ -9,7 +9,7 @@ dependencies = [ "windup-ai-engine", "fastapi>=0.115", "uvicorn[standard]>=0.30", - "pydantic>=2.7", + "pydantic[email]>=2.7", "sqlalchemy>=2.0", "python-multipart>=0.0.9", ] diff --git a/backend/packages/app/src/windup_app/bootstrap/app.py b/backend/packages/app/src/windup_app/bootstrap/app.py index 4c7f484f..58a31182 100644 --- a/backend/packages/app/src/windup_app/bootstrap/app.py +++ b/backend/packages/app/src/windup_app/bootstrap/app.py @@ -15,11 +15,14 @@ from fastapi.middleware.cors import CORSMiddleware from windup_app.server.orchestrator.executor import run_action_task, run_image_task +from windup_app.web.api.auth import router as auth_router from windup_app.web.api.character import router as character_router from windup_app.web.api.generation import router as generation_router from windup_app.web.api.media import router as media_router from windup_app.web.api.project import router as project_router from windup_app.web.handler.exception_handlers import register_exception_handlers +from windup_app.web.middleware.auth import AuthMiddleware +from windup_app.web.middleware.ratelimit import RateLimitMiddleware def _env_flag(name: str) -> bool: @@ -56,6 +59,13 @@ async def _lifespan(app: FastAPI): def create_app() -> FastAPI: app = FastAPI(title="windup", version="0.1.0", lifespan=_lifespan) + + # 中间件(执行顺序:请求先进 RateLimit,再进 Auth,最后到路由) + app.add_middleware(RateLimitMiddleware) + app.add_middleware(AuthMiddleware) + + # 路由 + app.include_router(auth_router) app.add_middleware( CORSMiddleware, allow_origins=_cors_origins(), @@ -68,19 +78,17 @@ def create_app() -> FastAPI: app.include_router(character_router) app.include_router(media_router) app.include_router(generation_router) - # 生成后台调度器注入 app.state:bootstrap(composition root)持有 ai_engine 依赖, - # web 端运行期从 request.app.state 取,避免 web 静态 import ai_engine(入口层门禁)。 + + # 生成后台调度器注入 app.state app.state.run_action_task = run_action_task app.state.run_image_task = run_image_task + register_exception_handlers(app) return app def main() -> None: - """开发启动入口:用 uvicorn 跑 ``create_app``。 - - host/port/reload 可用 ``WINDUP_HOST`` / ``WINDUP_PORT`` / ``WINDUP_RELOAD`` 覆盖。 - """ + """开发启动入口:用 uvicorn 跑 ``create_app``。""" import uvicorn uvicorn.run( @@ -92,6 +100,5 @@ def main() -> None: ) - if __name__ == "__main__": - main() + main() diff --git a/backend/packages/app/src/windup_app/server/user/interface.py b/backend/packages/app/src/windup_app/server/user/interface.py index 8e7fe2ec..84c22755 100644 --- a/backend/packages/app/src/windup_app/server/user/interface.py +++ b/backend/packages/app/src/windup_app/server/user/interface.py @@ -1,6 +1,6 @@ """用户领域服务抽象接口。 -API 层只依赖本模块定义的抽象,不感知具体实现(ORM / Redis / OAuth SDK)。 +API 层只依赖本模块定义的抽象,不感知具体实现(ORM / Redis / Resend)。 """ from abc import ABC, abstractmethod @@ -11,7 +11,7 @@ LoginByPasswordInput, LoginResult, RegisterInput, - User, + UserView, ) @@ -37,9 +37,10 @@ def login_by_password(self, input: LoginByPasswordInput) -> LoginResult: """ @abstractmethod - def send_verification_code(self, email: str) -> None: + def send_verification_code(self, email: str, purpose: str) -> None: """发送邮箱验证码。 + :param purpose: 用途,如 "login" / "register" / "reset_password"。 :raises windup_common.exceptions.BizException: 发送频率超限。 """ @@ -53,23 +54,20 @@ def login_by_code(self, input: LoginByCodeInput) -> LoginResult: # -- 登出 ------------------------------------------------------------ @abstractmethod - def logout(self, session_token: str) -> None: - """销毁会话。""" + def logout(self, refresh_token: str) -> None: + """销毁 refresh_token。""" - # -- OAuth ----------------------------------------------------------- - # 第三方认证暂不设计、不实现。保留该区域作为后续扩展占位。 - # 相关 authorize / callback / bind 接口和 UserOAuth 模型暂时停用。 # -- 会话管理 --------------------------------------------------------- @abstractmethod - def validate_session(self, session_token: str) -> User | None: - """校验会话并返回用户,过期 / 无效返回 ``None``。""" + def validate_access_token(self, token: str) -> UserView | None: + """校验 access_token 并返回用户,过期 / 无效返回 ``None``。""" @abstractmethod - def refresh_session(self, session_token: str) -> str: - """刷新会话,返回新 token;旧 token 立即失效。 + def refresh_tokens(self, refresh_token: str) -> LoginResult: + """刷新 token,返回新的 access+refresh。 - :raises windup_common.exceptions.BizException: 会话无效。 + :raises windup_common.exceptions.BizException: refresh token 无效 / 已撤销。 """ # -- 密码 ------------------------------------------------------------ @@ -84,11 +82,9 @@ def change_password(self, user_id: int, input: ChangePasswordInput) -> None: # -- 查询 ------------------------------------------------------------ @abstractmethod - def get_by_id(self, user_id: int) -> User | None: + def get_by_id(self, user_id: int) -> UserView | None: """按 ID 查询用户。""" @abstractmethod - def get_by_email(self, email: str) -> User | None: + def get_by_email(self, email: str) -> UserView | None: """按邮箱查询用户。""" - - # 第三方认证暂不设计、不实现,因此没有 OAuth 绑定查询接口。 \ No newline at end of file diff --git a/backend/packages/app/src/windup_app/server/user/model.py b/backend/packages/app/src/windup_app/server/user/model.py index 9e48b9a4..a8821a09 100644 --- a/backend/packages/app/src/windup_app/server/user/model.py +++ b/backend/packages/app/src/windup_app/server/user/model.py @@ -1,16 +1,73 @@ """用户领域模型。 -与 ``windup_user`` / ``windup_user_oauth`` 表一一对应, -字段名与数据库列名保持一致,方便后续 ORM 映射。 +与 ``windup_user`` 表一一对应,字段名与数据库列名保持一致。 + +ORM 模型 +-------- + +:: + + windup_user + ├── id BigInteger PK: 自增主键 + ├── email String(255) UNIQUE: 邮箱 + ├── password_hash String(255): bcrypt 哈希 + ├── nickname String(50) NULL: 昵称 + ├── email_verified_at DateTime(tz) NULL: 邮箱验证时间 + ├── status SmallInteger: 0=正常, 1=封禁 + ├── last_login_at DateTime(tz) NULL: 最后登录 + ├── create_at DateTime(tz): 创建时间 + └── update_at DateTime(tz): 更新时间 """ from dataclasses import dataclass, field from datetime import datetime, timezone from enum import IntEnum +from sqlalchemy import BigInteger, DateTime, Integer, SmallInteger, String +from sqlalchemy.orm import Mapped, mapped_column + +from windup_framework.db import Base + + +# -- ORM ---------------------------------------------------------------- + + +class User(Base): + """用户表。""" + + __tablename__ = "windup_user" + + id: Mapped[int] = mapped_column( + BigInteger().with_variant(Integer, "sqlite"), + primary_key=True, + autoincrement=True, + ) + email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False) + password_hash: Mapped[str] = mapped_column(String(255), nullable=False, default="") + nickname: Mapped[str | None] = mapped_column(String(50), nullable=True) + email_verified_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + status: Mapped[int] = mapped_column(SmallInteger, nullable=False, default=0) + last_login_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) + create_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + default=lambda: datetime.now(timezone.utc), + ) + update_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + ) + # -- 枚举 ---------------------------------------------------------------- + class UserStatus(IntEnum): """用户状态。 @@ -22,22 +79,15 @@ class UserStatus(IntEnum): BANNED = 1 -class OAuthProvider(str): - """第三方登录平台(值约束)。""" - - GITHUB = "github" - GOOGLE = "google" - +# -- 领域模型 ------------------------------------------------------------ -# -- 数据模型 ------------------------------------------------------------ @dataclass -class User: - """用户(对应 ``windup_user`` 表)。""" +class UserView: + """用户视图(脱敏,不含 password_hash)。""" id: int | None = None email: str | None = None - password_hash: str = "" nickname: str | None = None email_verified_at: datetime | None = None status: UserStatus = UserStatus.NORMAL @@ -54,27 +104,16 @@ def is_email_verified(self) -> bool: return self.email_verified_at is not None -@dataclass -class UserOAuth: - """第三方登录绑定(对应 ``windup_user_oauth`` 表)。""" - - id: int | None = None - user_id: int = 0 - provider: str = "" # "github" / "google" - provider_user_id: str = "" - provider_email: str | None = None - create_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) - update_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) - - # -- 输入/输出模型 -------------------------------------------------------- + @dataclass class RegisterInput: """邮箱注册入参。""" email: str password: str + code: str nickname: str | None = None @@ -84,6 +123,7 @@ class LoginByPasswordInput: email: str password: str + code: str @dataclass @@ -94,15 +134,6 @@ class LoginByCodeInput: code: str -@dataclass -class OAuthCallbackInput: - """OAuth 回调入参。""" - - provider: str # "github" / "google" - code: str - state: str # CSRF 防护 - - @dataclass class ChangePasswordInput: """修改密码入参。""" @@ -113,11 +144,8 @@ class ChangePasswordInput: @dataclass class LoginResult: - """登录结果。 - - ``session_token`` 由调用方通过 Set-Cookie 写入客户端; - ``user`` 返回脱敏后的用户信息(不含 password_hash)。 - """ + """登录结果。""" - user: User - session_token: str \ No newline at end of file + user: UserView + access_token: str + refresh_token: str diff --git a/backend/packages/app/src/windup_app/server/user/service.py b/backend/packages/app/src/windup_app/server/user/service.py new file mode 100644 index 00000000..d1543dab --- /dev/null +++ b/backend/packages/app/src/windup_app/server/user/service.py @@ -0,0 +1,438 @@ +"""用户领域服务的 SQLAlchemy + Redis 实现。 + +:class:`SqlAlchemyUserService` 继承 :class:`UserService` 接口,用同步 +SQLAlchemy session 落库,Redis 存储验证码与 refresh_token。 + +事务边界由 ``windup_framework.db.get_session`` 依赖负责——成功 commit、异常 +rollback,故本实现只 ``flush``(把变更发到当前事务、取回生成的主键),不 commit。 +""" + +import hashlib +import logging +import random +import string +import uuid +from datetime import datetime, timezone + +import bcrypt +import jwt +import redis as redis_lib +from sqlalchemy import select +from sqlalchemy.orm import Session + +from windup_common.enums.biz_code import BizCode +from windup_common.exceptions import BizException + +from windup_app.server.user.interface import UserService +from windup_app.server.user.model import ( + ChangePasswordInput, + LoginByCodeInput, + LoginByPasswordInput, + LoginResult, + RegisterInput, + User, + UserStatus, + UserView, +) +from windup_framework.config.jwt import settings as jwt_settings +from windup_framework.providers.email import email_provider +from windup_framework.db.redis import get_redis + +logger = logging.getLogger("windup.user.service") + +# -- JWT 配置 ------------------------------------------------------------- + +JWT_SECRET = jwt_settings.secret +JWT_ALGORITHM = "HS256" +ACCESS_TOKEN_EXPIRE_SECONDS = 15 * 60 # 15 分钟 +REFRESH_TOKEN_EXPIRE_SECONDS = 7 * 24 * 3600 # 7 天 + +# -- 密码哈希 ------------------------------------------------------------- + + +def _hash_password(password: str) -> str: + """bcrypt 哈希密码。""" + return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode() + + +def _verify_password(password: str, hashed: str) -> bool: + """验证密码。""" + return bcrypt.checkpw(password.encode(), hashed.encode()) + +# -- Redis key 前缀 ------------------------------------------------------- + +VERIFY_COOLDOWN_KEY = "verify:cooldown:{email}" +VERIFY_CODE_KEY = "verify:{purpose}:{email}" +REFRESH_TOKEN_KEY = "refresh:{token_hash}" +RATELIMIT_SENSITIVE_KEY = "ratelimit:sensitive:{ip}" + +VERIFY_CODE_TTL = 300 # 5 分钟 +COOLDOWN_TTL = 60 # 60 秒 + + +def _hash_token(token: str) -> str: + """SHA256 哈希 token,用作 Redis key。""" + return hashlib.sha256(token.encode()).hexdigest() + + +def _generate_code() -> str: + """生成 6 位数字验证码。""" + return "".join(random.choices(string.digits, k=6)) + + +# -- User → UserView 转换 ------------------------------------------------ + + +def _to_view(user: User) -> UserView: + """ORM User → 脱敏 UserView。""" + return UserView( + id=user.id, + email=user.email, + nickname=user.nickname, + email_verified_at=user.email_verified_at, + status=UserStatus(user.status), + last_login_at=user.last_login_at, + create_at=user.create_at, + update_at=user.update_at, + ) + + +# -- JWT 工具函数 --------------------------------------------------------- + + +def create_access_token(user_id: int, email: str) -> str: + """签发 access_token。""" + now = datetime.now(timezone.utc) + payload = { + "sub": str(user_id), + "email": email, + "type": "access", + "iat": now, + "exp": now.timestamp() + ACCESS_TOKEN_EXPIRE_SECONDS, + } + return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM) + + +def create_refresh_token(user_id: int, email: str = "") -> tuple[str, str]: + """签发 refresh_token,返回 (token, jti)。""" + now = datetime.now(timezone.utc) + jti = str(uuid.uuid4()) + payload = { + "sub": str(user_id), + "email": email, + "type": "refresh", + "jti": jti, + "iat": now, + "exp": now.timestamp() + REFRESH_TOKEN_EXPIRE_SECONDS, + } + token = jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM) + return token, jti + + +def decode_token(token: str) -> dict: + """解码并验证 JWT,失败抛 BizException。""" + try: + return jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM]) + except jwt.ExpiredSignatureError: + raise BizException("token 已过期", code=BizCode.UNAUTHORIZED) from None + except jwt.InvalidTokenError: + raise BizException("token 无效", code=BizCode.UNAUTHORIZED) from None + + +# -- Service 实现 --------------------------------------------------------- + + +class SqlAlchemyUserService(UserService): + """基于 SQLAlchemy session + Redis 的用户服务实现。""" + + def __init__(self) -> None: + self._redis: redis_lib.Redis | None = None + + @property + def redis(self) -> redis_lib.Redis: + if self._redis is None: + self._redis = get_redis() + return self._redis + + # -- 注册 ------------------------------------------------------------ + + def register_by_email(self, input: RegisterInput) -> LoginResult: + # 检查邮箱是否已注册(通过全局 session,这里需要外部传入) + # 由于接口签名不含 session,改为类级持有或工厂注入 + # 但当前项目模式是 service 单例 + session 由调用方传入 + # 此处需要重构:register 不走 session 查询,直接用内部方法 + raise NotImplementedError("请通过 API 层调用带 session 的版本") + + def register_by_email_with_session( + self, session: Session, input: RegisterInput + ) -> LoginResult: + """邮箱+验证码+密码注册(带 session)。""" + # 校验验证码 + self._verify_code(input.email, input.code, "register") + + # 检查邮箱唯一 + existing = session.scalar( + select(User.id).where(User.email == input.email).limit(1) + ) + if existing is not None: + raise BizException("邮箱已注册", code=BizCode.BAD_REQUEST) + + user = User( + email=input.email, + password_hash=_hash_password(input.password), + nickname=input.nickname, + email_verified_at=datetime.now(timezone.utc), # 注册即验证(已通过验证码校验) + ) + session.add(user) + session.flush() + + # 注册即登录,签发 token + access_token = create_access_token(user.id, user.email) + refresh_token, jti = create_refresh_token(user.id, user.email) + self._store_refresh_token(jti, user.id) + + logger.info("[WINDUP] 用户注册成功 | user_id=%s email=%s", user.id, user.email) + return LoginResult( + user=_to_view(user), + access_token=access_token, + refresh_token=refresh_token, + ) + + # -- 登录 ------------------------------------------------------------ + + def login_by_password(self, input: LoginByPasswordInput) -> LoginResult: + raise NotImplementedError("请通过 API 层调用带 session 的版本") + + def login_by_password_with_session( + self, session: Session, input: LoginByPasswordInput + ) -> LoginResult: + """邮箱+密码+验证码登录(带 session)。""" + # 校验验证码 + self._verify_code(input.email, input.code, "login") + + user = session.scalar(select(User).where(User.email == input.email)) + if user is None: + raise BizException("邮箱或密码错误", code=BizCode.BAD_REQUEST) + + if not _verify_password(input.password, user.password_hash): + raise BizException("邮箱或密码错误", code=BizCode.BAD_REQUEST) + + if user.status == UserStatus.BANNED: + raise BizException("账号已被封禁", code=BizCode.BAD_REQUEST) + + # 更新最后登录时间 + user.last_login_at = datetime.now(timezone.utc) + session.flush() + + access_token = create_access_token(user.id, user.email) + refresh_token, jti = create_refresh_token(user.id, user.email) + self._store_refresh_token(jti, user.id) + + logger.info("[WINDUP] 用户登录成功 | user_id=%s email=%s", user.id, user.email) + return LoginResult( + user=_to_view(user), + access_token=access_token, + refresh_token=refresh_token, + ) + + # -- 验证码 ---------------------------------------------------------- + + def send_verification_code(self, email: str, purpose: str) -> None: + """发送邮箱验证码。""" + # 频率限制 + cooldown_key = VERIFY_COOLDOWN_KEY.format(email=email) + if self.redis.get(cooldown_key): + raise BizException("发送过于频繁,请稍后再试", code=BizCode.TOO_MANY_REQUESTS) + + code = _generate_code() + code_key = VERIFY_CODE_KEY.format(purpose=purpose, email=email) + + # 存储验证码 + 设置冷却 + pipe = self.redis.pipeline() + pipe.setex(code_key, VERIFY_CODE_TTL, code) + pipe.setex(cooldown_key, COOLDOWN_TTL, "1") + pipe.execute() + + # 发送邮件 + email_provider.send_verification_code(email, code) + logger.info("[WINDUP] 验证码已发送 | email=%s purpose=%s", email, purpose) + + def _verify_code(self, email: str, code: str, purpose: str) -> None: + """校验验证码,失败抛 BizException。""" + code_key = VERIFY_CODE_KEY.format(purpose=purpose, email=email) + stored_code = self.redis.get(code_key) + if stored_code is None: + raise BizException("验证码已过期", code=BizCode.BAD_REQUEST) + if stored_code != code: + raise BizException("验证码错误", code=BizCode.BAD_REQUEST) + # 验证通过,删除验证码 + self.redis.delete(code_key) + + def login_by_code(self, input: LoginByCodeInput) -> LoginResult: + raise NotImplementedError("请通过 API 层调用带 session 的版本") + + def login_by_code_with_session( + self, session: Session, input: LoginByCodeInput + ) -> LoginResult: + """邮箱+验证码登录,无账号自动注册(带 session)。""" + # 校验验证码 + self._verify_code(input.email, input.code, "login") + + # 查找或创建用户 + user = session.scalar(select(User).where(User.email == input.email)) + if user is None: + user = User(email=input.email, email_verified_at=datetime.now(timezone.utc)) + session.add(user) + session.flush() + logger.info("[WINDUP] 验证码自动注册 | user_id=%s email=%s", user.id, user.email) + else: + if user.status == UserStatus.BANNED: + raise BizException("账号已被封禁", code=BizCode.BAD_REQUEST) + # 标记邮箱已验证 + if user.email_verified_at is None: + user.email_verified_at = datetime.now(timezone.utc) + + user.last_login_at = datetime.now(timezone.utc) + session.flush() + + access_token = create_access_token(user.id, user.email) + refresh_token, jti = create_refresh_token(user.id, user.email) + self._store_refresh_token(jti, user.id) + + return LoginResult( + user=_to_view(user), + access_token=access_token, + refresh_token=refresh_token, + ) + + # -- 登出 ------------------------------------------------------------ + + def logout(self, refresh_token: str) -> None: + """撤销 refresh_token。""" + payload = decode_token(refresh_token) + if payload.get("type") != "refresh": + raise BizException("token 类型错误", code=BizCode.UNAUTHORIZED) + + jti = payload.get("jti") + if jti: + token_hash = _hash_token(jti) + self.redis.delete(REFRESH_TOKEN_KEY.format(token_hash=token_hash)) + + logger.info("[WINDUP] 用户登出 | user_id=%s", payload.get("sub")) + + # -- Token 验证 ------------------------------------------------------ + + def validate_access_token(self, token: str) -> UserView | None: + """校验 access_token,返回 UserView 或 None。""" + try: + payload = decode_token(token) + except BizException: + return None + + if payload.get("type") != "access": + return None + + return UserView( + id=int(payload["sub"]), + email=payload.get("email", ""), + ) + + def refresh_tokens(self, refresh_token: str) -> LoginResult: + """刷新 token。""" + payload = decode_token(refresh_token) + if payload.get("type") != "refresh": + raise BizException("token 类型错误", code=BizCode.UNAUTHORIZED) + + jti = payload.get("jti") + if not jti: + raise BizException("token 无效", code=BizCode.UNAUTHORIZED) + + token_hash = _hash_token(jti) + redis_key = REFRESH_TOKEN_KEY.format(token_hash=token_hash) + user_id_str = self.redis.get(redis_key) + + if user_id_str is None: + raise BizException("refresh token 已失效", code=BizCode.UNAUTHORIZED) + + user_id = int(user_id_str) + + # 撤销旧 token + self.redis.delete(redis_key) + + # 签发新 token(需要 email,从旧 token payload 取) + email = payload.get("email", "") + new_access = create_access_token(user_id, email) + new_refresh, new_jti = create_refresh_token(user_id, email) + self._store_refresh_token(new_jti, user_id) + + logger.info("[WINDUP] token 已刷新 | user_id=%s", user_id) + return LoginResult( + user=UserView(id=user_id, email=email), + access_token=new_access, + refresh_token=new_refresh, + ) + + # -- 密码 ------------------------------------------------------------ + + def change_password(self, user_id: int, input: ChangePasswordInput) -> None: + raise NotImplementedError("请通过 API 层调用带 session 的版本") + + def change_password_with_session( + self, session: Session, user_id: int, input: ChangePasswordInput + ) -> None: + """修改密码(带 session)。""" + user = session.get(User, user_id) + if user is None: + raise BizException("用户不存在", code=BizCode.NOT_FOUND) + + if not _verify_password(input.old_password, user.password_hash): + raise BizException("旧密码错误", code=BizCode.BAD_REQUEST) + + user.password_hash = _hash_password(input.new_password) + session.flush() + + # 修改密码后撤销该用户所有 refresh_token + self._revoke_all_user_tokens(user_id) + logger.info("[WINDUP] 密码已修改 | user_id=%s", user_id) + + # -- 查询 ------------------------------------------------------------ + + def get_by_id(self, user_id: int) -> UserView | None: + # 需要 session,由 API 层直接查 ORM + raise NotImplementedError("请通过 API 层直接查询 ORM") + + def get_by_email(self, email: str) -> UserView | None: + raise NotImplementedError("请通过 API 层直接查询 ORM") + + def get_by_id_with_session(self, session: Session, user_id: int) -> UserView | None: + user = session.get(User, user_id) + return _to_view(user) if user else None + + def get_by_email_with_session(self, session: Session, email: str) -> UserView | None: + user = session.scalar(select(User).where(User.email == email)) + return _to_view(user) if user else None + + # -- 内部方法 -------------------------------------------------------- + + def _store_refresh_token(self, jti: str, user_id: int) -> None: + """将 refresh_token 存入 Redis。""" + token_hash = _hash_token(jti) + self.redis.setex( + REFRESH_TOKEN_KEY.format(token_hash=token_hash), + REFRESH_TOKEN_EXPIRE_SECONDS, + str(user_id), + ) + + def _revoke_all_user_tokens(self, user_id: int) -> None: + """撤销指定用户的所有 refresh_token(改密时调用)。 + + 注意:Redis SCAN 在 key 数量大时有性能开销,当前阶段用户量小可接受。 + 后续可维护 user_id → token_hash 的反向索引优化。 + """ + pattern = "refresh:*" + for key in self.redis.scan_iter(match=pattern, count=100): + if self.redis.get(key) == str(user_id): + self.redis.delete(key) + + +service = SqlAlchemyUserService() diff --git a/backend/packages/app/src/windup_app/web/api/auth.py b/backend/packages/app/src/windup_app/web/api/auth.py new file mode 100644 index 00000000..610897b6 --- /dev/null +++ b/backend/packages/app/src/windup_app/web/api/auth.py @@ -0,0 +1,206 @@ +"""认证 API。 + +提供注册、登录、发码、刷新、登出、当前用户、修改密码等端点。 +""" + +import logging + +from fastapi import APIRouter, Depends, Request +from pydantic import BaseModel, ConfigDict, Field, EmailStr +from sqlalchemy.orm import Session + +from windup_common.result import Response + +from windup_framework.db import get_session + +from windup_app.server.user.model import User, UserView +from windup_app.server.user.service import service + +logger = logging.getLogger("windup.auth.api") + +router = APIRouter(prefix="/auth", tags=["auth"]) + + +# -- 请求模型 ------------------------------------------------------------ + + +class RegisterRequest(BaseModel): + """注册请求。""" + + email: EmailStr + password: str = Field(min_length=8, max_length=128) + code: str = Field(min_length=6, max_length=6, description="邮箱验证码") + nickname: str | None = Field(default=None, max_length=50) + + +class LoginRequest(BaseModel): + """密码登录请求。""" + + email: EmailStr + password: str + code: str = Field(min_length=6, max_length=6, description="邮箱验证码") + + +class SendCodeRequest(BaseModel): + """发送验证码请求。""" + + email: EmailStr + purpose: str = Field(default="login", pattern="^(login|register|reset_password)$") + + +class LoginByCodeRequest(BaseModel): + """验证码登录请求。""" + + email: EmailStr + code: str = Field(min_length=6, max_length=6) + + +class RefreshRequest(BaseModel): + """刷新 token 请求。""" + + refresh_token: str + + +class ChangePasswordRequest(BaseModel): + """修改密码请求。""" + + old_password: str + new_password: str = Field(min_length=8, max_length=128) + + +# -- 响应模型 ------------------------------------------------------------ + + +class TokenResponse(BaseModel): + """登录/注册/刷新成功响应。""" + + model_config = ConfigDict(from_attributes=True) + + access_token: str + refresh_token: str + user: UserView + + +class UserOut(BaseModel): + """用户信息响应(脱敏)。""" + + model_config = ConfigDict(from_attributes=True) + + id: int + email: str + nickname: str | None = None + email_verified_at: str | None = None + status: int = 0 + + +# -- 路由 ---------------------------------------------------------------- + + +@router.post("/register", response_model=Response[TokenResponse]) +def register(body: RegisterRequest, session: Session = Depends(get_session)): + """邮箱+验证码+密码注册,注册即登录。""" + result = service.register_by_email_with_session( + session, + type("RegisterInput", (), {"email": body.email, "password": body.password, "code": body.code, "nickname": body.nickname})(), + ) + return Response.success( + TokenResponse( + access_token=result.access_token, + refresh_token=result.refresh_token, + user=result.user, + ), + message="注册成功", + ) + + +@router.post("/login", response_model=Response[TokenResponse]) +def login(body: LoginRequest, session: Session = Depends(get_session)): + """邮箱+密码+验证码登录。""" + result = service.login_by_password_with_session( + session, + type("LoginByPasswordInput", (), {"email": body.email, "password": body.password, "code": body.code})(), + ) + return Response.success( + TokenResponse( + access_token=result.access_token, + refresh_token=result.refresh_token, + user=result.user, + ), + message="登录成功", + ) + + +@router.post("/send-code", response_model=Response[None]) +def send_code(body: SendCodeRequest): + """发送邮箱验证码。""" + service.send_verification_code(body.email, body.purpose) + return Response.success(None, message="验证码已发送") + + +@router.post("/login-by-code", response_model=Response[TokenResponse]) +def login_by_code(body: LoginByCodeRequest, session: Session = Depends(get_session)): + """验证码登录,无账号自动注册。""" + result = service.login_by_code_with_session( + session, + type("LoginByCodeInput", (), {"email": body.email, "code": body.code})(), + ) + return Response.success( + TokenResponse( + access_token=result.access_token, + refresh_token=result.refresh_token, + user=result.user, + ), + message="登录成功", + ) + + +@router.post("/refresh", response_model=Response[TokenResponse]) +def refresh(body: RefreshRequest): + """刷新 token。""" + result = service.refresh_tokens(body.refresh_token) + return Response.success( + TokenResponse( + access_token=result.access_token, + refresh_token=result.refresh_token, + user=result.user, + ), + ) + + +@router.post("/logout", response_model=Response[None]) +def logout(body: RefreshRequest): + """登出,撤销 refresh_token。""" + service.logout(body.refresh_token) + return Response.success(None, message="已登出") + + +@router.get("/me", response_model=Response[UserOut]) +def get_me(request: Request, session: Session = Depends(get_session)): + """获取当前用户信息。""" + current_user = request.state.current_user + user = session.get(User, current_user.id) + if user is None: + from windup_common.enums.biz_code import BizCode + from windup_common.exceptions import BizException + raise BizException("用户不存在", code=BizCode.NOT_FOUND) + return Response.success( + UserOut( + id=user.id, + email=user.email, + nickname=user.nickname, + email_verified_at=user.email_verified_at.isoformat() if user.email_verified_at else None, + status=user.status, + ) + ) + + +@router.post("/change-password", response_model=Response[None]) +def change_password(body: ChangePasswordRequest, request: Request, session: Session = Depends(get_session)): + """修改密码。""" + current_user = request.state.current_user + service.change_password_with_session( + session, + current_user.id, + type("ChangePasswordInput", (), {"old_password": body.old_password, "new_password": body.new_password})(), + ) + return Response.success(None, message="密码修改成功") diff --git a/backend/packages/app/src/windup_app/web/middleware/__init__.py b/backend/packages/app/src/windup_app/web/middleware/__init__.py new file mode 100644 index 00000000..49724533 --- /dev/null +++ b/backend/packages/app/src/windup_app/web/middleware/__init__.py @@ -0,0 +1 @@ +"""Web 中间件。""" diff --git a/backend/packages/app/src/windup_app/web/middleware/auth.py b/backend/packages/app/src/windup_app/web/middleware/auth.py new file mode 100644 index 00000000..4321bfa9 --- /dev/null +++ b/backend/packages/app/src/windup_app/web/middleware/auth.py @@ -0,0 +1,79 @@ +"""JWT 鉴权中间件。 + +统一拦截请求,白名单路径放行,其余路径验证 JWT access_token。 +验证通过后将用户信息注入 ``request.state.current_user``。 +""" + +from fastapi.responses import JSONResponse +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.responses import Response + +from windup_common.enums.biz_code import BizCode +from windup_common.result import Response as Resp + +from windup_app.server.user.service import decode_token + + +def _biz_error(msg: str, code: int) -> JSONResponse: + """BizException → JSONResponse,用于 middleware 层(绕过 ExceptionMiddleware)。""" + return JSONResponse( + status_code=200, + content=Resp.fail(msg, code=code).model_dump(mode="json"), + ) + +# -- 白名单路径(不需要鉴权)--------------------------------------------- + +AUTH_WHITELIST: set[str] = { + "/auth/register", + "/auth/login", + "/auth/send-code", + "/auth/login-by-code", + "/auth/refresh", + "/auth/logout", + "/docs", + "/openapi.json", + "/health", +} + +# 前缀白名单(如 /docs 子路径、Swagger 静态资源) +AUTH_WHITELIST_PREFIXES: tuple[str, ...] = ( + "/docs", + "/redoc", + "/openapi", +) + + +def _is_whitelisted(path: str) -> bool: + """判断路径是否在白名单中。""" + if path in AUTH_WHITELIST: + return True + return any(path.startswith(prefix) for prefix in AUTH_WHITELIST_PREFIXES) + + +class AuthMiddleware(BaseHTTPMiddleware): + """JWT 鉴权中间件。""" + + async def dispatch(self, request: Request, call_next) -> Response: + # 白名单放行 + if _is_whitelisted(request.url.path): + return await call_next(request) + + # 提取 Authorization header + auth_header = request.headers.get("authorization", "") + if not auth_header.startswith("Bearer "): + return _biz_error("未登录", BizCode.UNAUTHORIZED) + + token = auth_header[7:] # 去掉 "Bearer " 前缀 + + # 解码 + 验证 + payload = decode_token(token) + if payload.get("type") != "access": + return _biz_error("token 类型错误", BizCode.UNAUTHORIZED) + + # 注入当前用户到 request.state + request.state.current_user = type( + "CurrentUser", (), {"id": int(payload["sub"]), "email": payload.get("email", "")} + )() + + return await call_next(request) diff --git a/backend/packages/app/src/windup_app/web/middleware/ratelimit.py b/backend/packages/app/src/windup_app/web/middleware/ratelimit.py new file mode 100644 index 00000000..014b5bf2 --- /dev/null +++ b/backend/packages/app/src/windup_app/web/middleware/ratelimit.py @@ -0,0 +1,125 @@ +"""接口限流中间件。 + +基于 Redis 的滑动窗口计数器,在鉴权中间件之前执行。 +Redis 不可用时优雅降级(跳过限流)。 +""" + +import logging + +from fastapi.responses import JSONResponse +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.responses import Response + +from windup_common.enums.biz_code import BizCode +from windup_common.result import Response as Resp + +logger = logging.getLogger("windup.ratelimit") + +# -- 限流配置 ------------------------------------------------------------ + +# 全局 API 限流:单 IP 60 次/分钟 +GLOBAL_RATE = 60 +GLOBAL_WINDOW = 60 + +# 敏感接口限流:单 IP 10 次/分钟 +SENSITIVE_RATE = 10 +SENSITIVE_WINDOW = 60 + +# 用户级限流:120 次/分钟 +USER_RATE = 120 +USER_WINDOW = 60 + +# 敏感接口路径 +SENSITIVE_PATHS: set[str] = { + "/auth/register", + "/auth/login", + "/auth/send-code", + "/auth/login-by-code", +} + +# -- Redis key 模板 ------------------------------------------------------ + +RATELIMIT_API_KEY = "ratelimit:api:{ip}" +RATELIMIT_SENSITIVE_KEY = "ratelimit:sensitive:{ip}" +RATELIMIT_USER_KEY = "ratelimit:api:{user_id}" + + +def _get_client_ip(request: Request) -> str: + """获取客户端 IP(优先 X-Forwarded-For)。""" + forwarded = request.headers.get("x-forwarded-for") + if forwarded: + return forwarded.split(",")[0].strip() + return request.client.host if request.client else "unknown" + + +def _check_rate(redis_client, key: str, limit: int, window: int) -> bool: + """检查是否超出限流,返回 True 表示允许通过。""" + try: + current = redis_client.incr(key) + if current == 1: + redis_client.expire(key, window) + return current <= limit + except Exception: + # Redis 不可用时跳过限流 + logger.warning("[WINDUP] Redis 不可用,跳过限流检查 | key=%s", key) + return True + + +class RateLimitMiddleware(BaseHTTPMiddleware): + """接口限流中间件。""" + + def __init__(self, app) -> None: + super().__init__(app) + self._redis = None + self._redis_available = True + + @property + def redis(self): + if self._redis is None: + try: + from windup_framework.db.redis import get_redis + self._redis = get_redis() + # 测试连接 + self._redis.ping() + except Exception: + self._redis_available = False + logger.warning("[WINDUP] Redis 连接失败,限流中间件将跳过限流检查") + return None + return self._redis + + async def dispatch(self, request: Request, call_next) -> Response: + # Redis 不可用时直接放行 + if not self._redis_available or self.redis is None: + return await call_next(request) + + client_ip = _get_client_ip(request) + + # 全局限流 + if not _check_rate(self.redis, RATELIMIT_API_KEY.format(ip=client_ip), GLOBAL_RATE, GLOBAL_WINDOW): + logger.warning("[WINDUP] 全局限流触发 | ip=%s path=%s", client_ip, request.url.path) + return JSONResponse( + status_code=200, + content=Resp.fail("请求过于频繁", code=BizCode.TOO_MANY_REQUESTS).model_dump(mode="json"), + ) + + # 敏感接口额外限流 + if request.url.path in SENSITIVE_PATHS: + if not _check_rate(self.redis, RATELIMIT_SENSITIVE_KEY.format(ip=client_ip), SENSITIVE_RATE, SENSITIVE_WINDOW): + logger.warning("[WINDUP] 敏感接口限流触发 | ip=%s path=%s", client_ip, request.url.path) + return JSONResponse( + status_code=200, + content=Resp.fail("请求过于频繁,请稍后再试", code=BizCode.TOO_MANY_REQUESTS).model_dump(mode="json"), + ) + + # 用户级限流(已登录用户) + user_id = getattr(getattr(request.state, "current_user", None), "id", None) + if user_id is not None: + if not _check_rate(self.redis, RATELIMIT_USER_KEY.format(user_id=user_id), USER_RATE, USER_WINDOW): + logger.warning("[WINDUP] 用户限流触发 | user_id=%s", user_id) + return JSONResponse( + status_code=200, + content=Resp.fail("请求过于频繁", code=BizCode.TOO_MANY_REQUESTS).model_dump(mode="json"), + ) + + return await call_next(request) diff --git a/backend/packages/common/src/windup_common/enums/biz_code.py b/backend/packages/common/src/windup_common/enums/biz_code.py index f30af712..382a8596 100644 --- a/backend/packages/common/src/windup_common/enums/biz_code.py +++ b/backend/packages/common/src/windup_common/enums/biz_code.py @@ -20,6 +20,8 @@ class BizCode(int, Enum): SUCCESS = 200 # 成功 BAD_REQUEST = 400 # 请求参数校验失败 + UNAUTHORIZED = 401 # 未登录 / token 无效 NOT_FOUND = 404 # 资源不存在 + TOO_MANY_REQUESTS = 429 # 请求过于频繁 INTERNAL_ERROR = 500 # 服务器内部错误 / 兜底 MODEL_UNAVAILABLE = 503 # 模型服务不可用 diff --git a/backend/packages/framework/pyproject.toml b/backend/packages/framework/pyproject.toml index 17726b58..7084c760 100644 --- a/backend/packages/framework/pyproject.toml +++ b/backend/packages/framework/pyproject.toml @@ -11,9 +11,23 @@ dependencies = [ "psycopg[binary]>=3.2", "httpx>=0.27", "pyjwt>=2.9", - # 以下两项按选型启用: + # AI 模型适配器(providers/):chat 走 langchain,video/image 走 httpx。 + "langchain-core>=0.3", + "langchain-openai>=0.3", + # 抠图 MatteProvider:onnxruntime 直跑 u2netp(替代 rembg,其 numba 老链在 3.12 无轮子)。 + # 上限 <1.24:onnxruntime 自 1.24 起砍了 macOS Intel(x86_64)轮子;1.23.x 仍覆盖 + # Intel/arm64/Linux + py3.12,保证 Intel Mac 也能装。API 与新版一致,不改抠图代码。 + "numpy>=1.26", + "onnxruntime>=1.17,<1.24", + "pillow>=10.4", + # 对象存储(七牛 Kodo);若换 OSS/S3/MinIO 改 oss2 / boto3 / minio。 + "qiniu>=7.14", + # 用户模块:密码哈希 / Redis / 邮件 + "passlib[bcrypt]>=1.7", + "redis>=5.0", + "resend>=2.0", + # 以下按选型启用: # "rocketmq-client", # RocketMQ Python 客户端(5.x gRPC 版 / C++ 绑定版二选一) - # "minio", # 对象存储;若用 OSS/S3 换 oss2 / boto3 ] [tool.uv.sources] diff --git a/backend/packages/framework/src/windup_framework/config/email.py b/backend/packages/framework/src/windup_framework/config/email.py new file mode 100644 index 00000000..93be10bb --- /dev/null +++ b/backend/packages/framework/src/windup_framework/config/email.py @@ -0,0 +1,23 @@ +"""Resend 邮件服务配置。 + +从环境变量(或 ``.env``)读取,字段前缀 ``RESEND_``。 +""" + +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class EmailSettings(BaseSettings): + """Resend 邮件服务配置。""" + + model_config = SettingsConfigDict( + env_prefix="RESEND_", + env_file=("../.env", ".env"), + env_file_encoding="utf-8", + extra="ignore", + ) + + api_key: str = "" + from_email: str = "noreply@windup.dev" + + +settings = EmailSettings() diff --git a/backend/packages/framework/src/windup_framework/config/jwt.py b/backend/packages/framework/src/windup_framework/config/jwt.py new file mode 100644 index 00000000..037cd90f --- /dev/null +++ b/backend/packages/framework/src/windup_framework/config/jwt.py @@ -0,0 +1,22 @@ +"""JWT 配置。 + +从环境变量(或 ``.env``)读取,字段前缀 ``JWT_``。 +""" + +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class JWTSettings(BaseSettings): + """JWT 签名配置。""" + + model_config = SettingsConfigDict( + env_prefix="JWT_", + env_file=("../.env", ".env"), + env_file_encoding="utf-8", + extra="ignore", + ) + + secret: str = "change-me-in-production" + + +settings = JWTSettings() diff --git a/backend/packages/framework/src/windup_framework/config/redis.py b/backend/packages/framework/src/windup_framework/config/redis.py new file mode 100644 index 00000000..357994e2 --- /dev/null +++ b/backend/packages/framework/src/windup_framework/config/redis.py @@ -0,0 +1,23 @@ +"""Redis 连接配置。 + +从环境变量(或 ``.env``)读取,字段前缀 ``REDIS_``。 +本地开发默认值 ``redis://localhost:6379/0``。 +""" + +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class RedisSettings(BaseSettings): + """Redis 连接配置。""" + + model_config = SettingsConfigDict( + env_prefix="REDIS_", + env_file=("../.env", ".env"), + env_file_encoding="utf-8", + extra="ignore", + ) + + url: str = "redis://localhost:6379/0" + + +settings = RedisSettings() diff --git a/backend/packages/framework/src/windup_framework/db/redis.py b/backend/packages/framework/src/windup_framework/db/redis.py new file mode 100644 index 00000000..9a22aa0e --- /dev/null +++ b/backend/packages/framework/src/windup_framework/db/redis.py @@ -0,0 +1,15 @@ +"""Redis 客户端单例。 + +模块级 import 时创建连接池,调用方通过 ``get_redis`` 获取连接。 +""" + +import redis + +from windup_framework.config.redis import settings as redis_settings + +_pool = redis.ConnectionPool.from_url(redis_settings.url, decode_responses=True) + + +def get_redis() -> redis.Redis: + """获取 Redis 连接(从连接池)。""" + return redis.Redis(connection_pool=_pool) diff --git a/backend/packages/framework/src/windup_framework/providers/email.py b/backend/packages/framework/src/windup_framework/providers/email.py new file mode 100644 index 00000000..c3ad97f6 --- /dev/null +++ b/backend/packages/framework/src/windup_framework/providers/email.py @@ -0,0 +1,51 @@ +"""邮件发送服务。 + +:class:`ResendEmailProvider` 基于 Resend SDK 实现验证码邮件发送。 +""" + +import logging +from abc import ABC, abstractmethod + +import resend + +from windup_framework.config.email import settings as email_settings + +logger = logging.getLogger("windup.email") + + +class EmailProvider(ABC): + """邮件发送抽象接口。""" + + @abstractmethod + def send_verification_code(self, to: str, code: str) -> None: + """发送验证码邮件。""" + + +class ResendEmailProvider(EmailProvider): + """基于 Resend 的邮件发送实现。""" + + def __init__(self) -> None: + resend.api_key = email_settings.api_key + + def send_verification_code(self, to: str, code: str) -> None: + """发送 6 位数字验证码邮件。""" + try: + resend.Emails.send( + { + "from": email_settings.from_email, + "to": [to], + "subject": "【Windup】您的验证码", + "html": ( + f"

您的验证码是 {code}," + f"5 分钟内有效。

" + f"

如非本人操作,请忽略此邮件。

" + ), + } + ) + logger.info("[WINDUP] 验证码邮件已发送 | to=%s", to) + except Exception: + logger.exception("[WINDUP] 验证码邮件发送失败 | to=%s", to) + raise + + +email_provider = ResendEmailProvider() diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 00000000..aa420c0a --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,103 @@ +"""共享测试夹具。 + +用 SQLite 内存库(``StaticPool`` 单连接)做隔离,不依赖 Docker Postgres, +CI 友好。每个用例各自独立的 engine,互不污染。``Project`` 表按需创建在测试 +engine 上(不碰全局 Postgres engine)。 +""" + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import StaticPool + +from windup_app.bootstrap.app import create_app +from windup_app.server.character.model import Character +from windup_app.server.project.model import Project +from windup_app.server.user.model import User +from windup_app.server.user.service import create_access_token +from windup_framework.db import Base, get_session + + +def _make_engine(): + """单连接内存 SQLite;``check_same_thread=False`` 让 TestClient 线程可共用。""" + return create_engine( + "sqlite:///:memory:", + poolclass=StaticPool, + connect_args={"check_same_thread": False}, + ) + + +@pytest.fixture() +def engine(): + """建好 ``windup_project`` 和 ``windup_user`` 表的内存 engine。""" + engine = _make_engine() + Base.metadata.create_all(engine, tables=[Project.__table__, User.__table__, Character.__table__]) + yield engine + engine.dispose() + + +@pytest.fixture() +def db_session(engine): + """绑定到测试 engine 的 session,供 service 层单测直接传入。""" + session_local = sessionmaker(bind=engine, expire_on_commit=False) + session = session_local() + try: + yield session + finally: + session.close() + + +@pytest.fixture() +def client(engine): + """FastAPI TestClient;覆盖 ``get_session`` 指向测试 engine。 + + 不进入 lifespan 上下文(跳过 ``print_banner`` 噪音);启动逻辑无 DB 依赖。 + """ + session_local = sessionmaker(bind=engine, expire_on_commit=False) + + def override_get_session(): + session = session_local() + try: + yield session + session.commit() + except Exception: + session.rollback() + raise + finally: + session.close() + + app = create_app() + app.dependency_overrides[get_session] = override_get_session + yield TestClient(app) + app.dependency_overrides.clear() + + +@pytest.fixture() +def auth_client(engine): + """带认证 token 的 FastAPI TestClient。 + + 自动在请求头中添加 Authorization Bearer token,绕过鉴权中间件。 + """ + session_local = sessionmaker(bind=engine, expire_on_commit=False) + + def override_get_session(): + session = session_local() + try: + yield session + session.commit() + except Exception: + session.rollback() + raise + finally: + session.close() + + app = create_app() + app.dependency_overrides[get_session] = override_get_session + + # 生成测试用 token + token = create_access_token(1, "test@example.com") + client = TestClient(app, headers={"Authorization": f"Bearer {token}"}) + + yield client + app.dependency_overrides.clear() diff --git a/backend/tests/test_user_service.py b/backend/tests/test_user_service.py new file mode 100644 index 00000000..b35723de --- /dev/null +++ b/backend/tests/test_user_service.py @@ -0,0 +1,366 @@ +"""``SqlAlchemyUserService`` 单元测试。 + +用 SQLite 内存库 + mock Redis + mock 邮件服务做隔离,不依赖外部服务。 +""" + +import pytest +from unittest.mock import MagicMock, patch + +from windup_common.exceptions import BizException + +from windup_app.server.user.model import ( + ChangePasswordInput, + LoginByCodeInput, + LoginByPasswordInput, + RegisterInput, + User, + UserStatus, +) +from windup_app.server.user.service import ( + SqlAlchemyUserService, + _hash_password, + _verify_password, + create_access_token, + create_refresh_token, + decode_token, +) + + +# -- Fixtures ------------------------------------------------------------ + + +@pytest.fixture() +def mock_redis(): + """Mock Redis 客户端。""" + redis_mock = MagicMock() + redis_mock.get.return_value = None + redis_mock.setex.return_value = True + redis_mock.delete.return_value = True + redis_mock.pipeline.return_value = MagicMock( + execute=MagicMock(return_value=[True, True]) + ) + return redis_mock + + +@pytest.fixture() +def service(mock_redis): + """带 mock Redis 的 UserService 实例。""" + svc = SqlAlchemyUserService() + svc._redis = mock_redis + return svc + + +@pytest.fixture() +def mock_email(): + """Mock 邮件服务。""" + with patch("windup_app.server.user.service.email_provider") as mock: + yield mock + + +# -- 密码哈希测试 -------------------------------------------------------- + + +def test_hash_password(): + hashed = _hash_password("test123") + assert hashed != "test123" + assert _verify_password("test123", hashed) is True + + +def test_verify_password_wrong(): + hashed = _hash_password("test123") + assert _verify_password("wrong", hashed) is False + + +# -- JWT 测试 ------------------------------------------------------------ + + +def test_create_and_decode_access_token(): + token = create_access_token(1, "test@example.com") + payload = decode_token(token) + + assert payload["sub"] == "1" + assert payload["email"] == "test@example.com" + assert payload["type"] == "access" + + +def test_create_and_decode_refresh_token(): + token, jti = create_refresh_token(1, "test@example.com") + payload = decode_token(token) + + assert payload["sub"] == "1" + assert payload["email"] == "test@example.com" + assert payload["type"] == "refresh" + assert payload["jti"] == jti + + +def test_decode_expired_token(): + import jwt + from datetime import datetime, timezone + from windup_app.server.user.service import JWT_SECRET + + # 创建一个已过期的 token + payload = { + "sub": "1", + "type": "access", + "exp": datetime.now(timezone.utc).timestamp() - 100, + } + token = jwt.encode(payload, JWT_SECRET, algorithm="HS256") + + with pytest.raises(BizException, match="token 已过期"): + decode_token(token) + + +def test_decode_invalid_token(): + with pytest.raises(BizException, match="token 无效"): + decode_token("invalid-token") + + +# -- 注册测试 ------------------------------------------------------------ + + +def test_register_success(db_session, service, mock_email): + # Mock Redis 验证码 + service._redis.get.return_value = "123456" + + input_data = RegisterInput( + email="new@example.com", + password="password123", + code="123456", + ) + + result = service.register_by_email_with_session(db_session, input_data) + + assert result.user.email == "new@example.com" + assert result.access_token is not None + assert result.refresh_token is not None + assert result.user.email_verified_at is not None # 注册即验证 + + +def test_register_duplicate_email(db_session, service): + # 先注册一个用户 + service._redis.get.return_value = "123456" + input_data = RegisterInput(email="dup@example.com", password="pass123", code="123456") + service.register_by_email_with_session(db_session, input_data) + + # 尝试重复注册 + with pytest.raises(BizException, match="邮箱已注册"): + service.register_by_email_with_session(db_session, input_data) + + +def test_register_wrong_code(db_session, service): + service._redis.get.return_value = "123456" + + input_data = RegisterInput( + email="new@example.com", + password="password123", + code="999999", # 错误验证码 + ) + + with pytest.raises(BizException, match="验证码错误"): + service.register_by_email_with_session(db_session, input_data) + + +def test_register_expired_code(db_session, service): + service._redis.get.return_value = None # 验证码已过期 + + input_data = RegisterInput( + email="new@example.com", + password="password123", + code="123456", + ) + + with pytest.raises(BizException, match="验证码已过期"): + service.register_by_email_with_session(db_session, input_data) + + +# -- 登录测试 ------------------------------------------------------------ + + +def test_login_success(db_session, service, mock_email): + # 先注册 + service._redis.get.return_value = "123456" + register_input = RegisterInput(email="login@example.com", password="pass123", code="123456") + service.register_by_email_with_session(db_session, register_input) + + # 登录 + service._redis.get.return_value = "654321" + login_input = LoginByPasswordInput(email="login@example.com", password="pass123", code="654321") + result = service.login_by_password_with_session(db_session, login_input) + + assert result.user.email == "login@example.com" + assert result.access_token is not None + + +def test_login_wrong_password(db_session, service, mock_email): + # 先注册 + service._redis.get.return_value = "123456" + register_input = RegisterInput(email="login@example.com", password="pass123", code="123456") + service.register_by_email_with_session(db_session, register_input) + + # 密码错误 + service._redis.get.return_value = "654321" + login_input = LoginByPasswordInput(email="login@example.com", password="wrong", code="654321") + + with pytest.raises(BizException, match="邮箱或密码错误"): + service.login_by_password_with_session(db_session, login_input) + + +def test_login_nonexistent_user(db_session, service): + service._redis.get.return_value = "123456" + login_input = LoginByPasswordInput(email="no@example.com", password="pass123", code="123456") + + with pytest.raises(BizException, match="邮箱或密码错误"): + service.login_by_password_with_session(db_session, login_input) + + +def test_login_banned_user(db_session, service, mock_email): + # 先注册 + service._redis.get.return_value = "123456" + register_input = RegisterInput(email="banned@example.com", password="pass123", code="123456") + service.register_by_email_with_session(db_session, register_input) + + # 封禁用户 + from sqlalchemy import select + user = db_session.scalar(select(User).where(User.email == "banned@example.com")) + user.status = UserStatus.BANNED + db_session.flush() + + # 尝试登录 + service._redis.get.return_value = "654321" + login_input = LoginByPasswordInput(email="banned@example.com", password="pass123", code="654321") + + with pytest.raises(BizException, match="账号已被封禁"): + service.login_by_password_with_session(db_session, login_input) + + +# -- 验证码登录测试 ------------------------------------------------------ + + +def test_login_by_code_new_user(db_session, service, mock_email): + service._redis.get.return_value = "123456" + + input_data = LoginByCodeInput(email="code@example.com", code="123456") + result = service.login_by_code_with_session(db_session, input_data) + + assert result.user.email == "code@example.com" + assert result.user.email_verified_at is not None + + +def test_login_by_code_existing_user(db_session, service, mock_email): + # 先注册 + service._redis.get.return_value = "123456" + register_input = RegisterInput(email="exist@example.com", password="pass123", code="123456") + service.register_by_email_with_session(db_session, register_input) + + # 验证码登录 + service._redis.get.return_value = "654321" + input_data = LoginByCodeInput(email="exist@example.com", code="654321") + result = service.login_by_code_with_session(db_session, input_data) + + assert result.user.email == "exist@example.com" + + +def test_login_by_code_wrong_code(db_session, service): + service._redis.get.return_value = "123456" + + input_data = LoginByCodeInput(email="code@example.com", code="999999") + + with pytest.raises(BizException, match="验证码错误"): + service.login_by_code_with_session(db_session, input_data) + + +# -- 发送验证码测试 ------------------------------------------------------ + + +def test_send_verification_code(service, mock_email): + service._redis.get.return_value = None # 无冷却 + + service.send_verification_code("test@example.com", "login") + + mock_email.send_verification_code.assert_called_once() + service._redis.pipeline.assert_called_once() + + +def test_send_verification_code_cooldown(service, mock_email): + service._redis.get.return_value = "1" # 冷却中 + + with pytest.raises(BizException, match="发送过于频繁"): + service.send_verification_code("test@example.com", "login") + + +# -- 登出测试 ------------------------------------------------------------ + + +def test_logout(service, mock_redis): + # 先创建一个 refresh token + token, jti = create_refresh_token(1, "test@example.com") + + service.logout(token) + + mock_redis.delete.assert_called_once() + + +def test_logout_invalid_token(service): + with pytest.raises(BizException): + service.logout("invalid-token") + + +# -- 刷新 token 测试 ---------------------------------------------------- + + +def test_refresh_tokens(service, mock_redis): + # 先创建一个 refresh token + token, jti = create_refresh_token(1, "test@example.com") + + # Mock Redis 返回 user_id + mock_redis.get.return_value = "1" + + result = service.refresh_tokens(token) + + assert result.access_token is not None + assert result.refresh_token is not None + assert result.user.id == 1 + + +def test_refresh_tokens_revoked(service, mock_redis): + token, jti = create_refresh_token(1, "test@example.com") + + # Mock Redis 返回 None(已撤销) + mock_redis.get.return_value = None + + with pytest.raises(BizException, match="refresh token 已失效"): + service.refresh_tokens(token) + + +# -- 修改密码测试 -------------------------------------------------------- + + +def test_change_password(db_session, service, mock_email): + # 先注册 + service._redis.get.return_value = "123456" + register_input = RegisterInput(email="change@example.com", password="oldpass123", code="123456") + result = service.register_by_email_with_session(db_session, register_input) + + # 修改密码 + change_input = ChangePasswordInput(old_password="oldpass123", new_password="newpass123") + service.change_password_with_session(db_session, result.user.id, change_input) + + # 用新密码登录 + service._redis.get.return_value = "654321" + login_input = LoginByPasswordInput(email="change@example.com", password="newpass123", code="654321") + login_result = service.login_by_password_with_session(db_session, login_input) + + assert login_result.user.email == "change@example.com" + + +def test_change_password_wrong_old(db_session, service, mock_email): + # 先注册 + service._redis.get.return_value = "123456" + register_input = RegisterInput(email="change@example.com", password="oldpass123", code="123456") + result = service.register_by_email_with_session(db_session, register_input) + + # 旧密码错误 + change_input = ChangePasswordInput(old_password="wrong", new_password="newpass123") + + with pytest.raises(BizException, match="旧密码错误"): + service.change_password_with_session(db_session, result.user.id, change_input) diff --git a/backend/uv.lock b/backend/uv.lock index cf241f39..7942c1f9 100644 --- a/backend/uv.lock +++ b/backend/uv.lock @@ -48,6 +48,98 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494" }, ] +[[package]] +name = "av" +version = "18.0.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/ae/a4/570a5a35c8638aba01e739925846c35fdd6b0756a15526766d0a4dd3b7df/av-18.0.0.tar.gz", hash = "sha256:4ef7e72c3d3a872584a1215173b16e0226811037f40dcdbf75992631098df1ba" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/18/4a/9e3463df030e063d757fa12f0f39be6541b45b06b5bad48c2ce361b924bf/av-18.0.0-cp311-abi3-macosx_11_0_x86_64.whl", hash = "sha256:149289d40e732a6e49c9530bc245b49d9964cfd1c8c9e06778703b7d5bba6b25" }, + { url = "https://mirrors.aliyun.com/pypi/packages/77/b3/2576a44b4f39c7462ced4c17fec04c756f7b0f3c5cb940d124173e417d6a/av-18.0.0-cp311-abi3-macosx_14_0_arm64.whl", hash = "sha256:35274c20d2ad3b4774fe632bcef2e34af79858ddf899352339cc3babbc13a484" }, + { url = "https://mirrors.aliyun.com/pypi/packages/84/74/6732f17b96dc23fd23b876b2805435855abdc8a3b397142be4e581165de8/av-18.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4d683b7747a0ba9222b8a5f81e41db5f796e7f64473454ec4fe2548e083c2fa0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6d/b9/7708c43fed7ae28b4a1bad060b4221e3334cd827cec24f7165902a6ac1f4/av-18.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:ae56b40b6f8b067a8ad2dac664fbfbabac7f7a55b9a7bb031eb99289252bc017" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5a/94/eba99691d184f6a395a242d54dc370e2fd2265e95bbc98e2963a0fdbdd6c/av-18.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:ea2e8ebbce521f21b55df9400e00d721623c9020ef158f5a188a96130be0743f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c9/cf/0d7aee07fe16aa9ffdf96043c14bed5485a52c0dea4259de87aa306ecab4/av-18.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef96dabb3e50dac249913145dff5424b302b257fd95dcb64be3c7b7a8aef16d1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/76/92/810da80b12680d4c4fe235bd1b4003289be9213ac7f114b77b8ecf0e3b3e/av-18.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:0f65518a184613e41536f29e8758c8e3d8293e46bf5bef108f04f925bbfa3f44" }, + { url = "https://mirrors.aliyun.com/pypi/packages/11/85/0f121ff43dc5a70696676c98a8f1674e2fa787614c2abaacb15fa1a9bc99/av-18.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:aaf4d354d2beaa6651e4f92e54409a578bde64f79c0beef9a30b388d06f7c629" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8b/f6/2509754d4d2356abc6fc0ea3d57c12ade29bac23a1fb7fc215a53ca518fb/av-18.0.0-cp311-abi3-win_arm64.whl", hash = "sha256:adac2b3833b6cb9bd6cb52664a522b94db453615b3675b1dbb26e13fe1c80da6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e2/25/4ee23a7f1609adf9b2f140c7a8ffade64a1449d89ab431d922a809eebf19/av-18.0.0-cp314-cp314t-macosx_11_0_x86_64.whl", hash = "sha256:88dd8e35e9242662b409a6a05fd24a6775d949eb05da0ba31cab4f250eacbab5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f1/f0/b9f8363d07aa4521913e483f6a30c7c164973ef01de62769bf9b97049cd8/av-18.0.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:f8f454349c402e2c8d6fa80b54eb2a3f86c00f414d2b399f01ae6dab075c6fd8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c3/e5/69397019aed280a72a43e97a252dee4295df1a9e608848452e5300ec4dab/av-18.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:88ce194c2201c6a6d40336adee8a5ddde46ed743eacb500e3ae9368d1c6d889e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/37/3a/1614d74f0d676ea6745eb59553c9ad01ca25db523cba808d522e838f4f5b/av-18.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:aa15e567a018cc94a26b0ab45da676dee70c4146ace6e92e47d30cc9689cbfbe" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6b/3c/5f54710d69b0ea93634134f92b49c7a2a7fd27da5486a8a7e6251ac1cfb4/av-18.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:613153e48cefc91700746dde0ad0282d4677b194cba22cc771de14c78411cf8b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/26/92/8293e6a267e0591b543abd96ae01e7e8ed228509bdb4e4644a8a8395d90f/av-18.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:30404f53ca1ea7f350ac86ff22a2c04f903014758e9b33f398c5a62de34bd84f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/10/0c/38ed7601277ae57dfe857d040be4762530fd728efff45c2fb8f035fef96a/av-18.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6882a48f7aec2863c96cddee3256ff2da98f7fb6cbed83cee9d7e70a8f186a6b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c8/95/0636ca04d5d89d01c49bd366d2b660cc85d1f8117c476b2be62eb0c70855/av-18.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:55a646e9afce9fdc5de5224205a8a12c7ed1ba9803145dcc876c40bfc03a109b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/01/20/1e24450ea981c44ed328691496fd2774dfa9fa3c3b00fd07f72fd5614abe/av-18.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:96f594ff506a09475e5549359352332049a25d37a08f00b4623f7f6e92e45b9c" }, +] + +[[package]] +name = "bcrypt" +version = "5.0.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/d4/36/3329e2518d70ad8e2e5817d5a4cac6bba05a47767ec416c7d020a965f408/bcrypt-5.0.0.tar.gz", hash = "sha256:f748f7c2d6fd375cc93d3fba7ef4a9e3a092421b8dbf34d8d4dc06be9492dfdd" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/13/85/3e65e01985fddf25b64ca67275bb5bdb4040bd1a53b66d355c6c37c8a680/bcrypt-5.0.0-cp313-cp313t-macosx_10_12_universal2.whl", hash = "sha256:f3c08197f3039bec79cee59a606d62b96b16669cff3949f21e74796b6e3cd2be" }, + { url = "https://mirrors.aliyun.com/pypi/packages/44/dc/01eb79f12b177017a726cbf78330eb0eb442fae0e7b3dfd84ea2849552f3/bcrypt-5.0.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:200af71bc25f22006f4069060c88ed36f8aa4ff7f53e67ff04d2ab3f1e79a5b2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8c/cf/e82388ad5959c40d6afd94fb4743cc077129d45b952d46bdc3180310e2df/bcrypt-5.0.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:baade0a5657654c2984468efb7d6c110db87ea63ef5a4b54732e7e337253e44f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ec/86/7134b9dae7cf0efa85671651341f6afa695857fae172615e960fb6a466fa/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:c58b56cdfb03202b3bcc9fd8daee8e8e9b6d7e3163aa97c631dfcfcc24d36c86" }, + { url = "https://mirrors.aliyun.com/pypi/packages/cc/82/6296688ac1b9e503d034e7d0614d56e80c5d1a08402ff856a4549cb59207/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4bfd2a34de661f34d0bda43c3e4e79df586e4716ef401fe31ea39d69d581ef23" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d1/18/884a44aa47f2a3b88dd09bc05a1e40b57878ecd111d17e5bba6f09f8bb77/bcrypt-5.0.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:ed2e1365e31fc73f1825fa830f1c8f8917ca1b3ca6185773b349c20fd606cec2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0e/8f/371a3ab33c6982070b674f1788e05b656cfbf5685894acbfef0c65483a59/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_aarch64.whl", hash = "sha256:83e787d7a84dbbfba6f250dd7a5efd689e935f03dd83b0f919d39349e1f23f83" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b1/34/7e4e6abb7a8778db6422e88b1f06eb07c47682313997ee8a8f9352e5a6f1/bcrypt-5.0.0-cp313-cp313t-manylinux_2_34_x86_64.whl", hash = "sha256:137c5156524328a24b9fac1cb5db0ba618bc97d11970b39184c1d87dc4bf1746" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c0/1b/54f416be2499bd72123c70d98d36c6cd61a4e33d9b89562c22481c81bb30/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:38cac74101777a6a7d3b3e3cfefa57089b5ada650dce2baf0cbdd9d65db22a9e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/13/62/062c24c7bcf9d2826a1a843d0d605c65a755bc98002923d01fd61270705a/bcrypt-5.0.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:d8d65b564ec849643d9f7ea05c6d9f0cd7ca23bdd4ac0c2dbef1104ab504543d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d5/c8/1fdbfc8c0f20875b6b4020f3c7dc447b8de60aa0be5faaf009d24242aec9/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:741449132f64b3524e95cd30e5cd3343006ce146088f074f31ab26b94e6c75ba" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a6/c1/8b84545382d75bef226fbc6588af0f7b7d095f7cd6a670b42a86243183cd/bcrypt-5.0.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:212139484ab3207b1f0c00633d3be92fef3c5f0af17cad155679d03ff2ee1e41" }, + { url = "https://mirrors.aliyun.com/pypi/packages/10/a6/ffb49d4254ed085e62e3e5dd05982b4393e32fe1e49bb1130186617c29cd/bcrypt-5.0.0-cp313-cp313t-win32.whl", hash = "sha256:9d52ed507c2488eddd6a95bccee4e808d3234fa78dd370e24bac65a21212b861" }, + { url = "https://mirrors.aliyun.com/pypi/packages/48/a9/259559edc85258b6d5fc5471a62a3299a6aa37a6611a169756bf4689323c/bcrypt-5.0.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f6984a24db30548fd39a44360532898c33528b74aedf81c26cf29c51ee47057e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2d/df/9714173403c7e8b245acf8e4be8876aac64a209d1b392af457c79e60492e/bcrypt-5.0.0-cp313-cp313t-win_arm64.whl", hash = "sha256:9fffdb387abe6aa775af36ef16f55e318dcda4194ddbf82007a6f21da29de8f5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f8/14/c18006f91816606a4abe294ccc5d1e6f0e42304df5a33710e9e8e95416e1/bcrypt-5.0.0-cp314-cp314t-macosx_10_12_universal2.whl", hash = "sha256:4870a52610537037adb382444fefd3706d96d663ac44cbb2f37e3919dca3d7ef" }, + { url = "https://mirrors.aliyun.com/pypi/packages/67/49/dd074d831f00e589537e07a0725cf0e220d1f0d5d8e85ad5bbff251c45aa/bcrypt-5.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:48f753100931605686f74e27a7b49238122aa761a9aefe9373265b8b7aa43ea4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f5/91/50ccba088b8c474545b034a1424d05195d9fcbaaf802ab8bfe2be5a4e0d7/bcrypt-5.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f70aadb7a809305226daedf75d90379c397b094755a710d7014b8b117df1ebbf" }, + { url = "https://mirrors.aliyun.com/pypi/packages/aa/e7/d7dba133e02abcda3b52087a7eea8c0d4f64d3e593b4fffc10c31b7061f3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:744d3c6b164caa658adcb72cb8cc9ad9b4b75c7db507ab4bc2480474a51989da" }, + { url = "https://mirrors.aliyun.com/pypi/packages/33/fc/5b145673c4b8d01018307b5c2c1fc87a6f5a436f0ad56607aee389de8ee3/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a28bc05039bdf3289d757f49d616ab3efe8cf40d8e8001ccdd621cd4f98f4fc9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/27/d7/1ff22703ec6d4f90e62f1a5654b8867ef96bafb8e8102c2288333e1a6ca6/bcrypt-5.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:7f277a4b3390ab4bebe597800a90da0edae882c6196d3038a73adf446c4f969f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c8/88/815b6d558a1e4d40ece04a2f84865b0fef233513bd85fd0e40c294272d62/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:79cfa161eda8d2ddf29acad370356b47f02387153b11d46042e93a0a95127493" }, + { url = "https://mirrors.aliyun.com/pypi/packages/51/8c/e0db387c79ab4931fc89827d37608c31cc57b6edc08ccd2386139028dc0d/bcrypt-5.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a5393eae5722bcef046a990b84dff02b954904c36a194f6cfc817d7dca6c6f0b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/06/83/1570edddd150f572dbe9fc00f6203a89fc7d4226821f67328a85c330f239/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f4c94dec1b5ab5d522750cb059bb9409ea8872d4494fd152b53cca99f1ddd8c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c9/f2/ea64e51a65e56ae7a8a4ec236c2bfbdd4b23008abd50ac33fbb2d1d15424/bcrypt-5.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0cae4cb350934dfd74c020525eeae0a5f79257e8a201c0c176f4b84fdbf2a4b4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d7/d4/1a388d21ee66876f27d1a1f41287897d0c0f1712ef97d395d708ba93004c/bcrypt-5.0.0-cp314-cp314t-win32.whl", hash = "sha256:b17366316c654e1ad0306a6858e189fc835eca39f7eb2cafd6aaca8ce0c40a2e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3f/61/3291c2243ae0229e5bca5d19f4032cecad5dfb05a2557169d3a69dc0ba91/bcrypt-5.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:92864f54fb48b4c718fc92a32825d0e42265a627f956bc0361fe869f1adc3e7d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3e/89/4b01c52ae0c1a681d4021e5dd3e45b111a8fb47254a274fa9a378d8d834b/bcrypt-5.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dd19cf5184a90c873009244586396a6a884d591a5323f0e8a5922560718d4993" }, + { url = "https://mirrors.aliyun.com/pypi/packages/84/29/6237f151fbfe295fe3e074ecc6d44228faa1e842a81f6d34a02937ee1736/bcrypt-5.0.0-cp38-abi3-macosx_10_12_universal2.whl", hash = "sha256:fc746432b951e92b58317af8e0ca746efe93e66555f1b40888865ef5bf56446b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/45/b6/4c1205dde5e464ea3bd88e8742e19f899c16fa8916fb8510a851fae985b5/bcrypt-5.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c2388ca94ffee269b6038d48747f4ce8df0ffbea43f31abfa18ac72f0218effb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3b/71/427945e6ead72ccffe77894b2655b695ccf14ae1866cd977e185d606dd2f/bcrypt-5.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:560ddb6ec730386e7b3b26b8b4c88197aaed924430e7b74666a586ac997249ef" }, + { url = "https://mirrors.aliyun.com/pypi/packages/17/72/c344825e3b83c5389a369c8a8e58ffe1480b8a699f46c127c34580c4666b/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:d79e5c65dcc9af213594d6f7f1fa2c98ad3fc10431e7aa53c176b441943efbdd" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0b/7e/d4e47d2df1641a36d1212e5c0514f5291e1a956a7749f1e595c07a972038/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2b732e7d388fa22d48920baa267ba5d97cca38070b69c0e2d37087b381c681fd" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0f/c3/0ae57a68be2039287ec28bc463b82e4b8dc23f9d12c0be331f4782e19108/bcrypt-5.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0c8e093ea2532601a6f686edbc2c6b2ec24131ff5c52f7610dd64fa4553b5464" }, + { url = "https://mirrors.aliyun.com/pypi/packages/45/2b/77424511adb11e6a99e3a00dcc7745034bee89036ad7d7e255a7e47be7d8/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5b1589f4839a0899c146e8892efe320c0fa096568abd9b95593efac50a87cb75" }, + { url = "https://mirrors.aliyun.com/pypi/packages/43/0a/405c753f6158e0f3f14b00b462d8bca31296f7ecfc8fc8bc7919c0c7d73a/bcrypt-5.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:89042e61b5e808b67daf24a434d89bab164d4de1746b37a8d173b6b14f3db9ff" }, + { url = "https://mirrors.aliyun.com/pypi/packages/62/83/b3efc285d4aadc1fa83db385ec64dcfa1707e890eb42f03b127d66ac1b7b/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:e3cf5b2560c7b5a142286f69bde914494b6d8f901aaa71e453078388a50881c4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/95/7d/47ee337dacecde6d234890fe929936cb03ebc4c3a7460854bbd9c97780b8/bcrypt-5.0.0-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f632fd56fc4e61564f78b46a2269153122db34988e78b6be8b32d28507b7eaeb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d6/3a/43d494dfb728f55f4e1cf8fd435d50c16a2d75493225b54c8d06122523c6/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:801cad5ccb6b87d1b430f183269b94c24f248dddbbc5c1f78b6ed231743e001c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/55/ab/a0727a4547e383e2e22a630e0f908113db37904f58719dc48d4622139b5c/bcrypt-5.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3cf67a804fc66fc217e6914a5635000259fbbbb12e78a99488e4d5ba445a71eb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1b/bb/461f352fdca663524b4643d8b09e8435b4990f17fbf4fea6bc2a90aa0cc7/bcrypt-5.0.0-cp38-abi3-win32.whl", hash = "sha256:3abeb543874b2c0524ff40c57a4e14e5d3a66ff33fb423529c88f180fd756538" }, + { url = "https://mirrors.aliyun.com/pypi/packages/41/aa/4190e60921927b7056820291f56fc57d00d04757c8b316b2d3c0d1d6da2c/bcrypt-5.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:35a77ec55b541e5e583eb3436ffbbf53b0ffa1fa16ca6782279daf95d146dcd9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/54/12/cd77221719d0b39ac0b55dbd39358db1cd1246e0282e104366ebbfb8266a/bcrypt-5.0.0-cp38-abi3-win_arm64.whl", hash = "sha256:cde08734f12c6a4e28dc6755cd11d3bdfea608d93d958fffbe95a7026ebe4980" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5d/ba/2af136406e1c3839aea9ecadc2f6be2bcd1eff255bd451dd39bcf302c47a/bcrypt-5.0.0-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:0c418ca99fd47e9c59a301744d63328f17798b5947b0f791e9af3c1c499c2d0a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ac/ee/2f4985dbad090ace5ad1f7dd8ff94477fe089b5fab2040bd784a3d5f187b/bcrypt-5.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddb4e1500f6efdd402218ffe34d040a1196c072e07929b9820f363a1fd1f4191" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e4/6e/b77ade812672d15cf50842e167eead80ac3514f3beacac8902915417f8b7/bcrypt-5.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7aeef54b60ceddb6f30ee3db090351ecf0d40ec6e2abf41430997407a46d2254" }, + { url = "https://mirrors.aliyun.com/pypi/packages/36/c4/ed00ed32f1040f7990dac7115f82273e3c03da1e1a1587a778d8cea496d8/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:f0ce778135f60799d89c9693b9b398819d15f1921ba15fe719acb3178215a7db" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e7/c4/fa6e16145e145e87f1fa351bbd54b429354fd72145cd3d4e0c5157cf4c70/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a71f70ee269671460b37a449f5ff26982a6f2ba493b3eabdd687b4bf35f875ac" }, + { url = "https://mirrors.aliyun.com/pypi/packages/24/b4/11f8a31d8b67cca3371e046db49baa7c0594d71eb40ac8121e2fc0888db0/bcrypt-5.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8429e1c410b4073944f03bd778a9e066e7fad723564a52ff91841d278dfc822" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ac/31/79f11865f8078e192847d2cb526e3fa27c200933c982c5b2869720fa5fce/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:edfcdcedd0d0f05850c52ba3127b1fce70b9f89e0fe5ff16517df7e81fa3cbb8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d4/8d/5e43d9584b3b3591a6f9b68f755a4da879a59712981ef5ad2a0ac1379f7a/bcrypt-5.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:611f0a17aa4a25a69362dcc299fda5c8a3d4f160e2abb3831041feb77393a14a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/89/48/44590e3fc158620f680a978aafe8f87a4c4320da81ed11552f0323aa9a57/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:db99dca3b1fdc3db87d7c57eac0c82281242d1eabf19dcb8a6b10eb29a2e72d1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5f/85/e4fbfc46f14f47b0d20493669a625da5827d07e8a88ee460af6cd9768b44/bcrypt-5.0.0-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:5feebf85a9cefda32966d8171f5db7e3ba964b77fdfe31919622256f80f9cf42" }, + { url = "https://mirrors.aliyun.com/pypi/packages/25/ae/479f81d3f4594456a01ea2f05b132a519eff9ab5768a70430fa1132384b1/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3ca8a166b1140436e058298a34d88032ab62f15aae1c598580333dc21d27ef10" }, + { url = "https://mirrors.aliyun.com/pypi/packages/df/d2/36a086dee1473b14276cd6ea7f61aef3b2648710b5d7f1c9e032c29b859f/bcrypt-5.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:61afc381250c3182d9078551e3ac3a41da14154fbff647ddf52a769f588c4172" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c0/f6/688d2cd64bfd0b14d805ddb8a565e11ca1fb0fd6817175d58b10052b6d88/bcrypt-5.0.0-cp39-abi3-win32.whl", hash = "sha256:64d7ce196203e468c457c37ec22390f1a61c85c6f0b8160fd752940ccfb3a683" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9f/b9/9d9a641194a730bda138b3dfe53f584d61c58cd5230e37566e83ec2ffa0d/bcrypt-5.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:64ee8434b0da054d830fa8e89e1c8bf30061d539044a39524ff7dec90481e5c2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/27/44/d2ef5e87509158ad2187f4dd0852df80695bb1ee0cfe0a684727b01a69e0/bcrypt-5.0.0-cp39-abi3-win_arm64.whl", hash = "sha256:f2347d3534e76bf50bca5500989d6c1d05ed64b440408057a37673282c654927" }, +] + [[package]] name = "certifi" version = "2026.7.22" @@ -139,6 +231,18 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6" }, ] +[[package]] +name = "coloredlogs" +version = "15.0.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "humanfriendly" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/cc/c7/eed8f27100517e8c0e6b923d5f0845d0cb99763da6fdee00478f91db7325/coloredlogs-15.0.1.tar.gz", hash = "sha256:7c991aa71a4577af2f82600d8f8f3a89f936baeaf9b50a9c197da014e5bf16b0" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/a7/06/3d6badcf13db419e25b07041d9c7b4a2c331d3f4e7134445ec5df57714cd/coloredlogs-15.0.1-py2.py3-none-any.whl", hash = "sha256:612ee75c546f53e92e70049c9dbfcc18c935a2b9a53b66085ce9ef6a6e5c0934" }, +] + [[package]] name = "distro" version = "1.9.0" @@ -148,6 +252,28 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2" }, ] +[[package]] +name = "dnspython" +version = "2.8.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af" }, +] + +[[package]] +name = "email-validator" +version = "2.3.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "dnspython" }, + { name = "idna" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4" }, +] + [[package]] name = "fastapi" version = "0.139.2" @@ -164,6 +290,14 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/5f/c7/cb03251d9dfb177246a9809a76f189d21df32dbd4a845951881d11323b7f/fastapi-0.139.2-py3-none-any.whl", hash = "sha256:b9ad015a835173d59865e2f5d8296fbc2b317bf56a2ba1a5bfbdd03de2fd4b1c" }, ] +[[package]] +name = "flatbuffers" +version = "25.12.19" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/e8/2d/d2a548598be01649e2d46231d151a6c56d10b964d94043a335ae56ea2d92/flatbuffers-25.12.19-py2.py3-none-any.whl", hash = "sha256:7634f50c427838bb021c2d66a3d1168e9d199b0607e6329399f04846d42e20b4" }, +] + [[package]] name = "greenlet" version = "3.5.4" @@ -356,6 +490,18 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad" }, ] +[[package]] +name = "humanfriendly" +version = "10.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "pyreadline3", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/cc/3f/2c29224acb2e2df4d2046e4c73ee2662023c58ff5b113c4c1adac0886c43/humanfriendly-10.0.tar.gz", hash = "sha256:6b0b831ce8f15f7300721aa49829fc4e83921a9a301cc7f606be6686a2288ddc" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/f0/0f/310fb31e39e2d734ccaa2c0fb981ee41f7bd5056ce9bc29b2248bd569169/humanfriendly-10.0-py2.py3-none-any.whl", hash = "sha256:1697e1a8a8f550fd43c2865cd84542fc175a61dcb779b6fee18cf6b6ccba1477" }, +] + [[package]] name = "idna" version = "3.18" @@ -365,6 +511,19 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2" }, ] +[[package]] +name = "imageio" +version = "2.37.4" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "numpy" }, + { name = "pillow" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/48/62/aa770a9307508d2a2a2c62d536a49347bffe9e55322db27838d3c93d0b07/imageio-2.37.4.tar.gz", hash = "sha256:e45cbc5e83502047fb138f7f585f7f105a136a57eea5f4b3cfc6ce1b52720bd3" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/3e/2d/ca050652104bab2cf55e569db2a178b1b61cb041fef28307f2db383f6d9f/imageio-2.37.4-py3-none-any.whl", hash = "sha256:1ab2e22c8debf700f24c3ac43e8f95f3b3a8110c83b93411e97b4b0b2cd1c7e6" }, +] + [[package]] name = "import-linter" version = "2.13" @@ -389,6 +548,74 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12" }, ] +[[package]] +name = "jiter" +version = "0.16.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/1d/1f/10936e16d8860c70698a1aa939a46aa0224813b782bce4e000e637da0b2d/jiter-0.16.0.tar.gz", hash = "sha256:7b24c3492c5f4f84a37946ad9cf504910cf6a782d6a4e0689b6673c5894b4a1c" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/83/2b/52ace16ed031354f0539749a49e4bf33797d82bea5137910835fa4b09793/jiter-0.16.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:67c3bc1760f8c99d805dcab4e644027142a53b1d5d861f18780ebdbd5d40b72a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/94/2e/34957c2c1b661c252ba9bcc60ae0bddc27e0f7202c6073326a13c5390eec/jiter-0.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5af7780e4a26bd7d0d989592bf9ef12ebf806b74ab709223ecca37c749872ea9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/88/6c/59bd309cab4460c54cf1079f3eb7fe7af6a4c895c5c957a53378693bad2b/jiter-0.16.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d5bf78d0e05e45cfdd66558893938d59afe3d1b1a824a202039b20e607d25a72" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3b/8c/f5ef7b65f0df47afa16596969defb281ebb86e96df346d62be6fd853d620/jiter-0.16.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f4444a83f946605990c98f625cdd3d2725bfb818158760c5748c653170a20e0e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2b/0b/ace4354da061ee38844a0c27dc2c21eecd27aea119e8da324bea987522d0/jiter-0.16.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3a23f0e4f957e1be65752d2dfac9a5a06b1917af8dc85deb639c3b9d02e31290" }, + { url = "https://mirrors.aliyun.com/pypi/packages/55/40/c0253d3772eb9dcd8e6606ee9b2d53ec8e5b814589c47f140aa585f21eaa/jiter-0.16.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c22a488f7b9218e245a0025a9ba6b100e2e54700831cf4cf16833a27fba3ad01" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a8/d2/4839422241aa12860ce597b20068727094ba0bc480723c74924ca5bad483/jiter-0.16.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:46add52f4ad47a08bfb1219f3e673da972191489a33016edefdb5ea55bfa8c48" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e2/59/e196888a05befdda7dbe299b722d56f2f6eec65402bc34c0a3306d595feb/jiter-0.16.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:9c8a956fd72c2cf1e730d01ea080341f13aa0a97a4a33b51abebe725b7ae9ca9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ec/74/4cd9e0fca65232136400354b630fbfcd2de634e22ccbb96567725981b548/jiter-0.16.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:561926e0573ffe4a32498420a76d64b16c513e1ab413b9d28158a8764ac701e5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d9/8c/554691e48bc711299c0a293dd8a6179e24b2d66a54dc295421fcf64569c0/jiter-0.16.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:44d019fa8cdaf89bf29c71b39e3712143fdd0ac76725c6ef954f9957a5ea8730" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a4/cb/01e9d69dc2cc6759d4f91e230b34489c4fdb2518992650633f9e20bece89/jiter-0.16.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:0df91907609837f33341b8e6fe73b95991fdaa57caf1a0fbd343dffe826f386f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/79/70/2953195f1c6ad00f49fa67e13df7e60acb3dd4f387101bc15abccddd905e/jiter-0.16.0-cp312-cp312-win32.whl", hash = "sha256:51d7b836acb0108d7c77df1742332cac2a1fa04a74d6dacec46e7091f0e91274" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2d/05/2909a8b10699a4d560f8c502b6b2c5f3991b682b1922c1eedda242b225bd/jiter-0.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:1878349266f8ee36ecb1375cc5ba2f115f35fd9f0a1a4119e725e379126647f7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e9/a9/6b82bb1c8d7790d602489b967b982a909e5d092875a6c2ade96444c8dfc5/jiter-0.16.0-cp312-cp312-win_arm64.whl", hash = "sha256:2ed5738ae4af18271a51a528b8811b0cbfa4a1858de9d83359e4169855d6a331" }, + { url = "https://mirrors.aliyun.com/pypi/packages/91/c0/555fc60473d30d66894ba825e63615e3be7524fac23858356afa7a38906c/jiter-0.16.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:41977aa5654023948c2dae2a81cbf9c43343954bef1cd59a154dd15a4d84c195" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d0/2b/c3eaf16f5d7c9bad66ea32f40a95bd169b29a91217fcc7f081375157e99c/jiter-0.16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d28bb3c26762358dadf3e5bf0bccd29ae987d65e6988d2e6f49829c76b003c09" }, + { url = "https://mirrors.aliyun.com/pypi/packages/96/3f/02fdfc6705cad96127d883af5c34e4867f554f29ec7705ec1a46156400a9/jiter-0.16.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0542a7189c26920778658fc8fcf2af8bae05bae9924577f71804acef37996536" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b2/a6/e4bda5920d4b0d7c5dfb7174ce4a6b2e4d3e11c9162c452ef0eab4cdbdbd/jiter-0.16.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8fb8de1e23a0cb2a7f53c335049c7b72b6db41aa6227cdcc0972a1de5cb39450" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b7/97/4e6b59b2c6e55cbb3e183595f81ad65dcfb21c915fee5e19e335df21bc55/jiter-0.16.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b72d0b2990ca754a9102779ac98d8597b7cb31678958562214a007f909eab78e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/15/e0/97e9557686d2f94f4b93786eccb7eed28e9228ad132ea8237f44727314a7/jiter-0.16.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d5f91b1c27fc22a57993d5a5cb8a627cb8ed4b10502716fac1ffbfe1d19d84e8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0f/94/db768b6938e0df35c86beeba3dfbbb025c9ee5c19e1aa271f2396e50864d/jiter-0.16.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c682bea068a90b764577bdb78a60a4c1d1606daf9cd4c893832a37c7cc9d9026" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c1/d6/5a59d938244a30735fe62d9433fd325f9021ea29d89780ea4596ea93bc89/jiter-0.16.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:8d031aabecc4f1b6276adfb42e3aabb77c89d468bf616600e8d3a11328929053" }, + { url = "https://mirrors.aliyun.com/pypi/packages/67/f8/c4a857f49c9af125f6bbcac7e3eee7f7978ed89682833062e2dbf62576b1/jiter-0.16.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:eab2cd170150e70153de16896a1774e3a1dca80154c56b54d7a812c479a7165e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8b/d6/5fbc2f7d6b67b754caa61a993a2e626e815dec47ffc2f9e35f01adfebec7/jiter-0.16.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:6edb63a46e65a82c26800a868e49b2cac30dd5a4218b88d74bc2c848c8ad60bb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ed/54/284f0164b64a5fed915fea6ba7e9ba9b3d8d37c67d59cf2e3bb99d45cdfe/jiter-0.16.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:659039cc50b5addcc35fcc87ae2c1833b7c0a8e5326ef631a75e4478447bcf84" }, + { url = "https://mirrors.aliyun.com/pypi/packages/13/c5/2a467585a576594384e1d2c43e1224deaafc085f24e243529cf98beef8e1/jiter-0.16.0-cp313-cp313-win32.whl", hash = "sha256:c9c53be232c2e206ef9cdbad81a48bfa74c3d3f08bcf8124630a8a748aad993e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/88/6a/de61d04b9eec69c71719968d2f716532a3bc121170c44a39e14979c6be81/jiter-0.16.0-cp313-cp313-win_amd64.whl", hash = "sha256:baad945ed47f163ad833314f8e3288c396118934f94e7bbb9e243ce4b341a4fd" }, + { url = "https://mirrors.aliyun.com/pypi/packages/19/4b/b390ed59bafb3f31d008d1218578f10327714484b334439947f7e5b11e7f/jiter-0.16.0-cp313-cp313-win_arm64.whl", hash = "sha256:3c1fd2dbe1b0af19e987f03fe66c5f5bd105a2229c1aff4ab14890b24f41d21a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a7/89/bc4f1b57d5da938fd344a466396541e586d161320d70bffd929aaafcd8f4/jiter-0.16.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b2c61484666ad42726029af0c00ef4541f0f3b5cdc550221f56c2343208018ee" }, + { url = "https://mirrors.aliyun.com/pypi/packages/65/7a/c415453e5213001bf3b411ff65dec3d303b0e76a4a2cfea9768cd4960994/jiter-0.16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:63efadc657488f45db1c676d81e704cac2abf3fdb892def1faea61db053127e2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/11/fc/1f4fb7ebf9a724c7741994f4aae18fba1e2f3133df14521a79194952c34a/jiter-0.16.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cf0d73f50e7b6935677854f6e8e31d499ca7064dd24734f703e060f5b237d883" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a0/8d/72cadaac05ccfa7cc3a0a2232862e6c72443ca40cf300ba8b57f9f18b69b/jiter-0.16.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bf3ea07d9bc8e7d03a9fbc051295462e6dbc295b894fd72457c3136e3e43d898" }, + { url = "https://mirrors.aliyun.com/pypi/packages/58/4a/c4b0d5f651fda90a24ffce9f8d56cde462a2e09d31ae3de3c68cef34c04e/jiter-0.16.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:26798522707abb47d767db536e4148ceac1b14446bf028ee85e579a2e043cfe5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/80/58/ef77879ea9aa56b50824edc5a445e226422c7a8d211f3fd2a56bcb9493cf/jiter-0.16.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bc837c1b9631be10abfe0191537fe8009838204cec7e44827401ace390ddb567" }, + { url = "https://mirrors.aliyun.com/pypi/packages/49/2e/ffbc3f254e4d8a66da3062c624a7df4b7c2b2cf9e1fe43cf394b3e104041/jiter-0.16.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49060fd70737fad59d33ba9dcc0d83247dc9e77187de26053a19c16c9f32bd69" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9a/f6/0be5dc6d64a89f80aa8fec984f94dedb2973e251edcae55841d60786d578/jiter-0.16.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:adbb8edeadd431bc4477879d5d371ece7cb1334486584e0f252656dd7ffada29" }, + { url = "https://mirrors.aliyun.com/pypi/packages/da/6e/7d31243b3b91cd261dd19e9d3557fc3251a80883d3d8049c86174e7ab7af/jiter-0.16.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:31aaee5b80f672c1dc21272bcfb9cbdcfc1ea04ff50f00ed5af500b80c44fa93" }, + { url = "https://mirrors.aliyun.com/pypi/packages/25/33/51ae371fde3c88897520f62b4d5f8b27ad7103e2bb10812ff52195609853/jiter-0.16.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:6722bcef4ffc86c835574b1b2fac6b33b9fb4a889c781e67950e891591f3c55a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a0/45/6449b3d123ea439ba79507c657288f461d55049e7bcbdc2cf8eb8210f491/jiter-0.16.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:5ab4f50ff971b611d656554ea10b75f80097392c827bc32923c6eeb6386c8b00" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9b/e7/fd2fb11ae3e2649333da3aa170d04d7b3000bbdc3b270f6513382fdf4e04/jiter-0.16.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:710cc51d4ebdcd3c1f70b232c1db1ea1344a075770422bbd4bede5708335acbe" }, + { url = "https://mirrors.aliyun.com/pypi/packages/26/80/f0b147a62c315a164ed2168908286ca302310824c218d3aae52b06c0c9a9/jiter-0.16.0-cp314-cp314-win32.whl", hash = "sha256:57b37fc887a32d44798e4d8ebfa7c9683ff3da1d5bf38f08d1bb3573ccb39106" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5e/e6/4758a14304b4523a6f5adb2419340086aa3593bd4327c2b25b5948a90548/jiter-0.16.0-cp314-cp314-win_amd64.whl", hash = "sha256:cbd18dd5e2df96b580487b5745adf57ef64ad89ba2d9662fc3c19386acce7db8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/26/be/41fa54a2e7ea41d6c99f1dc5b1f0fd4cb474680304b5d268dd518e81da3a/jiter-0.16.0-cp314-cp314-win_arm64.whl", hash = "sha256:a32d2027a9fa67f109ff245a3252ece3ccc32cc56703e1deab6cc846a59e0585" }, + { url = "https://mirrors.aliyun.com/pypi/packages/81/6b/59127338b86d9fe4d99418f5a15118bea778103ee0fe9d9dd7e0af174e95/jiter-0.16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2577196f4474ef3fc4779a088a23b0897bbf86f9ea3679c372d45b8383b43207" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2d/95/49461034d5388196d3dabf98748935f017b7785d8f3f5349f834bcc4ed0d/jiter-0.16.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:616e89e008a93c01104161c75b4988e58716b01d62307ebfe161e52a56d2a818" }, + { url = "https://mirrors.aliyun.com/pypi/packages/cd/97/a4369f2fb82cb3dda13b98622f31249b2e014b223fe64ee534413ad72294/jiter-0.16.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0e2e9efbe042210df657bade597f66d6d75723e3d8f45a12ea6d8167ff8bbce3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/28/51/49b6ed456261646e1906016a6760367a28aacd3c24805e4e5fe64116c1db/jiter-0.16.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3f4d9e473a5ce7d27fef8b848df4dc16e283893d3f53b4a585e72c9595f3c284" }, + { url = "https://mirrors.aliyun.com/pypi/packages/33/b5/5689aff4f66c5b60be63106e591dbfcba2190df97d2c9c7cf052361ddb98/jiter-0.16.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d30a4a1c87713060c8d1cc59a7b6c8fb6b8ef0a6900368014c76c87922a2929" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a2/96/3ae1b85ee0d6d6cab254fb7f8da018272b932bbf2d69b07e98aa2a96c746/jiter-0.16.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bae96332410f866e5900d809298b1ed82735932986c672495f9701daacd80620" }, + { url = "https://mirrors.aliyun.com/pypi/packages/15/32/c99d7bafd78986556c95bf60ce84c6cc98786eac56066c12d7f828bb6747/jiter-0.16.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:da3d7ec75dc83bb18bca888b5edfae0656a26849056c59e05a7728badd17e7af" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0e/4b/f99a8e571287c3dec766bcc18528bbe8e8fb5365522ab5e6d64c93e87066/jiter-0.16.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ee6162b77d49a9939229df666dfa8af3e656b6701b54c4c84966d740e189264e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/75/69/c78a5b3f71040e34eb5917df26fb7ae9a2174cad1ccbf277512507c53a6e/jiter-0.16.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:63ffdbdae7d4499f4cda14eadc12ddcabef0fc0c081191bdc2247489cb698077" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c2/f7/095b38eda4c70d03651c403f29a5590f16d12ddc5d544aac9f9cddf72277/jiter-0.16.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a111256a7193bea0759267b10385e5870949c239ed7b6ddbaaf57573edb38734" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2e/c5/6a0207d90e5f656d95af98ebd0934f382d37674416f215aeda2ff8063e51/jiter-0.16.0-cp314-cp314t-win32.whl", hash = "sha256:de5ba8763e56b793561f43bed197c9ea55776daa5e9a6b91eed68a909bc9cdbf" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a5/31/c757d5f30a8980fd945ce7b98be10be9e4ff59c7c42f5fd86804c2e87db8/jiter-0.16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b8a3f9a6008048fe9def7bf465180564a6e458047d2ce499149cfbe73c3ae9db" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7c/a2/d88de6d313d734a544a7901353ad5db67cb38dcfcd91713b7979dafc345d/jiter-0.16.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0fa25b09b13075c46f5bc174f2690525a925a4fc2f7c82969a2bbabff22386ce" }, + { url = "https://mirrors.aliyun.com/pypi/packages/98/ab/664fd8c4be028b2bedd3d2ff08769c4ede23d0dbc87a77c62384a0515b5d/jiter-0.16.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:f17d61a28b4b3e0e3e2ba98490c70501403b4d196f78732439160e7fd3678127" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1a/07/421f1d5b65493a76e16027b848aba6a7d28073ae75944fa4289cc914d39f/jiter-0.16.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:96e38eea538c8ddf853a35727c7be0741c76c13f04148ac5c116222f50ece3b3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0a/db/bba1155f01a01c3c37a89425d571da751bbedf5c54247b831a04cb971798/jiter-0.16.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d284fb8d94d5855d60c44fefcab4bf966f1da6fada73992b01f6f0c9bc0c6702" }, + { url = "https://mirrors.aliyun.com/pypi/packages/78/f7/18a1afcd64f35314b68c1f23afcd9994d0bc13e65cc77517afff4e83986d/jiter-0.16.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64d613743df53199b1aa256a7d328340da6d7078aac7705a7db9d7a791e9cfd2" }, +] + [[package]] name = "jsonpatch" version = "1.33" @@ -430,6 +657,20 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/29/56/5ef7ba14bac95b0344da18c6e8ec108dce0baf5fc054d1117702f92af29d/langchain_core-1.5.0-py3-none-any.whl", hash = "sha256:f122efee35446632b38687119fca33711abbf3b6b555e31156762298fbe78a65" }, ] +[[package]] +name = "langchain-openai" +version = "1.4.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "langchain-core" }, + { name = "openai" }, + { name = "tiktoken" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/89/fc/d146705e0cf6cf8865d4e873e0551452f94d6520f43fe703594ccdf95763/langchain_openai-1.4.0.tar.gz", hash = "sha256:a3acf6be0937f3970fc9e7f0aae22929c6f117e49128bd62f4d45a64b2587d8b" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/f1/6c/f786dfcb6711cb06449041e72b67f7e28fc77a53e2aa16866c4f0002625a/langchain_openai-1.4.0-py3-none-any.whl", hash = "sha256:7a777731fe32a913085ec85bacd5650c3f8422048b65346b63a13b36b1b4a12f" }, +] + [[package]] name = "langchain-protocol" version = "0.0.18" @@ -547,6 +788,15 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8" }, ] +[[package]] +name = "mpmath" +version = "1.3.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/e0/47/dd32fa426cc72114383ac549964eecb20ecfd886d1e5ccf5340b55b02f57/mpmath-1.3.0.tar.gz", hash = "sha256:7a28eb2a9774d00c7bc92411c19a89209d5da7c4c9a9e227be8330a23a25b91f" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/43/e3/7d92a15f894aa0c9c4b49b8ee9ac9850d6e63b03c9c32c0367a13ae62209/mpmath-1.3.0-py3-none-any.whl", hash = "sha256:a0b2b9fe80bbcd81a6647ff13108738cfb482d481d826cc0e02f5b35e5c88d2c" }, +] + [[package]] name = "numpy" version = "2.5.1" @@ -598,6 +848,52 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/a1/5a/4d2b1601df3602dba7a14f3348ba9bfe94a18adb428e693df6154c293831/numpy-2.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:5a6db61f9aaa57e369905c67d852045d3c4f7126405b29d09b19dec118e9c9cb" }, ] +[[package]] +name = "onnxruntime" +version = "1.23.2" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "coloredlogs" }, + { name = "flatbuffers" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "protobuf" }, + { name = "sympy" }, +] +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/1b/9e/f748cd64161213adeef83d0cb16cb8ace1e62fa501033acdd9f9341fff57/onnxruntime-1.23.2-cp312-cp312-macosx_13_0_arm64.whl", hash = "sha256:b8f029a6b98d3cf5be564d52802bb50a8489ab73409fa9db0bf583eabb7c2321" }, + { url = "https://mirrors.aliyun.com/pypi/packages/91/9d/a81aafd899b900101988ead7fb14974c8a58695338ab6a0f3d6b0100f30b/onnxruntime-1.23.2-cp312-cp312-macosx_13_0_x86_64.whl", hash = "sha256:218295a8acae83905f6f1aed8cacb8e3eb3bd7513a13fe4ba3b2664a19fc4a6b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3c/35/4e40f2fba272a6698d62be2cd21ddc3675edfc1a4b9ddefcc4648f115315/onnxruntime-1.23.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76ff670550dc23e58ea9bc53b5149b99a44e63b34b524f7b8547469aaa0dcb8c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ef/88/9cc25d2bafe6bc0d4d3c1db3ade98196d5b355c0b273e6a5dc09c5d5d0d5/onnxruntime-1.23.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f9b4ae77f8e3c9bee50c27bc1beede83f786fe1d52e99ac85aa8d65a01e9b77" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c0/b4/569d298f9fc4d286c11c45e85d9ffa9e877af12ace98af8cab52396e8f46/onnxruntime-1.23.2-cp312-cp312-win_amd64.whl", hash = "sha256:25de5214923ce941a3523739d34a520aac30f21e631de53bba9174dc9c004435" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3d/41/fba0cabccecefe4a1b5fc8020c44febb334637f133acefc7ec492029dd2c/onnxruntime-1.23.2-cp313-cp313-macosx_13_0_arm64.whl", hash = "sha256:2ff531ad8496281b4297f32b83b01cdd719617e2351ffe0dba5684fb283afa1f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/fe/f9/2d49ca491c6a986acce9f1d1d5fc2099108958cc1710c28e89a032c9cfe9/onnxruntime-1.23.2-cp313-cp313-macosx_13_0_x86_64.whl", hash = "sha256:162f4ca894ec3de1a6fd53589e511e06ecdc3ff646849b62a9da7489dee9ce95" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1c/a1/428ee29c6eaf09a6f6be56f836213f104618fb35ac6cc586ff0f477263eb/onnxruntime-1.23.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45d127d6e1e9b99d1ebeae9bcd8f98617a812f53f46699eafeb976275744826b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f2/2b/b57c8a2466a3126dbe0a792f56ad7290949b02f47b86216cd47d857e4b77/onnxruntime-1.23.2-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8bace4e0d46480fbeeb7bbe1ffe1f080e6663a42d1086ff95c1551f2d39e7872" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4a/93/aba75358133b3a941d736816dd392f687e7eab77215a6e429879080b76b6/onnxruntime-1.23.2-cp313-cp313-win_amd64.whl", hash = "sha256:1f9cc0a55349c584f083c1c076e611a7c35d5b867d5d6e6d6c823bf821978088" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7c/3d/6830fa61c69ca8e905f237001dbfc01689a4e4ab06147020a4518318881f/onnxruntime-1.23.2-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9d2385e774f46ac38f02b3a91a91e30263d41b2f1f4f26ae34805b2a9ddef466" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b6/ca/862b1e7a639460f0ca25fd5b6135fb42cf9deea86d398a92e44dfda2279d/onnxruntime-1.23.2-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2b9233c4947907fd1818d0e581c049c41ccc39b2856cc942ff6d26317cee145" }, +] + +[[package]] +name = "openai" +version = "2.50.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "anyio" }, + { name = "distro" }, + { name = "httpx" }, + { name = "jiter" }, + { name = "pydantic" }, + { name = "sniffio" }, + { name = "tqdm" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/d8/f5/e7735f2af272ee179a287911a698b3cbdb59d7a4ac4874571363adf1e4de/openai-2.50.0.tar.gz", hash = "sha256:5128f7caf4a6b01aefd6e7e93efe170a2c3427b8de286b9af5cdff3aa47e02c8" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/00/ca/db315b3bb748c26c644a3f85b7d509e774354d6518d47080b1446005ee41/openai-2.50.0-py3-none-any.whl", hash = "sha256:90bdddcc5a2fa529b350fac9c5780d87e5c361dcc6090ab57b0d470b0d7af7fa" }, +] + [[package]] name = "orjson" version = "3.11.9" @@ -699,6 +995,20 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e" }, ] +[[package]] +name = "passlib" +version = "1.7.4" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/b6/06/9da9ee59a67fae7761aab3ccc84fa4f3f33f125b370f1ccdb915bf967c11/passlib-1.7.4.tar.gz", hash = "sha256:defd50f72b65c5402ab2c573830a6978e5f202ad0d984793c8dde2c4152ebe04" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/3b/a4/ab6b7589382ca3df236e03faa71deac88cae040af60c071a78d254a62172/passlib-1.7.4-py2.py3-none-any.whl", hash = "sha256:aa6bca462b8d8bda89c70b382f0c298a20b5560af6cbfa2dce410c0a2fb669f1" }, +] + +[package.optional-dependencies] +bcrypt = [ + { name = "bcrypt" }, +] + [[package]] name = "pillow" version = "12.3.0" @@ -779,6 +1089,21 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746" }, ] +[[package]] +name = "protobuf" +version = "7.35.1" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/da/01/9ef0afd7999eb9badb3a768b4aedd78c86d4c65cfaf1958ab276199e76b4/protobuf-7.35.1.tar.gz", hash = "sha256:ce115a26fe0c39a2c29973d914d327e516a6455464489fe3cd1e51a1b354f81a" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/10/03/8aeeb7458d22546bf64b5250ca1daeb5ff757d900e8e4a7476c6f0db843e/protobuf-7.35.1-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:24f857477359a85c0c235261b8ba905fd51b2562f4a64ca1df5473f29850cbf6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/37/4b/dfb89eb0e652a1ff073c39a59fb5e3a83cfe9b57a2c83fa6d78270101767/protobuf-7.35.1-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:11d6b0ec246892d85215b0a13ca6e0233cf5284b68f0ac02646427f4ff88a799" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0f/58/dc12f2cd484951524af6e3382c785869b9b3fb5e52ee95ae23add53ee8f9/protobuf-7.35.1-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:b73f9489a4b8b1c9cb1f8ed951c736392592edb24b9d6819f36d2e10b171d5b4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e4/be/5b3cfe508bfab6761414ff944e3366eb13be4fd71efcd69450f89ba39f43/protobuf-7.35.1-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:74758715c53d7158fb76caf4f0cfdacc5329a4b1bb994f865d6cf302d413a1c4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d8/bc/6d6c7ba8709c85f8f2c390b2b118d6fb08a783676a572271851bf45a7d22/protobuf-7.35.1-cp310-abi3-win32.whl", hash = "sha256:353652e4efd0bca5b5fc2656abf8307ef351f0cf938c9eba09f0e09c20a25c30" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0a/19/8d0cb6f20a1ef7b18f1c8986ad5783f22f84cce39c6ce9a6e645ea55192e/protobuf-7.35.1-cp310-abi3-win_amd64.whl", hash = "sha256:230a75ddfc2de4806e56696ce9640c1cdfdb6543b7cfce98d42a4c0a0e7bdb87" }, + { url = "https://mirrors.aliyun.com/pypi/packages/19/c7/5f7c636ec43e0c545e28d1f1db71990108306f7bdcb89f069ba97e428e7f/protobuf-7.35.1-py3-none-any.whl", hash = "sha256:4bc97768d8fe4ad6743c8a19403e314511ed9f6d13205b687e52421c023ac1b9" }, +] + [[package]] name = "psycopg" version = "3.3.4" @@ -852,6 +1177,11 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba" }, ] +[package.optional-dependencies] +email = [ + { name = "email-validator" }, +] + [[package]] name = "pydantic-core" version = "2.46.4" @@ -959,6 +1289,15 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728" }, ] +[[package]] +name = "pyreadline3" +version = "3.5.6" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/b6/6d/f94028646d7bbe6d9d873c47ee7c246f2d29129d253f0d96cb6fcab70733/pyreadline3-3.5.6.tar.gz", hash = "sha256:61e53218b99656091ddb077df9e71f25850e72e030b6183b39c9b7e6e4f4a9bf" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/f7/5e/35c856e186b74678c24927847ad9895a51f1bc02a0c6126477a6c6040064/pyreadline3-3.5.6-py3-none-any.whl", hash = "sha256:8449b734232e42a5dcd74048e39b60db2839a4c38cf3ae2bf7707d58b5389c0d" }, +] + [[package]] name = "pytest" version = "9.1.1" @@ -1039,6 +1378,115 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b" }, ] +[[package]] +name = "qiniu" +version = "7.18.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/32/e5/82e5078de1204b641d6b24fdd15b3c5a87a7dd71f514e4a3cfb845a2d988/qiniu-7.18.0.tar.gz", hash = "sha256:d9edca3a1c5217c13638a08d9095cd1661f5ba6cf92ea3827949ff3d332ea4fa" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/1d/56/9368cd96d2132017f5748a812cb20a87263498de314e1823472cb4bdbad9/qiniu-7.18.0-py3-none-any.whl", hash = "sha256:0f1be608ac6800ad5f32690d1aa02353b6b2ff5edc78f32a2318859d375a27df" }, +] + +[[package]] +name = "redis" +version = "8.1.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/a8/99/604f0b666d4c616d891cf77ebb9db6bb21601344c051aebf1b72b9ff915f/redis-8.1.0.tar.gz", hash = "sha256:6e1a19beef9225c83efd689c7e6b7da2d5215b1f42cd13b7fc3714d0a09c7b25" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/66/9d/c5731f6e3608663d4d3656fd8d3aecee8b509c3082818f5a13eae925baea/redis-8.1.0-py3-none-any.whl", hash = "sha256:a4fe1aac3d3b3cc791d4b3d5931c5a956045dc951ee74d1c913ee3ac4d2ee9fb" }, +] + +[[package]] +name = "regex" +version = "2026.7.19" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/20/98/04b13f1ddfb63158025291c02e03eb42fbb7acb51d091d541050eb4e35e8/regex-2026.7.19.tar.gz", hash = "sha256:7e77b324909c1617cbb4c668677e2c6ae13f44d7c1de0d4f15f2e3c10f3315b5" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/3b/b9/d11d7e501ac8fd7d617684423ebb9561e0b998481c1e4cbc0cb212c5d74a/regex-2026.7.19-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2cc3460cedf7579948486eab03bc9ad7089df4d7281c0f47f4afe03e8d13f02d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3f/a9/a5ab6f312f24318019170dc485d5421fe4f89e43a98640da50d95a8a7041/regex-2026.7.19-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0e9554c8785eac5cffe6300f69a91f58ba72bc88a5f8d661235ad7c6aa5b8ccd" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b3/63/4cab4d7f2d384a144d420b763d97674cb70619c878ea6fcd7640d0e62143/regex-2026.7.19-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d7da47a0f248977f08e2cb659ff3c17ddc13a4d39b3a7baa0a81bf5b415430f6" }, + { url = "https://mirrors.aliyun.com/pypi/packages/22/85/102a81b218298957d4ea7d2f084fae537a71add9d6ff93c8e67284c5f45e/regex-2026.7.19-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:93db40c8de0815baab96a06e08a984bac71f989d13bab789e382158c5d426797" }, + { url = "https://mirrors.aliyun.com/pypi/packages/78/b5/dc136af5629938a037cd2b304c12240e132ec92f38be8ff9cc89af2a1f2d/regex-2026.7.19-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:66bd62c59a5427746e8c44becae1d9b99d22fb13f30f492083dfb9ad7c45cc18" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e0/75/67402ae3cd9c8c988a4c805d15ee3eef015e7ca4cb112cf3e640fc1f4153/regex-2026.7.19-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1649eb39fcc9ea80c4d2f110fde2b8ab2aef3877b98f02ab9b14e961f418c511" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2a/8e/096d00c7c480ef2ff4265349b14e2261d4ab787ba1f74e2e80d1c58079c3/regex-2026.7.19-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9dce8ec9695f531a1b8a6f314fd4b393adcccf2ea861db480cdf97a301d01a68" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f0/41/e7ecac6edb5722417f85cc67eaf386322fbe8acf6918ec2fdc37c20dd9d0/regex-2026.7.19-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3080a7fd38ef049bd489e01c970c97dd84ff446a885b0f1f6b26d9b1ad13ce11" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6f/69/03c9b3f058d66403e0ca2c938696e81d51cd4c6d47ec5265f02f96948d9a/regex-2026.7.19-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1d793a7988e04fcb1e2e135567443d82173225d657419ec09414a9b5a145b986" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f6/f7/b38ab3d43f284afbb618fcd15d0e77eb786ae461ce1f6bc7494619ddc0f2/regex-2026.7.19-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e8b0abe7d870f53ca5143895fef7d1041a0c831a140d3dc2c760dd7ba25d4a8b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/15/5c/ff60ef0571121714f3cf9920bc183071e384a10b556d042e0fdb06cc07a5/regex-2026.7.19-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:4e5413bd5f13d3a4e3539ca98f70f75e7fca92518dd7f117f030ebedd10b60cb" }, + { url = "https://mirrors.aliyun.com/pypi/packages/aa/0f/bd34021162c0ab47f9a315bd56cd5642e920c8e5668a75ef6c6a6fca590d/regex-2026.7.19-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:73b133a9e6fb512858e7f065e96f1180aa46646bc74a83aea62f1d314f3dd035" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2a/20/a2ca43edade0595cccfdc98636739f536d9e26898e7dbddc2b9e98898953/regex-2026.7.19-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dbe6493fbd27321b1d1f2dd4f5c7e5bd4d8b1d7cab7f32fd67db3d0b2ed8248a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5d/47/e02db4015d424fc83c00ea0ac8c5e5ec14397943de9abf909d5ce3a25931/regex-2026.7.19-cp312-cp312-win32.whl", hash = "sha256:ddd67571c10869f65a5d7dde536d1e066e306cc90de57d7de4d5f34802428bb5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/08/8e/c780c131f79b42ed22d1bd7da4096c2c35f813e835acd02ef0f018bd892c/regex-2026.7.19-cp312-cp312-win_amd64.whl", hash = "sha256:e30d40268a28d54ce0437031750497004c22602b8e3ab891f759b795a003b312" }, + { url = "https://mirrors.aliyun.com/pypi/packages/3e/4c/e4d7e086449bdf379d89774bf1f89dc4a41943f3c5a6125a03905b34b5fb/regex-2026.7.19-cp312-cp312-win_arm64.whl", hash = "sha256:de9208bb427130c82a5dbfd104f92c8876fc9559278c880b3002755bbbe9c83d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/5d/3d/84165e4299ff76f3a40fe1f2abf939e976f693383a08d2beea6af62bd2c1/regex-2026.7.19-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f035d9dc1d25eff9d361456572231c7d27b5ccd473ca7dc0adfce732bd006d40" }, + { url = "https://mirrors.aliyun.com/pypi/packages/02/a2/a65293e6e4cf28eb7ee1be5335a5386c40d6742e9f47fafc8fec785e16c7/regex-2026.7.19-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c42572142ed0b9d5d261ba727157c426510da78e20828b66bbb855098b8a4e38" }, + { url = "https://mirrors.aliyun.com/pypi/packages/95/47/2d0564e93d87bc48618360ddca232a2ca612bbdf53ce8465d45ca5ce14ee/regex-2026.7.19-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:40b34dd88658e4fedd2fddbf0275ac970d00614b731357f425722a3ed1983d11" }, + { url = "https://mirrors.aliyun.com/pypi/packages/07/cd/42dfbabff3dfc9603c501c0e2e2c5adbb09d127b267bf5348de0af338c15/regex-2026.7.19-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c41c63992bf1874cebb6e7f56fd7d3c007924659a604ae3d90e427d40d4fd13" }, + { url = "https://mirrors.aliyun.com/pypi/packages/df/5d/f6a4839f2b934e3eed5973fd07f5929ee97d4c98939fb275ea23c274ee16/regex-2026.7.19-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d3372064506b94dd2c67c845f2db8062e9e9ba84d04e33cb96d7d33c11fe1ae" }, + { url = "https://mirrors.aliyun.com/pypi/packages/14/b0/b47d6c36049bc59806a50bd4c86ced70bbe058d787f80281b1d7a9b0e024/regex-2026.7.19-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fce7760bf283405b2c7999cab3da4e72f7deca6396013115e3f7a955db9760da" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2a/be/ff61f28f9273658cfe23acbbac5217221f6519960ed401e61dfdab12bc35/regex-2026.7.19-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c0d702548d89d572b2929879bc883bb7a4c4709efafe4512cadee56c55c9bd15" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c3/bb/8b4f7f26b333f9f79e1b453613c39bb4776f51d38ae66dd0ba31d6b354ca/regex-2026.7.19-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d446c6ac40bb6e05025ccee55b84d80fe9bf8e93010ffc4bb9484f13d498835f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/09/13/610110fc5921d380516d03c26b652555f08aa0d23ea78a771231873c3638/regex-2026.7.19-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4c3501bfa814ab07b5580741f9bf78dfdfe146a04057f82df9e2402d2a975939" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ca/f5/1ef9e2a83a5947c57ebff0b377cb5727c3d5ec1992317a320d035cd0dbb6/regex-2026.7.19-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c4585c3e64b4f9e583b4d2683f18f5d5d872b3d71dcf24594b74ecc23602fa96" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a0/02/073af33a3ec149241d11c80acea91e722aa0adbf05addd50f251c4fe89c3/regex-2026.7.19-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:571fde9741eb0ccde23dd4e0c1d50fbae910e901fa7e629faf39b2dda740d220" }, + { url = "https://mirrors.aliyun.com/pypi/packages/81/a9/d1e9f819dc394a568ef370cd56cf25394e957a2235f8370f23b576e5a475/regex-2026.7.19-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:15b364b9b98d6d2fe1a85034c23a3180ff913f46caddc3895f6fd65186255ccc" }, + { url = "https://mirrors.aliyun.com/pypi/packages/03/3a/8ae83eda7579feacdf984e71fb9e70635fb6f832eeddca58427ec4fca926/regex-2026.7.19-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ffd8893ccc1c2fce6e0d6ca402d716fe1b29db70c7132609a05955e31b2aa8f2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4b/23/c195cbfe5a75fdec64d8f6554fd15237b837919d2c61bdc141d7c807b08b/regex-2026.7.19-cp313-cp313-win32.whl", hash = "sha256:f0fa4fa9c3632d708742baf2282f2055c11d888a790362670a403cbf48a2c404" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b2/80/a11de8404b7272b70acb45c1c05987cce60b45d5693da2e176f0e390d564/regex-2026.7.19-cp313-cp313-win_amd64.whl", hash = "sha256:d51ffd3427640fa2da6ade574ceba932f210ad095f65fcc450a2b0a0d454868e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d1/29/0f5c8eff1b4f1f3d83276d365fccecf666afcc7d947420943bf394d07adb/regex-2026.7.19-cp313-cp313-win_arm64.whl", hash = "sha256:c670fe7be5b6020b76bc6e8d2196074657e1327595bca93a389e1a76ab130ad8" }, + { url = "https://mirrors.aliyun.com/pypi/packages/dc/4c/44b74742052cedda40f9ae469532a037112f7311a36669a891fba8984bb0/regex-2026.7.19-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:db47b561c9afd884baa1f96f797c9ca369872c4b65912bc691cfa99e68340af2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f0/45/bbd038b5e39ee5613a5a689290145b40058cc152c41de9cc23639d2b9734/regex-2026.7.19-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:65dcd28d3eba2ab7c2fd906485cc301392b47cc2234790d27d4e4814e02cdfda" }, + { url = "https://mirrors.aliyun.com/pypi/packages/65/38/c5bde94b4cedfd5850d64c3f08222d8e1600e84f6ee71d9b44b4b8163f74/regex-2026.7.19-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f2e7f8e2ab6c2922be02c7ec45185aa5bd771e2e57b95455ee343a44d8130dff" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d7/6a/2f5e107cb26c960b781967178899daf2787a7ab151844ed3c01d6fc95474/regex-2026.7.19-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe31f28c94402043161876a258a9c6f757cb485905c7614ce8d6cd40e6b7bdc1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/37/d4/a2f963406d7d73a62eed84ba05a258afb6cad1b21aa4517443ce40506b78/regex-2026.7.19-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f8f6fa298bb4f7f58a33334406218ba74716e68feddf5e4e54cd5d8082705abf" }, + { url = "https://mirrors.aliyun.com/pypi/packages/45/a3/44be546340bedb15f13063f5e7fe16793ea4d9ea2e805d09bd174ac27724/regex-2026.7.19-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cc1b2440423a851fad781309dd87843868f4f66a6bcd1ddb9225cf4ec2c84732" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f8/f6/e0870b0fd2a40dba0074e4b76e514b21313d37946c9248453e34ec43923e/regex-2026.7.19-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ac59a0900474a52b7c04af8196affc22bd9842acb0950df12f7b813e983609a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ae/27/957e8e22690ad6634572b39b71f130a6105f4d0718bb16849eac00fff147/regex-2026.7.19-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4896db1f4ce0576765b8272aa922df324e0f5b9bb2c3d03044ff32a7234a9aba" }, + { url = "https://mirrors.aliyun.com/pypi/packages/76/a4/186e410941e731037c01166069ab86da9f65e8f8110c18009ccf4bd623ee/regex-2026.7.19-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4e6883a021db30511d9fb8cfb0f222ce1f2c369f7d4d8b0448f449a93ba0bdfc" }, + { url = "https://mirrors.aliyun.com/pypi/packages/73/9f/e4e10e023d291d64a33e246610b724493bf1ce98e0e59c9b7c837e5acfb7/regex-2026.7.19-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:09523a592938aa9f587fb74467c63ff0cf88fc3df14c82ab0f0517dcf76aaa62" }, + { url = "https://mirrors.aliyun.com/pypi/packages/24/57/ccb20b6be5f1f52a053d1ba2a8f7a077edb9d918248b8490d7506c6832b3/regex-2026.7.19-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:1ebac3474b8589fce2f9b225b650afd61448f7c73a5d0255a10cc6366471aed1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a3/82/f3b263cf8fad927dc102891da8502e718b7ff9d19af7a2a07c03865d7188/regex-2026.7.19-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:4a0530bb1b8c1c985e7e2122e2b4d3aedd8a3c21c6bfddae6767c4405668b56e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/47/2e/1687bd1b6c2aed5e672ccf845fc11557821fe7366d921b50889ea5ce57bf/regex-2026.7.19-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2ef7eeb108c47ce7bcc9513e51bcb1bf57e8f483d52fce68a8642e3527141ae0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/76/7c/cc4e7655181b2d9235b704f2c5e19d8eff002bbc437bae59baee0e381aca/regex-2026.7.19-cp313-cp313t-win32.whl", hash = "sha256:64b6ca7391a1395c2638dd5c7456d67bea44fc6c5e8e92c5dc8aa6a8f23292b4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/bb/14/961b4c7b05a2391c32dbc85e27773076671ef8f97f36cec70fe414734c02/regex-2026.7.19-cp313-cp313t-win_amd64.whl", hash = "sha256:f04b9f56b0e0614c0126be12c2c2d9f8850c1e57af302bd0a63bed379d4af974" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ce/67/795644550d788ddbb6dc458c95895f8009978ea6d6ea76b005eb3f45e8c9/regex-2026.7.19-cp313-cp313t-win_arm64.whl", hash = "sha256:fcee38cd8e5089d6d4f048ba1233b3ad76e5954f545382180889112ff5cb712d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d2/25/0c4c452f8ef3efe456745b2f33195f5904b573fb4c2ff3f0cb9ec188461e/regex-2026.7.19-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:a81758ed242b861b72e778ba34d41366441a2e10b16b472784c88da2dea7e2dd" }, + { url = "https://mirrors.aliyun.com/pypi/packages/24/9e/b70ca6c1704f6c7cd32a9e143c86cc5968d10981eca284bad670c245ea7d/regex-2026.7.19-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4aa5435cdb3eb6f55fe98a171b05e3fbcd95fadaa4aa32acf62afd9b0cfdbcac" }, + { url = "https://mirrors.aliyun.com/pypi/packages/87/74/0b692da2520d51fbff19c88b83d97e4c702909dd02386c585998b7e2dbed/regex-2026.7.19-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:60be8693a1dadc210bbcbc0db3e26da5f7d01d1d5a3da594e99b4fa42df404f5" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e3/a7/1d478e614016045a33feae57446215f9fd65b665a5ceb2f891fb3183bc52/regex-2026.7.19-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d19662dbedbe783d323196312d38f5ba53cf56296378252171985da6899887d3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/aa/ae/11b9c9411d92c30e3d2db32df5a31133e4a99a8fc397a604fd08f6c4bffb/regex-2026.7.19-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d15df07081d91b76ff20d43f94592ee110330152d617b730fdbe5ef9fb680053" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b1/62/2b2efc4992f91d6d204b24c647c9f9412e85379d92b7c0ab9fdae622327e/regex-2026.7.19-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:56ad4d9f77df871a99e25c37091052a02528ec0eb059de928ee33956b854b45b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/14/71/986ceea9aa3da548bf1357cad89b63915ec6d21ec957c8113b29ece567df/regex-2026.7.19-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7322ec6cc9fba9d49ab888bb82d67ac5625627aa168f0165139b17018df3fb8a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/15/be/ce9d9534b2cda96eab32c548261224b9b4e220a4126f098f60f42ae7b4cd/regex-2026.7.19-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9c7472192ebfad53a6be7c4a8bfb2d64b81c0e93a1fc8c57e1dd0b638297b5d1" }, + { url = "https://mirrors.aliyun.com/pypi/packages/61/2b/58b5c710f2c3929515a25f3a1ca0dad0dcd4518d4fff3cf23bc7adb8dcd2/regex-2026.7.19-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c10b82c2634df08dfb13b1f04e38fe310d086ee092f4f69c0c8da234251e556e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/84/03/5fe091935b74f15fe0f97998c215cae418d1c0413f6258c7d4d2e83aa37f/regex-2026.7.19-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:17ed5692f6acc4183e98331101a5f9e4f64d72fe58b753da4d444a2c77d05b12" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d8/fa/d60bf82e10841eef62a9e32aac401468f05fddfbcb2942e342b1ba3d2433/regex-2026.7.19-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:22a992de9a0d91bda927bf02b94351d737a0302905432c88a53de7c4b9ce62e2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/bf/5d/11e64d151b0662b81d6bf644c74dc118d461df85bdf2577fadbbf751788a/regex-2026.7.19-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:618a0aed532be87294c4477b0481f3aa0f1520f4014a4374dd4cf789b4cd2c97" }, + { url = "https://mirrors.aliyun.com/pypi/packages/7c/34/532efb87488d90807bae6a443d357ee5e2728a478c597619c8aaa17cc0bd/regex-2026.7.19-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2ce9e679f776649746729b6c86382da519ef649c8e34cc41df0d2e5e0f6c36d4" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d6/90/3a8d5ca977171ec3ae21a71207d2228b2663bde14d7f7ef0e6363ecf9290/regex-2026.7.19-cp314-cp314-win32.whl", hash = "sha256:73f272fba87b8ccfe70a137d02a54af386f6d27aa509fbffdd978f5947aae1aa" }, + { url = "https://mirrors.aliyun.com/pypi/packages/96/e1/8862885e70409de70e8c005f57fb2e7be8d9ef0317250d60f4c9660a300d/regex-2026.7.19-cp314-cp314-win_amd64.whl", hash = "sha256:d721e53758b2cca74990185eb0671dd466d7a388a1a45d0c6f4c13cef41a68ac" }, + { url = "https://mirrors.aliyun.com/pypi/packages/08/82/2693e53e29f9104d9de95d37ce4dd826bd32d5f9c0085d3aa6ac042675c4/regex-2026.7.19-cp314-cp314-win_arm64.whl", hash = "sha256:65fa6cb38ed5e9c3637e68e544f598b39c3b86b808ed0627a67b68320384b459" }, + { url = "https://mirrors.aliyun.com/pypi/packages/92/b7/9a01aa16461a18cde9d7b9c3ab21e501db2ce33725f53014342b91df2b0a/regex-2026.7.19-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:5a2721c8720e2cb3c209925dfb9200199b4b07361c9e01d321719404b21458b3" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f3/5e/bbaeca815dc9191c424c94a4fdc5c87c75748a64a6271821212ebdd4e1a3/regex-2026.7.19-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:199535629f25caf89698039af3d1ad5fcae7f933e2112c73f1cdf49165c99518" }, + { url = "https://mirrors.aliyun.com/pypi/packages/cd/d6/0dd1a321afaab95eb7ff44aa0f637301786f1dc71c6b797b9ed236ed8890/regex-2026.7.19-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9b60d7814174f059e5de4ab98271cc5ba9259cfea55273a81544dceea32dc8d9" }, + { url = "https://mirrors.aliyun.com/pypi/packages/92/5f/40bacf91d0904f812e13bbbab3864604c463eced8afdc54aeaa50492ea95/regex-2026.7.19-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dbece16025afda5e3031af0c4059207e61dcf73ef13af844964f57f387d1c435" }, + { url = "https://mirrors.aliyun.com/pypi/packages/94/7c/4902744261f775aeede8b5627314b38482da29cf49a57b66a6fb753246c5/regex-2026.7.19-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d24ecb4f5e009ea0bd275ee37ad9953b32005e2e5e60f8bbae16da0dbbf0d3a0" }, + { url = "https://mirrors.aliyun.com/pypi/packages/16/70/6980c9be6bf21c0a60ed3e0aea39cf419ecf3b08d1d9947bc56e196ef186/regex-2026.7.19-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8cae6fd77a5b72dae505084b1a2ee0360139faf72fedbab667cd7cc65aae7a6a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/52/92/8b2bd872782ce8c42691e39acb38eb8efe014e5ddb78ad7d943d6f197ce9/regex-2026.7.19-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9724e6cb5e478cd7d8cabf027826178739cb18cf0e117d0e32814d479fa02276" }, + { url = "https://mirrors.aliyun.com/pypi/packages/de/2d/33a602f657bdc4041f17d79f92ab18261d255d91a06117a6e29df023e5e2/regex-2026.7.19-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:572fc57b0009c735ee56c175ea021b637a15551a312f56734277f923d6fd0f6c" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9e/36/0987cf4cb271680064a70d24a475873775a151d0b7058698a006cb0cae4a/regex-2026.7.19-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:20568e182eb82d39a6bf7cff3fd58566f14c75c6f74b2c8c96537eecf9010e3a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a8/24/c14f31c135e1ba55fa4f9a58ca98d0842512bf6188230763c31c8f449e3b/regex-2026.7.19-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:1d58561843f0ff7dc78b4c28b5e2dc388f3eff94ebc8a232a3adba961fc00009" }, + { url = "https://mirrors.aliyun.com/pypi/packages/14/85/181a12211f22469f24d2de1ebddfe397d2396e2c29013b9a58134a91069a/regex-2026.7.19-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:61bb1bd45520aacd56dd80943bd34991fb5350afdd1f36f2282230fd5154a218" }, + { url = "https://mirrors.aliyun.com/pypi/packages/23/58/bd1a0c1a62251366f8d21f41b1ea3c76994962071b8b6ea42f72d505c0f0/regex-2026.7.19-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:cd3584591ea4429026cdb931b054342c2bcf189b44ff367f8d5c15bc092a2966" }, + { url = "https://mirrors.aliyun.com/pypi/packages/e4/4f/f7e2dad6756b2fe1fe75dd90a628c3b45f249d39f948dd90cd2476325417/regex-2026.7.19-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5cc26a66e212fa5d6c6170c3a40d99d888db3020c6fdab1523250d4341382e44" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2b/d7/01d31d5bdb09bc026fab77f59a371fdf8f9b292e4810546c56182ca70498/regex-2026.7.19-cp314-cp314t-win32.whl", hash = "sha256:2c4e61e2e1be56f63ec3cc618aa9e0de81ef6f43d177205451840022e24f5b78" }, + { url = "https://mirrors.aliyun.com/pypi/packages/52/0e/cea4ce73bc0a8247a0748228ae6669984c7e1f8134b6fa66e59c0572e0ea/regex-2026.7.19-cp314-cp314t-win_amd64.whl", hash = "sha256:c639ea314df70a7b2811e8020448c75af8c9445f5a60f8a4ced81c306a9380c2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/6f/b6/26e41975febae63b7a6e3e02f32cff6cff2e4f10d19c929082f56aebf7c6/regex-2026.7.19-cp314-cp314t-win_arm64.whl", hash = "sha256:9a15e785f244f3e07847b984ce8773fc3da10a9f3c131cc49a4c5b4d672b4547" }, +] + [[package]] name = "requests" version = "2.34.2" @@ -1066,6 +1514,19 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06" }, ] +[[package]] +name = "resend" +version = "2.35.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "requests" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/f0/b9/e07f2f2bd992ef4565b9c315dfd46b3df66ce89df1d928f442b9b39cbe3b/resend-2.35.0.tar.gz", hash = "sha256:26ced7b22cbd89f7b8c7ba9719d0708ab10c06ddd0f91ba6ec861f3a328101b3" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/cc/92/1c0912b68ae082a55dfdd32e4b5117905ae9fd1b6efc5f5e7c69a4ee6894/resend-2.35.0-py2.py3-none-any.whl", hash = "sha256:cd75299d626f4735af52910989b3f51919032ef316e2d60563c79c319e7b24a6" }, +] + [[package]] name = "rich" version = "15.0.0" @@ -1167,6 +1628,18 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6" }, ] +[[package]] +name = "sympy" +version = "1.14.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "mpmath" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/83/d3/803453b36afefb7c2bb238361cd4ae6125a569b4db67cd9e79846ba2d68c/sympy-1.14.0.tar.gz", hash = "sha256:d3d3fe8df1e5a0b42f0e7bdf50541697dbe7d23746e894990c030e2b05e72517" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/a2/09/77d55d46fd61b4a135c444fc97158ef34a095e5681d0a6c10b75bf356191/sympy-1.14.0-py3-none-any.whl", hash = "sha256:e091cc3e99d2141a0ba2847328f5479b05d94a6635cb96148ccb3f34671bd8f5" }, +] + [[package]] name = "tenacity" version = "9.1.4" @@ -1176,6 +1649,65 @@ wheels = [ { url = "https://mirrors.aliyun.com/pypi/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55" }, ] +[[package]] +name = "tiktoken" +version = "0.13.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "regex" }, + { name = "requests" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/e4/e5/5f3cb2159769d0f4324c0e9e87f9de3c4b1cd45848a96b2eb3566ad5ca77/tiktoken-0.13.0.tar.gz", hash = "sha256:c9435714c3a84c2319499de9a300c0e604449dd0799ff246458b3bb6a7f433c1" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/85/8e/144bde4e01df66b34bb865557c7cd754ed08b036217ebd79c9db5e9048a9/tiktoken-0.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:32ac870a806cfb260a02d0cb70426aef02e038297f8ad50df5040bb5af360791" }, + { url = "https://mirrors.aliyun.com/pypi/packages/36/18/d4ac9d20956cdebca04841316660ed584c2fecdc2b81722a28bc7ad3b1e4/tiktoken-0.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4d9980f11429ed2d737c463bb1fb78cf330caa026adf002f714aced7849a687b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/74/ed/6bb8d05b9f731f749fee5c6f5ca63e981143c826a5985877330507bd13b7/tiktoken-0.13.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:3f277ebea5edd7b8bf03c6f9431e1d67d517530115572b2dc1d465326e8f88c7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/34/de/2ca96b07a82d972b74fe4b46de055b79c904e45c7eab699354a0bfa697dc/tiktoken-0.13.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:a116178fa7e1b4065bff05214360373a65cac22f965be7b3f73d00a0dbfe7649" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ee/dc/9dafec002c2d4424378563cf4cf5c7fb93631d2a55013c8b87554ee4012c/tiktoken-0.13.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c397ddda233208345b01bd30f2fca79ff730e55731d0108a603f9bc57f6af3b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/a1/d0/1f8578c45b2f24759b46f0b50d31878c63c73e6bf0f2227e10ec5c5408dc/tiktoken-0.13.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:95097e4f89b06403976e498abf61a0ee73a7497e73fb599cb211d8197a054d91" }, + { url = "https://mirrors.aliyun.com/pypi/packages/aa/90/28d7f154888610aa9237e541986beb62b479df29d193a5a0617dbb1514d0/tiktoken-0.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:8f2d16e7a7c783ad81f36e457d046d1f1c8af70b22aec8a13238efe531977c41" }, + { url = "https://mirrors.aliyun.com/pypi/packages/9c/83/b096c859c2a47c11731bf2f5885f4028b809dfe2396582883eed9cae372f/tiktoken-0.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5df5d1507bd245f1ccad4a074698240021239e455eb0bb4ced4e3d7181872154" }, + { url = "https://mirrors.aliyun.com/pypi/packages/53/61/c68e123b6d753e3fc2751e9b18e732c9d8bf1e1926762e736eee935d931c/tiktoken-0.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8fe806a50664e83a6ffd56cbd1e4f5dcc6cd32a3e7538f70dc38b1a271384545" }, + { url = "https://mirrors.aliyun.com/pypi/packages/ef/8b/96cc178cc584e65d363134500f297790b06cd48cdeb1e8fcf7bbe60f4715/tiktoken-0.13.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:125bc05005e747f993a83dc67934249932d6e4209854452cd4c0b1d53fba3ba2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/86/f5/bab735d2c72ea55404b295d02d092644eb5f7cc6205e34d35eb9abfb9ab2/tiktoken-0.13.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:5e6358911cab4adee6712da27d65573496a4f68cf8a2b5fca6a4ad10fc5748cf" }, + { url = "https://mirrors.aliyun.com/pypi/packages/4e/b9/6de04ebdf904edfaad87788011b3735087a0c9ea671b9027e1e4e965e8c8/tiktoken-0.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:975cbd78d085d75d26b59660e262736dcaed1e35f8f142cd6291025c01d25486" }, + { url = "https://mirrors.aliyun.com/pypi/packages/0d/9c/470a05f3b1caf038f44880e334d47ab674e0c80d514c66b375d14d5afa10/tiktoken-0.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:75ab9bc99fa020a4c283424590ecd7f3afd70c1c281cb3fa3192a6c3af9f9615" }, + { url = "https://mirrors.aliyun.com/pypi/packages/42/a6/c1936d16055436cb32e6c6128d68629622e00f4768562f55653752d34768/tiktoken-0.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:6b1615f0ff71953d19729ceb18865429c185b0a23c5353f1bbca34a394bf60f7" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d6/07/acb5992c3772b5a36284f742cfb7a5895aa4471d1848ac31464ad50d7fdf/tiktoken-0.13.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6eb4a5bfbc6426938026b1a334e898ac53541360d62d8c689870160cc80abd67" }, + { url = "https://mirrors.aliyun.com/pypi/packages/14/e9/742e9aec30f59b9f161f7ff7cd072e02ea836c9e1c0854a8076dfcd40d5c/tiktoken-0.13.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:43cee3e5400573b2046fbf092cc7a5bc30164f9e4c95ce20714da929df48737a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/72/74/ca1541b053e7648254d2e4b42a253e1bb4359f2c91a0a8d49228c794e1a0/tiktoken-0.13.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:7de52e3f566d19b3b11bd37eea552c6c305ad74081f736882bd44d148ed4c48d" }, + { url = "https://mirrors.aliyun.com/pypi/packages/46/e3/93825eaf5a4a504795b787e5d5dea07fbeb3dabf97aa7b450be8bde59c89/tiktoken-0.13.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:51384448aa508e4df84c0f7c1dc3211c7f7b8096325660ee5fc82f3e11b381ce" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8c/46/002b68de6827091d5ae90b048f326e8aad8d953520950e5ce1508879414f/tiktoken-0.13.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e28157350f7ebf35008dd8e9e0fdb621f976e4230c881099c85e8cf07eaa50e2" }, + { url = "https://mirrors.aliyun.com/pypi/packages/db/c6/d393e3185a276505182f7abd93fe714f3c444a2be9180798fa052347504e/tiktoken-0.13.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:165cf1820ea4a354985c2490a5205d4cc74661c934aca79dd0368232fff94e0f" }, + { url = "https://mirrors.aliyun.com/pypi/packages/b7/4d/bc07d1f1635d4897a202acc0ae11c2886eaa7325c359ba4741b47bf8e225/tiktoken-0.13.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6c43a675ca14f6f2749ba7f12075d37456015a24b859f2517b9beb4ef30807ec" }, + { url = "https://mirrors.aliyun.com/pypi/packages/8c/93/0dd6adca026a616c3a92974566b43381eea4b475ce1f36c062b8271a9ac5/tiktoken-0.13.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaaaef47c2406277181d2086484c317bf7fc433e2d5d03ff94f56b0dcec87471" }, + { url = "https://mirrors.aliyun.com/pypi/packages/d9/77/5ec6e6bc5b30bed6d93f7f2162d8f6b32437b3ba27cb527cfe004f6109c9/tiktoken-0.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ca8b310bd93b3772cb1b7922d915446864860f562bdfe4825c63a0aed3fb28cd" }, + { url = "https://mirrors.aliyun.com/pypi/packages/94/b0/c8ae9aff00d625c50659b4513e707a0462c4bf5d4d6cc1b802103225c02e/tiktoken-0.13.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:32e0c12305105002c047b3bb1070b0dd9a73b0cb3b2856a8972b810e7a4f5881" }, + { url = "https://mirrors.aliyun.com/pypi/packages/1b/ac/6a5dddd1d0a6018ecb389bd0353e6b4a515eb4d2286611bd0ace1937b9e1/tiktoken-0.13.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:5ba5fd62507a932d1241346179e3b39bc7bf7408f03c272652d93b3bedf5db24" }, + { url = "https://mirrors.aliyun.com/pypi/packages/f4/b8/585032b4384b2f7dcdaddcb52865c83a701a420d09e3c2b4a2be1c450c57/tiktoken-0.13.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d108bc2d470fc53c8ecd24f2c0fd2b5f98c33e87cdb6aa2e9b8c5dced703d273" }, + { url = "https://mirrors.aliyun.com/pypi/packages/cd/b6/993ff1ded3958215fd341a847b8e5ffeb5de473f435296870d314fc91ac4/tiktoken-0.13.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cb99cb5127449f58d0a2d5f5ccfb390d8dbdfd919c221246caaee29d8725ed51" }, + { url = "https://mirrors.aliyun.com/pypi/packages/dd/3d/fef7e06e3b33e7538db0ced734cf9fe23b6832d2ac4990c119c377aec55e/tiktoken-0.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:115c4f26ffa11caac8b54eea35c2ad38c612c20a48d35dd15d70a02ac6f51f58" }, + { url = "https://mirrors.aliyun.com/pypi/packages/c1/82/a7fc44582bc32ab00de988a2299bf77c077f59068b233109e34b7d6ca7e6/tiktoken-0.13.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:472527e9132952f2fbf77cd290658bacf003d4d5a3fabc18e5fbd407cbae4d9b" }, + { url = "https://mirrors.aliyun.com/pypi/packages/37/d0/24d8a890c14f432a05cea669c17bebeaa99f96a7c79523b590f564246411/tiktoken-0.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:4e2f67d27c9626cdd25fe33d9313c5cdb3d8d82da646b68d6eb8e7e9c20e6448" }, + { url = "https://mirrors.aliyun.com/pypi/packages/49/b7/2ab43f62788a9266187a9bfc1d3af99ad83e5eaa25fbef168a69cd5ad14f/tiktoken-0.13.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:2b920b35805cd64585a37c3dc7ce65fba4d2d36016be01e1d7942482ca29093a" }, + { url = "https://mirrors.aliyun.com/pypi/packages/64/39/1494321ed323ce7a14d88e3cd6cb9058625977df1c6961ddc492bd10a9f3/tiktoken-0.13.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:493af3aa28a4aaf2e3d2600a2ee717252c9bf5ab38fff94eb5a02db5ab77e5ad" }, + { url = "https://mirrors.aliyun.com/pypi/packages/96/d9/dfd086aa2d918c563a140720e0ce296cada1634efd2783d5cf51e05f984e/tiktoken-0.13.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6644c9c2b5cf3916f5a3641d7d12fdb3f006a7b3d9ff6acdaec44e29ab1ff91e" }, + { url = "https://mirrors.aliyun.com/pypi/packages/2f/68/a18b4f307086954fdae32714cb4f85562e34f9d34ab206e61f1816aa6018/tiktoken-0.13.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5cb65b60b9408563676d874a3a4ee573370066f0dc4e29d84e82e989c6517424" }, + { url = "https://mirrors.aliyun.com/pypi/packages/16/5b/f2aa703a4fc5d2dff73460a7d46cc2f3f44aa0f3dd8eeb20d2a0ecf68862/tiktoken-0.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:85b78cc3a2c3d48723ca751fa981f1fedccd54194ca0471b957364353a898b07" }, +] + +[[package]] +name = "tqdm" +version = "4.70.0" +source = { registry = "https://mirrors.aliyun.com/pypi/simple/" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://mirrors.aliyun.com/pypi/packages/21/3b/6c24bec5be5e743ffd99576daa5cc077722fc7d5bbc00bd133fa0c698dc6/tqdm-4.70.0.tar.gz", hash = "sha256:55b0b0dbd97462d06ebee91e4dac24ed4d4702be82b24f07e6c1d27e08cea220" } +wheels = [ + { url = "https://mirrors.aliyun.com/pypi/packages/f9/1c/01bfd571a64e7f270e6bab5e33777debe0edc56759233ce84f27dec92d14/tqdm-4.70.0-py3-none-any.whl", hash = "sha256:7f585706bfddbdebf89daac705b2dfcc16890130727d3197ca62c732b4310953" }, +] + [[package]] name = "typing-extensions" version = "4.16.0" @@ -1458,6 +1990,8 @@ name = "windup-ai-engine" version = "0.1.0" source = { editable = "packages/ai_engine" } dependencies = [ + { name = "av" }, + { name = "imageio" }, { name = "langchain-core" }, { name = "langgraph" }, { name = "numpy" }, @@ -1468,6 +2002,8 @@ dependencies = [ [package.metadata] requires-dist = [ + { name = "av", specifier = ">=14.0" }, + { name = "imageio", specifier = ">=2.36" }, { name = "langchain-core", specifier = ">=0.3" }, { name = "langgraph", specifier = ">=0.2" }, { name = "numpy", specifier = ">=1.26" }, @@ -1482,7 +2018,7 @@ version = "0.1.0" source = { editable = "packages/app" } dependencies = [ { name = "fastapi" }, - { name = "pydantic" }, + { name = "pydantic", extra = ["email"] }, { name = "python-multipart" }, { name = "sqlalchemy" }, { name = "uvicorn", extra = ["standard"] }, @@ -1494,7 +2030,7 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "fastapi", specifier = ">=0.115" }, - { name = "pydantic", specifier = ">=2.7" }, + { name = "pydantic", extras = ["email"], specifier = ">=2.7" }, { name = "python-multipart", specifier = ">=0.0.9" }, { name = "sqlalchemy", specifier = ">=2.0" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.30" }, @@ -1520,10 +2056,19 @@ version = "0.1.0" source = { editable = "packages/framework" } dependencies = [ { name = "httpx" }, + { name = "langchain-core" }, + { name = "langchain-openai" }, + { name = "numpy" }, + { name = "onnxruntime" }, + { name = "passlib", extra = ["bcrypt"] }, + { name = "pillow" }, { name = "psycopg", extra = ["binary"] }, { name = "pydantic" }, { name = "pydantic-settings" }, { name = "pyjwt" }, + { name = "qiniu" }, + { name = "redis" }, + { name = "resend" }, { name = "sqlalchemy" }, { name = "windup-common" }, ] @@ -1531,10 +2076,19 @@ dependencies = [ [package.metadata] requires-dist = [ { name = "httpx", specifier = ">=0.27" }, + { name = "langchain-core", specifier = ">=0.3" }, + { name = "langchain-openai", specifier = ">=0.3" }, + { name = "numpy", specifier = ">=1.26" }, + { name = "onnxruntime", specifier = ">=1.17,<1.24" }, + { name = "passlib", extras = ["bcrypt"], specifier = ">=1.7" }, + { name = "pillow", specifier = ">=10.4" }, { name = "psycopg", extras = ["binary"], specifier = ">=3.2" }, { name = "pydantic", specifier = ">=2.7" }, { name = "pydantic-settings", specifier = ">=2.4" }, { name = "pyjwt", specifier = ">=2.9" }, + { name = "qiniu", specifier = ">=7.14" }, + { name = "redis", specifier = ">=5.0" }, + { name = "resend", specifier = ">=2.0" }, { name = "sqlalchemy", specifier = ">=2.0" }, { name = "windup-common", editable = "packages/common" }, ] From 401cd9225ccb67422c75ab25251bd3c644b71df7 Mon Sep 17 00:00:00 2001 From: xiaocheny214 <187097481+xiaocheny214@users.noreply.github.com> Date: Tue, 4 Aug 2026 23:57:03 +0800 Subject: [PATCH 6/8] fix(test): add auth_client fixture for project API tests - Add auth_client fixture with valid JWT token - Update test_project_api.py to use auth_client - Fix CI failures caused by auth middleware blocking unauthenticated requests --- backend/tests/test_project_api.py | 121 ++++++++++++++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 backend/tests/test_project_api.py diff --git a/backend/tests/test_project_api.py b/backend/tests/test_project_api.py new file mode 100644 index 00000000..c3508dbd --- /dev/null +++ b/backend/tests/test_project_api.py @@ -0,0 +1,121 @@ +"""项目 CRUD API 集成测试。 + +通过 ``TestClient`` 打全链路:请求 -> 路由 -> service -> SQLite -> 统一响应。 +验证统一响应契约(HTTP 恒 200、code 在 body、``ListResponse`` 分页字段、 +``timestamp`` 默认省略)与 400/404 业务码路径。 +""" + + +def _payload(**overrides): + """构造合法的创建请求体(对齐 ``ProjectCreate``)。""" + base = { + "user_id": 10001, + "project_name": "像素游戏", + "character_perspective": 1, + "directional_movement": 2, + "sprite_width": 64, + "sprite_height": 64, + } + base.update(overrides) + return base + + +# -- POST /projects ---------------------------------------------------------- + + +def test_create_success(auth_client): + resp = auth_client.post("/projects", json=_payload(project_name="新建")) + + assert resp.status_code == 200 + body = resp.json() + assert body["code"] == 200 + assert body["message"] == "创建成功" + assert body["data"]["id"] is not None + assert body["data"]["project_name"] == "新建" + assert body["data"]["create_at"] + assert "timestamp" not in body + + +def test_create_duplicate_name_returns_400(auth_client): + auth_client.post("/projects", json=_payload(project_name="重名")) + resp = auth_client.post("/projects", json=_payload(project_name="重名")) + + assert resp.status_code == 200 + body = resp.json() + assert body["code"] == 400 + assert body["message"] == "项目名称已存在" + assert body["data"] is None + + +def test_create_validation_error_returns_400(auth_client): + resp = auth_client.post("/projects", json=_payload(project_name="x" * 21)) + + assert resp.status_code == 200 + assert resp.json()["code"] == 400 + + +# -- GET /projects/{id} ------------------------------------------------------ + + +def test_get_success(auth_client): + created = auth_client.post("/projects", json=_payload(project_name="详情")).json()["data"] + resp = auth_client.get(f"/projects/{created['id']}") + + assert resp.json()["code"] == 200 + assert resp.json()["data"]["project_name"] == "详情" + + +def test_get_not_found_returns_404(auth_client): + resp = auth_client.get("/projects/99999") + + body = resp.json() + assert body["code"] == 404 + assert body["message"] == "项目不存在" + assert body["data"] is None + + +# -- GET /projects ----------------------------------------------------------- + + +def test_list_empty(auth_client): + resp = auth_client.get("/projects") + + body = resp.json() + assert body["code"] == 200 + assert body["data"] == [] + assert body["total"] == 0 + assert body["page"] == 1 + assert body["page_size"] == 20 + + +def test_list_paginates_and_filters(auth_client): + for i in range(3): + auth_client.post("/projects", json=_payload(user_id=10001, project_name=f"a{i}")) + auth_client.post("/projects", json=_payload(user_id=20002, project_name="other")) + + resp = auth_client.get("/projects", params={"page": 1, "page_size": 2, "user_id": 10001}) + + body = resp.json() + assert body["total"] == 3 + assert len(body["data"]) == 2 + assert [item["project_name"] for item in body["data"]] == ["a2", "a1"] + assert all(item["user_id"] == 10001 for item in body["data"]) + + +# -- DELETE /projects/{id} --------------------------------------------------- + + +def test_delete_success(auth_client): + created = auth_client.post("/projects", json=_payload(project_name="删除")).json()["data"] + resp = auth_client.delete(f"/projects/{created['id']}") + + body = resp.json() + assert body["code"] == 200 + assert body["message"] == "删除成功" + assert auth_client.get(f"/projects/{created['id']}").json()["code"] == 404 + + +def test_delete_not_found_returns_404(auth_client): + resp = auth_client.delete("/projects/99999") + + assert resp.json()["code"] == 404 From d4f89f0e4e945ce412e1518453bda9a1aa5c9546 Mon Sep 17 00:00:00 2001 From: CyberSeeker_Sea Date: Thu, 6 Aug 2026 18:29:21 +0800 Subject: [PATCH 7/8] =?UTF-8?q?feat(domain):=20=E5=AE=9A=E4=B9=89=E9=A2=86?= =?UTF-8?q?=E5=9F=9F=E5=9F=BA=E7=A1=80=E5=B1=82=E2=80=94=E2=80=94=E6=8E=A5?= =?UTF-8?q?=E5=8F=A3=E3=80=81=E6=95=B0=E6=8D=AE=E7=BB=93=E6=9E=84=E4=B8=8E?= =?UTF-8?q?=E6=A8=A1=E5=9D=97=E9=AA=A8=E6=9E=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - workflow_run 模块:接口、ORM 模型、JSONB 节点树 schema - agent 模块:SSE 会话管理骨架 - project/character 模块:接口、ORM 模型、service 实现 - 统一异常处理器(BizException 继承体系) - media 上传 API --- .../src/windup_app/server/character/model.py | 4 +- .../windup_app/server/character/service.py | 69 +++++++ .../windup_app/server/project/interface.py | 24 ++- .../src/windup_app/server/project/model.py | 10 +- .../src/windup_app/server/project/service.py | 60 +++++++ .../server/workflow_run/__init__.py | 1 + .../server/workflow_run/interface.py | 63 +++++++ .../windup_app/server/workflow_run/model.py | 39 ++++ .../windup_app/server/workflow_run/schema.py | 54 ++++++ .../app/src/windup_app/web/api/agent.py | 103 +++++++++++ .../app/src/windup_app/web/api/character.py | 170 ++++++++++++++++++ .../app/src/windup_app/web/api/project.py | 123 +++++++++++++ .../src/windup_app/web/api/workflow_run.py | 88 +++++++++ backend/tests/test_character_api.py | 57 ++++++ backend/tests/test_project_api.py | 10 +- 15 files changed, 859 insertions(+), 16 deletions(-) create mode 100644 backend/packages/app/src/windup_app/server/character/service.py create mode 100644 backend/packages/app/src/windup_app/server/project/service.py create mode 100644 backend/packages/app/src/windup_app/server/workflow_run/__init__.py create mode 100644 backend/packages/app/src/windup_app/server/workflow_run/interface.py create mode 100644 backend/packages/app/src/windup_app/server/workflow_run/model.py create mode 100644 backend/packages/app/src/windup_app/server/workflow_run/schema.py create mode 100644 backend/packages/app/src/windup_app/web/api/agent.py create mode 100644 backend/packages/app/src/windup_app/web/api/character.py create mode 100644 backend/packages/app/src/windup_app/web/api/project.py create mode 100644 backend/packages/app/src/windup_app/web/api/workflow_run.py create mode 100644 backend/tests/test_character_api.py diff --git a/backend/packages/app/src/windup_app/server/character/model.py b/backend/packages/app/src/windup_app/server/character/model.py index 5ea1c134..435b9c7b 100644 --- a/backend/packages/app/src/windup_app/server/character/model.py +++ b/backend/packages/app/src/windup_app/server/character/model.py @@ -35,7 +35,7 @@ from datetime import datetime, timezone from pydantic import BaseModel, Field -from sqlalchemy import BigInteger, DateTime, Integer, JSON, SmallInteger, Text +from sqlalchemy import BigInteger, DateTime, Integer, JSON, SmallInteger, String, Text from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.orm import Mapped, mapped_column @@ -60,6 +60,8 @@ class Character(Base): project_id: Mapped[int] = mapped_column(BigInteger, nullable=False) + name: Mapped[str | None] = mapped_column(String(20), nullable=True) + description: Mapped[str | None] = mapped_column(Text, nullable=True) reference_image_url: Mapped[str | None] = mapped_column(Text, nullable=True) diff --git a/backend/packages/app/src/windup_app/server/character/service.py b/backend/packages/app/src/windup_app/server/character/service.py new file mode 100644 index 00000000..e1ff22d4 --- /dev/null +++ b/backend/packages/app/src/windup_app/server/character/service.py @@ -0,0 +1,69 @@ +"""角色领域服务的 SQLAlchemy 实现。 + +:class:`SqlAlchemyCharacterService` 继承 :class:`CharacterService` 接口,用同步 +SQLAlchemy session 落库。无状态:``session`` 由调用方按请求传入,本对象可作 +模块级单例(:data:`service`)。 + +事务边界由 ``windup_framework.db.get_session`` 依赖负责--成功 commit、异常 +rollback,故本实现只 ``flush``(把变更发到当前事务、取回生成的主键),不 commit。 +""" + +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from windup_app.server.character.interface import CharacterService +from windup_app.server.character.model import Character + + +class SqlAlchemyCharacterService(CharacterService): + """基于 SQLAlchemy session 的角色 CRUD 实现。""" + + def create_character(self, session: Session, **fields) -> Character: + character = Character(**fields) + session.add(character) + session.flush() + return character + + def get_character(self, session: Session, character_id: int) -> Character | None: + return session.get(Character, character_id) + + def list_characters( + self, session: Session, *, project_id: int, page: int, page_size: int, + ) -> tuple[list[Character], int]: + count_stmt = ( + select(func.count()) + .select_from(Character) + .where(Character.project_id == project_id) + ) + stmt = ( + select(Character) + .where(Character.project_id == project_id) + .order_by(Character.id.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + ) + total = session.scalar(count_stmt) or 0 + items = list(session.scalars(stmt)) + return items, total + + def update_character( + self, session: Session, character_id: int, **fields, + ) -> Character | None: + character = session.get(Character, character_id) + if character is None: + return None + for key, value in fields.items(): + setattr(character, key, value) + session.flush() + return character + + def delete_character(self, session: Session, character_id: int) -> bool: + character = session.get(Character, character_id) + if character is None: + return False + session.delete(character) + session.flush() + return True + + +service = SqlAlchemyCharacterService() diff --git a/backend/packages/app/src/windup_app/server/project/interface.py b/backend/packages/app/src/windup_app/server/project/interface.py index f9b89de3..1bacf4fd 100644 --- a/backend/packages/app/src/windup_app/server/project/interface.py +++ b/backend/packages/app/src/windup_app/server/project/interface.py @@ -2,10 +2,16 @@ 项目 API 只依赖本模块定义的抽象接口。数据库、缓存或其他具体实现应在 应用装配层继承 :class:`ProjectService` 后通过依赖注入提供。 + +约定为 session-per-call:``session`` 由调用方(FastAPI 的 ``get_session`` 依赖) +按请求传入,具体实现(如 :mod:`windup_app.server.project.service`)保持无状态, +可作为模块级单例。 """ from abc import ABC, abstractmethod +from sqlalchemy.orm import Session + from windup_app.server.project.model import Project @@ -13,23 +19,27 @@ class ProjectService(ABC): """项目 CRUD 用例的抽象边界。""" @abstractmethod - def create_project(self, project: Project) -> Project: - """创建项目。""" + def create_project(self, session: Session, **fields) -> Project: + """创建项目。 + + ``fields`` 为项目字段(对齐 ``ProjectCreate`` 的字段集),由实现组装成 + :class:`Project` 后持久化。 + """ @abstractmethod - def project_name_exists(self, *, user_id: int, project_name: str) -> bool: + def project_name_exists(self, session: Session, *, user_id: int, project_name: str) -> bool: """判断用户下的项目名称是否已存在。""" @abstractmethod - def get_project(self, project_id: int) -> Project | None: + def get_project(self, session: Session, project_id: int) -> Project | None: """按 ID 查询项目。""" @abstractmethod def list_projects( - self, *, page: int, page_size: int, user_id: int | None = None + self, session: Session, *, page: int, page_size: int, user_id: int | None = None ) -> tuple[list[Project], int]: - """分页查询项目。""" + """分页查询项目,返回 (当前页数据, 总数)。""" @abstractmethod - def delete_project(self, project_id: int) -> bool: + def delete_project(self, session: Session, project_id: int) -> bool: """删除项目并返回是否找到。""" diff --git a/backend/packages/app/src/windup_app/server/project/model.py b/backend/packages/app/src/windup_app/server/project/model.py index 440cafc5..50080a0e 100644 --- a/backend/packages/app/src/windup_app/server/project/model.py +++ b/backend/packages/app/src/windup_app/server/project/model.py @@ -2,7 +2,7 @@ from datetime import datetime, timezone -from sqlalchemy import BigInteger, DateTime, SmallInteger, String, Text, UniqueConstraint +from sqlalchemy import BigInteger, DateTime, Integer, SmallInteger, String, Text, UniqueConstraint from sqlalchemy.orm import Mapped, mapped_column from windup_framework.db import Base @@ -16,7 +16,13 @@ class Project(Base): UniqueConstraint("user_id", "project_name", name="uq_windup_project_user_name"), ) - id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + # Postgres 上 BigInteger 自增;variant 到 Integer 让 SQLite(测试库)走 + # INTEGER PRIMARY KEY 自增(SQLite 仅对该声明自动分配 rowid)。 + id: Mapped[int] = mapped_column( + BigInteger().with_variant(Integer, "sqlite"), + primary_key=True, + autoincrement=True, + ) user_id: Mapped[int] = mapped_column(BigInteger, nullable=False) workflow_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True) project_name: Mapped[str] = mapped_column(String(20), nullable=False) diff --git a/backend/packages/app/src/windup_app/server/project/service.py b/backend/packages/app/src/windup_app/server/project/service.py new file mode 100644 index 00000000..85cee08a --- /dev/null +++ b/backend/packages/app/src/windup_app/server/project/service.py @@ -0,0 +1,60 @@ +"""项目领域服务的 SQLAlchemy 实现。 + +:class:`SqlAlchemyProjectService` 继承 :class:`ProjectService` 接口,用同步 +SQLAlchemy session 落库。无状态:``session`` 由调用方按请求传入,本对象可作 +模块级单例(:data:`service`)。 + +事务边界由 ``windup_framework.db.get_session`` 依赖负责--成功 commit、异常 +rollback,故本实现只 ``flush``(把变更发到当前事务、取回生成的主键),不 commit。 +""" + +from sqlalchemy import func, select +from sqlalchemy.orm import Session + +from windup_app.server.project.interface import ProjectService +from windup_app.server.project.model import Project + + +class SqlAlchemyProjectService(ProjectService): + """基于 SQLAlchemy session 的项目 CRUD 实现。""" + + def create_project(self, session: Session, **fields) -> Project: + project = Project(**fields) + session.add(project) + session.flush() # 取回自增主键 id 与 Python 侧默认值(create_at/update_at) + return project + + def project_name_exists(self, session: Session, *, user_id: int, project_name: str) -> bool: + stmt = ( + select(Project.id) + .where(Project.user_id == user_id, Project.project_name == project_name) + .limit(1) + ) + return session.scalar(stmt) is not None + + def get_project(self, session: Session, project_id: int) -> Project | None: + return session.get(Project, project_id) + + def list_projects( + self, session: Session, *, page: int, page_size: int, user_id: int | None = None + ) -> tuple[list[Project], int]: + count_stmt = select(func.count()).select_from(Project) + stmt = select(Project) + if user_id is not None: + count_stmt = count_stmt.where(Project.user_id == user_id) + stmt = stmt.where(Project.user_id == user_id) + total = session.scalar(count_stmt) or 0 + stmt = stmt.order_by(Project.id.desc()).offset((page - 1) * page_size).limit(page_size) + items = list(session.scalars(stmt)) + return items, total + + def delete_project(self, session: Session, project_id: int) -> bool: + project = session.get(Project, project_id) + if project is None: + return False + session.delete(project) + session.flush() + return True + + +service = SqlAlchemyProjectService() diff --git a/backend/packages/app/src/windup_app/server/workflow_run/__init__.py b/backend/packages/app/src/windup_app/server/workflow_run/__init__.py new file mode 100644 index 00000000..18b7be42 --- /dev/null +++ b/backend/packages/app/src/windup_app/server/workflow_run/__init__.py @@ -0,0 +1 @@ +"""工作流执行记录领域。""" diff --git a/backend/packages/app/src/windup_app/server/workflow_run/interface.py b/backend/packages/app/src/windup_app/server/workflow_run/interface.py new file mode 100644 index 00000000..c4d65e3b --- /dev/null +++ b/backend/packages/app/src/windup_app/server/workflow_run/interface.py @@ -0,0 +1,63 @@ +"""工作流执行记录领域服务接口。 + +API 层只依赖本模块定义的抽象。具体实现在应用装配层继承后通过依赖注入提供。 + +职责 +---- +- 存储执行记录(含前端维护的节点树 JSONB) +- 支持版本管理(修改角色模板 → 新版本 run) + +不做 +---- +- 不感知节点结构(前端自定义 nodes JSONB 内容) +- 不管节点拼装和推进(由前端负责) +- 不执行业务逻辑(由原子能力 API 负责) +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod + +from windup_app.server.workflow_run.model import ( + RunStatus, + WorkflowRun, +) + + +class WorkflowRunService(ABC): + """执行记录用例的抽象边界。""" + + # -- 执行记录 CRUD -------------------------------------------------------- + + @abstractmethod + def create_run( + self, + *, + project_id: int, + nodes: list | None = None, + ) -> WorkflowRun: + """创建执行记录。 + + nodes 为前端定义的初始节点树(可选)。 + """ + + @abstractmethod + def get_run(self, run_id: int) -> WorkflowRun | None: + """获取执行记录详情(含 nodes JSONB)。""" + + @abstractmethod + def update_run( + self, + run_id: int, + *, + nodes: list | None = None, + status: RunStatus | None = None, + ) -> WorkflowRun: + """全量更新执行记录。 + + 前端维护节点树后,通过此接口全量写回。 + """ + + @abstractmethod + def delete_run(self, run_id: int) -> None: + """软删除执行记录。""" diff --git a/backend/packages/app/src/windup_app/server/workflow_run/model.py b/backend/packages/app/src/windup_app/server/workflow_run/model.py new file mode 100644 index 00000000..e3ff7226 --- /dev/null +++ b/backend/packages/app/src/windup_app/server/workflow_run/model.py @@ -0,0 +1,39 @@ +"""工作流执行记录领域模型。 + +后端只做存储,不感知节点结构。 +节点树由前端维护,通过 workflow_run.nodes JSONB 字段全量读写。 +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import datetime, timezone +from enum import StrEnum + + +# -- 枚举 ---------------------------------------------------------------- + + +class RunStatus(StrEnum): + """执行记录状态。""" + + ACTIVE = "active" + SOFT_DELETED = "soft_deleted" + + +# -- 执行记录 ------------------------------------------------------------- + + +@dataclass +class WorkflowRun: + """执行记录——前端维护的节点树的持久化容器。 + + 后端不校验 nodes 内部结构,仅做全量读写。 + """ + + id: int | None = None + project_id: int = 0 + nodes: list = field(default_factory=list) # 节点树(前端自定义结构,后端不校验) + status: RunStatus = RunStatus.ACTIVE + version: int = 1 + created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) diff --git a/backend/packages/app/src/windup_app/server/workflow_run/schema.py b/backend/packages/app/src/windup_app/server/workflow_run/schema.py new file mode 100644 index 00000000..9c571bb4 --- /dev/null +++ b/backend/packages/app/src/windup_app/server/workflow_run/schema.py @@ -0,0 +1,54 @@ +"""工作流执行记录 API Schema。 + +定义前端请求/响应的 Pydantic 模型,与 server 层解耦。 +前端团队参考此文件了解接口契约。 + +后端只做存储,不感知 nodes 字段的内部结构,前端自定义。 +""" + +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict, Field + + +# ══════════════════════════════════════════════════════════════════════════════ +# 执行记录 +# ══════════════════════════════════════════════════════════════════════════════ + + +class WorkflowRunCreateRequest(BaseModel): + """创建执行记录。""" + + project_id: int = Field(description="关联项目 ID") + nodes: list = Field( + default_factory=list, + description="节点树(前端自定义结构,后端不校验)", + ) + + +class WorkflowRunUpdateRequest(BaseModel): + """全量更新执行记录。 + + 前端维护节点树后,通过此接口全量写回。 + """ + + nodes: list = Field( + default_factory=list, + description="节点树(前端自定义结构,后端不校验)", + ) + status: str | None = Field( + default=None, + description="状态:active / soft_deleted", + ) + + +class WorkflowRunOut(BaseModel): + """执行记录响应。""" + + model_config = ConfigDict(from_attributes=True) + + id: int + project_id: int + nodes: list = Field(default_factory=list, description="节点树(前端自定义结构)") + status: str = Field(description="active / soft_deleted") + version: int = Field(description="版本号,从 1 递增") diff --git a/backend/packages/app/src/windup_app/web/api/agent.py b/backend/packages/app/src/windup_app/web/api/agent.py new file mode 100644 index 00000000..de721bd3 --- /dev/null +++ b/backend/packages/app/src/windup_app/web/api/agent.py @@ -0,0 +1,103 @@ +"""AI Proxy(LLM 代理)API。 + +后端是无状态 LLM 代理,不做 agent 编排。 +前端维护 conversation history,传完整 messages 数组。 +OpenAI 兼容格式,前端可用 OpenAI SDK 解析。 + +端点 +---- +POST /ai/chat LLM 代理(流式响应) + +认证 +---- +复用现有 JWT 体系,user_id 从 token 解析(用于日志/审计,不存对话)。 +""" + +from __future__ import annotations + +import json +import logging + +from fastapi import APIRouter, Request +from fastapi.responses import StreamingResponse +from pydantic import BaseModel, Field + +logger = logging.getLogger("windup.ai.proxy") + +router = APIRouter(prefix="/ai", tags=["ai"]) + + +# ══════════════════════════════════════════════════════════════════════════════ +# 请求/响应模型 +# ══════════════════════════════════════════════════════════════════════════════ + + +class ChatMessage(BaseModel): + """对话消息。""" + + role: str = Field(description="消息角色:system / user / assistant / tool") + content: str | None = Field(default=None, description="消息内容") + tool_calls: list[dict] | None = Field(default=None, description="工具调用(assistant 角色)") + tool_call_id: str | None = Field(default=None, description="工具结果关联的调用 ID(tool 角色)") + + +class ToolDefinition(BaseModel): + """工具定义(OpenAI function calling 格式)。""" + + type: str = Field(default="function", description="工具类型,固定为 function") + function: dict = Field(description="函数定义:{name, description, parameters}") + + +class ChatRequest(BaseModel): + """LLM 代理请求。""" + + system: str | None = Field(default=None, description="系统提示词") + messages: list[ChatMessage] = Field(description="对话历史(前端维护完整列表)") + model: str | None = Field(default=None, description="模型名称,省略则使用默认模型") + temperature: float | None = Field(default=None, ge=0, le=2, description="温度参数") + tools: list[ToolDefinition] | None = Field( + default=None, + description="可用工具列表(前端定义,后端透传给 LLM)", + ) + + +# ══════════════════════════════════════════════════════════════════════════════ +# 端点 +# ══════════════════════════════════════════════════════════════════════════════ + + +@router.post("/chat") +async def chat( + body: ChatRequest, + request: Request, +) -> StreamingResponse: + """LLM 代理(流式响应)。 + + 后端职责: + 1. 从 JWT 解析 user_id(用于日志/审计) + 2. 调用 create_chat_model 工厂创建 LLM 实例 + 3. 流式转发 LLM 响应 + 4. 不解析 tool_calls、不执行工具、不存对话历史 + + 响应格式:OpenAI 兼容 SSE + ``` + data: {"choices": [{"delta": {"content": "好"}}]} + data: {"choices": [{"delta": {"tool_calls": [...]}}]} + data: [DONE] + ``` + """ + # TODO: 调用 create_chat_model 创建 LLM 实例,流式转发 + # 当前返回占位实现 + async def _placeholder(): + yield f'data: {json.dumps({"choices": [{"delta": {"content": "AI Proxy 尚未实现,请配置 LLM provider。"}}]}, ensure_ascii=False)}\n\n' + yield "data: [DONE]\n\n" + + return StreamingResponse( + _placeholder(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + ) diff --git a/backend/packages/app/src/windup_app/web/api/character.py b/backend/packages/app/src/windup_app/web/api/character.py new file mode 100644 index 00000000..e41e890b --- /dev/null +++ b/backend/packages/app/src/windup_app/web/api/character.py @@ -0,0 +1,170 @@ +"""角色 CRUD API。""" + +import logging + +from fastapi import APIRouter, Depends, Query +from pydantic import BaseModel, ConfigDict, Field +from sqlalchemy.orm import Session + +from windup_common.enums.biz_code import BizCode +from windup_common.exceptions import BizException +from windup_common.result import ListResponse, Response +from windup_framework.config.storage import settings as storage_settings +from windup_framework.db import get_session + +from windup_app.server.character.model import Character, CharacterData +from windup_app.server.character.service import service as character_service +from windup_app.server.media.service import service as media_service + +logger = logging.getLogger("windup.character.api") + +router = APIRouter(prefix="/characters", tags=["characters"]) + + +# ── 请求 / 响应模型 ───────────────────────────────────────────────────────── + + +class CharacterCreate(BaseModel): + """创建角色请求。""" + + project_id: int = Field(gt=0) + name: str | None = Field(default=None, max_length=20) + description: str | None = None + reference_image_url: str | None = None + character_data: CharacterData = Field(default_factory=CharacterData) + + +class CharacterUpdate(BaseModel): + """更新角色请求——所有字段可选。""" + + name: str | None = Field(default=None, max_length=20) + description: str | None = None + reference_image_url: str | None = None + character_data: CharacterData | None = None + + +class CharacterOut(BaseModel): + """角色响应。""" + + model_config = ConfigDict(from_attributes=True) + + id: int + project_id: int + name: str | None = None + description: str | None = None + reference_image_url: str | None = None + character_data: dict + status: int + + +# ── 辅助函数 ───────────────────────────────────────────────────────────────── + + +def _extract_object_keys(character: Character) -> list[str]: + """从角色中提取所有对象存储 key,用于删除时清理资源。 + + URL 格式: ``{download_base}/{object_key}`` + """ + prefix = storage_settings.download_base + "/" + keys: list[str] = [] + + # 参考图 + url = character.reference_image_url + if url and url.startswith(prefix): + keys.append(url[len(prefix):]) + + # character_data 内的 URL + data = character.character_data or {} + for outfit in data.get("outfits", []): + url = outfit.get("preview_url") + if url and url.startswith(prefix): + keys.append(url[len(prefix):]) + for action in outfit.get("actions", []): + for frame in action.get("frames", []): + url = frame.get("image_url") + if url and url.startswith(prefix): + keys.append(url[len(prefix):]) + + return keys + + +# ── 端点 ───────────────────────────────────────────────────────────────────── + + +@router.post("", response_model=Response[CharacterOut]) +def create_character( + body: CharacterCreate, session: Session = Depends(get_session), +) -> Response[CharacterOut]: + character = character_service.create_character( + session, + project_id=body.project_id, + name=body.name, + description=body.description, + reference_image_url=body.reference_image_url, + character_data=body.character_data.model_dump(), + ) + return Response.success(CharacterOut.model_validate(character), message="创建成功") + + +@router.get("", response_model=ListResponse[CharacterOut]) +def list_characters( + project_id: int = Query(..., gt=0), + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + session: Session = Depends(get_session), +) -> ListResponse[CharacterOut]: + items, total = character_service.list_characters( + session, project_id=project_id, page=page, page_size=page_size, + ) + return ListResponse.success( + [CharacterOut.model_validate(c) for c in items], + total=total, + page=page, + page_size=page_size, + ) + + +@router.get("/{character_id}", response_model=Response[CharacterOut]) +def get_character( + character_id: int, session: Session = Depends(get_session), +) -> Response[CharacterOut]: + character = character_service.get_character(session, character_id) + if character is None: + raise BizException("角色不存在", code=BizCode.NOT_FOUND) + return Response.success(CharacterOut.model_validate(character)) + + +@router.patch("/{character_id}", response_model=Response[CharacterOut]) +def update_character( + character_id: int, + body: CharacterUpdate, + session: Session = Depends(get_session), +) -> Response[CharacterOut]: + fields = body.model_dump(exclude_unset=True) + character = character_service.update_character(session, character_id, **fields) + if character is None: + raise BizException("角色不存在", code=BizCode.NOT_FOUND) + return Response.success(CharacterOut.model_validate(character), message="更新成功") + + +@router.delete("/{character_id}", response_model=Response[None]) +def delete_character( + character_id: int, session: Session = Depends(get_session), +) -> Response[None]: + character = character_service.get_character(session, character_id) + if character is None: + raise BizException("角色不存在", code=BizCode.NOT_FOUND) + + # 先提取对象 key,再删 DB 记录 + object_keys = _extract_object_keys(character) + + character_service.delete_character(session, character_id) + + # 清理对象存储——失败只记日志,不回滚 DB + for key in object_keys: + try: + media_service.delete(key) + except Exception: + logger.warning("[WINDUP] 媒体清理失败(已跳过) | key=%s", key, exc_info=True) + + return Response.success(None, message="删除成功") diff --git a/backend/packages/app/src/windup_app/web/api/project.py b/backend/packages/app/src/windup_app/web/api/project.py new file mode 100644 index 00000000..ead71016 --- /dev/null +++ b/backend/packages/app/src/windup_app/web/api/project.py @@ -0,0 +1,123 @@ +"""项目 CRUD API。""" + +import logging +from datetime import datetime + +from fastapi import APIRouter, Depends, Query, Request +from pydantic import BaseModel, ConfigDict, Field +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session + +from windup_common.enums.biz_code import BizCode +from windup_common.exceptions import BizException +from windup_common.result import ListResponse, Response +from windup_framework.db import get_session + +from windup_app.server.project.service import service + +logger = logging.getLogger("windup.project.api") + +router = APIRouter(prefix="/projects", tags=["projects"]) + + +class ProjectCreate(BaseModel): + """创建项目请求。""" + + workflow_id: int | None = None + project_name: str = Field(min_length=1, max_length=20) + character_perspective: int = Field(ge=1, le=3) + directional_movement: int = Field(ge=1, le=3) + sprite_width: int = Field(ge=32, le=2048) + sprite_height: int = Field(ge=32, le=2048) + game_style: str | None = None + sprite_sample_url: str | None = None + + +class ProjectOut(BaseModel): + """项目响应。""" + + model_config = ConfigDict(from_attributes=True) + + id: int + user_id: int + workflow_id: int | None + project_name: str + character_perspective: int + directional_movement: int + sprite_width: int + sprite_height: int + game_style: str | None + sprite_sample_url: str | None + create_at: datetime + update_at: datetime + + +@router.post("", response_model=Response[ProjectOut]) +def create_project( + body: ProjectCreate, + request: Request, + session: Session = Depends(get_session), +) -> Response[ProjectOut]: + user_id = request.state.current_user.id + if service.project_name_exists( + session, user_id=user_id, project_name=body.project_name + ): + logger.warning( + "[WINDUP] 创建拒绝-名称重复 | user_id=%s project_name=%s", + user_id, body.project_name, + ) + raise BizException("项目名称已存在", code=BizCode.BAD_REQUEST) + try: + project = service.create_project(session, user_id=user_id, **body.model_dump()) + except IntegrityError: + logger.warning( + "[WINDUP] 创建拒绝-并发冲突 | user_id=%s project_name=%s", + user_id, body.project_name, + ) + session.rollback() + raise BizException("项目名称已存在", code=BizCode.BAD_REQUEST) from None + return Response.success(ProjectOut.model_validate(project), message="创建成功") + + +@router.get("", response_model=ListResponse[ProjectOut]) +def list_projects( + request: Request, + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + session: Session = Depends(get_session), +) -> ListResponse[ProjectOut]: + user_id = request.state.current_user.id + projects, total = service.list_projects( + session, page=page, page_size=page_size, user_id=user_id + ) + return ListResponse.success( + [ProjectOut.model_validate(item) for item in projects], + total=total, + page=page, + page_size=page_size, + ) + + +@router.get("/{project_id}", response_model=Response[ProjectOut]) +def get_project( + project_id: int, + request: Request, + session: Session = Depends(get_session), +) -> Response[ProjectOut]: + project = service.get_project(session, project_id) + if project is None or project.user_id != request.state.current_user.id: + raise BizException("项目不存在", code=BizCode.NOT_FOUND) + return Response.success(ProjectOut.model_validate(project)) + + +@router.delete("/{project_id}", response_model=Response[None]) +def delete_project( + project_id: int, + request: Request, + session: Session = Depends(get_session), +) -> Response[None]: + project = service.get_project(session, project_id) + if project is None or project.user_id != request.state.current_user.id: + raise BizException("项目不存在", code=BizCode.NOT_FOUND) + service.delete_project(session, project_id) + return Response.success(None, message="删除成功") diff --git a/backend/packages/app/src/windup_app/web/api/workflow_run.py b/backend/packages/app/src/windup_app/web/api/workflow_run.py new file mode 100644 index 00000000..0cfc61a0 --- /dev/null +++ b/backend/packages/app/src/windup_app/web/api/workflow_run.py @@ -0,0 +1,88 @@ +"""工作流执行记录 API。 + +契约层:定义端点和请求/响应模型,与 server 层解耦。 +实际逻辑由 server 层实现,本文件只做参数校验和格式转换。 + +端点一览 +-------- +POST /workflow-runs 创建执行记录 +GET /workflow-runs/{id} 获取执行记录(含 nodes) +PATCH /workflow-runs/{id} 全量更新(含 nodes) +DELETE /workflow-runs/{id} 软删除 + +设计原则 +-------- +后端只做存储,不感知节点结构。 +nodes 字段由前端自定义,后端只做全量读写,不校验 nodes 内部结构。 +""" + +from __future__ import annotations + +import logging + +from fastapi import APIRouter, Depends +from sqlalchemy.orm import Session + +from windup_common.result import Response +from windup_framework.db import get_session + +from windup_app.server.workflow_run.schema import ( + WorkflowRunCreateRequest, + WorkflowRunOut, + WorkflowRunUpdateRequest, +) + +logger = logging.getLogger("windup.workflow_run.api") + +router = APIRouter(prefix="/workflow-runs", tags=["workflow-run"]) + + +# ── 执行记录 CRUD ─────────────────────────────────────────────────────────── + + +@router.post("", response_model=Response[WorkflowRunOut]) +def create_run( + body: WorkflowRunCreateRequest, + session: Session = Depends(get_session), +) -> Response[WorkflowRunOut]: + """创建执行记录。 + + nodes 为前端定义的初始节点树(可选)。 + """ + # TODO: service.create_run + raise NotImplementedError + + +@router.get("/{run_id}", response_model=Response[WorkflowRunOut]) +def get_run( + run_id: int, + session: Session = Depends(get_session), +) -> Response[WorkflowRunOut]: + """获取执行记录详情(含 nodes JSONB)。""" + # TODO: service.get_run + raise NotImplementedError + + +@router.patch("/{run_id}", response_model=Response[WorkflowRunOut]) +def update_run( + run_id: int, + body: WorkflowRunUpdateRequest, + session: Session = Depends(get_session), +) -> Response[WorkflowRunOut]: + """全量更新执行记录。 + + 前端维护节点树后,通过此接口全量写回。 + 后端不校验 nodes 内部结构。 + """ + # TODO: service.update_run + raise NotImplementedError + + +@router.delete("/{run_id}", response_model=Response[None]) +def delete_run( + run_id: int, + session: Session = Depends(get_session), +) -> Response[None]: + """软删除执行记录。""" + # TODO: service.delete_run + raise NotImplementedError diff --git a/backend/tests/test_character_api.py b/backend/tests/test_character_api.py new file mode 100644 index 00000000..f2a0b6b8 --- /dev/null +++ b/backend/tests/test_character_api.py @@ -0,0 +1,57 @@ +"""角色 CRUD API 集成测试。""" + + +def _create_project(auth_client, name: str = "默认项目") -> dict: + """创建一个项目并返回响应 data。""" + return auth_client.post("/projects", json={ + "project_name": name, + "character_perspective": 1, + "directional_movement": 2, + "sprite_width": 64, + "sprite_height": 64, + }).json()["data"] + + +def _payload(project_id: int, **overrides): + """构造合法的创建角色请求体。""" + base = { + "project_id": project_id, + "name": "勇者", + "description": "主角", + } + base.update(overrides) + return base + + +# -- POST /characters -------------------------------------------------------- + + +def test_create_with_name(auth_client): + project = _create_project(auth_client) + resp = auth_client.post("/characters", json=_payload(project["id"])) + + assert resp.status_code == 200 + body = resp.json() + assert body["code"] == 200 + assert body["data"]["name"] == "勇者" + assert body["data"]["description"] == "主角" + assert body["data"]["project_id"] == project["id"] + + +def test_create_without_name(auth_client): + project = _create_project(auth_client) + resp = auth_client.post("/characters", json=_payload(project["id"], name=None)) + + assert resp.json()["code"] == 200 + assert resp.json()["data"]["name"] is None + + +def test_create_name_roundtrip(auth_client): + """名称持久化后可通过 GET 读回。""" + project = _create_project(auth_client) + created = auth_client.post( + "/characters", json=_payload(project["id"], name="小精灵"), + ).json()["data"] + + resp = auth_client.get(f"/characters/{created['id']}") + assert resp.json()["data"]["name"] == "小精灵" diff --git a/backend/tests/test_project_api.py b/backend/tests/test_project_api.py index c3508dbd..957ddb75 100644 --- a/backend/tests/test_project_api.py +++ b/backend/tests/test_project_api.py @@ -9,7 +9,6 @@ def _payload(**overrides): """构造合法的创建请求体(对齐 ``ProjectCreate``)。""" base = { - "user_id": 10001, "project_name": "像素游戏", "character_perspective": 1, "directional_movement": 2, @@ -88,18 +87,17 @@ def test_list_empty(auth_client): assert body["page_size"] == 20 -def test_list_paginates_and_filters(auth_client): +def test_list_paginates(auth_client): for i in range(3): - auth_client.post("/projects", json=_payload(user_id=10001, project_name=f"a{i}")) - auth_client.post("/projects", json=_payload(user_id=20002, project_name="other")) + auth_client.post("/projects", json=_payload(project_name=f"a{i}")) - resp = auth_client.get("/projects", params={"page": 1, "page_size": 2, "user_id": 10001}) + resp = auth_client.get("/projects", params={"page": 1, "page_size": 2}) body = resp.json() assert body["total"] == 3 assert len(body["data"]) == 2 assert [item["project_name"] for item in body["data"]] == ["a2", "a1"] - assert all(item["user_id"] == 10001 for item in body["data"]) + assert all(item["user_id"] == 1 for item in body["data"]) # -- DELETE /projects/{id} --------------------------------------------------- From e987575a92840342c6a6d68946ce08ac64b3c90b Mon Sep 17 00:00:00 2001 From: johnnyzhang-eng Date: Thu, 6 Aug 2026 18:30:40 +0800 Subject: [PATCH 8/8] =?UTF-8?q?refactor(app):=20generation=20=E5=8C=85?= =?UTF-8?q?=E6=9B=B4=E5=90=8D=20orchestrator=20+=20=E7=94=9F=E6=88=90?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=E7=BC=96=E6=8E=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - server/generation → server/orchestrator(生成任务编排/调度) - orchestrator 模块:interface、model、service、task_repo、executor - generation API:图片生成 + 动作生成 + 任务轮询 - after_commit 回调修复任务行未提交竞态 - generation 端点从 JWT 取 user_id,加项目归属校验 --- .../server/orchestrator/__init__.py | 34 ++ .../server/orchestrator/executor.py | 406 ++++++++++++++++++ .../server/orchestrator/interface.py | 70 +++ .../windup_app/server/orchestrator/model.py | 185 ++++++++ .../windup_app/server/orchestrator/service.py | 56 +++ .../server/orchestrator/task_repo.py | 158 +++++++ .../app/src/windup_app/web/api/generation.py | 142 +++++- .../src/windup_framework/providers/sufy.py | 178 ++++++++ backend/tests/test_sufy_video_download.py | 82 ++++ 9 files changed, 1288 insertions(+), 23 deletions(-) create mode 100644 backend/packages/app/src/windup_app/server/orchestrator/__init__.py create mode 100644 backend/packages/app/src/windup_app/server/orchestrator/executor.py create mode 100644 backend/packages/app/src/windup_app/server/orchestrator/interface.py create mode 100644 backend/packages/app/src/windup_app/server/orchestrator/model.py create mode 100644 backend/packages/app/src/windup_app/server/orchestrator/service.py create mode 100644 backend/packages/app/src/windup_app/server/orchestrator/task_repo.py create mode 100644 backend/packages/framework/src/windup_framework/providers/sufy.py create mode 100644 backend/tests/test_sufy_video_download.py diff --git a/backend/packages/app/src/windup_app/server/orchestrator/__init__.py b/backend/packages/app/src/windup_app/server/orchestrator/__init__.py new file mode 100644 index 00000000..2cea0d63 --- /dev/null +++ b/backend/packages/app/src/windup_app/server/orchestrator/__init__.py @@ -0,0 +1,34 @@ +"""生成任务编排(orchestrator):提交 / 调度 / 查询生成任务。 + +本包只做**任务编排调度**——建任务记录、后台驱动执行、查询状态;实际 AI 生成 +(调 ai_engine)在 :mod:`.executor` 后台跑。原名 ``generation``,更名为 ``orchestrator`` +以准确表达职责(调度而非生成本身)。 +""" + +from windup_app.server.orchestrator.model import ( + ActionType, + CharacterActionFrame, + CharacterActionInput, + CharacterActionOutput, + CharacterImageInput, + GenerationTask, + GenerationTaskRecord, + GenerationType, + TaskStatus, +) +from windup_app.server.orchestrator.service import service as generation_service +from windup_app.server.orchestrator import task_repo + +__all__ = [ + "ActionType", + "CharacterActionFrame", + "CharacterActionInput", + "CharacterActionOutput", + "CharacterImageInput", + "GenerationTask", + "GenerationTaskRecord", + "GenerationType", + "TaskStatus", + "generation_service", + "task_repo", +] diff --git a/backend/packages/app/src/windup_app/server/orchestrator/executor.py b/backend/packages/app/src/windup_app/server/orchestrator/executor.py new file mode 100644 index 00000000..3afd7663 --- /dev/null +++ b/backend/packages/app/src/windup_app/server/orchestrator/executor.py @@ -0,0 +1,406 @@ +"""动作生成后台编排(调 ai_engine)。 + +编排链:``mark RUNNING → 取母版 → ai_engine 出帧 → 逐帧上传对象存储 → 写回结果/COMPLETED``。 +异常兜底为 FAILED,不抛。 + +**分层**:本模块调 ai_engine,故 web/worker **不得 import 本模块**(否则牵出 ai_engine, +违反"入口层不经 ai_engine 直连"门禁)。由 bootstrap(composition root)import + 注入 +``app.state``,web 端从 ``request.app.state`` 运行期取回调度,不产生静态依赖。 + +依赖(generator / upload / 取母版 / session 工厂)全可注入,缺省用真实实现(懒加载, +避免 import-time 触发 AI 配置)。测试注入桩即可离线跑通,不联网、不碰对象存储。 +""" + +from __future__ import annotations + +import logging +from collections.abc import Callable +from dataclasses import dataclass +from typing import TYPE_CHECKING + +import httpx +from sqlalchemy.orm import Session + +from windup_common.models import ActionSpec, ActionType as EngineActionType, CharacterCard + +from windup_app.server.orchestrator import task_repo +from windup_app.server.orchestrator.model import ( + CharacterActionInput, + CharacterImageInput, + TaskStatus, +) + +if TYPE_CHECKING: + from windup_ai_engine.ports import CharacterGeneratorPort, ProgressPort + +logger = logging.getLogger("windup.generation.executor") + +_ACTION_RESULT = "character_action" # task_repo._deserialize_result 按此标签反序列化 + +# ── 项目全局约束(Project 表)→ 统合喂给生成逻辑 ───────────────────────── +# character_perspective 游戏视角:1=横版(侧视) 2=俯视 3=2.5D → 生成朝向/视角 +_PERSPECTIVE_FACING: dict[int, str] = {1: "side", 2: "front", 3: "front"} +_PERSPECTIVE_VIEW: dict[int, str] = { + 1: "side view, horizontal side-scroller", + 2: "top-down view", + 3: "2.5D three-quarter view", +} +# directional_movement 移动方向:1=单向 2=四向 3=八向 → 需生成的方向数 +_MOVEMENT_DIRECTIONS: dict[int, int] = {1: 1, 2: 4, 3: 8} + + +@dataclass +class ProjectConstraints: + """从 Project 取的全局生成约束,统一约束角色图/动作生成。""" + + facing: str = "side" # character_perspective → 朝向(须与母版一致 #35) + view: str = "side view, horizontal side-scroller" + perspective: int = 1 # 1横版 2俯视 3 2.5D + directions: int = 1 # directional_movement → 方向数(1/4/8) + sprite_w: int = 256 # 输出/切帧尺寸(关键) + sprite_h: int = 256 + style: str = "" # game_style 画风 + stylize: str = "none" # 由 style 推:像素游戏 → pixel + sprite_sample_url: str = "" # 项目风格参考图 URL + + +def _load_constraints(session: Session, project_id: int | None) -> ProjectConstraints: + """查 Project 组装全局约束;无 project_id / 查不到 → 缺省。""" + if project_id is None: + return ProjectConstraints() + from windup_app.server.project.service import SqlAlchemyProjectService + + p = SqlAlchemyProjectService().get_project(session, project_id) + if p is None: + return ProjectConstraints() + style = p.game_style or "" + is_pixel = "pixel" in style.lower() or "像素" in style + return ProjectConstraints( + facing=_PERSPECTIVE_FACING.get(p.character_perspective, "side"), + view=_PERSPECTIVE_VIEW.get(p.character_perspective, _PERSPECTIVE_VIEW[1]), + perspective=p.character_perspective, + directions=_MOVEMENT_DIRECTIONS.get(p.directional_movement, 1), + sprite_w=p.sprite_width, + sprite_h=p.sprite_height, + style=style, + stylize="pixel" if is_pixel else "none", + sprite_sample_url=p.sprite_sample_url or "", + ) + + +def _fit_to(png: bytes, w: int, h: int) -> bytes: + """把帧等比缩放进 w×h(透明补边),落实项目 sprite 尺寸约束。""" + import io + + from PIL import Image + + im = Image.open(io.BytesIO(png)).convert("RGBA") + if im.size == (w, h): + return png + fitted = im.copy() + fitted.thumbnail((w, h), Image.NEAREST) + canvas = Image.new("RGBA", (w, h), (0, 0, 0, 0)) + canvas.alpha_composite(fitted, ((w - fitted.width) // 2, (h - fitted.height) // 2)) + buf = io.BytesIO() + canvas.save(buf, "PNG") + return buf.getvalue() + + +class _LogProgress: + """进度上报占位:MVP 无 SSE,记日志即可。""" + + def step(self, stage: str, i: int, total: int, note: str = "") -> None: + logger.info("[gen] %s %s/%s %s", stage, i, total, note) + + +def _to_engine_action(t) -> EngineActionType: + """generation.ActionType → 引擎 common.ActionType(按值映射)。 + + walk/idle/attack 直通;custom 等引擎未覆盖的类型暂不支持视频路线。 + """ + try: + return EngineActionType(t.value) + except ValueError as e: + raise ValueError(f"动作类型 {t.value!r} 暂不支持视频生成路线") from e + + +class ActionTaskExecutor: + """把一个 PENDING 动作任务跑成 COMPLETED/FAILED。""" + + def __init__( + self, + *, + generator: CharacterGeneratorPort | None = None, + upload: Callable[[bytes], str] | None = None, + fetch_master: Callable[[CharacterActionInput], bytes] | None = None, + fetch_constraints: Callable[[Session, int | None], ProjectConstraints] | None = None, + session_factory: Callable[[], Session] | None = None, + ) -> None: + self._generator = generator # None → 懒加载真实装配 + self._upload = upload # None → 真实对象存储上传 + self._fetch_master = fetch_master # None → 下载 reference_image_urls[0] + self._fetch_constraints = fetch_constraints # None → 查 project 全局约束 + self._session_factory = session_factory # None → SessionLocal + + def run_action_task( + self, + task_id: int, + input: CharacterActionInput, + project_id: int | None = None, + *, + session: Session | None = None, + ) -> None: + """跑一个动作任务;异常兜底为 FAILED,不抛。 + + 先从 ``project`` 取全局约束(朝向/画风/尺寸/方向)再调 ai_engine。``session`` + 缺省时自开一个(后台场景);测试可传入自己的 session。 + """ + own = session is None + session = session or self._make_session() + try: + task_repo.update_status(session, task_id, TaskStatus.RUNNING) + if own: + session.commit() + + cons = (self._fetch_constraints or _load_constraints)(session, project_id) + result = self._produce_action(input, cons) + task_repo.update_result(session, task_id, _ACTION_RESULT, result) + if own: + session.commit() + except Exception as exc: # noqa: BLE001 —— 兜底任何生成/上传/网络异常 + logger.exception("动作任务 %s 失败", task_id) + task_repo.update_status( + session, task_id, TaskStatus.FAILED, error_message=str(exc), + ) + if own: + session.commit() + finally: + if own: + session.close() + + # -- 内部 -------------------------------------------------------------- + + def _produce_action(self, input: CharacterActionInput, cons: ProjectConstraints) -> dict: + """母版 → ai_engine 出帧 → 按项目尺寸切帧 → 逐帧上传 → 组结果 dict。 + + 项目约束落实:``facing`` 随视角、``stylize`` 随画风(像素游戏→像素化)、 + 输出帧尺寸随 ``sprite_w×sprite_h``。方向数(directions)MVP 先出主方向, + 四向/八向为扩展(需多次生成或镜像)。 + """ + if cons.directions > 1: + logger.info("项目要求 %s 方向,MVP 先出主方向(多方向待扩展)", cons.directions) + master = (self._fetch_master or self._download_master)(input) + # 视频 i2v 没有独立的 style reference 字段,风格约束走提示词文字 + desc_parts = [input.custom_prompt or ""] + if cons.style: + desc_parts.append(f"Art style: {cons.style}") + card = CharacterCard(name=f"char-{input.character_id}", desc=" ".join(desc_parts)) + action = ActionSpec( + action=_to_engine_action(input.action_type), + poses=[""] * input.num_frames, + facing=cons.facing, + stylize=cons.stylize, + ) + progress: ProgressPort = _LogProgress() + generated = self._get_generator().generate(card, action, master, progress) + + upload = self._upload or self._upload_frame + frames = [ + {"index": i, + "image_url": upload(_fit_to(png, cons.sprite_w, cons.sprite_h)), + "duration_ms": dur} + for i, (png, dur) in enumerate(zip(generated.frames, generated.durations)) + ] + return {"type": "character_action", "action_type": input.action_type.value, "frames": frames} + + def _get_generator(self) -> CharacterGeneratorPort: + """懒装配真实 CharacterGenerator(视频路线 + 桩路线)。""" + if self._generator is None: + from windup_ai_engine.impl import CharacterGenerator + from windup_ai_engine.strategy.concrete import ( + PerFrameStrategy, + ProcIdleStrategy, + VideoFrameStrategy, + ) + from windup_common.models import GenRoute + from windup_framework.providers import ( + OnnxU2NetMatteProvider, + SufyImageProvider, + SufyVideoProvider, + ) + + matte = OnnxU2NetMatteProvider() + video = SufyVideoProvider() + image = SufyImageProvider() + self._generator = CharacterGenerator({ + GenRoute.VIDEO_I2V: VideoFrameStrategy(video, matte), + GenRoute.PER_FRAME: PerFrameStrategy(image, matte), + GenRoute.PROC_IDLE: ProcIdleStrategy(image, matte), + }) + return self._generator + + def _download_master(self, input: CharacterActionInput) -> bytes: + if not input.reference_image_urls: + raise ValueError("缺少母版:reference_image_urls 为空") + resp = httpx.get(input.reference_image_urls[0], timeout=30.0) + resp.raise_for_status() + return resp.content + + def _upload_frame(self, png: bytes) -> str: + from windup_app.server.media.model import MediaCategory, MediaUploadInput + from windup_app.server.media.service import service as media_service + + meta = MediaUploadInput( + filename="frame.png", + content_type="image/png", + size=len(png), + category=MediaCategory.ACTION_FRAME, + ) + return media_service.upload(png, meta).url + + def _make_session(self) -> Session: + if self._session_factory is not None: + return self._session_factory() + from windup_framework.db.session import SessionLocal + + return SessionLocal() + + +_IMAGE_RESULT = "character_image" # task_repo._deserialize_result 按此标签反序列化 + + +class ImageTaskExecutor: + """跑角色图片生成任务:参考图 + prompt → 图生图 → 上传 → 回写 image_url。""" + + def __init__( + self, + *, + image=None, # None → 懒加载 SufyImageProvider + upload: Callable[[bytes], str] | None = None, # None → 真实对象存储上传 + fetch_ref: Callable[[str], bytes] | None = None, # None → 下载 reference_image_url + session_factory: Callable[[], Session] | None = None, + ) -> None: + self._image = image + self._upload = upload + self._fetch_ref = fetch_ref + self._session_factory = session_factory + + def run_image_task( + self, + task_id: int, + input: CharacterImageInput, + project_id: int | None = None, + *, + session: Session | None = None, + ) -> None: + own = session is None + session = session or self._make_session() + try: + task_repo.update_status(session, task_id, TaskStatus.RUNNING) + if own: + session.commit() + cons = _load_constraints(session, project_id) # 角色图也受项目约束 + urls = self._produce_image(input, cons) + task_repo.update_result(session, task_id, _IMAGE_RESULT, { + "type": "character_image", + "image_urls": urls, + }) + if own: + session.commit() + except Exception as exc: # noqa: BLE001 —— 兜底 + logger.exception("图片任务 %s 失败", task_id) + task_repo.update_status(session, task_id, TaskStatus.FAILED, error_message=str(exc)) + if own: + session.commit() + finally: + if own: + session.close() + + def _produce_image(self, input: CharacterImageInput, cons: ProjectConstraints) -> list[str]: + """根据项目约束决定生成模式,返回 URL 列表。 + + 模式判断: + - 项目有 sprite_sample_url → **图生图**: 风格参考图 + 提示词 + - 项目无 sprite_sample_url → **文生图**: 纯提示词 + 用户传入的 reference_image_url 始终作为角色一致性参考(可选)。 + """ + fetch = self._fetch_ref or self._download + refs: list[bytes] = [] + has_style_ref = False + + # 1. 角色参考图(用户传入,可选,做角色一致性约束) + char_url = (input.reference_image_url or "").strip() + if char_url and char_url.lower() not in ("null", "none", ""): + refs.append(fetch(char_url)) + + # 2. 风格参考图(项目级,有 sprite_sample_url 时走图生图模式) + style_url = (cons.sprite_sample_url or "").strip() + if style_url and style_url.lower() not in ("null", "none", ""): + try: + refs.append(fetch(style_url)) + has_style_ref = True + except Exception: + pass # 风格参考图下载失败不阻断 + + # 3. 构建提示词 + base = input.prompt or "Clean full-body character reference of the figure in the image." + parts = [base, f"{cons.view}, full body head to feet, centered."] + if cons.style: + parts.append(f"Art style: {cons.style}.") + parts.append("Plain light-gray background, no shadow.") + + # 图生图模式:明确标注两张图的各自用途 + if has_style_ref: + prefix = ( + "This is an image-to-image task. " + "The first image is the CHARACTER reference — preserve its identity. " + "The second image is the STYLE reference — follow its art style, " + "color palette, and rendering technique. " + ) + parts.insert(0, prefix) + + prompt = " ".join(parts) + + image_gen = self._get_image() + upload = self._upload or self._upload_image + urls: list[str] = [] + for _ in range(max(1, input.num_images)): + img = image_gen.gen_image(prompt, refs) + urls.append(upload(img)) + return urls + + def _get_image(self): + if self._image is None: + from windup_framework.providers import SufyImageProvider + + self._image = SufyImageProvider() + return self._image + + def _download(self, url: str) -> bytes: + resp = httpx.get(url, timeout=30.0) + resp.raise_for_status() + return resp.content + + def _upload_image(self, png: bytes) -> str: + from windup_app.server.media.model import MediaCategory, MediaUploadInput + from windup_app.server.media.service import service as media_service + + meta = MediaUploadInput( + filename="character.png", content_type="image/png", + size=len(png), category=MediaCategory.REFERENCE_IMAGE, + ) + return media_service.upload(png, meta).url + + def _make_session(self) -> Session: + if self._session_factory is not None: + return self._session_factory() + from windup_framework.db.session import SessionLocal + + return SessionLocal() + + +# 默认执行器(真实依赖);bootstrap 取 run_action_task / run_image_task 注入 app.state +executor = ActionTaskExecutor() +run_action_task = executor.run_action_task +image_executor = ImageTaskExecutor() +run_image_task = image_executor.run_image_task diff --git a/backend/packages/app/src/windup_app/server/orchestrator/interface.py b/backend/packages/app/src/windup_app/server/orchestrator/interface.py new file mode 100644 index 00000000..cab6904c --- /dev/null +++ b/backend/packages/app/src/windup_app/server/orchestrator/interface.py @@ -0,0 +1,70 @@ +"""生成任务领域服务接口。 + +API 层只依赖本模块定义的抽象。具体实现(AI 引擎调用、任务队列等) +在应用装配层继承 :class:`GenerationService` 后通过依赖注入提供。 + +调用流程 +-------- +1. 前端调用 ``generate_character_image`` / ``generate_character_action`` 提交任务, + 拿到 ``task_id``。 +2. 前端轮询 ``get_task`` 查询任务状态;后续可扩展为 SSE 推送。 +3. 前端从 ``task.status`` 判断完成,从 ``result`` 取出出参,回填 character 模块: + + .. code-block:: text + + CharacterImageOutput.image_url → Character.reference_image_url + CharacterActionOutput.frames[] → character_data.outfits[].actions[].frames[] + +约定 +---- +- session-per-call: ``session`` 由调用方(FastAPI 的 ``get_session`` 依赖)按请求传入。 +- 具体实现保持无状态,可作为模块级单例。 +""" + +from abc import ABC, abstractmethod + +from sqlalchemy.orm import Session + +from windup_app.server.orchestrator.model import ( + CharacterActionInput, + CharacterImageInput, + GenerationTask, +) + + +class GenerationService(ABC): + """生成任务用例的抽象边界。""" + + # -- 任务提交 ------------------------------------------------------------ + + @abstractmethod + def generate_character_image( + self, session: Session, *, user_id: int, input: CharacterImageInput, + ) -> GenerationTask: + """提交角色图片生成任务。 + + 入参包含参考图 URL 和 prompt 等参数;出参为 ``CharacterImageOutput``, + 前端拿到 ``image_url`` 后回填 ``Character.reference_image_url``。 + """ + + @abstractmethod + def generate_character_action( + self, session: Session, *, user_id: int, input: CharacterActionInput, + ) -> GenerationTask: + """提交角色动作生成任务。 + + 入参包含角色 ID、动作类型和参考素材;出参为 ``CharacterActionOutput``, + 前端拿到 ``frames[]`` 后回填 ``character_data.outfits[].actions[].frames[]``。 + """ + + # -- 查询 ---------------------------------------------------------------- + + @abstractmethod + def get_task( + self, session: Session, project_id: int, task_id: int, + ) -> GenerationTask | None: + """查询任务状态与结果。 + + 返回完整的 ``GenerationTask``,前端根据 ``status`` 判断是否完成, + 从 ``result`` 中读取对应类型的出参。 + """ diff --git a/backend/packages/app/src/windup_app/server/orchestrator/model.py b/backend/packages/app/src/windup_app/server/orchestrator/model.py new file mode 100644 index 00000000..2558f416 --- /dev/null +++ b/backend/packages/app/src/windup_app/server/orchestrator/model.py @@ -0,0 +1,185 @@ +"""生成任务领域模型。 + +生成任务按类型区分:角色图片生成(→ ``Character.reference_image_url``)、 +角色动作生成(→ ``character_data.outfits[].actions[].frames[]``)。 +前端拿到生成结果后可直接回填 character 模块的对应字段。 +""" + +from dataclasses import dataclass, field +from datetime import datetime, timezone +from enum import StrEnum + +from sqlalchemy import BigInteger, DateTime, Integer, JSON, Text +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import Mapped, mapped_column + +from windup_framework.db import Base + + +# -- 枚举 ---------------------------------------------------------------- + + +class GenerationType(StrEnum): + """生成任务类型——每新增一种生成能力,在此加一个成员。""" + + CHARACTER_IMAGE = "character_image" # 角色参考图 + CHARACTER_ACTION = "character_action" # 角色动作帧序列 + + +class ActionType(StrEnum): + """角色动作子类型。""" + + WALK = "walk" + IDLE = "idle" + JUMP = "jump" + ATTACK = "attack" + CUSTOM = "custom" + + +class TaskStatus(StrEnum): + """生成任务状态。""" + + PENDING = "pending" + RUNNING = "running" + COMPLETED = "completed" + FAILED = "failed" + + +# -- 入参 ---------------------------------------------------------------- + + +@dataclass +class CharacterImageInput: + """角色图片生成入参。""" + + reference_image_url: str | None = None + prompt: str = "" + negative_prompt: str = "" + width: int = 1024 + height: int = 1024 + num_images: int = 1 + + +@dataclass +class CharacterActionInput: + """角色动作生成入参。""" + + character_id: int + action_type: ActionType + custom_prompt: str | None = None + reference_video_url: str | None = None + reference_image_urls: list[str] = field(default_factory=list) + num_frames: int = 16 + + +# -- 出参(按任务类型细化,前端可直接回填 character 模块)------------------ + + +@dataclass +class CharacterImageOutput: + """角色图片生成结果。 + + 前端拿到 ``image_urls`` 后写入 ``Character.reference_image_url``。 + 单张也用列表: ``["url"]``。 + """ + + type: str = "character_image" + image_urls: list[str] = field(default_factory=list) + + +@dataclass +class CharacterActionFrame: + """动作帧——前端写入 ``CharacterAction.frames[]``。""" + + index: int + image_url: str + duration_ms: int | None = None + + +@dataclass +class CharacterActionOutput: + """角色动作生成结果。 + + 前端拿到后写入 ``character_data.outfits[].actions[]``: + ``action_type`` → ``CharacterAction.type``, + ``frames`` → ``CharacterAction.frames[]``。 + """ + + type: str = "character_action" + action_type: str = "" + frames: list[CharacterActionFrame] = field(default_factory=list) + + +# -- 任务记录 ------------------------------------------------------------ + + +@dataclass +class GenerationTask: + """生成任务(贯穿整个生命周期)。""" + + id: int | None = None + user_id: int = 0 + project_id: int | None = None + task_type: GenerationType = GenerationType.CHARACTER_IMAGE + status: TaskStatus = TaskStatus.PENDING + input_payload: dict | None = None + result: CharacterImageOutput | CharacterActionOutput | None = None + error_message: str | None = None + create_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + update_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + + @property + def is_terminal(self) -> bool: + return self.status in (TaskStatus.COMPLETED, TaskStatus.FAILED) + + +# -- ORM ----------------------------------------------------------------- + + +class GenerationTaskRecord(Base): + """生成任务持久化记录。 + + ``input_payload`` 和 ``result`` 以 JSON 存储;``result_type`` 标识 + ``result`` 的具体类型,读出后按类型反序列化为对应 dataclass。 + """ + + __tablename__ = "windup_generation_task" + + id: Mapped[int] = mapped_column( + BigInteger().with_variant(Integer, "sqlite"), + primary_key=True, + autoincrement=True, + ) + user_id: Mapped[int] = mapped_column(BigInteger, nullable=False) + project_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True) + task_type: Mapped[str] = mapped_column( + Text, nullable=False, + default=GenerationType.CHARACTER_IMAGE.value, + ) + status: Mapped[str] = mapped_column( + Text, nullable=False, + default=TaskStatus.PENDING.value, + ) + input_payload: Mapped[dict] = mapped_column( + JSON().with_variant(JSONB, "postgresql"), + nullable=False, + default=dict, + ) + result_type: Mapped[str | None] = mapped_column(Text, nullable=True) + result: Mapped[dict | None] = mapped_column( + JSON().with_variant(JSONB, "postgresql"), + nullable=True, + ) + error_message: Mapped[str | None] = mapped_column(Text, nullable=True) + + create_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + default=lambda: datetime.now(timezone.utc), + ) + update_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc), + ) diff --git a/backend/packages/app/src/windup_app/server/orchestrator/service.py b/backend/packages/app/src/windup_app/server/orchestrator/service.py new file mode 100644 index 00000000..ab46abac --- /dev/null +++ b/backend/packages/app/src/windup_app/server/orchestrator/service.py @@ -0,0 +1,56 @@ +"""生成任务领域服务(提交 + 查询)。 + +:class:`AiGenerationService` 只负责**建任务记录 + 查任务**——web 层依赖本模块。 +实际 AI 生成(调 ai_engine)在 :mod:`.executor` 后台跑,本模块**不碰 ai_engine**, +以满足"入口层(web/worker)不经 ai_engine 直连"的分层门禁(web → service 不得牵出 ai_engine)。 + +无状态:``session`` 由调用方按请求传入,本对象作模块级单例(:data:`service`)。 +""" + +from __future__ import annotations + +import dataclasses + +from sqlalchemy.orm import Session + +from windup_app.server.orchestrator import task_repo +from windup_app.server.orchestrator.interface import GenerationService +from windup_app.server.orchestrator.model import ( + CharacterActionInput, + CharacterImageInput, + GenerationTask, + GenerationType, +) + + +class AiGenerationService(GenerationService): + """生成任务服务:提交(建 PENDING 记录)+ 查询。生成执行在 executor 后台。""" + + def generate_character_image( + self, session: Session, *, user_id: int, project_id: int | None = None, + input: CharacterImageInput, + ) -> GenerationTask: + return task_repo.create_task( + session, user_id=user_id, project_id=project_id, + task_type=GenerationType.CHARACTER_IMAGE, + input_payload=dataclasses.asdict(input), + ) + + def generate_character_action( + self, session: Session, *, user_id: int, project_id: int | None = None, + input: CharacterActionInput, + ) -> GenerationTask: + """建动作生成任务(PENDING)并返回;实际生成由 executor 后台跑,前端轮询 get_task。""" + return task_repo.create_task( + session, user_id=user_id, project_id=project_id, + task_type=GenerationType.CHARACTER_ACTION, + input_payload=dataclasses.asdict(input), + ) + + def get_task( + self, session: Session, project_id: int, task_id: int, + ) -> GenerationTask | None: + return task_repo.get_task(session, task_id) + + +service = AiGenerationService() diff --git a/backend/packages/app/src/windup_app/server/orchestrator/task_repo.py b/backend/packages/app/src/windup_app/server/orchestrator/task_repo.py new file mode 100644 index 00000000..26f12212 --- /dev/null +++ b/backend/packages/app/src/windup_app/server/orchestrator/task_repo.py @@ -0,0 +1,158 @@ +"""生成任务数据访问层。 + +纯 CRUD 操作,不含业务逻辑。所有函数接收 ``session: Session``, +由调用方(FastAPI ``get_session`` 依赖)管理事务边界——本模块只 +``flush`` 不 ``commit``。 +""" + +from __future__ import annotations + +from datetime import datetime, timezone + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from windup_app.server.orchestrator.model import ( + CharacterActionOutput, + CharacterImageOutput, + GenerationTask, + GenerationTaskRecord, + GenerationType, + TaskStatus, +) + + +# ── 写入 ───────────────────────────────────────────────────────────────── + + +def create_task( + session: Session, + *, + user_id: int, + project_id: int | None, + task_type: GenerationType, + input_payload: dict, +) -> GenerationTask: + """创建生成任务记录,返回领域对象。""" + record = GenerationTaskRecord( + user_id=user_id, + project_id=project_id, + task_type=task_type.value, + status=TaskStatus.PENDING.value, + input_payload=input_payload, + ) + session.add(record) + session.flush() + return _record_to_domain(record) + + +def update_status( + session: Session, + task_id: int, + status: TaskStatus, + *, + error_message: str | None = None, +) -> None: + """更新任务状态(可选附带错误信息)。""" + record = session.get(GenerationTaskRecord, task_id) + if record is None: + return + record.status = status.value + record.error_message = error_message + record.update_at = datetime.now(timezone.utc) + session.flush() + + +def update_result( + session: Session, + task_id: int, + result_type: str, + result: dict, +) -> None: + """写入任务结果。""" + record = session.get(GenerationTaskRecord, task_id) + if record is None: + return + record.result_type = result_type + record.result = result + record.status = TaskStatus.COMPLETED.value + record.update_at = datetime.now(timezone.utc) + session.flush() + + +# ── 读取 ───────────────────────────────────────────────────────────────── + + +def get_task(session: Session, task_id: int) -> GenerationTask | None: + """按 task_id 查询任务。""" + record = session.get(GenerationTaskRecord, task_id) + if record is None: + return None + return _record_to_domain(record) + + +def get_task_by_user( + session: Session, + user_id: int, + task_id: int, +) -> GenerationTask | None: + """按 user_id + task_id 查询(校验归属)。""" + stmt = select(GenerationTaskRecord).where( + GenerationTaskRecord.id == task_id, + GenerationTaskRecord.user_id == user_id, + ) + record = session.scalar(stmt) + if record is None: + return None + return _record_to_domain(record) + + +# ── 转换 ───────────────────────────────────────────────────────────────── + + +def _record_to_domain(record: GenerationTaskRecord) -> GenerationTask: + """ORM 记录 → 领域 dataclass。""" + result = _deserialize_result(record.result_type, record.result) + return GenerationTask( + id=record.id, + user_id=record.user_id, + project_id=record.project_id, + task_type=GenerationType(record.task_type), + status=TaskStatus(record.status), + input_payload=record.input_payload, + result=result, + error_message=record.error_message, + create_at=record.create_at, + update_at=record.update_at, + ) + + +def _deserialize_result( + result_type: str | None, + raw: dict | None, +) -> CharacterImageOutput | CharacterActionOutput | None: + """根据 ``result_type`` 将 JSON dict 反序列化为对应的 dataclass。""" + if raw is None or result_type is None: + return None + if result_type == "character_image": + return CharacterImageOutput( + type=raw.get("type", "character_image"), + image_urls=raw.get("image_urls", []), + ) + if result_type == "character_action": + from windup_app.server.orchestrator.model import CharacterActionFrame + + frames = [ + CharacterActionFrame( + index=f["index"], + image_url=f["image_url"], + duration_ms=f.get("duration_ms"), + ) + for f in raw.get("frames", []) + ] + return CharacterActionOutput( + type=raw.get("type", "character_action"), + action_type=raw.get("action_type", ""), + frames=frames, + ) + return None diff --git a/backend/packages/app/src/windup_app/web/api/generation.py b/backend/packages/app/src/windup_app/web/api/generation.py index be3a5e90..0f0bc485 100644 --- a/backend/packages/app/src/windup_app/web/api/generation.py +++ b/backend/packages/app/src/windup_app/web/api/generation.py @@ -1,14 +1,16 @@ -"""生成任务 API。 +"""生成任务 API。""" -契约层:定义前端请求/响应的 Pydantic 模型,与 server 层解耦。 -实际逻辑由 server 层实现,本文件只做参数校验和格式转换。 -""" +from __future__ import annotations +import asyncio import dataclasses import logging +import threading +from collections import defaultdict -from fastapi import APIRouter, Depends, Query, Request +from fastapi import APIRouter, Depends, Request from pydantic import BaseModel, ConfigDict, Field +from sqlalchemy import event from sqlalchemy.orm import Session from windup_common.enums.biz_code import BizCode @@ -16,23 +18,68 @@ from windup_common.result import Response from windup_framework.db import get_session -from windup_app.server.generation.model import ( +from windup_app.server.orchestrator import task_repo +from windup_app.server.orchestrator.model import ( + CharacterActionInput, + CharacterImageInput, ActionType, GenerationTask, ) +from windup_app.server.orchestrator.service import service as generation_service +from windup_app.server.project.service import service as project_service logger = logging.getLogger("windup.generation.api") router = APIRouter(prefix="/generation", tags=["generation"]) -# ── 请求模型 ───────────────────────────────────────────────────────────────── +# ══════════════════════════════════════════════════════════════════════════════ +# EventBus(任务进度推送) +# ══════════════════════════════════════════════════════════════════════════════ + +# 心跳间隔(秒) +_HEARTBEAT_TIMEOUT = 30.0 + +# 终态事件 +_TERMINAL_EVENTS = {"completed", "failed"} + + +class _EventBus: + """任务进度内存发布-订阅。""" + + def __init__(self) -> None: + self._queues: dict[str, list[asyncio.Queue]] = defaultdict(list) + + async def subscribe(self, task_id: int) -> asyncio.Queue: + queue: asyncio.Queue = asyncio.Queue() + self._queues[str(task_id)].append(queue) + return queue + + async def unsubscribe(self, task_id: int, queue: asyncio.Queue) -> None: + key = str(task_id) + subs = self._queues.get(key) + if subs and queue in subs: + subs.remove(queue) + if not subs: + del self._queues[key] + + def publish(self, task_id: int, event: str, data: dict) -> None: + for queue in self._queues.get(str(task_id), []): + queue.put_nowait((event, data)) + + +# 全局实例,挂到 app.state.event_bus +event_bus = _EventBus() + + +# ══════════════════════════════════════════════════════════════════════════════ +# 请求/响应模型 +# ══════════════════════════════════════════════════════════════════════════════ class CharacterImageGenerateRequest(BaseModel): """提交角色图片生成任务。""" - user_id: int = Field(gt=0) project_id: int | None = None reference_image_url: str | None = None prompt: str = "" @@ -45,7 +92,6 @@ class CharacterImageGenerateRequest(BaseModel): class CharacterActionGenerateRequest(BaseModel): """提交角色动作生成任务。""" - user_id: int = Field(gt=0) project_id: int | None = None character_id: int = Field(gt=0) action_type: ActionType @@ -55,9 +101,6 @@ class CharacterActionGenerateRequest(BaseModel): num_frames: int = 16 -# ── 响应模型 ───────────────────────────────────────────────────────────────── - - class GenerationTaskOut(BaseModel): """生成任务响应。""" @@ -90,16 +133,38 @@ def _task_to_out(task: GenerationTask) -> GenerationTaskOut: ) -# ── 端点 ───────────────────────────────────────────────────────────────────── +# ══════════════════════════════════════════════════════════════════════════════ +# 端点 +# ══════════════════════════════════════════════════════════════════════════════ + + +def _dispatch_after_commit(session: Session, target, *args) -> None: + """注册 after_commit 回调:session 提交成功后再启动后台线程。 + + 解决竞态: create_task() 只 flush,session 在 handler 返回后才 commit。 + 若直接起线程,后台 session 可能读不到未提交的行,导致 update 静默跳过。 + """ + @event.listens_for(session, "after_commit", once=True) + def _after_commit(session): + threading.Thread(target=target, args=args, daemon=True).start() + + +def _validate_project_ownership( + session: Session, project_id: int | None, user_id: int, +): + """校验项目是否存在且归属当前用户;不存在或不匹配抛 404。""" + if project_id is None: + return + project = project_service.get_project(session, project_id) + if project is None or project.user_id != user_id: + raise BizException("项目不存在", code=BizCode.NOT_FOUND) def _validate_project_size(session: Session, project_id: int | None, width: int, height: int) -> None: """校验输入尺寸与项目约束是否一致;不一致则抛异常。""" if project_id is None: return - from windup_app.server.project.service import SqlAlchemyProjectService - - project = SqlAlchemyProjectService().get_project(session, project_id) + project = project_service.get_project(session, project_id) if project is None: return if width != project.sprite_width or height != project.sprite_height: @@ -116,9 +181,23 @@ def submit_image_generation( session: Session = Depends(get_session), ) -> Response[GenerationTaskOut]: """提交角色图片生成任务:建 PENDING 记录立即返回,实际图生图后台跑。""" + user_id = request.state.current_user.id + _validate_project_ownership(session, body.project_id, user_id) _validate_project_size(session, body.project_id, body.width, body.height) - # TODO: service.create_image_task + background_tasks.add_task - raise BizException("接口待实现", code=BizCode.BAD_REQUEST) + input_data = CharacterImageInput( + reference_image_url=body.reference_image_url, + prompt=body.prompt, + negative_prompt=body.negative_prompt, + width=body.width, + height=body.height, + num_images=body.num_images, + ) + task = generation_service.generate_character_image( + session, user_id=user_id, project_id=body.project_id, input=input_data, + ) + # commit 后再启动后台线程,避免任务行未提交时线程读不到记录 + _dispatch_after_commit(session, request.app.state.run_image_task, task.id, input_data, body.project_id) + return Response.success(_task_to_out(task), message="任务已提交") @router.post("/action", response_model=Response[GenerationTaskOut]) @@ -128,16 +207,33 @@ def submit_action_generation( session: Session = Depends(get_session), ) -> Response[GenerationTaskOut]: """提交角色动作生成任务:建 PENDING 记录立即返回,实际生成后台跑。""" - # TODO: service.create_action_task + background_tasks.add_task - raise BizException("接口待实现", code=BizCode.BAD_REQUEST) + user_id = request.state.current_user.id + _validate_project_ownership(session, body.project_id, user_id) + input_data = CharacterActionInput( + character_id=body.character_id, + action_type=body.action_type, + custom_prompt=body.custom_prompt, + reference_video_url=body.reference_video_url, + reference_image_urls=body.reference_image_urls, + num_frames=body.num_frames, + ) + task = generation_service.generate_character_action( + session, user_id=user_id, project_id=body.project_id, input=input_data, + ) + # commit 后再启动后台线程,避免任务行未提交时线程读不到记录 + _dispatch_after_commit(session, request.app.state.run_action_task, task.id, input_data, body.project_id) + return Response.success(_task_to_out(task), message="任务已提交") @router.get("/tasks/{task_id}", response_model=Response[GenerationTaskOut]) def get_task( task_id: int, - project_id: int = Query(..., gt=0), + request: Request, session: Session = Depends(get_session), ) -> Response[GenerationTaskOut]: """查询生成任务状态与结果。""" - # TODO: service.get_task - raise BizException("接口待实现", code=BizCode.BAD_REQUEST) + user_id = request.state.current_user.id + task = task_repo.get_task_by_user(session, user_id, task_id) + if task is None: + raise BizException("任务不存在", code=BizCode.NOT_FOUND) + return Response.success(_task_to_out(task)) diff --git a/backend/packages/framework/src/windup_framework/providers/sufy.py b/backend/packages/framework/src/windup_framework/providers/sufy.py new file mode 100644 index 00000000..c5a4ffa0 --- /dev/null +++ b/backend/packages/framework/src/windup_framework/providers/sufy.py @@ -0,0 +1,178 @@ +"""Provider 接口的 SUFY / qnaigc(OpenAI-compatible)同步实现。 + +视频走异步任务协议(2026-07-27 端到端实测): + POST /videos {model, prompt, size, seconds, mode, input_reference} + 轮询 GET /videos/{id} → status==completed → task_result.videos[0].url → 下载 mp4 +key / base_url 由 ``AIProviderSettings`` 注入,provider 内不读 env。 +重依赖(rembg)惰性导入,保证模块导入零成本。 +""" +from __future__ import annotations + +import base64 +import io +import time + +import httpx + +from windup_framework.config.provider import AIProviderSettings, settings + +from .interfaces import ImageProvider, VideoProvider + +# 只有 kling-video-o1 走 image_list;v2 系列 / sora 走 input_reference(字段按模型选,塞错任务会 failed)。 +_IMAGE_LIST_MODELS = ("kling-video-o1",) +DEFAULT_VIDEO_MODEL = "kling-v2-5-turbo" + + +def _first_frame_datauri(frame: bytes, size: str) -> str: + """首帧 bytes → 等比缩放 + 背景色补边到目标尺寸 → JPG(RGB,q90) base64 dataURI。 + + 不强拉到目标尺寸(母版多为横幅,强压成方会把角色压成瘦长鬼影);JPG 因 PNG base64 + 会 VENDOR_FAILED(实测)。 + """ + from PIL import Image + + w, h = (int(x) for x in size.split("x")) + im = Image.open(io.BytesIO(frame)).convert("RGB") + pad = im.getpixel((0, 0)) + fitted = im.copy() + fitted.thumbnail((w, h), Image.LANCZOS) + canvas = Image.new("RGB", (w, h), pad) + canvas.paste(fitted, ((w - fitted.width) // 2, (h - fitted.height) // 2)) + buf = io.BytesIO() + canvas.save(buf, "JPEG", quality=90) + return "data:image/jpeg;base64," + base64.b64encode(buf.getvalue()).decode() + + +class SufyVideoProvider(VideoProvider): + """kling i2v(默认 v2-5-turbo)。首帧 + 动作 prompt → mp4 bytes。""" + + def __init__( + self, + config: AIProviderSettings = settings, + model: str = DEFAULT_VIDEO_MODEL, + mode: str = "std", + poll_interval: float = 60.0, + max_min: int = 30, + ) -> None: + self._cfg = config + self._model = model + self._mode = mode + self._poll = poll_interval + self._max_min = max_min + + def _client(self) -> httpx.Client: + return httpx.Client( + base_url=self._cfg.normalized_base_url, + headers={"Authorization": f"Bearer {self._cfg.api_key}"}, + timeout=self._cfg.timeout, + ) + + def i2v( + self, first_frame: bytes, prompt: str, seconds: int = 5, size: str = "1280x720" + ) -> bytes: + body: dict = { + "model": self._model, + "prompt": prompt, + "size": size, + "seconds": str(seconds), + "mode": self._mode, + } + if self._model in _IMAGE_LIST_MODELS: + b64 = _first_frame_datauri(first_frame, size).split(",", 1)[1] + body["image_list"] = [{"image": b64}] + else: + body["input_reference"] = _first_frame_datauri(first_frame, size) + + with self._client() as client: + job = client.post("/videos", json=body).raise_for_status().json() + jid = job.get("id") + url = None + for _ in range(max(1, int(self._max_min * 60 // self._poll))): + time.sleep(self._poll) + st = client.get(f"/videos/{jid}").raise_for_status().json() + status = st.get("status") + if status == "completed": + vids = (st.get("task_result") or {}).get("videos") or [] + url = vids[0].get("url") if vids else None + break + if status in ("failed", "cancelled"): + raise RuntimeError(f"i2v 失败: {status}") + if not url: + raise RuntimeError("i2v 未取得视频 URL(超时或失败)") + return _download(client, url) + + +class IncompleteDownloadError(RuntimeError): + """视频下载到的字节数与 ``Content-Length`` 不符。""" + + +def _download(client: httpx.Client, url: str, tries: int = 3) -> bytes: + """下载已生成好的视频,带重试 + 长度校验。 + + 为什么单次读取不够(2026-08-05 实测,同一角色连续两单复现):原实现是 + ``client.get(url).raise_for_status().content``。**视频此时已经生成、费用已经产生**, + 只要读 body 时连接断一次,整单就废:: + + peer closed connection without sending complete message body + (received 720450 bytes, expected 929531) + + 重试是安全的:这是对成品 URL 的 GET,幂等且不再计费——**代价是一次重下, + 不重试的代价是一次重新生成**。 + + 长度校验是因为截断不一定抛异常:服务端提前关流而客户端已收到部分 body 时, + ``.content`` 可能直接返回短 bytes,那样坏视频会一路流到出帧环节才暴露, + 在那里看起来像"解码失败",很难回溯到这里。``Content-Length`` 缺失(分块传输)时跳过校验。 + """ + last: Exception | None = None + for attempt in range(tries): + try: + response = client.get(url) + response.raise_for_status() + body = response.content + expected = response.headers.get("content-length") + if expected and len(body) != int(expected): + raise IncompleteDownloadError(f"视频下载不完整: {len(body)}/{expected} 字节") + return body + except (httpx.HTTPError, IncompleteDownloadError) as exc: + last = exc + if attempt < tries - 1: + time.sleep(2**attempt) + raise RuntimeError(f"视频下载失败(已重试 {tries} 次): {last}") from last + + +class SufyImageProvider(ImageProvider): + """图像 provider:gemini 系图生图(OpenAI 兼容 ``/chat/completions``,返回 base64 图)。 + + 参考图 + 文字约束 → 生成一张图(角色基准图 CHARACTER_IMAGE / 逐帧图生图)。 + key / base_url 由 ``AIProviderSettings`` 注入,provider 内不读 env。 + """ + + def __init__( + self, + config: AIProviderSettings = settings, + model: str = "gemini-2.5-flash-image", + ) -> None: + self._cfg = config + self._model = model + + def gen_image(self, prompt: str, refs: list[bytes]) -> bytes: + import json + import re + + content: list = [{"type": "text", "text": prompt}] + for r in refs: + b64 = base64.b64encode(r).decode() + content.append( + {"type": "image_url", "image_url": {"url": "data:image/png;base64," + b64}} + ) + body = {"model": self._model, "messages": [{"role": "user", "content": content}]} + with httpx.Client( + base_url=self._cfg.normalized_base_url, + headers={"Authorization": f"Bearer {self._cfg.api_key}"}, + timeout=self._cfg.timeout, + ) as client: + res = client.post(self._cfg.chat_completions_path, json=body).raise_for_status().json() + m = re.search(r"data:image/[^;]+;base64,([A-Za-z0-9+/=]{100,})", json.dumps(res)) + if not m: + raise RuntimeError(f"图像响应无有效图: {json.dumps(res)[:200]}") + return base64.b64decode(m.group(1)) diff --git a/backend/tests/test_sufy_video_download.py b/backend/tests/test_sufy_video_download.py new file mode 100644 index 00000000..07671656 --- /dev/null +++ b/backend/tests/test_sufy_video_download.py @@ -0,0 +1,82 @@ +"""视频成品下载的重试与完整性校验(不联网:用 httpx MockTransport)。 + +回归对象是 2026-08-05 实测两次连续复现的一类失败:视频已生成、费用已产生, +却因为读 body 时断了一次连接就整单丢弃。见 ``providers.sufy._download`` 的 docstring。 +""" + +import httpx +import pytest + +from windup_framework.providers.sufy import IncompleteDownloadError, _download + +VIDEO = b"\x00\x01mp4-bytes" * 64 + + +def _client(handler) -> httpx.Client: + return httpx.Client(transport=httpx.MockTransport(handler)) + + +def test_retries_after_peer_closed_connection(monkeypatch): + """第一次断连、第二次成功 —— 原实现在这里会整单丢弃。""" + monkeypatch.setattr("windup_framework.providers.sufy.time.sleep", lambda _: None) + calls = {"n": 0} + + def handler(request: httpx.Request) -> httpx.Response: + calls["n"] += 1 + if calls["n"] == 1: + raise httpx.RemoteProtocolError( + "peer closed connection without sending complete message body", request=request + ) + return httpx.Response(200, content=VIDEO) + + with _client(handler) as client: + assert _download(client, "https://example.invalid/v.mp4") == VIDEO + assert calls["n"] == 2 + + +def test_rejects_truncated_body_that_does_not_raise(monkeypatch): + """服务端声明的长度与实收不符时必须失败,而不是把坏视频往下游送。 + + 截断不一定抛异常。放过去的话,坏视频要到出帧环节才暴露成"解码失败", + 很难回溯到下载这一步。 + """ + monkeypatch.setattr("windup_framework.providers.sufy.time.sleep", lambda _: None) + + def handler(request: httpx.Request) -> httpx.Response: + # 只回一半 body,但 Content-Length 仍声明全长 + return httpx.Response( + 200, content=VIDEO[: len(VIDEO) // 2], headers={"content-length": str(len(VIDEO))} + ) + + with _client(handler) as client, pytest.raises(RuntimeError, match="已重试 3 次"): + _download(client, "https://example.invalid/v.mp4") + + +def test_accepts_chunked_response_without_content_length(monkeypatch): + """分块传输没有 Content-Length,此时跳过校验而不是误判为不完整。""" + monkeypatch.setattr("windup_framework.providers.sufy.time.sleep", lambda _: None) + + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, stream=httpx.ByteStream(VIDEO)) + + with _client(handler) as client: + assert _download(client, "https://example.invalid/v.mp4") == VIDEO + + +def test_gives_up_after_three_tries_and_reports_the_last_cause(monkeypatch): + """一直断连时要显式失败,并把最后一次的真实原因带出来。""" + monkeypatch.setattr("windup_framework.providers.sufy.time.sleep", lambda _: None) + calls = {"n": 0} + + def handler(request: httpx.Request) -> httpx.Response: + calls["n"] += 1 + raise httpx.ConnectError("connection reset", request=request) + + with _client(handler) as client, pytest.raises(RuntimeError, match="connection reset"): + _download(client, "https://example.invalid/v.mp4") + assert calls["n"] == 3 + + +def test_incomplete_download_error_is_a_runtime_error(): + """调用方按 RuntimeError 兜底即可,不必单独 import 这个子类。""" + assert issubclass(IncompleteDownloadError, RuntimeError)