diff --git a/.env.example b/.env.example index 7797e0b6..affa5e38 100644 --- a/.env.example +++ b/.env.example @@ -40,8 +40,10 @@ AI_IMAGE_MODEL=gemini-2.5-flash-image AI_VIDEO_MODEL=kling-v2-5-turbo # ── 积分定价 ── -QUOTA_REGISTER_GIFT_AMOUNT=100 +QUOTA_REGISTER_GIFT_AMOUNT=300 QUOTA_INVITE_REWARD_AMOUNT=200 +QUOTA_INVITE_REWARD_DAILY_LIMIT=3 +QUOTA_INVITE_CODE_TTL_DAYS=30 QUOTA_GENERATE_IMAGE_COST=10 QUOTA_GENERATE_ACTION_COST=50 diff --git a/.github/assets/invite-rewards/invite-rewards-desktop.jpg b/.github/assets/invite-rewards/invite-rewards-desktop.jpg deleted file mode 100644 index 5213386a..00000000 Binary files a/.github/assets/invite-rewards/invite-rewards-desktop.jpg and /dev/null differ diff --git a/.github/assets/invite-rewards/invite-rewards-mobile.jpg b/.github/assets/invite-rewards/invite-rewards-mobile.jpg deleted file mode 100644 index 46ace97c..00000000 Binary files a/.github/assets/invite-rewards/invite-rewards-mobile.jpg and /dev/null differ diff --git a/.github/assets/readme/character-journey.webp b/.github/assets/readme/character-journey.webp new file mode 100644 index 00000000..9298c878 Binary files /dev/null and b/.github/assets/readme/character-journey.webp differ diff --git a/README.md b/README.md index 685a6ea5..ae6e3f01 100644 --- a/README.md +++ b/README.md @@ -8,9 +8,43 @@ 面向国产小游戏开发者的 2D 角色动态素材生成与资产工作台

-

交付的是资产,而不是图片。

+

让你的角色,真正登场。

-Windup 面向缺少美术产能的个人开发者和小型团队,把角色构思、动作生成、逐帧质检、试玩与引擎导出收进同一条生产链。用户从文字描述或参考图出发,最终得到可以持续补充动作、修正缺陷和重新导出的角色资产。 +

+ 在线使用 + · + 问题与建议 + · + OpenAPI +

+ +

+ windup.xin status + Frontend CI + Backend CI + Test coverage +

+ +

Windup 已上线,现已开放注册。

+ +

+ Windup 角色从线稿、母版到游戏资产的生成旅程 +

+ +Windup 面向缺少美术产能的个人开发者和小型团队,把角色构思、动作生成、逐帧审核、试玩与引擎导出收进同一条生产链。用户从文字描述或参考图出发,最终得到可以持续补充动作、修正缺陷和重新导出的角色资产。 + +## 当前能力 / What You Can Do + +| 能力 | 当前可用内容 | +| --- | --- | +| 项目与资产库 | 管理项目约束、角色、造型、动作与帧,继续扩展已有角色资产 | +| Quick Start | 用自然语言描述角色和动作,由系统建立标准制作流程 | +| Workflow Editor | 在真实节点画布中确认角色母版、动作首帧、生成方式、完整动画与审核状态 | +| 角色与动作生成 | 接入真实生成任务,保存任务状态与产物,支持失败恢复与结果追溯 | +| 审核与局部返工 | 对候选图和动作结果进行确认,在具体节点重试而不必重做整条流程 | +| Playtest 与导出 | 在浏览器中预览动作,并导出透明 PNG、Sprite Sheet、动画 JSON 与 ZIP 资源包 | + +三渲二、多方向资产和更多引擎适配仍在推进。相关基础能力进入仓库不等于已进入在线产品主流程;当前进度以 [`main`](https://github.com/1024XEngineer/Windup/tree/main) 与 [Issues](https://github.com/1024XEngineer/Windup/issues) 为准。 ## 产品链路 / Product Workflow @@ -18,12 +52,12 @@ Windup 面向缺少美术产能的个人开发者和小型团队,把角色构 新角色:文字描述 / 参考图 → 项目约束 → 角色母版 已有角色:从资产库继续生产 ─────────────┘ ↓ - 动作序列帧 → 逐帧审核 / 局部重生成 + 动作序列帧 → 审核 / 局部重生成 ↓ Playtest 试玩 → PNG / Sprite Sheet / 元数据 → 游戏引擎 ``` -Windup 用角色母版约束跨帧、跨动作的视觉一致性,再用确定性的工程后处理完成去背景、切帧、对齐和打包。出现缺陷时,返工可以缩小到具体帧或节点,已通过的结果继续保留。 +Windup 用角色母版约束跨帧、跨动作的视觉一致性,再用确定性的工程后处理完成去背景、切帧、对齐和打包。出现缺陷时,返工可以缩小到具体节点,已经确认的结果继续保留。 ## 核心对象 / Core Concepts @@ -31,42 +65,31 @@ Windup 用角色母版约束跨帧、跨动作的视觉一致性,再用确定 | --- | --- | | `Project` | 统一管理题材、美术风格、视角与精灵尺寸等项目级约束 | | `Character` | 角色资产本体;造型、动作实例与帧属于它的资产树 | -| `ActionTemplate` | 可在不同角色间复用的动作规格与生产配方 | | `Generation` | 一次生成任务及其输入、状态和结果,用于恢复与追溯 | -| `WorkflowRun` | 一次前端制作流程的运行记录,连接生成、确认、回退与导出 | - -产品提供两种入口:`Quick Start` 用自然语言建立标准生产流程;`Workflow Editor` 在系统预置的成熟管线上追加动作分支、微调参数和局部返工。两者共用同一套流程状态和质量门禁,分别服务快速创建与精细控制。 - -## 当前阶段 / Project Status +| `WorkflowRun` | 一次制作流程的持久化运行记录,连接生成、确认、回退与导出 | -MS2 已完成 Windup 的产品 MVP,验证了角色资产生产的核心链路。MS3 的重点从“完成一次生成”转向“持续完善已有角色资产”:用户可以从资产库回到已有角色,为它补充动作、重做有问题的分支,并保留未受影响的资产。 - -| 状态 | 内容 | -| --- | --- | -| MS2 产出 | 完成产品 MVP,跑通并验证角色资产生产的核心体验 | -| MS3 产品主线 | 已有角色补动作;工作流采用固定成熟管线,通过卡片加号追加分支,支持参数微调与局部重跑 | -| MS3 工程重点 | 持久化 `WorkflowRun` 并关联角色,串起工作流编辑、产物审核、节点回退与 Playtest | -| 后续探索 | Quick Start Agent、3D 动作生成路线、多视角资产与项目级导出 | - -项目进度见 [`main`](https://github.com/1024XEngineer/Windup/tree/main) 与 [Issues](https://github.com/1024XEngineer/Windup/issues)。 +`Quick Start` 与 `Workflow Editor` 是同一套流程状态的两种入口:前者用于快速建立标准流程,后者用于查看节点依赖、调整生成方式和处理局部返工。 ## 技术栈 / Tech Stack - 前端:React 19、TypeScript 6、Vite 8、Tailwind CSS 4、Vitest - 后端:Python 3.12、FastAPI、Pydantic、SQLAlchemy、uv workspace +- 基础设施:PostgreSQL、Redis、Docker Compose、Nginx - 工程约束:GitHub Actions、Ruff、Pytest、Import Linter、oxlint、oxfmt ## 本地开发 / Local Development -前端支持 Node.js `^20.19.0`、`^22.12.0` 或 `>=24.0.0`;CI 使用 Node.js 24: +需要 Node.js 24、Python 3.12、[uv](https://docs.astral.sh/uv/)、PostgreSQL 与 Redis。 + +先准备本地配置和依赖服务: ```bash -cd frontend -npm ci -npm run dev +cp .env.example .env +# 在 .env 中配置 POSTGRES_PASSWORD、JWT_SECRET(至少 32 字符)及所需服务凭据 +docker compose up -d postgres redis ``` -后端使用 Python 3.12 和 [uv](https://docs.astral.sh/uv/): +启动后端: ```bash cd backend @@ -74,40 +97,55 @@ uv sync --frozen uv run uvicorn windup_app.bootstrap.app:create_app --factory --reload ``` +另开一个终端启动前端: + +```bash +cd frontend +npm ci +npm run dev +``` + +前端开发服务器默认访问 `http://localhost:5173`,后端健康检查为 `http://localhost:8000/health`。前端需要指向其他后端时,通过构建期变量 `VITE_API_BASE_URL` 配置。 + ## 质量检查 / Quality Checks +以下命令与 GitHub Actions 的主要检查保持一致: + ```bash # frontend/ npm run format:check npm run lint npm run typecheck -npm run test +npm run test:coverage npm run build # backend/ uv run ruff check . +uv run python -m scripts.export_openapi uv run lint-imports -uv run pytest -q +uv run pytest -q --cov=packages ``` ## 仓库结构 / Repository Structure ```text Windup/ -├── frontend/ # React 前端、页面与制作流程 -├── backend/ # Python 工作区、领域服务与 API -└── README.md +├── frontend/ # React 前端、产品页面与制作流程 +├── backend/ # FastAPI 应用、领域服务、生成引擎与基础设施 +├── openapi.json # 从后端自动生成的接口契约 +└── docker-compose.yml # PostgreSQL、Redis、后端与前端构建任务 ``` ## 相关文档 / Documentation +- [在线产品](https://windup.xin) - [Windup 产品策划案](https://github.com/1024XEngineer/Windup/issues/37) - [核心流程与工作流](https://github.com/1024XEngineer/Windup/issues/25) - [OpenAPI 接口契约](openapi.json) ## 参与贡献 / Contributing -问题、需求和实验记录统一进入 [Issues](https://github.com/1024XEngineer/Windup/issues)。功能和核心改动按 `Proposal → Issue → Branch → Pull Request → Review` 推进,开发前请先查看对应 Issue 与领域契约。 +Bug、需求和实验建议统一进入 [Issues](https://github.com/1024XEngineer/Windup/issues)。功能和核心改动按 `Proposal → Issue → Branch → Pull Request → Review` 推进,开发前请先查看对应 Issue 与领域契约。 项目的维护与历史贡献见 [Contributors](https://github.com/1024XEngineer/Windup/graphs/contributors)。 diff --git a/backend/packages/ai_engine/src/windup_ai_engine/master_prep.py b/backend/packages/ai_engine/src/windup_ai_engine/master_prep.py index fb11abb2..68d28959 100644 --- a/backend/packages/ai_engine/src/windup_ai_engine/master_prep.py +++ b/backend/packages/ai_engine/src/windup_ai_engine/master_prep.py @@ -3,9 +3,11 @@ **核心规律(三次实测验证,写死为契约):母版姿态决定动作,提示词只能微调。** - walk:母版**朝侧向**才不转身;正面母版配侧走词 → 模型靠转身调和图文矛盾。 - jump:母版**顶部留白**才不被视频画面裁掉。 - - attack:必须给**极限蓄力母版**(出手那只手已拉到身后腰际)。用站立母版时,即使提示词 + - attack:必须给**极限蓄力母版**(发力那一侧已拉到待发位)。用站立母版时,即使提示词 写死"不过头顶 / 不转身 / 只做一次",模型仍会抡过头顶、转到背面、劈两次 —— 强动作 - 先验压不住;换蓄力母版后模型只能"接着往前挥",没有再抡起的空间。 + 先验压不住;换蓄力母版后模型只能"接着往前发力",没有再抡起的空间。 + 蓄力姿态按运动拓扑分四支(见 :data:`ATTACK_MASTER_POSES`):同一张横挥蓄力母版 + 喂给直刺 / 远程 / 前扑,模型会先把收好的那一侧重新抡起来再做。 **姿势描述里不写装备名词(#195)。** 这几段是拿去生成母版的提示词,写"the weapon"等于 断言角色持械 —— 空手角色会被凭空塞一把武器,而母版是整条 i2v 链的身份来源,污染会一路 @@ -27,16 +29,26 @@ from PIL import Image +from windup_common.models import AttackArchetype + from windup_ai_engine._subject import bg_color as _bg_color from windup_ai_engine.prompt._md import load_section -__all__ = ["add_headroom", "prepare_master", "MASTER_POSES"] +__all__ = ["add_headroom", "prepare_master", "MASTER_POSES", "ATTACK_MASTER_POSES"] # 空值 = 该动作用中性站立母版即可。这是唯一允许空提示词的地方,故显式放行 —— # 别处的空串会一路跑到付费调用。 MASTER_POSES = { a: load_section("master_poses.md", a, allow_empty=True) - for a in ("walk", "run", "idle", "jump", "attack") + for a in ("walk", "run", "idle", "jump") +} + +# attack 按运动拓扑取母版姿态:四支的起手姿态互不兼容(横挥蓄力母版跑不出直刺), +# 而"母版姿态决定动作"对 attack 最狠 —— 见本模块开头。这里不放行空值: +# 四支都必须有自己的蓄力姿态,缺一支就该炸,不能退回中性站立。 +ATTACK_MASTER_POSES = { + arch: load_section("master_poses.md", f"attack.{arch.value}") + for arch in AttackArchetype } diff --git a/backend/packages/ai_engine/src/windup_ai_engine/prompt/_framing.py b/backend/packages/ai_engine/src/windup_ai_engine/prompt/_framing.py new file mode 100644 index 00000000..02966988 --- /dev/null +++ b/backend/packages/ai_engine/src/windup_ai_engine/prompt/_framing.py @@ -0,0 +1,21 @@ +"""所有动作共用的构图约束。 + +由代码统一追加而不是抄进每份 md:同一条约束抄 N 份会各自漂移。 + +只写正向计数句 —— 该 i2v 接口没有 negative_prompt,否定句里的名词会被 latch 进画面 +(实测"do not add dust"反而勾出更多灰尘),所以说"恰好一个",不说"不要第二个"。 +""" +from __future__ import annotations + +__all__ = ["SINGLE_SUBJECT_FRAMING", "with_framing"] + +# 攻击的两处留白(母版姿态要求 + 母版补边)让画面空得足以容下第二个主体。 +SINGLE_SUBJECT_FRAMING = ( + "Exactly one character is in the frame, alone against a plain flat solid-color background, " + "and the whole body stays inside the frame." +) + + +def with_framing(body: str) -> str: + """给一段动作正文接上构图约束。""" + return f"{body} {SINGLE_SUBJECT_FRAMING}" diff --git a/backend/packages/ai_engine/src/windup_ai_engine/prompt/actions.py b/backend/packages/ai_engine/src/windup_ai_engine/prompt/actions.py index a6b43c56..d615ce23 100644 --- a/backend/packages/ai_engine/src/windup_ai_engine/prompt/actions.py +++ b/backend/packages/ai_engine/src/windup_ai_engine/prompt/actions.py @@ -1,12 +1,13 @@ """待机 / 攻击 i2v 提示词。 提示词正文在 ``prompts/idle.md`` 与 ``prompts/attack.md``(#233)。 -本模块只留加载与按 facing 分流。 +本模块只留加载与按 facing / archetype 分流。 """ from __future__ import annotations -from windup_common.models import Facing +from windup_common.models import AttackArchetype, Facing +from windup_ai_engine.prompt._framing import with_framing from windup_ai_engine.prompt._md import load_section __all__ = ["build_idle_prompt", "build_attack_prompt"] @@ -16,11 +17,18 @@ def build_idle_prompt(facing: Facing | str = Facing.SIDE) -> str: """待机正文(循环类)。``facing`` 须与母版朝向一致。 """ - return load_section("idle.md", Facing(facing).value) + return with_framing(load_section("idle.md", Facing(facing).value)) -def build_attack_prompt(facing: Facing | str = Facing.SIDE) -> str: +def build_attack_prompt( + facing: Facing | str = Facing.SIDE, + *, + archetype: AttackArchetype | str = AttackArchetype.THRUST, +) -> str: """攻击正文(一次性类)。``facing`` 须与母版朝向一致。 + 默认取 THRUST:四支里只有 SWEEP 要求手里有一件有宽面的长条物,拿它当默认 = 对每个未知角色断言持械(#195)。 """ - return load_section("attack.md", Facing(facing).value) + # 两个枚举都过一遍构造:非法值要炸,不能静默落到某一节。 + section = f"{AttackArchetype(archetype).value}.{Facing(facing).value}" + return with_framing(load_section("attack.md", section)) diff --git a/backend/packages/ai_engine/src/windup_ai_engine/prompt/custom.py b/backend/packages/ai_engine/src/windup_ai_engine/prompt/custom.py index 5e6b937a..33ba36d9 100644 --- a/backend/packages/ai_engine/src/windup_ai_engine/prompt/custom.py +++ b/backend/packages/ai_engine/src/windup_ai_engine/prompt/custom.py @@ -9,6 +9,8 @@ from windup_common.models import Facing +from windup_ai_engine.prompt._framing import with_framing + __all__ = ["build_custom_prompt", "MAX_ACTION_CHARS"] # 不是接口限制,是产品判断:描述越长越容易夹带角色外观,而外观由母版承载,写两遍会打架。 @@ -68,4 +70,4 @@ def build_custom_prompt( lock = _FACING_LOCK[Facing(facing)] # 非法朝向要炸,不静默落到某一支 tail = _CYCLIC_TAIL if cyclic else _ONESHOT_TAIL # 朝向放最前:最强的约束先钉。 - return f"The character {lock}: {text}, {_KEEP_WHAT_IT_HAS}. {tail}" + return with_framing(f"The character {lock}: {text}, {_KEEP_WHAT_IT_HAS}. {tail}") diff --git a/backend/packages/ai_engine/src/windup_ai_engine/prompt/jump.py b/backend/packages/ai_engine/src/windup_ai_engine/prompt/jump.py index 4ee3d2f0..6aef07bb 100644 --- a/backend/packages/ai_engine/src/windup_ai_engine/prompt/jump.py +++ b/backend/packages/ai_engine/src/windup_ai_engine/prompt/jump.py @@ -6,6 +6,7 @@ from windup_common.models import Facing +from windup_ai_engine.prompt._framing import with_framing from windup_ai_engine.prompt._md import load_section __all__ = ["JUMP_PHASES", "build_jump_prompt"] @@ -23,4 +24,4 @@ def build_jump_prompt(facing: Facing | str = Facing.SIDE) -> str: facing: :class:`Facing` 成员(或其等价字符串),**必须与母版朝向一致**。 """ - return load_section(_DOC, Facing(facing).value) + return with_framing(load_section(_DOC, Facing(facing).value)) diff --git a/backend/packages/ai_engine/src/windup_ai_engine/prompt/prompts/attack.md b/backend/packages/ai_engine/src/windup_ai_engine/prompt/prompts/attack.md index da197a66..d46662ae 100644 --- a/backend/packages/ai_engine/src/windup_ai_engine/prompt/prompts/attack.md +++ b/backend/packages/ai_engine/src/windup_ai_engine/prompt/prompts/attack.md @@ -1,22 +1,96 @@ # 攻击 i2v 提示词(一次性类) -## side +节名是 `<运动拓扑>.<朝向>`。分支依据是**身体怎么发力**,不是手里拿着什么: +写"宽面""弧线"这类形状词等于断言角色手握一件有宽面的长条物,喂法杖 / 空手 / 四足角色时 +模型会凭空补出那件东西来调和图文矛盾(与 #195 同一个坑,只是从名词层退到形状层)。 + +四支都要写整体位移(whole body / torso / hips):i2v 强跟身体、弱跟持物, +只描述持物的运动会让它自行漂移。 + +## sweep.side + +```text +Seen from the side facing right, the character makes ONE single committed strike, staying in STRICT SIDE VIEW the whole time: +starting coiled with the weight on the back foot, the whole body uncoils and the hips drive forward as the weight surges onto the front foot, +the striking side of the body travelling in one continuous path from far behind the body, down across the front of the torso, out to full extension low in front, +whatever the character already wears or carries keeps its own shape and moves with the body, anything held in the hands stays in the same grip at the same angle, +then the torso settles back upright into guard and holds that stance. +The torso and hips keep pointing to the right the entire time and the character never turns toward or away from the viewer. +``` + +## sweep.front + +```text +Facing the viewer, the character makes ONE single committed strike: starting coiled with the weight on the back foot, +the whole body uncoils forward and the hips turn into the motion as the weight surges onto the front foot, +the striking side of the body travelling in one continuous path across the front of the torso out to full extension, +whatever the character already wears or carries keeps its own shape and moves with the body, anything held in the hands stays in the same grip at the same angle, +then the torso settles back upright into guard and holds that stance, standing steady and keeping FACING THE VIEWER. +``` + +## thrust.side + +```text +Seen from the side facing right, the character drives ONE single committed strike straight forward, staying in STRICT SIDE VIEW the whole time: +starting coiled low with the weight on the back foot and the striking side pulled in at waist height, +the hips snap forward and the whole body drives straight ahead as the weight lands on the front foot, +the striking side of the body extending in one straight line directly forward to full reach and stopping there, +whatever the character already wears or carries keeps its own shape and moves with the body, anything held in the hands stays in the same grip at the same angle, +then the torso draws back over the hips and settles into guard and holds that stance. +The torso and hips keep pointing to the right the entire time and the character never turns toward or away from the viewer. +``` + +## thrust.front + +```text +Facing the viewer, the character drives ONE single committed strike straight toward the viewer: +starting coiled low with the weight on the back foot and the striking side pulled in at waist height, +the hips snap forward and the whole body drives straight ahead as the weight lands on the front foot, +the striking side of the body extending in one straight line directly toward the viewer to full reach and stopping there, +whatever the character already wears or carries keeps its own shape and moves with the body, anything held in the hands stays in the same grip at the same angle, +then the torso draws back over the hips and settles into guard and holds that stance, standing steady and keeping FACING THE VIEWER. +``` + +## project.side + +```text +Seen from the side facing right, the character makes ONE single committed ranged release, staying in STRICT SIDE VIEW the whole time: +starting settled with the weight low over both feet, the torso presses forward over the front foot and the hips square up behind the motion, +the releasing side of the body reaching straight out in front of the chest and coming to a firm stop at full extension, +the whole body braced and steady at that moment, +whatever the character already wears or carries keeps its own shape and moves with the body, anything held in the hands stays in the same grip at the same angle, +then the character keeps that extended pose and stays still. +The torso and hips keep pointing to the right the entire time and the character never turns toward or away from the viewer. +``` + +## project.front + +```text +Facing the viewer, the character makes ONE single committed ranged release toward the viewer: +starting settled with the weight low over both feet, the torso presses forward and the hips square up behind the motion, +the releasing side of the body reaching straight out in front of the chest toward the viewer and coming to a firm stop at full extension, +the whole body braced and steady at that moment, +whatever the character already wears or carries keeps its own shape and moves with the body, anything held in the hands stays in the same grip at the same angle, +then the character keeps that extended pose and stays still, keeping FACING THE VIEWER. +``` + +## lunge.side ```text -Seen from the side facing right, the character makes ONE single committed attack, staying in STRICT SIDE VIEW the whole time: -starting coiled with the weight on the back foot, the body leans forward and the weight surges onto the front foot, -the leading arm sweeping through one smooth downward crescent arc from high behind the shoulder down across the front to full extension low, -anything held in that hand keeping its exact length and grip position and staying clearly in front of the body with its broad side facing the viewer the whole way, -whatever the character already wears swinging with the motion, then the body settles back upright into guard and holds that stance, -standing steady. The torso and hips keep pointing to the right the entire time and the character never turns toward or away from the viewer. +Seen from the side facing right, the character makes ONE single committed lunge forward, staying in STRICT SIDE VIEW the whole time: +starting crouched low with the weight loaded onto the rear limbs, the whole body surges forward in one burst with the head and the leading limbs arriving first, +the hips and torso following along that same line and the back stretching out level and low over the ground, +whatever the character already wears or carries keeps its own shape and moves with the body, +then the body gathers back under itself, settles low and holds that crouched stance. +The torso and hips keep pointing to the right the entire time and the character never turns toward or away from the viewer. ``` -## front +## lunge.front ```text -Facing the viewer, the character makes ONE single committed attack: starting coiled with the weight on the back foot, -the whole body uncoils forward, the leading arm sweeping through one smooth arc across the front to full extension, -anything held in that hand keeping its exact length and grip position and staying clearly in front of the body with its broad side facing the viewer the whole way, -whatever the character already wears swinging with the motion, then the body settles back upright into guard and holds that stance, -standing steady and keeping FACING THE VIEWER. +Facing the viewer, the character makes ONE single committed lunge toward the viewer: +starting crouched low with the weight loaded onto the rear limbs, the whole body surges forward in one burst with the head and the leading limbs arriving first, +the hips and torso following along that same line and the back stretching out level and low over the ground, +whatever the character already wears or carries keeps its own shape and moves with the body, +then the body gathers back under itself, settles low and holds that crouched stance, keeping FACING THE VIEWER. ``` diff --git a/backend/packages/ai_engine/src/windup_ai_engine/prompt/prompts/master_poses.md b/backend/packages/ai_engine/src/windup_ai_engine/prompt/prompts/master_poses.md index d508bca5..88acdb4e 100644 --- a/backend/packages/ai_engine/src/windup_ai_engine/prompt/prompts/master_poses.md +++ b/backend/packages/ai_engine/src/windup_ai_engine/prompt/prompts/master_poses.md @@ -1,5 +1,8 @@ # 各动作所需的母版姿态 +attack 按运动拓扑分四节(`attack.<拓扑>`):母版姿态决定动作、提示词只能微调, +而四支的起手姿态互不兼容 —— 拿横挥蓄力母版跑直刺,模型会先把收在腰际的那侧重新抡起来。 + ## walk ## run @@ -13,10 +16,36 @@ deep crouch coiled to spring straight upward: the knees bent low and the hips su the weight loaded onto both legs at the very moment before springing straight up, anything held in the hands kept in a fixed grip; leave generous empty space above the head ``` -## attack +## attack.sweep ```text extreme wind-up stance for a horizontal strike: the striking hand drawn far BACK behind the body at WAIST height, the torso twisted back and coiled, weight fully loaded on the back leg, both arms low and pulled back, that hand and anything held in it staying BELOW the shoulders; leave generous empty space on the swing side ``` + +## attack.thrust + +```text +low coiled stance ready to drive straight forward: the weight sunk onto the back leg with both knees bent, +the striking side pulled in tight against the body at WAIST height and held there ready to fire, +the torso squared low over the front foot, that side and anything held in it staying BELOW the shoulders; +leave generous empty space in front +``` + +## attack.project + +```text +braced stance ready to send something forward at a distance: both feet planted wide and firmly set, +the hips sunk low and the weight centred between the feet, the torso upright and square, +both hands drawn in close in front of the chest and held there, anything held in them kept in a fixed grip; +leave generous empty space in front +``` + +## attack.lunge + +```text +crouched stance coiled to spring forward: all four limbs folded under the body with the chest lowered close to the ground, +the rear limbs deeply loaded and ready to drive, the head and the leading limbs pointing forward along the line of travel, +anything the character carries kept in place; leave generous empty space in front +``` diff --git a/backend/packages/ai_engine/src/windup_ai_engine/prompt/walk.py b/backend/packages/ai_engine/src/windup_ai_engine/prompt/walk.py index 3e70ef6b..5f9a74fe 100644 --- a/backend/packages/ai_engine/src/windup_ai_engine/prompt/walk.py +++ b/backend/packages/ai_engine/src/windup_ai_engine/prompt/walk.py @@ -7,6 +7,7 @@ from windup_common.models import Facing +from windup_ai_engine.prompt._framing import with_framing from windup_ai_engine.prompt._md import load_section __all__ = ["build_walk_prompt"] @@ -22,4 +23,4 @@ def build_walk_prompt(facing: Facing | str = Facing.SIDE) -> str: """ # 过一遍 Facing() 构造:非法值要炸,不能静默落到某个模板。 - return load_section(_DOC, Facing(facing).value) + return with_framing(load_section(_DOC, Facing(facing).value)) diff --git a/backend/packages/ai_engine/src/windup_ai_engine/strategy/concrete.py b/backend/packages/ai_engine/src/windup_ai_engine/strategy/concrete.py index 4274ddbd..c7a5b757 100644 --- a/backend/packages/ai_engine/src/windup_ai_engine/strategy/concrete.py +++ b/backend/packages/ai_engine/src/windup_ai_engine/strategy/concrete.py @@ -56,10 +56,15 @@ def _build_prompt(self, action: ActionSpec) -> str: facing=action.facing, cyclic=bool(action.cyclic), ) + # attack 同样进不了那张表:它还要按运动拓扑选提示词分支。archetype 缺省时不在这里 + # 兜一个默认值 —— 缺省只由 build_attack_prompt 定义一次,写两处会各自漂移。 + if action.action is ActionType.ATTACK: + if action.archetype is None: + return build_attack_prompt(facing=action.facing) + return build_attack_prompt(facing=action.facing, archetype=action.archetype) builders = { ActionType.JUMP: build_jump_prompt, ActionType.IDLE: build_idle_prompt, - ActionType.ATTACK: build_attack_prompt, } build = builders.get(action.action, build_walk_prompt) return build(facing=action.facing) diff --git a/backend/packages/app/src/windup_app/bootstrap/app.py b/backend/packages/app/src/windup_app/bootstrap/app.py index 586c8b2d..b08dec5c 100644 --- a/backend/packages/app/src/windup_app/bootstrap/app.py +++ b/backend/packages/app/src/windup_app/bootstrap/app.py @@ -21,8 +21,7 @@ from windup_app.server.character.service import service as character_service from windup_app.server.orchestrator.dispatcher import GenerationDispatcher from windup_app.server.project.model import Project # noqa: F401 -from windup_app.server.quota.model import CreditAccount, CreditTransaction # noqa: F401 -# InviteCode, InviteRecord, TokenUsage 暂不实现 +from windup_app.server.quota.model import CreditAccount, CreditTransaction, InviteCode, InviteRecord # noqa: F401 from windup_app.server.user.model import User # noqa: F401 from windup_app.server.workflow_run.model import WorkflowRun # noqa: F401 from windup_app.web.api.auth import router as auth_router diff --git a/backend/packages/app/src/windup_app/server/quota/interface.py b/backend/packages/app/src/windup_app/server/quota/interface.py index c419c466..25b88e66 100644 --- a/backend/packages/app/src/windup_app/server/quota/interface.py +++ b/backend/packages/app/src/windup_app/server/quota/interface.py @@ -10,6 +10,8 @@ from windup_app.server.quota.model import ( CreditAccountView, CreditTransactionView, + InviteCode, + InviteCodeView, ) @@ -35,7 +37,12 @@ def reserve_credit( @abstractmethod def capture_credit( - self, session: Session, user_id: int, actual_amount: int, ref_id: str, frozen_amount: int + self, + session: Session, + user_id: int, + actual_amount: int, + ref_id: str, + frozen_amount: int, ) -> None: """预付费扣减:冻结转消耗。 @@ -65,7 +72,12 @@ def release_credit( @abstractmethod def credit( - self, session: Session, user_id: int, amount: int, reason: int, ref_id: str | None = None + self, + session: Session, + user_id: int, + amount: int, + reason: int, + ref_id: str | None = None, ) -> None: """入账:增加可用余额与累计获得。""" @@ -78,18 +90,22 @@ def list_transactions( """分页查询积分流水,返回 (列表, 总数)。""" # -- 邀请码 ----------------------------------------------------------- - # TODO 目前先不实现。 - # @abstractmethod - # def get_invite_code(self, session: Session, user_id: int) -> InviteCodeView | None: - # """获取用户当前邀请码。""" - # - # @abstractmethod - # def generate_invite_code(self, session: Session, user_id: int) -> InviteCodeView: - # """生成新邀请码(替换旧码)。""" - # - # @abstractmethod - # def redeem_invite_code(self, session: Session, user_id: int, code: str) -> None: - # """兑换邀请码,双方各得积分。 - # - # :raises BizException: 邀请码无效 / 已达上限 / 已填过码。 - # """ + + @abstractmethod + def get_invite_code(self, session: Session, user_id: int) -> InviteCodeView: + """获取当前未过期邀请码;没有或已过期则签发新行。""" + + @abstractmethod + def generate_invite_code(self, session: Session, user_id: int) -> InviteCodeView: + """签发新邀请码:插入新行,仍有效的旧码立即过期但保留。""" + + @abstractmethod + def require_active_invite(self, session: Session, code: str) -> InviteCode: + """注册前校验邀请码存在且未过期。非法返回「邀请码无效」,过期返回「邀请码已过期」。""" + + @abstractmethod + def redeem_invite_code(self, session: Session, user_id: int, code: str) -> None: + """注册时兑换邀请码。被邀请人始终得邀请奖励;邀请人受每日人数上限。 + + :raises BizException: 邀请码无效 / 已过期 / 已填过码 / 不能填自己的码。 + """ diff --git a/backend/packages/app/src/windup_app/server/quota/model.py b/backend/packages/app/src/windup_app/server/quota/model.py index 45e82ff4..e04a0ff3 100644 --- a/backend/packages/app/src/windup_app/server/quota/model.py +++ b/backend/packages/app/src/windup_app/server/quota/model.py @@ -15,11 +15,19 @@ """ from dataclasses import dataclass, field -from datetime import datetime, timezone - -from sqlalchemy import BigInteger, DateTime, Integer, SmallInteger, String, UniqueConstraint +from datetime import datetime, timedelta, timezone + +from sqlalchemy import ( + BigInteger, + DateTime, + Integer, + SmallInteger, + String, + UniqueConstraint, +) from sqlalchemy.orm import Mapped, mapped_column +from windup_framework.config.quota import settings as quota_settings from windup_framework.db import Base @@ -107,18 +115,70 @@ class CreditTransaction(Base): ) -# -- 以下 ORM 暂不实现(枚举 / 接口已预留)---------------------------------- -# -# class InviteCode(Base): -# """邀请码。""" -# __tablename__ = "windup_invite_code" -# ... -# -# class InviteRecord(Base): -# """邀请记录。""" -# __tablename__ = "windup_invite_record" -# ... -# +class InviteCode(Base): + """用户邀请码。只增不删;轮换插入新行,旧行保留。""" + + __tablename__ = "windup_invite_code" + + id: Mapped[int] = mapped_column( + BigInteger().with_variant(Integer, "sqlite"), + primary_key=True, + autoincrement=True, + ) + user_id: Mapped[int] = mapped_column( + BigInteger().with_variant(Integer, "sqlite"), + index=True, + nullable=False, + ) + code: Mapped[str] = mapped_column(String(16), unique=True, nullable=False) + used_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + expires_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + default=lambda: datetime.now(timezone.utc) + + timedelta(days=quota_settings.invite_code_ttl_days), + ) + 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 InviteRecord(Base): + """一次成功的邀请关系。被邀请人只能出现一次。""" + + __tablename__ = "windup_invite_record" + + id: Mapped[int] = mapped_column( + BigInteger().with_variant(Integer, "sqlite"), + primary_key=True, + autoincrement=True, + ) + inviter_id: Mapped[int] = mapped_column( + BigInteger().with_variant(Integer, "sqlite"), + nullable=False, + index=True, + ) + invitee_id: Mapped[int] = mapped_column( + BigInteger().with_variant(Integer, "sqlite"), + unique=True, + nullable=False, + ) + code: Mapped[str] = mapped_column(String(16), nullable=False) + create_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), + nullable=False, + default=lambda: datetime.now(timezone.utc), + ) + + # class TokenUsage(Base): # """Token 用量记录。""" # __tablename__ = "windup_token_usage" @@ -156,9 +216,12 @@ class CreditTransactionView: create_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) -# -- 暂不实现 -- -# -# @dataclass -# class InviteCodeView: -# """邀请码视图。""" -# ... +@dataclass +class InviteCodeView: + """邀请码视图。""" + + code: str + used_count: int = 0 + expires_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + create_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) + update_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) diff --git a/backend/packages/app/src/windup_app/server/quota/service.py b/backend/packages/app/src/windup_app/server/quota/service.py index 5fcb63bf..e7a8d72f 100644 --- a/backend/packages/app/src/windup_app/server/quota/service.py +++ b/backend/packages/app/src/windup_app/server/quota/service.py @@ -12,8 +12,12 @@ """ import logging +import re +import secrets +from datetime import datetime, timedelta, timezone from sqlalchemy import func, select +from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session from windup_common.enums.biz_code import BizCode @@ -26,10 +30,68 @@ CreditAccountView, CreditTransaction, CreditTransactionView, + InviteCode, + InviteCodeView, + InviteRecord, ) +from windup_app.server.user.model import User +from windup_framework.config.quota import settings as quota_settings logger = logging.getLogger("windup.quota.service") +_INVITE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" +_INVITE_CODE_LENGTH = 8 +_INVITE_CODE_RE = re.compile(rf"^[{re.escape(_INVITE_ALPHABET)}]{{4,16}}$") + + +def normalize_invite_code(code: str) -> str: + return code.strip().upper() + + +def parse_invite_code(code: str) -> str: + """解析邀请链接传入的邀请码,字符集与前端 INVITE_CODE_PATTERN 一致。""" + normalized = normalize_invite_code(code) + if _INVITE_CODE_RE.fullmatch(normalized) is None: + raise BizException("邀请码无效", code=BizCode.BAD_REQUEST) + return normalized + + +def _new_invite_code() -> str: + return "".join(secrets.choice(_INVITE_ALPHABET) for _ in range(_INVITE_CODE_LENGTH)) + + +def _is_invitee_unique_violation(exc: IntegrityError) -> bool: + text = f"{getattr(exc, 'orig', '')} {exc}".lower() + return "invitee" in text or "windup_invite_record" in text + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +def _utc_day_start(now: datetime | None = None) -> datetime: + current = now or _now() + if current.tzinfo is None: + current = current.replace(tzinfo=timezone.utc) + return current.astimezone(timezone.utc).replace( + hour=0, minute=0, second=0, microsecond=0 + ) + + +def _is_expired(expires_at: datetime) -> bool: + exp = expires_at if expires_at.tzinfo else expires_at.replace(tzinfo=timezone.utc) + return exp <= _now() + + +def _to_invite_view(row: InviteCode) -> InviteCodeView: + return InviteCodeView( + code=row.code, + used_count=row.used_count, + expires_at=row.expires_at, + create_at=row.create_at, + update_at=row.update_at, + ) + def _to_account_view(account: CreditAccount) -> CreditAccountView: return CreditAccountView( @@ -120,17 +182,30 @@ def reserve_credit( session.flush() self._write_txn( - session, user_id, -amount, CreditReason.FROZEN, - BillingMode.PREPAID, account.balance, ref_id, + session, + user_id, + -amount, + CreditReason.FROZEN, + BillingMode.PREPAID, + account.balance, + ref_id, ) logger.info( "[WINDUP] 积分冻结 | user_id=%s amount=%s ref_id=%s balance=%s", - user_id, amount, ref_id, account.balance, + user_id, + amount, + ref_id, + account.balance, ) def capture_credit( - self, session: Session, user_id: int, actual_amount: int, ref_id: str, frozen_amount: int + self, + session: Session, + user_id: int, + actual_amount: int, + ref_id: str, + frozen_amount: int, ) -> None: """预付费扣减:frozen -= frozen_amount, total_spent += actual_amount。 @@ -158,20 +233,34 @@ def capture_credit( # 写扣减流水 self._write_txn( - session, user_id, -actual_amount, CreditReason.CAPTURED, - BillingMode.PREPAID, account.balance, ref_id, + session, + user_id, + -actual_amount, + CreditReason.CAPTURED, + BillingMode.PREPAID, + account.balance, + ref_id, ) # 有差额退回时写退款流水(用不同 reason 区分,ref_id 加后缀去重) if refund > 0: self._write_txn( - session, user_id, refund, CreditReason.REFUND, - BillingMode.PREPAID, account.balance, f"{ref_id}:refund", + session, + user_id, + refund, + CreditReason.REFUND, + BillingMode.PREPAID, + account.balance, + f"{ref_id}:refund", ) logger.info( "[WINDUP] 积分扣减 | user_id=%s actual=%s frozen=%s refund=%s balance=%s", - user_id, actual_amount, frozen_amount, refund, account.balance, + user_id, + actual_amount, + frozen_amount, + refund, + account.balance, ) def release_credit( @@ -191,13 +280,21 @@ def release_credit( session.flush() self._write_txn( - session, user_id, amount, CreditReason.REFUND, - BillingMode.PREPAID, account.balance, f"{ref_id}:release", + session, + user_id, + amount, + CreditReason.REFUND, + BillingMode.PREPAID, + account.balance, + f"{ref_id}:release", ) logger.info( "[WINDUP] 积分解冻 | user_id=%s amount=%s ref_id=%s balance=%s", - user_id, amount, ref_id, account.balance, + user_id, + amount, + ref_id, + account.balance, ) # -- 后付费:原子扣减(暂不实现,AGENT_TOKEN / POSTPAID 枚举已预留)------ @@ -211,7 +308,12 @@ def release_credit( # -- 入账(赠送 / 奖励 / 管理员调整)---------------------------------- def credit( - self, session: Session, user_id: int, amount: int, reason: int, ref_id: str | None = None + self, + session: Session, + user_id: int, + amount: int, + reason: int, + ref_id: str | None = None, ) -> None: """入账:balance += amount, total_earned += amount。""" if amount <= 0: @@ -223,13 +325,21 @@ def credit( session.flush() self._write_txn( - session, user_id, amount, reason, - BillingMode.PREPAID, account.balance, ref_id, + session, + user_id, + amount, + reason, + BillingMode.PREPAID, + account.balance, + ref_id, ) logger.info( "[WINDUP] 积分入账 | user_id=%s amount=%s reason=%s balance=%s", - user_id, amount, reason, account.balance, + user_id, + amount, + reason, + account.balance, ) # -- 流水查询 --------------------------------------------------------- @@ -254,8 +364,128 @@ def list_transactions( return [_to_txn_view(r) for r in rows], total or 0 - # -- 邀请码(暂不实现)------------------------------------------------- - # TODO: get_invite_code / generate_invite_code / redeem_invite_code + # -- 邀请码 ----------------------------------------------------------- + + def get_invite_code(self, session: Session, user_id: int) -> InviteCodeView: + row = session.scalar( + select(InviteCode) + .where(InviteCode.user_id == user_id, InviteCode.expires_at > _now()) + .order_by(InviteCode.id.desc()) + ) + if row is not None: + return _to_invite_view(row) + return self.generate_invite_code(session, user_id) + + def generate_invite_code(self, session: Session, user_id: int) -> InviteCodeView: + if session.get(User, user_id) is None: + raise BizException("用户不存在", code=BizCode.NOT_FOUND) + + now = _now() + existing = session.scalars( + select(InviteCode) + .where(InviteCode.user_id == user_id) + .with_for_update() + ).all() + for row in existing: + if not _is_expired(row.expires_at): + row.expires_at = now + + row = InviteCode( + user_id=user_id, + code=self._allocate_invite_code(session), + used_count=0, + expires_at=now + + timedelta(days=quota_settings.invite_code_ttl_days), + ) + session.add(row) + session.flush() + logger.info("[WINDUP] 生成邀请码 | user_id=%s code=%s", user_id, row.code) + return _to_invite_view(row) + + def _allocate_invite_code(self, session: Session) -> str: + for _ in range(16): + code = _new_invite_code() + if session.scalar(select(InviteCode.id).where(InviteCode.code == code)) is None: + return code + raise BizException("邀请码生成失败,请稍后重试", code=BizCode.BAD_REQUEST) + + def require_active_invite(self, session: Session, code: str) -> InviteCode: + normalized = parse_invite_code(code) + invite = session.scalar( + select(InviteCode).where(InviteCode.code == normalized) + ) + if invite is None: + raise BizException("邀请码无效", code=BizCode.BAD_REQUEST) + if _is_expired(invite.expires_at): + raise BizException("邀请码已过期", code=BizCode.NOT_FOUND) + return invite + + def redeem_invite_code(self, session: Session, user_id: int, code: str) -> None: + invite = self.require_active_invite(session, code) + if invite.user_id == user_id: + raise BizException("不能填写自己的邀请码", code=BizCode.BAD_REQUEST) + if session.get(User, user_id) is None: + raise BizException("用户不存在", code=BizCode.NOT_FOUND) + + existing = session.scalar( + select(InviteRecord.id).where(InviteRecord.invitee_id == user_id) + ) + if existing is not None: + raise BizException("已填写过邀请码", code=BizCode.BAD_REQUEST) + + self._get_account_for_update(session, invite.user_id) + + record = InviteRecord( + inviter_id=invite.user_id, + invitee_id=user_id, + code=invite.code, + ) + session.add(record) + invite.used_count += 1 + try: + session.flush() + except IntegrityError as exc: + if _is_invitee_unique_violation(exc): + raise BizException("已填写过邀请码", code=BizCode.BAD_REQUEST) from exc + raise + + reward = quota_settings.invite_reward_amount + today_count = session.scalar( + select(func.count()) + .select_from(InviteRecord) + .where( + InviteRecord.inviter_id == invite.user_id, + InviteRecord.create_at >= _utc_day_start(), + ) + ) or 0 + if today_count <= quota_settings.invite_reward_daily_limit: + self.credit( + session, + invite.user_id, + reward, + int(CreditReason.INVITE_REWARD), + f"invite:{user_id}:inviter", + ) + else: + logger.info( + "[WINDUP] 邀请人日限额已满,跳过邀请人奖励 | inviter=%s invitee=%s count=%s", + invite.user_id, + user_id, + today_count, + ) + self.credit( + session, + user_id, + reward, + int(CreditReason.INVITE_REWARD), + f"invite:{user_id}:invitee", + ) + logger.info( + "[WINDUP] 兑换邀请码 | invitee=%s inviter=%s code=%s", + user_id, + invite.user_id, + invite.code, + ) service = SqlAlchemyQuotaService() 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 c8c0d0d2..6ea3d826 100644 --- a/backend/packages/app/src/windup_app/server/user/interface.py +++ b/backend/packages/app/src/windup_app/server/user/interface.py @@ -27,9 +27,9 @@ class UserService(ABC): @abstractmethod def register_by_email(self, session: Session, input: RegisterInput) -> LoginResult: - """邮箱+密码注册,注册成功即登录。 + """邮箱+验证码+密码注册。邀请码选填。 - :raises windup_common.exceptions.BizException: 邮箱已注册。 + :raises windup_common.exceptions.BizException: 邮箱已注册 / 邀请码无效。 """ # -- 登录 ------------------------------------------------------------ @@ -53,9 +53,9 @@ def send_verification_code(self, email: str, purpose: str) -> None: @abstractmethod def login_by_code(self, session: Session, input: LoginByCodeInput) -> LoginResult: - """邮箱+验证码登录。内测期间不自动建号。 + """邮箱+验证码登录。未知邮箱自动建号并赠送注册积分。 - :raises windup_common.exceptions.BizException: 验证码错误 / 已过期 / 账号不存在 / 账号已封禁。 + :raises windup_common.exceptions.BizException: 验证码错误 / 已过期 / 账号已封禁。 """ # -- 登出 ------------------------------------------------------------ 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 31b51464..c7460228 100644 --- a/backend/packages/app/src/windup_app/server/user/model.py +++ b/backend/packages/app/src/windup_app/server/user/model.py @@ -115,6 +115,7 @@ class RegisterInput: password: str code: str nickname: str | None = None + invite_code: str | None = None @dataclass diff --git a/backend/packages/app/src/windup_app/server/user/service.py b/backend/packages/app/src/windup_app/server/user/service.py index d5bc1b03..572cf099 100644 --- a/backend/packages/app/src/windup_app/server/user/service.py +++ b/backend/packages/app/src/windup_app/server/user/service.py @@ -49,7 +49,7 @@ JWT_SECRET = jwt_settings.secret.get_secret_value() JWT_ALGORITHM = "HS256" -ACCESS_TOKEN_EXPIRE_SECONDS = 15 * 60 # 15 分钟 +ACCESS_TOKEN_EXPIRE_SECONDS = 15 * 60 # 15 分钟 REFRESH_TOKEN_EXPIRE_SECONDS = 7 * 24 * 3600 # 7 天 # -- 密码哈希 ------------------------------------------------------------- @@ -64,6 +64,7 @@ def _verify_password(password: str, hashed: str) -> bool: """验证密码。""" return bcrypt.checkpw(password.encode(), hashed.encode()) + # -- Redis key 前缀 ------------------------------------------------------- VERIFY_COOLDOWN_KEY = "verify:cooldown:{email}" @@ -72,12 +73,12 @@ def _verify_password(password: str, hashed: str) -> bool: LOGIN_FAIL_KEY = "login:fail:{email}" LOGIN_LOCK_KEY = "login:lock:{email}" -VERIFY_CODE_TTL = 300 # 5 分钟 -COOLDOWN_TTL = 60 # 60 秒 +VERIFY_CODE_TTL = 300 # 5 分钟 +COOLDOWN_TTL = 60 # 60 秒 -LOGIN_FAIL_LIMIT = 5 # 连续错误密码上限 -LOGIN_FAIL_WINDOW = 15 * 60 # 失败计数窗口 15 分钟 -LOGIN_LOCK_DURATION = 15 * 60 # 锁定时长 15 分钟 +LOGIN_FAIL_LIMIT = 5 # 连续错误密码上限 +LOGIN_FAIL_WINDOW = 15 * 60 # 失败计数窗口 15 分钟 +LOGIN_LOCK_DURATION = 15 * 60 # 锁定时长 15 分钟 def _hash_token(token: str) -> str: @@ -166,11 +167,18 @@ def redis(self) -> redis_lib.Redis: # -- 注册 ------------------------------------------------------------ - def register_by_email( - self, session: Session, input: RegisterInput - ) -> LoginResult: - """邮箱+验证码+密码注册。""" - # 校验验证码 + def register_by_email(self, session: Session, input: RegisterInput) -> LoginResult: + """邮箱+验证码+密码注册。邀请码选填。""" + from windup_app.server.quota.service import ( + parse_invite_code, + service as quota_service, + ) + + raw_invite = (input.invite_code or "").strip() + invite_code = parse_invite_code(raw_invite) if raw_invite else None + if invite_code is not None: + quota_service.require_active_invite(session, invite_code) + self._verify_code(input.email, input.code, "register") # 检查邮箱唯一 @@ -184,13 +192,17 @@ def register_by_email( email=input.email, password_hash=_hash_password(input.password), nickname=input.nickname, - email_verified_at=datetime.now(timezone.utc), # 注册即验证(已通过验证码校验) + email_verified_at=datetime.now( + timezone.utc + ), # 注册即验证(已通过验证码校验) ) session.add(user) session.flush() - # 注册送积分 + # 注册送积分;有邀请码再发双方邀请奖励 self._create_credit_account(session, user.id) + if invite_code is not None: + quota_service.redeem_invite_code(session, user.id, invite_code) # 注册即登录,签发 token access_token = create_access_token(user.id, user.email) @@ -270,13 +282,12 @@ def login_by_password( def send_verification_code(self, email: str, purpose: str) -> None: """发送邮箱验证码。""" - if purpose == "register": - raise BizException("内测期间暂不开放注册", code=BizCode.BAD_REQUEST) - # 频率限制 cooldown_key = VERIFY_COOLDOWN_KEY.format(email=email) if self.redis.get(cooldown_key): - raise BizException("发送过于频繁,请稍后再试", code=BizCode.TOO_MANY_REQUESTS) + raise BizException( + "发送过于频繁,请稍后再试", code=BizCode.TOO_MANY_REQUESTS + ) code = _generate_code() code_key = VERIFY_CODE_KEY.format(purpose=purpose, email=email) @@ -302,20 +313,26 @@ def _verify_code(self, email: str, code: str, purpose: str) -> None: # 验证通过,删除验证码 self.redis.delete(code_key) - def login_by_code( - self, session: Session, input: LoginByCodeInput - ) -> LoginResult: - """邮箱+验证码登录。内测期间不自动建号。""" + def login_by_code(self, session: Session, input: LoginByCodeInput) -> LoginResult: + """邮箱+验证码登录。未知邮箱自动建号并赠送注册积分。""" # 校验验证码 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.NOT_FOUND) - 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 = User( + email=input.email, + password_hash="", + email_verified_at=datetime.now(timezone.utc), + ) + session.add(user) + session.flush() + self._create_credit_account(session, user.id) + 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() @@ -443,9 +460,7 @@ def change_password( self._revoke_all_user_tokens(user_id) logger.info("[WINDUP] 密码已修改 | user_id=%s", user_id) - def reset_password( - self, session: Session, input: ResetPasswordInput - ) -> None: + def reset_password(self, session: Session, input: ResetPasswordInput) -> None: """邮箱+验证码重置密码(忘记密码场景)。""" # 校验验证码(purpose 必须为 reset_password) self._verify_code(input.email, input.code, "reset_password") @@ -517,7 +532,8 @@ def _create_credit_account(self, session: Session, user_id: int) -> None: logger.info( "[WINDUP] 注册送积分 | user_id=%s amount=%s", - user_id, quota_settings.register_gift_amount, + user_id, + quota_settings.register_gift_amount, ) def _store_refresh_token(self, jti: str, user_id: int) -> None: diff --git a/backend/packages/app/src/windup_app/web/api/auth.py b/backend/packages/app/src/windup_app/web/api/auth.py index 72504c2f..eb76d7db 100644 --- a/backend/packages/app/src/windup_app/web/api/auth.py +++ b/backend/packages/app/src/windup_app/web/api/auth.py @@ -6,14 +6,20 @@ import logging from fastapi import APIRouter, Depends, Request -from pydantic import BaseModel, ConfigDict, Field, EmailStr +from pydantic import BaseModel, ConfigDict, Field, EmailStr, field_validator 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 ResetPasswordInput, UpdateNicknameInput, User, UserView +from windup_app.server.user.model import ( + RegisterInput, + ResetPasswordInput, + UpdateNicknameInput, + User, + UserView, +) from windup_app.server.user.service import service logger = logging.getLogger("windup.auth.api") @@ -31,6 +37,18 @@ class RegisterRequest(BaseModel): 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) + invite_code: str | None = Field( + default=None, + max_length=16, + description="邀请链接中的邀请码,选填;有则发双方邀请奖励", + ) + + @field_validator("invite_code", mode="before") + @classmethod + def blank_invite_code(cls, value: object) -> object: + if isinstance(value, str) and not value.strip(): + return None + return value class LoginRequest(BaseModel): @@ -77,7 +95,9 @@ class ResetPasswordRequest(BaseModel): """重置密码请求(忘记密码场景)。""" email: EmailStr - code: str = Field(min_length=6, max_length=6, description="reset_password 用途的验证码") + code: str = Field( + min_length=6, max_length=6, description="reset_password 用途的验证码" + ) new_password: str = Field(min_length=8, max_length=128) @@ -111,15 +131,25 @@ class UserOut(BaseModel): @router.post("/register", response_model=Response[TokenResponse]) def register(body: RegisterRequest, session: Session = Depends(get_session)): - """邮箱+验证码+密码注册。 - - 内测期间关闭公开注册,路由与请求模型保留以便以后重新开放。 - """ - from windup_common.enums.biz_code import BizCode - from windup_common.exceptions import BizException - - del body, session - raise BizException("内测期间暂不开放注册", code=BizCode.BAD_REQUEST) + """邮箱+验证码+密码注册。邀请码选填。""" + result = service.register_by_email( + session, + RegisterInput( + email=body.email, + password=body.password, + code=body.code, + nickname=body.nickname, + invite_code=body.invite_code, + ), + ) + 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]) @@ -127,7 +157,9 @@ def login(body: LoginRequest, session: Session = Depends(get_session)): """邮箱+密码+验证码登录。""" result = service.login_by_password( session, - type("LoginByPasswordInput", (), {"email": body.email, "password": body.password})(), + type( + "LoginByPasswordInput", (), {"email": body.email, "password": body.password} + )(), ) return Response.success( TokenResponse( @@ -148,7 +180,7 @@ def send_code(body: SendCodeRequest): @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( session, type("LoginByCodeInput", (), {"email": body.email, "code": body.code})(), @@ -191,26 +223,37 @@ def get_me(request: Request, session: Session = Depends(get_session)): 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, + 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)): +def change_password( + body: ChangePasswordRequest, + request: Request, + session: Session = Depends(get_session), +): """修改密码。""" current_user = request.state.current_user service.change_password( session, current_user.id, - type("ChangePasswordInput", (), {"old_password": body.old_password, "new_password": body.new_password})(), + type( + "ChangePasswordInput", + (), + {"old_password": body.old_password, "new_password": body.new_password}, + )(), ) return Response.success(None, message="密码修改成功") @@ -220,13 +263,19 @@ def reset_password(body: ResetPasswordRequest, session: Session = Depends(get_se """邮箱+验证码重置密码(忘记密码)。""" service.reset_password( session, - ResetPasswordInput(email=body.email, code=body.code, new_password=body.new_password), + ResetPasswordInput( + email=body.email, code=body.code, new_password=body.new_password + ), ) return Response.success(None, message="密码重置成功") @router.patch("/profile", response_model=Response[UserOut]) -def update_nickname(body: UpdateNicknameRequest, request: Request, session: Session = Depends(get_session)): +def update_nickname( + body: UpdateNicknameRequest, + request: Request, + session: Session = Depends(get_session), +): """修改当前用户昵称。""" current_user = request.state.current_user user_view = service.update_nickname( @@ -237,7 +286,9 @@ def update_nickname(body: UpdateNicknameRequest, request: Request, session: Sess id=user_view.id, email=user_view.email, nickname=user_view.nickname, - email_verified_at=user_view.email_verified_at.isoformat() if user_view.email_verified_at else None, + email_verified_at=user_view.email_verified_at.isoformat() + if user_view.email_verified_at + else None, status=user_view.status, ), message="昵称修改成功", diff --git a/backend/packages/app/src/windup_app/web/api/quota.py b/backend/packages/app/src/windup_app/web/api/quota.py index 90d3f56c..85e6c257 100644 --- a/backend/packages/app/src/windup_app/web/api/quota.py +++ b/backend/packages/app/src/windup_app/web/api/quota.py @@ -4,11 +4,8 @@ -------- GET /quota/balance 查询积分余额 GET /quota/transactions 查询积分流水(分页) - -暂不实现: -POST /quota/invite/redeem 兑换邀请码 GET /quota/invite/code 获取我的邀请码 -POST /quota/invite/generate 生成新邀请码 +POST /quota/invite/generate 签发新邀请码 """ from __future__ import annotations @@ -63,15 +60,14 @@ class CreditTransactionOut(BaseModel): create_at: datetime -# -- 暂不实现 ---------------------------------------------------------------- -# -# class InviteCodeOut(BaseModel): -# """邀请码响应。""" -# ... -# -# class RedeemRequest(BaseModel): -# """兑换邀请码请求。""" -# ... +class InviteCodeOut(BaseModel): + """邀请码响应。""" + + code: str + used_count: int + expires_at: datetime + create_at: datetime + update_at: datetime # -- 端点 ---------------------------------------------------------------- @@ -102,7 +98,9 @@ def list_transactions( ) -> ListResponse[CreditTransactionOut]: """查询积分流水(分页)。""" user_id = request.state.current_user.id - txns, total = service.list_transactions(session, user_id, page=page, page_size=page_size) + txns, total = service.list_transactions( + session, user_id, page=page, page_size=page_size + ) return ListResponse.success( [CreditTransactionOut.model_validate(t) for t in txns], total=total, @@ -111,13 +109,38 @@ def list_transactions( ) -# -- 邀请码端点(暂不实现)-------------------------------------------------- -# -# @router.get("/invite/code") -# def get_invite_code(...): ... -# -# @router.post("/invite/generate") -# def generate_invite_code(...): ... -# -# @router.post("/invite/redeem") -# def redeem_invite_code(...): ... +@router.get("/invite/code", response_model=Response[InviteCodeOut]) +def get_invite_code( + request: Request, + session: Session = Depends(get_session), +) -> Response[InviteCodeOut]: + """获取当前用户未过期邀请码;没有或已过期则签发新行。""" + view = service.get_invite_code(session, request.state.current_user.id) + return Response.success( + InviteCodeOut( + code=view.code, + used_count=view.used_count, + expires_at=view.expires_at, + create_at=view.create_at, + update_at=view.update_at, + ) + ) + + +@router.post("/invite/generate", response_model=Response[InviteCodeOut]) +def generate_invite_code( + request: Request, + session: Session = Depends(get_session), +) -> Response[InviteCodeOut]: + """签发新邀请码。旧码立即过期,行保留。""" + view = service.generate_invite_code(session, request.state.current_user.id) + return Response.success( + InviteCodeOut( + code=view.code, + used_count=view.used_count, + expires_at=view.expires_at, + create_at=view.create_at, + update_at=view.update_at, + ), + message="邀请码已更新", + ) diff --git a/backend/packages/common/src/windup_common/models/__init__.py b/backend/packages/common/src/windup_common/models/__init__.py index 52434b34..688979b1 100644 --- a/backend/packages/common/src/windup_common/models/__init__.py +++ b/backend/packages/common/src/windup_common/models/__init__.py @@ -2,6 +2,7 @@ DEFAULT_N_FRAMES, ActionSpec, ActionType, + AttackArchetype, CharacterCard, CharacterView, Facing, @@ -11,6 +12,7 @@ __all__ = [ "ActionType", + "AttackArchetype", "GenRoute", "Facing", "CharacterView", diff --git a/backend/packages/common/src/windup_common/models/character.py b/backend/packages/common/src/windup_common/models/character.py index 27a91174..4e0805e7 100644 --- a/backend/packages/common/src/windup_common/models/character.py +++ b/backend/packages/common/src/windup_common/models/character.py @@ -58,6 +58,19 @@ class ActionType(str, Enum): CUSTOM = "custom" +class AttackArchetype(str, Enum): + """攻击的运动拓扑 —— 决定 ``prompts/attack.md`` 取哪一节(取值即节名前缀)。 + + 按"身体怎么发力"分而不按装备形状分:提示词里的形状先验(宽面、弧线)等于断言角色 + 手握一件有宽面的长条物,喂空手 / 法杖 / 四足角色时模型会凭空补出那件东西来调和矛盾。 + """ + + SWEEP = "sweep" # 长条持物:横挥 / 下劈 + THRUST = "thrust" # 短持物或空手:直出 / 戳刺 + PROJECT = "project" # 远程:身体前压、送到位、终态保持 + LUNGE = "lunge" # 非双足:整体前扑,头部 / 前肢领先 + + class GenRoute(str, Enum): """生成路线 —— 实测挣得的分流依据(见 ai_engine.strategy 层 docstring)。 @@ -191,6 +204,19 @@ class ActionSpec(BaseModel): # pick_cycle 还是 pick_oneshot、出参要不要量 loop_seam。 cyclic: bool | None = None + # 攻击走哪一支运动拓扑。``None`` = 不指定,由 ``build_attack_prompt`` 的默认值决定 —— + # 这里不给默认值,否则同一个缺省被两处各写一份,改一处另一处静默不动。 + archetype: AttackArchetype | None = None + + @model_validator(mode="after") + def _archetype_belongs_to_attack_only(self) -> ActionSpec: + """非攻击动作带 archetype 要炸:它只被攻击提示词消费,传了不会生效。""" + if self.action is not ActionType.ATTACK and self.archetype is not None: + raise ValueError( + f"action={self.action.value} 不该带 archetype;它只决定攻击提示词取哪一支,传了不会生效" + ) + return self + @model_validator(mode="after") def _custom_needs_its_own_settings(self) -> ActionSpec: """两个方向都卡:缺了只能猜、而猜错是静默的;多给了调用方以为能覆盖 walk 的 diff --git a/backend/packages/framework/src/windup_framework/config/quota.py b/backend/packages/framework/src/windup_framework/config/quota.py index 57add5fb..af1ca72c 100644 --- a/backend/packages/framework/src/windup_framework/config/quota.py +++ b/backend/packages/framework/src/windup_framework/config/quota.py @@ -9,7 +9,7 @@ class QuotaSettings(BaseSettings): """积分定价配置。 - 环境变量前缀 ``QUOTA_``,例如 ``QUOTA_REGISTER_GIFT_AMOUNT=100``。 + 环境变量前缀 ``QUOTA_``,例如 ``QUOTA_REGISTER_GIFT_AMOUNT=300``。 """ model_config = SettingsConfigDict( @@ -20,8 +20,10 @@ class QuotaSettings(BaseSettings): ) # -- 注册 / 邀请 ------------------------------------------------------- - register_gift_amount: int = 100 # 注册赠送积分 + register_gift_amount: int = 300 # 注册赠送积分 invite_reward_amount: int = 200 # 邀请奖励(双方各得) + invite_reward_daily_limit: int = 3 # 邀请人每日可获奖励的邀请人数(3×200=600) + invite_code_ttl_days: int = 30 # 邀请码有效期(天) # -- 生成任务 ----------------------------------------------------------- generate_image_cost: int = 10 # 生成角色参考图 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 10ecb720..714cbfba 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -22,7 +22,12 @@ 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.quota.model import CreditAccount, CreditTransaction +from windup_app.server.quota.model import ( + CreditAccount, + CreditTransaction, + InviteCode, + InviteRecord, +) from windup_app.server.user.model import User from windup_app.server.orchestrator.model import GenerationTaskRecord from windup_app.server.workflow_run.model import WorkflowRun @@ -53,7 +58,19 @@ def _disable_generation_execution(app): app.state.run_image_task = lambda *args: None -def seed_credit_account(session, user_id: int, *, balance: int | None = None) -> CreditAccount: +def seed_invite_code(session, code: str = "AB23CD45") -> str: + """预置一个可重复使用的邀请码,供注册测试使用。""" + inviter = User(email=f"inviter-{code.lower()}@example.com", password_hash="x") + session.add(inviter) + session.flush() + session.add(InviteCode(user_id=inviter.id, code=code, used_count=0)) + seed_credit_account(session, inviter.id) + return code + + +def seed_credit_account( + session, user_id: int, *, balance: int | None = None +) -> CreditAccount: """给测试用户补一张积分账户(注册赠送口径)。""" gift = quota_settings.register_gift_amount account = CreditAccount( @@ -85,15 +102,29 @@ def _enable_sqlite_foreign_keys(dbapi_connection, _connection_record): return engine +@pytest.fixture() +def invite_code(db_session): + return seed_invite_code(db_session) + + @pytest.fixture() def engine(): """建好 ``windup_project`` 和 ``windup_user`` 表的内存 engine。""" engine = _make_engine() - Base.metadata.create_all(engine, tables=[ - Project.__table__, User.__table__, Character.__table__, WorkflowRun.__table__, - CreditAccount.__table__, CreditTransaction.__table__, - GenerationTaskRecord.__table__, - ]) + Base.metadata.create_all( + engine, + tables=[ + Project.__table__, + User.__table__, + Character.__table__, + WorkflowRun.__table__, + CreditAccount.__table__, + CreditTransaction.__table__, + InviteCode.__table__, + InviteRecord.__table__, + GenerationTaskRecord.__table__, + ], + ) yield engine engine.dispose() diff --git a/backend/tests/test_attack_archetypes.py b/backend/tests/test_attack_archetypes.py new file mode 100644 index 00000000..4a75dc11 --- /dev/null +++ b/backend/tests/test_attack_archetypes.py @@ -0,0 +1,209 @@ +"""攻击提示词按运动拓扑分支 + 全动作统一构图约束。 + +这一片锁的是 #195 的**形状层**残留:名词清干净了,`crescent arc` / `broad side` 这类 +形状短语仍在断言"手里有一件有宽面的长条物"。喂法杖 / 空手 / 四足角色时,模型调和图文 +矛盾最省力的解法就是凭空补出那件东西 —— 而帧数、时长、成色全部正常,没有一道会红。 +""" +from __future__ import annotations + +import itertools + +import pytest +from pydantic import ValidationError + +from windup_ai_engine.master_prep import ATTACK_MASTER_POSES +from windup_ai_engine.prompt import ( + build_attack_prompt, + build_custom_prompt, + build_idle_prompt, + build_jump_prompt, + build_walk_prompt, +) +from windup_ai_engine.prompt._framing import SINGLE_SUBJECT_FRAMING +from windup_ai_engine.strategy.concrete import VideoFrameStrategy +from windup_common.models import ActionSpec, ActionType, AttackArchetype, Facing + +FACINGS = (Facing.SIDE, Facing.FRONT) +COMBOS = list(itertools.product(AttackArchetype, FACINGS)) + +# 装备名词(#195)+ 形状短语(本片)。后者不点名任何物件,却同样断言了它的几何。 +_SHAPE_PRIORS = ( + "sword", "blade", "staff", "wand", "weapon", "claw", "fist", + "broad side", "crescent", "long edge", "flat of the", +) + +# 远程支不许写接触弧:写"挥过去 / 划过去"就是在逼模型造一个被打的对象。 +_CONTACT_ARC = ("sweep", "swing", "arc", "slash", "across the front", "connects with") + +# 整体位移词。i2v 强跟身体、弱跟持物,只写持物动作会让它自行漂移。 +_WHOLE_BODY = ("whole body", "torso", "hips", "lunge") + + +def _hits(text: str, words) -> list[str]: + low = text.lower() + return [w for w in words if w in low] + + +# ── ① 八种组合都取得到,且互不相同 ──────────────────────────────────────── + + +@pytest.mark.parametrize("archetype,facing", COMBOS) +def test_every_archetype_and_facing_resolves_to_real_text(archetype, facing): + text = build_attack_prompt(facing=facing, archetype=archetype) + assert len(text) > 200, f"{archetype.value}.{facing.value} 短得不像正文:{text!r}" + + +def test_the_eight_combinations_are_all_distinct(): + """任意两支撞车 = 有一支没写、静默复用了别支的运动拓扑。""" + texts = [build_attack_prompt(facing=f, archetype=a) for a, f in COMBOS] + assert len(set(texts)) == len(COMBOS) + + +def test_illegal_archetype_raises_instead_of_falling_back(): + with pytest.raises(ValueError): + build_attack_prompt(facing=Facing.SIDE, archetype="sweeping") + + +# ── ② 四支都不带装备形状先验 ───────────────────────────────────────────── + + +@pytest.mark.parametrize("archetype,facing", COMBOS) +def test_no_branch_asserts_a_held_shape(archetype, facing): + named = _hits(build_attack_prompt(facing=facing, archetype=archetype), _SHAPE_PRIORS) + assert not named, f"{archetype.value}.{facing.value} 断言了持物形状: {named}" + + +@pytest.mark.parametrize("archetype,facing", COMBOS) +def test_the_striking_subject_is_a_body_part_not_an_arm(archetype, facing): + """主语写"手臂"= 断言角色有手臂,四足角色没有。""" + low = build_attack_prompt(facing=facing, archetype=archetype).lower() + assert "arm" not in low.replace("armour", "").replace("armor", ""), \ + f"{archetype.value}.{facing.value} 把发力主语写成了手臂" + + +@pytest.mark.parametrize("archetype,facing", COMBOS) +def test_every_branch_moves_the_whole_body(archetype, facing): + text = build_attack_prompt(facing=facing, archetype=archetype) + assert _hits(text, _WHOLE_BODY), \ + f"{archetype.value}.{facing.value} 只写了肢体动作,没写身体整体位移" + + +@pytest.mark.parametrize("facing", FACINGS) +def test_ranged_branch_describes_no_contact_arc(facing): + """远程支写"挥过去"= 逼模型在画面里造一个被打的对象。""" + text = build_attack_prompt(facing=facing, archetype=AttackArchetype.PROJECT) + assert not _hits(text, _CONTACT_ARC), f"project.{facing.value} 写了接触弧: {_hits(text, _CONTACT_ARC)}" + + +def test_the_contact_arc_check_would_catch_a_real_swing(): + """反向校准:这组词在近战支上确实会命中,否则上一条恒真、什么也没测。""" + swept = build_attack_prompt(facing=Facing.SIDE, archetype=AttackArchetype.SWEEP) + assert _hits(swept, _CONTACT_ARC) + + +# ── ③ 默认支必须是 THRUST ──────────────────────────────────────────────── + + +def test_default_archetype_is_thrust_not_sweep(): + """SWEEP 是唯一要求"手里有一件有宽面长条物"的一支;拿它当默认 = 对每个未知角色断言持械。""" + assert build_attack_prompt() == build_attack_prompt(archetype=AttackArchetype.THRUST) + assert build_attack_prompt() != build_attack_prompt(archetype=AttackArchetype.SWEEP) + + +# ── ④ ActionSpec 契约:archetype 只属于 attack ──────────────────────────── + + +@pytest.mark.parametrize( + "action", [a for a in ActionType if a is not ActionType.ATTACK and a is not ActionType.CUSTOM] +) +def test_non_attack_actions_must_not_carry_an_archetype(action): + with pytest.raises(ValidationError, match="archetype"): + ActionSpec(action=action, archetype=AttackArchetype.SWEEP) + + +def test_custom_action_must_not_carry_an_archetype_either(): + with pytest.raises(ValidationError, match="archetype"): + ActionSpec( + action=ActionType.CUSTOM, custom_action="waves", cyclic=False, + archetype=AttackArchetype.SWEEP, + ) + + +def test_attack_takes_an_archetype_and_defaults_to_unspecified(): + """不指定就是 None:缺省只由 build_attack_prompt 定义一次,契约层不兜第二份。""" + assert ActionSpec(action=ActionType.ATTACK).archetype is None + assert ActionSpec( + action=ActionType.ATTACK, archetype=AttackArchetype.LUNGE + ).archetype is AttackArchetype.LUNGE + + +# ── ⑤ 派生层真的把 archetype 传下去了 ──────────────────────────────────── + + +@pytest.mark.parametrize("archetype", list(AttackArchetype)) +def test_strategy_builds_the_prompt_of_the_requested_archetype(archetype): + """契约字段填了却没人读,是本项目最典型的静默失败(见 ActionSpec.fps 那段)。""" + strat = VideoFrameStrategy(video=None, matte=None) + spec = ActionSpec(action=ActionType.ATTACK, archetype=archetype, facing=Facing.FRONT) + assert strat._build_prompt(spec) == build_attack_prompt( + facing=Facing.FRONT, archetype=archetype + ) + + +def test_strategy_without_an_archetype_falls_back_to_the_builder_default(): + strat = VideoFrameStrategy(video=None, matte=None) + spec = ActionSpec(action=ActionType.ATTACK, facing=Facing.SIDE) + assert strat._build_prompt(spec) == build_attack_prompt(facing=Facing.SIDE) + + +# ── ⑥ 统一构图后缀:五个动作都要带 ─────────────────────────────────────── + + +def _all_prompts() -> dict[str, str]: + out: dict[str, str] = {} + for facing in FACINGS: + out[f"walk.{facing.value}"] = build_walk_prompt(facing=facing) + out[f"jump.{facing.value}"] = build_jump_prompt(facing=facing) + out[f"idle.{facing.value}"] = build_idle_prompt(facing=facing) + out[f"custom.{facing.value}"] = build_custom_prompt( + "waves the right hand", facing=facing, cyclic=False + ) + for archetype in AttackArchetype: + out[f"attack.{archetype.value}.{facing.value}"] = build_attack_prompt( + facing=facing, archetype=archetype + ) + return out + + +def test_every_action_prompt_carries_the_framing_clause(): + """attack 是唯一没有构图约束的动作,而它恰恰有两处留白(母版姿态要留白 + 母版补边)。""" + missing = [k for k, v in _all_prompts().items() if SINGLE_SUBJECT_FRAMING not in v] + assert not missing, f"这些提示词没带构图约束: {missing}" + + +def test_the_framing_clause_is_appended_by_code_not_copied_into_the_markdown(): + """抄进每份 md 会各自漂移;md 正文里出现它就说明有人开始抄了。""" + from windup_ai_engine.prompt._md import load_doc + + for doc in ("walk.md", "jump.md", "idle.md", "attack.md"): + for section, text in load_doc(doc).items(): + assert "exactly one character" not in text.lower(), \ + f"{doc} 的 {section} 把构图句抄进了 md" + + +def test_the_framing_clause_counts_positively_instead_of_forbidding(): + """该接口没有 negative_prompt:否定句里的名词会被 latch 进画面。""" + low = SINGLE_SUBJECT_FRAMING.lower() + assert "exactly one character" in low + hits = [w for w in (" not ", " no ", "n't", "without", "avoid", "never", "只") if w in low] + assert not hits, f"构图句写成了否定式: {hits}" + + +# ── ⑦ 母版姿态的四支同样不带形状先验 ───────────────────────────────────── + + +@pytest.mark.parametrize("archetype", list(AttackArchetype)) +def test_attack_master_poses_carry_no_shape_prior(archetype): + """母版是整条 i2v 链的身份来源,污染会一路带到所有动作。""" + named = _hits(ATTACK_MASTER_POSES[archetype], _SHAPE_PRIORS) + assert not named, f"attack.{archetype.value} 的母版姿态断言了持物形状: {named}" diff --git a/backend/tests/test_auth_api.py b/backend/tests/test_auth_api.py index 16094461..1eb8cff9 100644 --- a/backend/tests/test_auth_api.py +++ b/backend/tests/test_auth_api.py @@ -4,6 +4,8 @@ import pytest +from conftest import seed_invite_code + from windup_app.server.user.model import User from windup_app.server.user.service import _hash_password, service @@ -91,6 +93,62 @@ def test_reset_password_endpoint(auth_client, seeded_user, mock_user_redis): assert body["message"] == "密码重置成功" +def test_register_endpoint_success(client, db_session, mock_user_redis): + seed_invite_code(db_session) + db_session.commit() + mock_user_redis.get.return_value = "123456" + + resp = client.post( + "/auth/register", + json={ + "email": "invitee@example.com", + "password": "password123", + "code": "123456", + "invite_code": "AB23CD45", + "nickname": "受邀用户", + }, + ) + assert resp.status_code == 200 + body = resp.json() + assert body["code"] == 200 + assert body["message"] == "注册成功" + assert body["data"]["user"]["email"] == "invitee@example.com" + assert body["data"]["access_token"] + + +def test_register_endpoint_success_without_invite_code(client, mock_user_redis): + mock_user_redis.get.return_value = "123456" + resp = client.post( + "/auth/register", + json={ + "email": "open@example.com", + "password": "password123", + "code": "123456", + }, + ) + assert resp.status_code == 200 + body = resp.json() + assert body["code"] == 200 + assert body["data"]["user"]["email"] == "open@example.com" + assert body["data"]["access_token"] + + +def test_login_by_code_endpoint_creates_unknown_email(client, db_session, mock_user_redis): + mock_user_redis.get.return_value = "123456" + resp = client.post( + "/auth/login-by-code", + json={"email": "fresh@example.com", "code": "123456"}, + ) + assert resp.status_code == 200 + body = resp.json() + assert body["code"] == 200 + assert body["data"]["user"]["email"] == "fresh@example.com" + assert ( + db_session.query(User).filter(User.email == "fresh@example.com").one_or_none() + is not None + ) + + def test_update_nickname_endpoint(auth_client, seeded_user, mock_user_redis): resp = auth_client.patch("/auth/profile", json={"nickname": "新昵称"}) assert resp.status_code == 200 diff --git a/backend/tests/test_auth_registration_closed.py b/backend/tests/test_auth_registration_closed.py index aa77c335..28fa99df 100644 --- a/backend/tests/test_auth_registration_closed.py +++ b/backend/tests/test_auth_registration_closed.py @@ -1,30 +1,25 @@ -"""内测关闭公开注册:公开建号路径必须被拒绝。""" +"""无效邀请码不得建号。""" from windup_common.enums.biz_code import BizCode +from windup_app.server.user.model import User -def test_register_endpoint_rejects_public_signup(client): + +def test_register_endpoint_rejects_invalid_invite_code(client, db_session): resp = client.post( "/auth/register", json={ "email": "new@example.com", "password": "password123", "code": "123456", + "invite_code": "NOPE1234", }, ) assert resp.status_code == 200 body = resp.json() assert body["code"] == BizCode.BAD_REQUEST - assert body["message"] == "内测期间暂不开放注册" - assert body["data"] is None - - -def test_send_code_rejects_register_purpose(client): - resp = client.post( - "/auth/send-code", - json={"email": "new@example.com", "purpose": "register"}, + assert body["message"] == "邀请码无效" + assert ( + db_session.query(User).filter(User.email == "new@example.com").one_or_none() + is None ) - assert resp.status_code == 200 - body = resp.json() - assert body["code"] == BizCode.BAD_REQUEST - assert body["message"] == "内测期间暂不开放注册" diff --git a/backend/tests/test_character_contract.py b/backend/tests/test_character_contract.py index 9fca5505..0ebddf29 100644 --- a/backend/tests/test_character_contract.py +++ b/backend/tests/test_character_contract.py @@ -15,7 +15,7 @@ import pytest from pydantic import ValidationError -from windup_ai_engine.master_prep import MASTER_POSES +from windup_ai_engine.master_prep import ATTACK_MASTER_POSES, MASTER_POSES from windup_ai_engine.prompt import ( build_attack_prompt, build_idle_prompt, @@ -27,6 +27,7 @@ DEFAULT_N_FRAMES, ActionSpec, ActionType, + AttackArchetype, CharacterCard, CharacterView, Facing, @@ -178,9 +179,10 @@ def test_prompt_names_no_equipment(build, facing): def test_master_poses_name_no_equipment(): """母版姿势描述同样不许写装备 —— 母版是整条 i2v 链的身份来源,污染会传到所有动作。""" - for action, pose in MASTER_POSES.items(): + poses = {**MASTER_POSES, **{a.value: t for a, t in ATTACK_MASTER_POSES.items()}} + for action, pose in poses.items(): named = _named_equipment(pose) - assert not named, f"MASTER_POSES[{action!r}] 断言了装备: {named}" + assert not named, f"母版姿态 {action!r} 断言了装备: {named}" @pytest.mark.parametrize( @@ -193,8 +195,21 @@ def test_prompt_builders_expose_facing_only(build): 而调用侧(``strategy.concrete._build_prompt``)只传 facing,默认值就成了全体角色的 实际取值。要按角色定制装备文字,得先有地方存它,那是角色卡契约的事;在这里留一个 没人传的参数,只会让人以为该能力已经存在。 + + attack 多一个 ``archetype``:它选的是运动拓扑(身体怎么发力)、取值是枚举、且真有写入方 + (``ActionSpec.archetype`` → ``_build_prompt``),与"没人传的装备参数"不是一类。 """ - assert list(inspect.signature(build).parameters) == ["facing"] + allowed = ["facing", "archetype"] if build is build_attack_prompt else ["facing"] + assert list(inspect.signature(build).parameters) == allowed + + +def test_attack_archetype_is_an_enum_not_free_text(): + """拼错的拓扑要当场炸,不能静默落到某一支 —— 理由同 facing 用枚举。""" + p = inspect.signature(build_attack_prompt).parameters["archetype"] + assert p.kind is inspect.Parameter.KEYWORD_ONLY, "archetype 必须是关键字参数,免得与 facing 传串位" + assert p.default is AttackArchetype.THRUST + with pytest.raises(ValueError): + build_attack_prompt(facing=Facing.SIDE, archetype="swep") def test_strategy_passes_only_facing_into_prompt_builders(): diff --git a/backend/tests/test_prompt_assets.py b/backend/tests/test_prompt_assets.py index 96674e2d..50b91d3f 100644 --- a/backend/tests/test_prompt_assets.py +++ b/backend/tests/test_prompt_assets.py @@ -13,7 +13,7 @@ import pytest -from windup_ai_engine.master_prep import MASTER_POSES +from windup_ai_engine.master_prep import ATTACK_MASTER_POSES, MASTER_POSES from windup_ai_engine.prompt import ( build_attack_prompt, build_idle_prompt, @@ -21,6 +21,7 @@ build_walk_prompt, ) from windup_ai_engine.prompt._md import PromptAssetError, load_doc, load_section +from windup_common.models import AttackArchetype BUILDERS = { "walk": build_walk_prompt, @@ -29,6 +30,13 @@ "attack": build_attack_prompt, } +# 每份 md 里必须真的有内容的节。attack 的节名是 `<运动拓扑>.<朝向>`。 +SECTIONS = { + doc: ["side", "front"] for doc in ("walk.md", "jump.md", "idle.md") +} | { + "attack.md": [f"{a.value}.{f}" for a in AttackArchetype for f in ("side", "front")], +} + # ── ① 缺失 / 空 必须当场抛错,不返回空串 ────────────────────────────────── @@ -152,14 +160,21 @@ def test_master_poses_keeps_its_intentional_blanks(): assert MASTER_POSES["walk"] == "" and MASTER_POSES["run"] == "" assert MASTER_POSES["idle"] == "" assert "deep crouch" in MASTER_POSES["jump"] - assert "wind-up" in MASTER_POSES["attack"] + + +def test_every_attack_archetype_has_its_own_master_pose(): + """四支的起手姿态互不兼容,缺一支就该炸 —— 母版姿态决定动作,不能退回中性站立。""" + assert set(ATTACK_MASTER_POSES) == set(AttackArchetype) + texts = [t.strip() for t in ATTACK_MASTER_POSES.values()] + assert all(texts), f"有空的攻击母版姿态:{ATTACK_MASTER_POSES}" + assert len(set(texts)) == len(texts), "有两支共用同一段母版姿态" def test_only_master_poses_may_be_empty(): """``allow_empty`` 是给"空本身有含义"的地方开的口子,别的提示词不许走这条。""" - for action in BUILDERS: - for facing in ("side", "front"): - assert load_section(f"{action}.md", facing), f"{action}.{facing} 空了" + for doc, sections in SECTIONS.items(): + for section in sections: + assert load_section(doc, section), f"{doc} 的 {section} 空了" # ── ④ 打包:md 必须真的进 wheel,不能只在源码树里存在 ───────────────────── diff --git a/backend/tests/test_quota.py b/backend/tests/test_quota.py index 64364464..775c8176 100644 --- a/backend/tests/test_quota.py +++ b/backend/tests/test_quota.py @@ -106,18 +106,25 @@ def test_get_nonexistent_account(self, db_session, quota_service): class TestReserveCredit: def test_reserve_success(self, db_session, quota_service, user_with_account): uid = user_with_account.id - quota_service.reserve_credit(db_session, uid, quota_settings.generate_image_cost, "task:1") + quota_service.reserve_credit( + db_session, uid, quota_settings.generate_image_cost, "task:1" + ) account = db_session.scalar( select(CreditAccount).where(CreditAccount.user_id == uid) ) - assert account.balance == quota_settings.register_gift_amount - quota_settings.generate_image_cost + assert ( + account.balance + == quota_settings.register_gift_amount - quota_settings.generate_image_cost + ) assert account.frozen == quota_settings.generate_image_cost def test_reserve_insufficient(self, db_session, quota_service, user_with_account): uid = user_with_account.id with pytest.raises(BizException, match="积分不足"): - quota_service.reserve_credit(db_session, uid, quota_settings.register_gift_amount + 1, "task:2") + quota_service.reserve_credit( + db_session, uid, quota_settings.register_gift_amount + 1, "task:2" + ) def test_reserve_nonexistent_account(self, db_session, quota_service): with pytest.raises(BizException, match="积分账户不存在"): @@ -125,11 +132,14 @@ def test_reserve_nonexistent_account(self, db_session, quota_service): def test_reserve_writes_txn(self, db_session, quota_service, user_with_account): uid = user_with_account.id - quota_service.reserve_credit(db_session, uid, quota_settings.generate_image_cost, "task:3") + quota_service.reserve_credit( + db_session, uid, quota_settings.generate_image_cost, "task:3" + ) txn = db_session.scalar( - select(CreditTransaction) - .where(CreditTransaction.user_id == uid, CreditTransaction.ref_id == "task:3") + select(CreditTransaction).where( + CreditTransaction.user_id == uid, CreditTransaction.ref_id == "task:3" + ) ) assert txn is not None assert txn.delta == -quota_settings.generate_image_cost @@ -157,8 +167,12 @@ def test_capture_full(self, db_session, quota_service, user_with_account): def test_capture_partial_refund(self, db_session, quota_service, user_with_account): """冻结 50,实际扣 30,差额 20 退回。""" uid = user_with_account.id - quota_service.reserve_credit(db_session, uid, quota_settings.generate_action_cost, "task:4") - quota_service.capture_credit(db_session, uid, 30, "task:4", quota_settings.generate_action_cost) + quota_service.reserve_credit( + db_session, uid, quota_settings.generate_action_cost, "task:4" + ) + quota_service.capture_credit( + db_session, uid, 30, "task:4", quota_settings.generate_action_cost + ) account = db_session.scalar( select(CreditAccount).where(CreditAccount.user_id == uid) @@ -167,11 +181,17 @@ def test_capture_partial_refund(self, db_session, quota_service, user_with_accou assert account.frozen == 0 assert account.total_spent == 30 - def test_capture_writes_txn_and_refund(self, db_session, quota_service, user_with_account): + def test_capture_writes_txn_and_refund( + self, db_session, quota_service, user_with_account + ): """有差额退回时应写两条流水:扣减 + 退款。""" uid = user_with_account.id - quota_service.reserve_credit(db_session, uid, quota_settings.generate_action_cost, "task:5") - quota_service.capture_credit(db_session, uid, 30, "task:5", quota_settings.generate_action_cost) + quota_service.reserve_credit( + db_session, uid, quota_settings.generate_action_cost, "task:5" + ) + quota_service.capture_credit( + db_session, uid, 30, "task:5", quota_settings.generate_action_cost + ) txns = db_session.scalars( select(CreditTransaction).where(CreditTransaction.user_id == uid) @@ -180,10 +200,14 @@ def test_capture_writes_txn_and_refund(self, db_session, quota_service, user_wit assert CreditReason.CAPTURED in reasons assert CreditReason.REFUND in reasons - def test_capture_insufficient_frozen(self, db_session, quota_service, user_with_account): + def test_capture_insufficient_frozen( + self, db_session, quota_service, user_with_account + ): """冻结额度不足时应抛异常。""" uid = user_with_account.id - quota_service.reserve_credit(db_session, uid, quota_settings.generate_image_cost, "task:6") + quota_service.reserve_credit( + db_session, uid, quota_settings.generate_image_cost, "task:6" + ) with pytest.raises(BizException, match="冻结额度不足"): quota_service.capture_credit(db_session, uid, 100, "task:6", 100) @@ -198,8 +222,12 @@ def test_capture_nonexistent_account(self, db_session, quota_service): class TestReleaseCredit: def test_release_success(self, db_session, quota_service, user_with_account): uid = user_with_account.id - quota_service.reserve_credit(db_session, uid, quota_settings.generate_image_cost, "task:7") - quota_service.release_credit(db_session, uid, quota_settings.generate_image_cost, "task:7") + quota_service.reserve_credit( + db_session, uid, quota_settings.generate_image_cost, "task:7" + ) + quota_service.release_credit( + db_session, uid, quota_settings.generate_image_cost, "task:7" + ) account = db_session.scalar( select(CreditAccount).where(CreditAccount.user_id == uid) @@ -209,17 +237,25 @@ def test_release_success(self, db_session, quota_service, user_with_account): def test_release_writes_txn(self, db_session, quota_service, user_with_account): uid = user_with_account.id - quota_service.reserve_credit(db_session, uid, quota_settings.generate_image_cost, "task:8") - quota_service.release_credit(db_session, uid, quota_settings.generate_image_cost, "task:8") + quota_service.reserve_credit( + db_session, uid, quota_settings.generate_image_cost, "task:8" + ) + quota_service.release_credit( + db_session, uid, quota_settings.generate_image_cost, "task:8" + ) txn = db_session.scalar( - select(CreditTransaction) - .where(CreditTransaction.user_id == uid, CreditTransaction.reason == CreditReason.REFUND) + select(CreditTransaction).where( + CreditTransaction.user_id == uid, + CreditTransaction.reason == CreditReason.REFUND, + ) ) assert txn is not None assert txn.delta == quota_settings.generate_image_cost - def test_release_insufficient_frozen(self, db_session, quota_service, user_with_account): + def test_release_insufficient_frozen( + self, db_session, quota_service, user_with_account + ): uid = user_with_account.id with pytest.raises(BizException, match="冻结额度不足"): quota_service.release_credit(db_session, uid, 100, "task:9") @@ -248,8 +284,9 @@ def test_credit_writes_txn(self, db_session, quota_service, user_with_account): quota_service.credit(db_session, uid, 50, CreditReason.ADMIN_ADJUST, "admin:2") txn = db_session.scalar( - select(CreditTransaction) - .where(CreditTransaction.user_id == uid, CreditTransaction.ref_id == "admin:2") + select(CreditTransaction).where( + CreditTransaction.user_id == uid, CreditTransaction.ref_id == "admin:2" + ) ) assert txn is not None assert txn.delta == 50 @@ -290,8 +327,16 @@ def test_list_empty(self, db_session, quota_service, user_with_account): def test_list_after_operations(self, db_session, quota_service, user_with_account): uid = user_with_account.id - quota_service.reserve_credit(db_session, uid, quota_settings.generate_image_cost, "task:10") - quota_service.capture_credit(db_session, uid, quota_settings.generate_image_cost, "task:10", quota_settings.generate_image_cost) + quota_service.reserve_credit( + db_session, uid, quota_settings.generate_image_cost, "task:10" + ) + quota_service.capture_credit( + db_session, + uid, + quota_settings.generate_image_cost, + "task:10", + quota_settings.generate_image_cost, + ) txns, total = quota_service.list_transactions(db_session, uid) assert total >= 2 @@ -300,13 +345,19 @@ def test_list_after_operations(self, db_session, quota_service, user_with_accoun def test_list_pagination(self, db_session, quota_service, user_with_account): uid = user_with_account.id for i in range(5): - quota_service.credit(db_session, uid, 10, CreditReason.ADMIN_ADJUST, f"page:{i}") + quota_service.credit( + db_session, uid, 10, CreditReason.ADMIN_ADJUST, f"page:{i}" + ) - txns_p1, total = quota_service.list_transactions(db_session, uid, page=1, page_size=2) + txns_p1, total = quota_service.list_transactions( + db_session, uid, page=1, page_size=2 + ) assert total == 5 assert len(txns_p1) == 2 - txns_p3, _ = quota_service.list_transactions(db_session, uid, page=3, page_size=2) + txns_p3, _ = quota_service.list_transactions( + db_session, uid, page=3, page_size=2 + ) assert len(txns_p3) == 1 # 最后一页只有 1 条 def test_list_other_user_empty(self, db_session, quota_service, user_with_account): @@ -409,7 +460,9 @@ def test_list_transactions_empty(self, auth_quota_client): assert data["data"] == [] assert data["total"] == 0 - def test_list_transactions_pagination(self, auth_quota_client, db_session, user_with_account): + def test_list_transactions_pagination( + self, auth_quota_client, db_session, user_with_account + ): """先写入几条流水,再通过 API 分页查询。""" uid = user_with_account.id service = SqlAlchemyQuotaService() @@ -423,7 +476,9 @@ def test_list_transactions_pagination(self, auth_quota_client, db_session, user_ assert data["total"] == 5 assert len(data["data"]) == 2 - def test_list_transactions_default_pagination(self, auth_quota_client, db_session, user_with_account): + def test_list_transactions_default_pagination( + self, auth_quota_client, db_session, user_with_account + ): """默认分页参数。""" uid = user_with_account.id service = SqlAlchemyQuotaService() @@ -441,3 +496,346 @@ def test_unauthenticated_access(self, client): assert resp.status_code == 200 data = resp.json() assert data["code"] == 401 + + +def _gift_account(session: Session, user_id: int) -> None: + session.add( + CreditAccount( + user_id=user_id, + balance=quota_settings.register_gift_amount, + frozen=0, + total_earned=quota_settings.register_gift_amount, + total_spent=0, + ) + ) + session.flush() + + +class TestInviteCode: + """邀请码生成、查询与兑换。""" + + def test_get_invite_code_creates_when_missing(self, auth_quota_client): + resp = auth_quota_client.get("/quota/invite/code") + assert resp.status_code == 200 + data = resp.json() + assert data["code"] == 200 + assert len(data["data"]["code"]) == 8 + assert data["data"]["used_count"] == 0 + + again = auth_quota_client.get("/quota/invite/code") + assert again.json()["data"]["code"] == data["data"]["code"] + assert again.json()["data"]["expires_at"] + + def test_generate_invite_code_rotates(self, auth_quota_client): + first = auth_quota_client.get("/quota/invite/code").json()["data"]["code"] + second = auth_quota_client.post("/quota/invite/generate").json()["data"]["code"] + assert second != first + assert len(second) == 8 + assert auth_quota_client.get("/quota/invite/code").json()["data"]["code"] == second + + def test_generate_invite_code_locks_existing_row( + self, db_session, quota_service, monkeypatch + ): + from sqlalchemy.sql.selectable import Select + from windup_app.server.user.model import User + + host = User(email="lock-host@example.com", password_hash="x") + db_session.add(host) + db_session.flush() + quota_service.generate_invite_code(db_session, host.id) + + locked = [] + original = Select.with_for_update + + def tracking(self, *args, **kwargs): + locked.append(True) + return original(self, *args, **kwargs) + + monkeypatch.setattr(Select, "with_for_update", tracking) + quota_service.generate_invite_code(db_session, host.id) + assert locked, "轮换已有邀请码时应对该行加 FOR UPDATE" + + def test_generate_invite_code_keeps_old_row(self, db_session, quota_service): + from datetime import datetime, timezone + from sqlalchemy import select + from windup_app.server.quota.model import InviteCode + from windup_app.server.user.model import User + + host = User(email="append-host@example.com", password_hash="x") + db_session.add(host) + db_session.flush() + first = quota_service.generate_invite_code(db_session, host.id) + second = quota_service.generate_invite_code(db_session, host.id) + rows = db_session.scalars( + select(InviteCode).where(InviteCode.user_id == host.id) + ).all() + assert {row.code for row in rows} == {first.code, second.code} + old = next(row for row in rows if row.code == first.code) + now = datetime.now(timezone.utc) + exp = old.expires_at if old.expires_at.tzinfo else old.expires_at.replace( + tzinfo=timezone.utc + ) + assert exp <= now + + def test_get_invite_code_issues_new_row_after_expiry( + self, db_session, quota_service + ): + from datetime import datetime, timedelta, timezone + from sqlalchemy import select + from windup_app.server.quota.model import InviteCode + from windup_app.server.user.model import User + + host = User(email="expire-host@example.com", password_hash="x") + db_session.add(host) + db_session.flush() + first = quota_service.generate_invite_code(db_session, host.id) + row = db_session.scalar( + select(InviteCode).where(InviteCode.code == first.code) + ) + row.expires_at = datetime.now(timezone.utc) - timedelta(seconds=1) + db_session.flush() + + second = quota_service.get_invite_code(db_session, host.id) + assert second.code != first.code + assert ( + db_session.scalar( + select(InviteCode).where(InviteCode.code == first.code) + ) + is not None + ) + + def test_redeem_unique_violation_is_already_redeemed( + self, db_session, quota_service, monkeypatch + ): + """并发双兑时 unique(invitee_id) 应收敛为「已填写过邀请码」,而不是 500。""" + from sqlalchemy.exc import IntegrityError + from windup_app.server.user.model import User + from windup_common.exceptions import BizException + + host = User(email="race-host@example.com", password_hash="x") + guest = User(email="race-guest@example.com", password_hash="x") + db_session.add_all([host, guest]) + db_session.flush() + _gift_account(db_session, host.id) + _gift_account(db_session, guest.id) + view = quota_service.generate_invite_code(db_session, host.id) + + from windup_app.server.quota.model import InviteRecord + + orig_flush = db_session.flush + + def boom(*_args, **_kwargs): + if any(isinstance(obj, InviteRecord) for obj in db_session.new): + raise IntegrityError( + "INSERT", + {}, + Exception( + "UNIQUE constraint failed: windup_invite_record.invitee_id" + ), + ) + return orig_flush(*_args, **_kwargs) + + monkeypatch.setattr(db_session, "flush", boom) + + with pytest.raises(BizException, match="已填写过邀请码"): + quota_service.redeem_invite_code(db_session, guest.id, view.code) + + def test_redeem_invite_code_rewards_both_users(self, db_session, quota_service): + from windup_app.server.user.model import User + + inviter = User(email="host@example.com", password_hash="x") + invitee = User(email="guest@example.com", password_hash="x") + db_session.add_all([inviter, invitee]) + db_session.flush() + _gift_account(db_session, inviter.id) + _gift_account(db_session, invitee.id) + view = quota_service.generate_invite_code(db_session, inviter.id) + + quota_service.redeem_invite_code(db_session, invitee.id, view.code.lower()) + + host = quota_service.get_account(db_session, inviter.id) + guest = quota_service.get_account(db_session, invitee.id) + assert ( + host.balance + == quota_settings.register_gift_amount + quota_settings.invite_reward_amount + ) + assert ( + guest.balance + == quota_settings.register_gift_amount + quota_settings.invite_reward_amount + ) + + def test_inviter_daily_reward_stops_after_three_invites( + self, db_session, quota_service + ): + from windup_app.server.quota.model import InviteRecord + from windup_app.server.user.model import User + + inviter = User(email="cap-host@example.com", password_hash="x") + db_session.add(inviter) + db_session.flush() + _gift_account(db_session, inviter.id) + view = quota_service.generate_invite_code(db_session, inviter.id) + + guests = [] + for i in range(4): + guest = User(email=f"cap-guest-{i}@example.com", password_hash="x") + db_session.add(guest) + db_session.flush() + _gift_account(db_session, guest.id) + quota_service.redeem_invite_code(db_session, guest.id, view.code) + guests.append(guest) + + host = quota_service.get_account(db_session, inviter.id) + assert host.balance == quota_settings.register_gift_amount + ( + quota_settings.invite_reward_amount * 3 + ) + assert ( + db_session.scalar( + select(InviteRecord.id).where( + InviteRecord.invitee_id == guests[3].id + ) + ) + is not None + ) + fourth = quota_service.get_account(db_session, guests[3].id) + assert ( + fourth.balance + == quota_settings.register_gift_amount + quota_settings.invite_reward_amount + ) + + def test_inviter_daily_reward_resets_next_utc_day( + self, db_session, quota_service + ): + from datetime import timedelta + from windup_app.server.quota.model import InviteRecord + from windup_app.server.quota.service import _now + from windup_app.server.user.model import User + + inviter = User(email="nextday-host@example.com", password_hash="x") + db_session.add(inviter) + db_session.flush() + _gift_account(db_session, inviter.id) + view = quota_service.generate_invite_code(db_session, inviter.id) + + for i in range(3): + guest = User(email=f"old-guest-{i}@example.com", password_hash="x") + db_session.add(guest) + db_session.flush() + _gift_account(db_session, guest.id) + quota_service.redeem_invite_code(db_session, guest.id, view.code) + + yesterday = _now() - timedelta(days=1) + for row in db_session.scalars( + select(InviteRecord).where(InviteRecord.inviter_id == inviter.id) + ).all(): + row.create_at = yesterday + db_session.flush() + + today_guest = User(email="today-guest@example.com", password_hash="x") + db_session.add(today_guest) + db_session.flush() + _gift_account(db_session, today_guest.id) + quota_service.redeem_invite_code(db_session, today_guest.id, view.code) + + host = quota_service.get_account(db_session, inviter.id) + assert host.balance == quota_settings.register_gift_amount + ( + quota_settings.invite_reward_amount * 4 + ) + + def test_redeem_rejects_own_code_and_repeat(self, db_session, quota_service): + from windup_app.server.user.model import User + from windup_common.exceptions import BizException + + host = User(email="self@example.com", password_hash="x") + guest = User(email="once@example.com", password_hash="x") + db_session.add_all([host, guest]) + db_session.flush() + _gift_account(db_session, host.id) + _gift_account(db_session, guest.id) + view = quota_service.generate_invite_code(db_session, host.id) + + with pytest.raises(BizException, match="不能填写自己的邀请码"): + quota_service.redeem_invite_code(db_session, host.id, view.code) + + quota_service.redeem_invite_code(db_session, guest.id, view.code) + with pytest.raises(BizException, match="已填写过邀请码"): + quota_service.redeem_invite_code(db_session, guest.id, view.code) + + def test_generate_invite_code_rejects_missing_user(self, db_session, quota_service): + from windup_common.exceptions import BizException + + with pytest.raises(BizException, match="用户不存在"): + quota_service.generate_invite_code(db_session, 999999) + + def test_allocate_invite_code_gives_up_on_collision( + self, db_session, quota_service, monkeypatch + ): + from windup_app.server.quota import service as quota_mod + from windup_app.server.user.model import User + from windup_common.exceptions import BizException + + taken = User(email="taken@example.com", password_hash="x") + host = User(email="alloc@example.com", password_hash="x") + db_session.add_all([taken, host]) + db_session.flush() + occupied = quota_service.generate_invite_code(db_session, taken.id) + monkeypatch.setattr(quota_mod, "_new_invite_code", lambda: occupied.code) + + with pytest.raises(BizException, match="邀请码生成失败"): + quota_service.generate_invite_code(db_session, host.id) + + def test_redeem_rejects_blank_or_unknown_code(self, db_session, quota_service): + from windup_app.server.user.model import User + from windup_common.exceptions import BizException + + guest = User(email="blank@example.com", password_hash="x") + db_session.add(guest) + db_session.flush() + _gift_account(db_session, guest.id) + + with pytest.raises(BizException, match="邀请码无效"): + quota_service.redeem_invite_code(db_session, guest.id, " ") + with pytest.raises(BizException, match="邀请码无效"): + quota_service.redeem_invite_code(db_session, guest.id, "IO01") + with pytest.raises(BizException, match="邀请码无效"): + quota_service.redeem_invite_code(db_session, guest.id, "NOPE1234") + + def test_redeem_rejects_expired_code(self, db_session, quota_service): + from datetime import datetime, timedelta, timezone + from sqlalchemy import select + from windup_app.server.quota.model import InviteCode + from windup_app.server.user.model import User + from windup_common.enums.biz_code import BizCode + from windup_common.exceptions import BizException + + host = User(email="stale-host@example.com", password_hash="x") + guest = User(email="stale-guest@example.com", password_hash="x") + db_session.add_all([host, guest]) + db_session.flush() + _gift_account(db_session, host.id) + _gift_account(db_session, guest.id) + view = quota_service.generate_invite_code(db_session, host.id) + row = db_session.scalar(select(InviteCode).where(InviteCode.code == view.code)) + row.expires_at = datetime.now(timezone.utc) - timedelta(seconds=1) + db_session.flush() + + with pytest.raises(BizException, match="邀请码已过期") as exc: + quota_service.redeem_invite_code(db_session, guest.id, view.code) + assert exc.value.code == BizCode.NOT_FOUND + + def test_redeem_rejects_missing_invitee(self, db_session, quota_service): + from windup_app.server.user.model import User + from windup_common.exceptions import BizException + + host = User(email="orphan-host@example.com", password_hash="x") + db_session.add(host) + db_session.flush() + _gift_account(db_session, host.id) + view = quota_service.generate_invite_code(db_session, host.id) + + with pytest.raises(BizException, match="用户不存在"): + quota_service.redeem_invite_code(db_session, 999999, view.code) + + def test_invite_redeem_endpoint_removed(self, auth_quota_client): + resp = auth_quota_client.post("/quota/invite/redeem", json={"code": "AB23CD45"}) + assert resp.status_code == 404 diff --git a/backend/tests/test_user_service.py b/backend/tests/test_user_service.py index 671a677a..31cc1761 100644 --- a/backend/tests/test_user_service.py +++ b/backend/tests/test_user_service.py @@ -31,6 +31,12 @@ # -- Fixtures ------------------------------------------------------------ +@pytest.fixture(autouse=True) +def _seed_invite(request): + if "db_session" in request.fixturenames: + request.getfixturevalue("invite_code") + + @pytest.fixture() def mock_redis(): """Mock Redis 客户端。""" @@ -129,6 +135,7 @@ def test_register_success(db_session, service, mock_email): email="new@example.com", password="password123", code="123456", + invite_code="AB23CD45", ) result = service.register_by_email(db_session, input_data) @@ -148,6 +155,7 @@ def test_public_methods_accept_session(db_session, service, mock_email): email="public@example.com", password="password123", code="123456", + invite_code="AB23CD45", ), ) @@ -191,19 +199,21 @@ def test_register_creates_credit_account(db_session, service, mock_email): email="credit@example.com", password="password123", code="123456", + invite_code="AB23CD45", ) result = service.register_by_email(db_session, input_data) user_id = result.user.id + expected = quota_settings.register_gift_amount + quota_settings.invite_reward_amount # 验证积分账户已创建 account = db_session.scalar( select(CreditAccount).where(CreditAccount.user_id == user_id) ) assert account is not None - assert account.balance == quota_settings.register_gift_amount + assert account.balance == expected assert account.frozen == 0 - assert account.total_earned == quota_settings.register_gift_amount + assert account.total_earned == expected assert account.total_spent == 0 # 验证赠送流水已记录 @@ -222,7 +232,12 @@ def test_register_creates_credit_account(db_session, service, mock_email): 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") + input_data = RegisterInput( + email="dup@example.com", + password="pass123", + code="123456", + invite_code="AB23CD45", + ) service.register_by_email(db_session, input_data) # 尝试重复注册 @@ -237,6 +252,7 @@ def test_register_wrong_code(db_session, service): email="new@example.com", password="password123", code="999999", # 错误验证码 + invite_code="AB23CD45", ) with pytest.raises(BizException, match="验证码错误"): @@ -250,19 +266,84 @@ def test_register_expired_code(db_session, service): email="new@example.com", password="password123", code="123456", + invite_code="AB23CD45", ) with pytest.raises(BizException, match="验证码已过期"): service.register_by_email(db_session, input_data) +def test_register_blank_invite_code_only_gives_register_gift(db_session, service): + """未带邀请码时只发注册赠送,不挡注册。""" + from sqlalchemy import select + from windup_app.server.quota.model import CreditAccount + from windup_framework.config.quota import settings as quota_settings + + service._redis.get.return_value = "123456" + input_data = RegisterInput( + email="blank-invite@example.com", + password="password123", + code="123456", + invite_code=" ", + ) + + result = service.register_by_email(db_session, input_data) + account = db_session.scalar( + select(CreditAccount).where(CreditAccount.user_id == result.user.id) + ) + assert account is not None + assert account.balance == quota_settings.register_gift_amount + + +def test_register_rejects_invite_code_outside_link_charset(db_session, service): + """前端邀请链接用 A-H/J-N/P-Z/2-9,含 I/O/0/1 的码不会进注册请求。""" + service._redis.get.return_value = "123456" + input_data = RegisterInput( + email="bad-charset@example.com", + password="password123", + code="123456", + invite_code="IIII", + ) + + with pytest.raises(BizException, match="邀请码无效"): + service.register_by_email(db_session, input_data) + + +def test_register_expired_invite_code(db_session, service): + from datetime import datetime, timedelta, timezone + from sqlalchemy import select + from windup_app.server.quota.model import InviteCode + from windup_common.enums.biz_code import BizCode + + row = db_session.scalar(select(InviteCode).where(InviteCode.code == "AB23CD45")) + row.expires_at = datetime.now(timezone.utc) - timedelta(days=1) + db_session.flush() + + service._redis.get.return_value = "123456" + input_data = RegisterInput( + email="late@example.com", + password="password123", + code="123456", + invite_code="AB23CD45", + ) + with pytest.raises(BizException, match="邀请码已过期") as exc: + service.register_by_email(db_session, input_data) + assert exc.value.code == BizCode.NOT_FOUND + assert db_session.scalar(select(User).where(User.email == "late@example.com")) is None + + # -- 登录测试 ------------------------------------------------------------ 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") + register_input = RegisterInput( + email="login@example.com", + password="pass123", + code="123456", + invite_code="AB23CD45", + ) service.register_by_email(db_session, register_input) # 登录(不需要验证码) @@ -277,7 +358,12 @@ def test_login_success(db_session, service, mock_email): 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") + register_input = RegisterInput( + email="login@example.com", + password="pass123", + code="123456", + invite_code="AB23CD45", + ) service.register_by_email(db_session, register_input) # 密码错误 @@ -301,11 +387,17 @@ def test_login_nonexistent_user(db_session, service): 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") + register_input = RegisterInput( + email="banned@example.com", + password="pass123", + code="123456", + invite_code="AB23CD45", + ) service.register_by_email(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() @@ -321,29 +413,36 @@ def test_login_banned_user(db_session, service, mock_email): # -- 验证码登录测试 ------------------------------------------------------ -def test_login_by_code_unknown_email_does_not_create_user(db_session, service, mock_email): - """内测关闭公开注册后,验证码登录不得自动建号。""" +def test_login_by_code_unknown_email_creates_user_and_gifts( + db_session, service, mock_email +): + """未知邮箱验证码登录自动建号,并只发注册赠送。""" from sqlalchemy import select + from windup_app.server.quota.model import CreditAccount + from windup_framework.config.quota import settings as quota_settings service._redis.get.return_value = "123456" input_data = LoginByCodeInput(email="code@example.com", code="123456") - with pytest.raises(BizException, match="账号不存在") as exc: - service.login_by_code(db_session, input_data) - - from windup_common.enums.biz_code import BizCode + result = service.login_by_code(db_session, input_data) - assert exc.value.code == BizCode.NOT_FOUND - assert db_session.scalar(select(User).where(User.email == "code@example.com")) is None + user = db_session.scalar(select(User).where(User.email == "code@example.com")) + assert user is not None + assert result.user.id == user.id + assert result.user.email_verified_at is not None + account = db_session.scalar( + select(CreditAccount).where(CreditAccount.user_id == user.id) + ) + assert account is not None + assert account.balance == quota_settings.register_gift_amount -def test_send_verification_code_rejects_register_purpose(service, mock_email): +def test_send_verification_code_allows_register_purpose(service, mock_email): service._redis.get.return_value = None - with pytest.raises(BizException, match="内测期间暂不开放注册"): - service.send_verification_code("new@example.com", "register") + service.send_verification_code("new@example.com", "register") - mock_email.send_verification_code.assert_not_called() + mock_email.send_verification_code.assert_called_once() def test_login_by_code_banned_user(db_session, service, mock_email): @@ -352,9 +451,16 @@ def test_login_by_code_banned_user(db_session, service, mock_email): service._redis.get.return_value = "123456" service.register_by_email( db_session, - RegisterInput(email="banned-code@example.com", password="pass123", code="123456"), + RegisterInput( + email="banned-code@example.com", + password="pass123", + code="123456", + invite_code="AB23CD45", + ), + ) + user = db_session.scalar( + select(User).where(User.email == "banned-code@example.com") ) - user = db_session.scalar(select(User).where(User.email == "banned-code@example.com")) user.status = UserStatus.BANNED db_session.flush() @@ -384,7 +490,12 @@ def test_login_by_code_marks_unverified_email(db_session, service): 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") + register_input = RegisterInput( + email="exist@example.com", + password="pass123", + code="123456", + invite_code="AB23CD45", + ) service.register_by_email(db_session, register_input) # 验证码登录 @@ -490,16 +601,25 @@ def test_refresh_tokens_concurrent_reuse(service, mock_redis): 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") + register_input = RegisterInput( + email="change@example.com", + password="oldpass123", + code="123456", + invite_code="AB23CD45", + ) result = service.register_by_email(db_session, register_input) # 修改密码 - change_input = ChangePasswordInput(old_password="oldpass123", new_password="newpass123") + change_input = ChangePasswordInput( + old_password="oldpass123", new_password="newpass123" + ) service.change_password(db_session, result.user.id, change_input) # 用新密码登录 service._redis.get.return_value = None - login_input = LoginByPasswordInput(email="change@example.com", password="newpass123") + login_input = LoginByPasswordInput( + email="change@example.com", password="newpass123" + ) login_result = service.login_by_password(db_session, login_input) assert login_result.user.email == "change@example.com" @@ -508,7 +628,12 @@ def test_change_password(db_session, service, mock_email): 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") + register_input = RegisterInput( + email="change@example.com", + password="oldpass123", + code="123456", + invite_code="AB23CD45", + ) result = service.register_by_email(db_session, register_input) # 旧密码错误 @@ -524,7 +649,13 @@ def test_change_password_wrong_old(db_session, service, mock_email): def test_update_nickname(db_session, service, mock_email): """修改昵称后立即生效。""" service._redis.get.return_value = "123456" - register_input = RegisterInput(email="nick@example.com", password="pass1234", code="123456", nickname="旧昵称") + register_input = RegisterInput( + email="nick@example.com", + password="pass1234", + code="123456", + nickname="旧昵称", + invite_code="AB23CD45", + ) result = service.register_by_email(db_session, register_input) update_input = UpdateNicknameInput(nickname="新昵称") @@ -537,7 +668,12 @@ def test_update_nickname(db_session, service, mock_email): def test_update_nickname_max_length(db_session, service, mock_email): """昵称长度上限 50。""" service._redis.get.return_value = "123456" - register_input = RegisterInput(email="nick2@example.com", password="pass1234", code="123456") + register_input = RegisterInput( + email="nick2@example.com", + password="pass1234", + code="123456", + invite_code="AB23CD45", + ) result = service.register_by_email(db_session, register_input) long_nickname = "a" * 50 @@ -562,12 +698,19 @@ def test_reset_password(db_session, service, mock_email): """邮箱+验证码重置密码后,新密码可登录。""" # 先注册 service._redis.get.return_value = "123456" - register_input = RegisterInput(email="reset@example.com", password="oldpass123", code="123456") + register_input = RegisterInput( + email="reset@example.com", + password="oldpass123", + code="123456", + invite_code="AB23CD45", + ) service.register_by_email(db_session, register_input) # 重置密码(验证码 purpose 为 reset_password) service._redis.get.return_value = "654321" - reset_input = ResetPasswordInput(email="reset@example.com", code="654321", new_password="newpass123") + reset_input = ResetPasswordInput( + email="reset@example.com", code="654321", new_password="newpass123" + ) service.reset_password(db_session, reset_input) # 用新密码登录 @@ -581,12 +724,19 @@ def test_reset_password(db_session, service, mock_email): def test_reset_password_wrong_code(db_session, service, mock_email): """验证码错误时拒绝重置。""" service._redis.get.return_value = "123456" - register_input = RegisterInput(email="reset2@example.com", password="oldpass123", code="123456") + register_input = RegisterInput( + email="reset2@example.com", + password="oldpass123", + code="123456", + invite_code="AB23CD45", + ) service.register_by_email(db_session, register_input) # 验证码错误 service._redis.get.return_value = None # 验证码过期 - reset_input = ResetPasswordInput(email="reset2@example.com", code="000000", new_password="newpass123") + reset_input = ResetPasswordInput( + email="reset2@example.com", code="000000", new_password="newpass123" + ) with pytest.raises(BizException, match="验证码已过期"): service.reset_password(db_session, reset_input) @@ -595,7 +745,9 @@ def test_reset_password_wrong_code(db_session, service, mock_email): def test_reset_password_user_not_found(db_session, service): """用户不存在时拒绝重置。""" service._redis.get.return_value = "654321" - reset_input = ResetPasswordInput(email="noexist@example.com", code="654321", new_password="newpass123") + reset_input = ResetPasswordInput( + email="noexist@example.com", code="654321", new_password="newpass123" + ) with pytest.raises(BizException, match="用户不存在"): service.reset_password(db_session, reset_input) @@ -608,7 +760,12 @@ def test_login_account_locked(db_session, service, mock_email): """账号被锁定后拒绝登录(即使密码正确)。""" # 先注册 service._redis.get.return_value = "123456" - register_input = RegisterInput(email="lock@example.com", password="pass123", code="123456") + register_input = RegisterInput( + email="lock@example.com", + password="pass123", + code="123456", + invite_code="AB23CD45", + ) service.register_by_email(db_session, register_input) # 模拟账号锁定 diff --git a/frontend/src/app/layout/app-header.test.tsx b/frontend/src/app/layout/app-header.test.tsx index b20ce954..a4f149c9 100644 --- a/frontend/src/app/layout/app-header.test.tsx +++ b/frontend/src/app/layout/app-header.test.tsx @@ -1,5 +1,5 @@ // @vitest-environment jsdom -import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import { MemoryRouter, Route, Routes, useLocation } from 'react-router' @@ -108,6 +108,7 @@ function renderHeader( afterEach(() => { cleanup() window.localStorage.clear() + window.sessionStorage.clear() window.history.replaceState({ idx: 0 }, '') }) @@ -235,6 +236,52 @@ describe('AppHeader', () => { expect(apis.logout).toHaveBeenCalledWith('rotated-refresh-token') }) + it('登录工作台后显示一次邀请奖励提示,打开账号菜单时收起', async () => { + window.localStorage.setItem('windup.auth.refresh-token', 'stored-refresh-token') + renderHeader('/workspace') + + expect(await screen.findByRole('status', { name: '邀请奖励提示' })).toBeTruthy() + fireEvent.click(await screen.findByRole('button', { name: '打开账号菜单' })) + + expect(screen.queryByRole('status', { name: '邀请奖励提示' })).toBeNull() + }) + + it('邀请提示可以直达邀请奖励,并在关闭或十五秒后收起', async () => { + window.localStorage.setItem('windup.auth.refresh-token', 'stored-refresh-token') + const timeoutSpy = vi.spyOn(window, 'setTimeout') + renderHeader('/workspace') + + const hint = await screen.findByRole('status', { name: '邀请奖励提示' }) + expect(screen.getByRole('link', { name: '去看看邀请奖励' }).getAttribute('href')).toBe( + '/account?section=invite', + ) + const timerCall = timeoutSpy.mock.calls.find(([, delay]) => delay === 15_000) + expect(timerCall).toBeTruthy() + const timerCallback = timerCall?.[0] + expect(typeof timerCallback).toBe('function') + act(() => { + if (typeof timerCallback === 'function') timerCallback() + }) + expect(screen.queryByRole('status', { name: '邀请奖励提示' })).toBeNull() + + window.sessionStorage.clear() + cleanup() + renderHeader('/workspace') + expect(await screen.findByRole('status', { name: '邀请奖励提示' })).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: '关闭邀请奖励提示' })) + expect(hint.isConnected).toBe(false) + }) + + it('当前登录会话离开工作台后不重复显示', async () => { + window.localStorage.setItem('windup.auth.refresh-token', 'stored-refresh-token') + renderHeader('/workspace') + expect(await screen.findByRole('status', { name: '邀请奖励提示' })).toBeTruthy() + + fireEvent.click(screen.getByRole('link', { name: '项目资产' })) + fireEvent.click(screen.getByRole('link', { name: '首页' })) + expect(screen.queryByRole('status', { name: '邀请奖励提示' })).toBeNull() + }) + it('远端退出失败时仍清除本地会话并返回首页', async () => { window.localStorage.setItem('windup.auth.refresh-token', 'stored-refresh-token') const apis = createApis() diff --git a/frontend/src/app/layout/app-header.tsx b/frontend/src/app/layout/app-header.tsx index 357340fd..b698d69e 100644 --- a/frontend/src/app/layout/app-header.tsx +++ b/frontend/src/app/layout/app-header.tsx @@ -3,7 +3,7 @@ import { Link, useLocation, useNavigate } from 'react-router' import { quotaApis as defaultQuotaApis } from '@/entities' import type { QuotaApis } from '@/entities' -import { useAuthSession } from '@/features/auth-session' +import { AUTH_SESSION_STORAGE_PREFIX, useAuthSession } from '@/features/auth-session' import { useQuotaBalance } from '@/features/quota' import { PageBackButton } from './page-back-button' @@ -22,6 +22,7 @@ export interface AppHeaderProps { } const accountMenuExitDurationMs = 260 +const inviteHintStorageKey = `${AUTH_SESSION_STORAGE_PREFIX}invite-hint-seen.v1` /** 四个入口对应四种去处:回首页、看资产、做新东西、核验已完成的造型。 */ const productNavigation: ProductNavigationItem[] = [ @@ -79,6 +80,7 @@ export function AppHeader({ quotaApis = defaultQuotaApis }: AppHeaderProps = {}) const navigate = useNavigate() const session = useAuthSession() const [accountMenuState, setAccountMenuState] = useState('closed') + const [inviteHintVisible, setInviteHintVisible] = useState(false) const accountMenuOpen = accountMenuState === 'open' const creditBalance = useQuotaBalance( accountMenuState !== 'closed' && session.state.status === 'authenticated', @@ -99,15 +101,40 @@ export function AppHeader({ quotaApis = defaultQuotaApis }: AppHeaderProps = {}) return () => window.clearTimeout(timer) }, [accountMenuState]) + useEffect(() => { + if (pathname !== '/workspace' || session.state.status !== 'authenticated') { + setInviteHintVisible(false) + return + } + + if (window.sessionStorage.getItem(inviteHintStorageKey) === '1') return + + window.sessionStorage.setItem(inviteHintStorageKey, '1') + setInviteHintVisible(true) + + const timer = window.setTimeout(() => { + setInviteHintVisible(false) + }, 15_000) + + return () => window.clearTimeout(timer) + }, [pathname, session.state.status]) + function signOut() { + window.sessionStorage.removeItem(inviteHintStorageKey) const returnHome = () => navigate('/', { replace: true }) void session.logout().then(returnHome, returnHome) } function toggleAccountMenu() { + dismissInviteHint() setAccountMenuState((state) => (state === 'open' ? 'closing' : 'open')) } + function dismissInviteHint() { + window.sessionStorage.setItem(inviteHintStorageKey, '1') + setInviteHintVisible(false) + } + function finishAccountMenuMotion() { if (accountMenuState === 'closing') { setAccountMenuState('closed') @@ -210,6 +237,36 @@ export function AppHeader({ quotaApis = defaultQuotaApis }: AppHeaderProps = {}) ) : ( <> + {inviteHintVisible ? ( +
+
+
+

+ 邀请好友,双方各得 200 积分 +

+ + 去看看邀请奖励 + +
+ +
+
+ ) : null}
{ nodes, }) expect(request?.method).toBe('PATCH') - await expect(request?.json()).resolves.toEqual({ nodes, status: 'active' }) + await expect(request?.json()).resolves.toEqual({ nodes, status: 'active', version: 3 }) expect(updated.version).toBe(4) }) + it('exposes a version conflict as a workflow-run domain error', async () => { + const apis = await loadWorkflowRunApis( + async () => + new Response( + JSON.stringify({ code: 409, message: '执行记录版本冲突,请刷新后重试', data: null }), + { headers: { 'content-type': 'application/json' } }, + ), + ) + + await expect( + apis.update({ + id: '17', + projectId: '42', + version: 3, + storageStatus: 'active', + nodes, + }), + ).rejects.toMatchObject({ + name: 'WorkflowRunConflictError', + message: '执行记录版本冲突,请刷新后重试', + }) + }) + + it('preserves non-conflict API errors from an update', async () => { + const apis = await loadWorkflowRunApis( + async () => + new Response(JSON.stringify({ code: 500, message: '保存失败', data: null }), { + headers: { 'content-type': 'application/json' }, + }), + ) + + await expect( + apis.update({ + id: '17', + projectId: '42', + version: 3, + storageStatus: 'active', + nodes, + }), + ).rejects.toMatchObject({ name: 'ApiError', code: 500, message: '保存失败' }) + }) + it('soft deletes through the backend DELETE endpoint', async () => { let request: Request | undefined const apis = await loadWorkflowRunApis(async (input, init) => { diff --git a/frontend/src/entities/workflow-run/api.ts b/frontend/src/entities/workflow-run/api.ts index b1493317..a9569c45 100644 --- a/frontend/src/entities/workflow-run/api.ts +++ b/frontend/src/entities/workflow-run/api.ts @@ -17,6 +17,14 @@ import { WORKFLOW_RUN_STORAGE_STATUSES, } from './constants' +/** 当前 WorkflowRun 已被其他请求更新,调用方需要重新读取后再继续修改。 */ +export class WorkflowRunConflictError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options) + this.name = 'WorkflowRunConflictError' + } +} + interface WorkflowRunDto { id: number project_id: number @@ -299,12 +307,22 @@ export const workflowRunApis: WorkflowRunApis & Required(`/workflow-runs/${encodeURIComponent(run.id)}`, { - method: 'PATCH', - json: { nodes: run.nodes, status: run.storageStatus }, - }), - ) + try { + return mapWorkflowRun( + await getApiClient().request( + `/workflow-runs/${encodeURIComponent(run.id)}`, + { + method: 'PATCH', + json: { nodes: run.nodes, status: run.storageStatus, version: run.version }, + }, + ), + ) + } catch (cause) { + if (cause instanceof ApiError && cause.kind === 'business' && cause.code === 409) { + throw new WorkflowRunConflictError(cause.message, { cause }) + } + throw cause + } }, async remove(id) { await getApiClient().request(`/workflow-runs/${encodeURIComponent(id)}`, { diff --git a/frontend/src/entities/workflow-run/index.ts b/frontend/src/entities/workflow-run/index.ts index 3eae85c1..1c45613d 100644 --- a/frontend/src/entities/workflow-run/index.ts +++ b/frontend/src/entities/workflow-run/index.ts @@ -121,7 +121,7 @@ export type WorkflowNode = export interface WorkflowRun { id: string projectId: string - /** 后端更新序号;当前仅随 PATCH 递增,不承担并发冲突检测。 */ + /** 后端乐观锁版本;更新时原样回传,保存成功后使用响应中的新版本。 */ version: number /** 后端资源状态,仅表示正常或软删除。 */ storageStatus: WorkflowRunStorageStatus @@ -143,4 +143,4 @@ export interface WorkflowRunApis { remove(id: WorkflowRun['id']): Promise } -export { workflowRunApis } from './api' +export { WorkflowRunConflictError, workflowRunApis } from './api' diff --git a/frontend/src/features/auth-session/index.test.tsx b/frontend/src/features/auth-session/index.test.tsx index 126b7b6a..38c212dd 100644 --- a/frontend/src/features/auth-session/index.test.tsx +++ b/frontend/src/features/auth-session/index.test.tsx @@ -93,6 +93,7 @@ async function expectState(value: string) { afterEach(() => { cleanup() clearRefreshToken() + window.sessionStorage.clear() currentSession = null vi.useRealTimers() }) @@ -164,6 +165,7 @@ describe('AuthSessionProvider', () => { ) it('clears local state before best-effort logout finishes and never restores it on failure', async () => { + window.sessionStorage.setItem('windup.auth-session.invite-hint-seen.v1', '1') const logout = deferred() const apis = createApis() apis.logout.mockReturnValue(logout.promise) @@ -176,6 +178,7 @@ describe('AuthSessionProvider', () => { logoutPromise = session().logout() }) await expectState('guest:logged-out:') + expect(window.sessionStorage.getItem('windup.auth-session.invite-hint-seen.v1')).toBeNull() expect(getApiAccessToken()).toBeNull() expect(window.localStorage.getItem(REFRESH_TOKEN_STORAGE_KEY)).toBeNull() expect(apis.logout).toHaveBeenCalledWith('refresh-token') diff --git a/frontend/src/features/auth-session/index.tsx b/frontend/src/features/auth-session/index.tsx index fa5e568c..80499175 100644 --- a/frontend/src/features/auth-session/index.tsx +++ b/frontend/src/features/auth-session/index.tsx @@ -14,11 +14,14 @@ import type { AuthTokens, User, UserApis } from '@/entities' import { registerApiAccessTokenProvider, registerApiUnauthorizedRecovery } from '@/shared/api' import { REFRESH_TOKEN_STORAGE_KEY, + clearAuthSessionScopedStorage, clearRefreshToken, loadRefreshToken, saveRefreshToken, } from './session-storage' +export { AUTH_SESSION_STORAGE_PREFIX } from './session-storage' + export type AuthGuestReason = null | 'logged-out' | 'session-expired' | 'password-changed' export type AuthSessionState = @@ -97,6 +100,7 @@ export function AuthSessionProvider({ apis, children }: AuthSessionProviderProps accessTokenRef.current = null refreshTokenRef.current = null if (persist) clearRefreshToken() + clearAuthSessionScopedStorage() setAccessTokenVersion((version) => version + 1) updateState({ status: 'guest', user: null, reason }) }, diff --git a/frontend/src/features/auth-session/session-storage.test.ts b/frontend/src/features/auth-session/session-storage.test.ts index 8edf5824..6f2726a1 100644 --- a/frontend/src/features/auth-session/session-storage.test.ts +++ b/frontend/src/features/auth-session/session-storage.test.ts @@ -1,8 +1,47 @@ import { describe, expect, it, vi } from 'vitest' -import { REFRESH_TOKEN_STORAGE_KEY, createRefreshTokenStorage } from './session-storage' +import { + AUTH_SESSION_STORAGE_PREFIX, + REFRESH_TOKEN_STORAGE_KEY, + clearAuthSessionScopedStorage, + createRefreshTokenStorage, +} from './session-storage' describe('refresh token storage', () => { + it('clears only values owned by the current auth session namespace', () => { + const values = new Map([ + [`${AUTH_SESSION_STORAGE_PREFIX}invite-hint-seen.v1`, '1'], + ['unrelated.preference', 'keep'], + ]) + const storage = { + get length() { + return values.size + }, + key: (index: number) => [...values.keys()][index] ?? null, + removeItem: vi.fn((key: string) => values.delete(key)), + } + + clearAuthSessionScopedStorage(storage) + + expect(storage.removeItem).toHaveBeenCalledWith( + `${AUTH_SESSION_STORAGE_PREFIX}invite-hint-seen.v1`, + ) + expect(values.get('unrelated.preference')).toBe('keep') + }) + + it('does not let unavailable session storage block session teardown', () => { + expect(() => clearAuthSessionScopedStorage(null)).not.toThrow() + expect(() => + clearAuthSessionScopedStorage({ + length: 1, + key: () => { + throw new DOMException('Storage is disabled', 'SecurityError') + }, + removeItem: vi.fn(), + }), + ).not.toThrow() + }) + it('persists only the refresh token under the contracted key', () => { const values = new Map() const storage = { diff --git a/frontend/src/features/auth-session/session-storage.ts b/frontend/src/features/auth-session/session-storage.ts index a79851a8..811f4248 100644 --- a/frontend/src/features/auth-session/session-storage.ts +++ b/frontend/src/features/auth-session/session-storage.ts @@ -1,6 +1,8 @@ export const REFRESH_TOKEN_STORAGE_KEY = 'windup.auth.refresh-token' +export const AUTH_SESSION_STORAGE_PREFIX = 'windup.auth-session.' type RefreshTokenStorage = Pick +type SessionScopedStorage = Pick export interface RefreshTokenStore { load(): string | null @@ -16,6 +18,29 @@ function getLocalStorage(): RefreshTokenStorage | null { } } +function getSessionStorage(): SessionScopedStorage | null { + try { + return globalThis.sessionStorage + } catch { + return null + } +} + +/** 清除只属于一次登录会话的 UI 标记;登出、过期和改密都经过这一边界。 */ +export function clearAuthSessionScopedStorage(storage = getSessionStorage()): void { + if (!storage) return + try { + const keys: string[] = [] + for (let index = 0; index < storage.length; index += 1) { + const key = storage.key(index) + if (key?.startsWith(AUTH_SESSION_STORAGE_PREFIX)) keys.push(key) + } + for (const key of keys) storage.removeItem(key) + } catch { + // sessionStorage 不可用不应阻断登出或会话失效。 + } +} + /** * localStorage 是跨刷新、跨标签的增强能力,不是维持当前页面登录的前提。 * 浏览器拒绝存储访问时,闭包中的副本继续支撑本标签页会话。 diff --git a/frontend/src/features/workflow-controller/controller.test.ts b/frontend/src/features/workflow-controller/controller.test.ts index fe62ab39..a6d28de0 100644 --- a/frontend/src/features/workflow-controller/controller.test.ts +++ b/frontend/src/features/workflow-controller/controller.test.ts @@ -15,6 +15,7 @@ import type { WorkflowRun, WorkflowRunApis, } from '@/entities' +import { WorkflowRunConflictError } from '@/entities' import { createWorkflowController } from '.' function setupNode( @@ -255,20 +256,6 @@ async function flushAsyncWork() { } describe('WorkflowController', () => { - it('绑定角色后拒绝把同一条 WorkflowRun 改绑到另一角色', async () => { - const { controller } = createController() - - await controller.bindCharacter('setup-1', 'character-1') - - expect(controller.getWorkflow().nodes[0]).toMatchObject({ - type: 'character-setup', - input: { characterId: 'character-1' }, - }) - await expect(controller.bindCharacter('setup-1', 'character-2')).rejects.toThrow( - 'WorkflowRun 已绑定到另一角色,不能改绑', - ) - }) - it('只在角色设定节点仍处于配置阶段时更新提示词和参考媒体', async () => { const { controller } = createController() @@ -288,10 +275,19 @@ describe('WorkflowController', () => { it('接受上传母版时完成角色设定和母版节点', async () => { const { controller } = createController() - await controller.acceptUploadedCharacterTemplate('setup-1', 'https://img/uploaded-template.png') + await controller.acceptUploadedCharacterTemplate( + 'setup-1', + 'https://img/uploaded-template.png', + 'character-1', + ) expect(controller.getWorkflow().nodes).toMatchObject([ - { type: 'character-setup', status: 'passed', phase: 'completed' }, + { + type: 'character-setup', + status: 'passed', + phase: 'completed', + input: { characterId: 'character-1' }, + }, { type: 'character-template', status: 'passed', @@ -301,6 +297,64 @@ describe('WorkflowController', () => { ]) }) + it('母版确认和上传都拒绝错误节点状态与角色改绑', async () => { + const { controller: lockedController } = createController() + await expect( + lockedController.confirmCharacterTemplate( + 'template-1', + 'https://img/knight.png', + 'character-1', + ), + ).rejects.toThrow('角色母版节点当前不能确认候选图') + await expect( + lockedController.confirmCharacterTemplate( + 'setup-1' as never, + 'https://img/knight.png', + 'character-1', + ), + ).rejects.toThrow('目标节点不是角色母版') + + const boundRun = createRun([ + setupNode({ + status: 'passed', + phase: 'completed', + input: { + prompt: '像素骑士', + referenceMedia: [], + characterId: 'character-existing', + }, + }), + templateNode({ status: 'active', phase: 'selecting' }), + ]) + const { controller: boundController } = createController(boundRun) + await expect( + boundController.confirmCharacterTemplate( + 'template-1', + 'https://img/knight.png', + 'character-other', + ), + ).rejects.toThrow('WorkflowRun 已绑定到另一角色,不能改绑') + + const uploadRun = createRun([ + setupNode({ + input: { + prompt: '像素骑士', + referenceMedia: [], + characterId: 'character-existing', + }, + }), + templateNode(), + ]) + const { controller: uploadController } = createController(uploadRun) + await expect( + uploadController.acceptUploadedCharacterTemplate( + 'setup-1', + 'https://img/uploaded.png', + 'character-other', + ), + ).rejects.toThrow('WorkflowRun 已绑定到另一角色,不能改绑') + }) + it('页面通过订阅接收命令保存和 SSE 写回后的同一份 WorkflowRun', async () => { const { controller, generation } = createController() let renderedWorkflow = controller.getWorkflow() @@ -711,10 +765,14 @@ describe('WorkflowController', () => { ]) const { controller } = createController(run) - await controller.confirmCharacterTemplate('template-1', 'https://img/knight.png') + await controller.confirmCharacterTemplate('template-1', 'https://img/knight.png', 'character-1') expect(controller.getWorkflow().nodes).toEqual( expect.arrayContaining([ + expect.objectContaining({ + id: 'setup-1', + input: expect.objectContaining({ characterId: 'character-1' }), + }), expect.objectContaining({ id: 'template-1', status: 'passed', phase: 'completed' }), expect.objectContaining({ id: 'action-walk', status: 'active' }), expect.objectContaining({ id: 'action-jump', status: 'active' }), @@ -1294,7 +1352,7 @@ describe('WorkflowController', () => { ]) const { controller } = createController(run) - await controller.confirmCharacterTemplate('template-1', 'https://img/knight.png') + await controller.confirmCharacterTemplate('template-1', 'https://img/knight.png', 'character-1') expect(controller.getWorkflow().nodes[2]).toMatchObject({ status: 'locked', @@ -1311,7 +1369,7 @@ describe('WorkflowController', () => { vi.mocked(workflow.apis.update).mockRejectedValueOnce(new Error('后端保存失败')) await expect( - controller.confirmCharacterTemplate('template-1', 'https://img/knight.png'), + controller.confirmCharacterTemplate('template-1', 'https://img/knight.png', 'character-1'), ).rejects.toThrow('后端保存失败') expect(controller.getWorkflow().nodes[1]).toMatchObject({ @@ -1321,6 +1379,47 @@ describe('WorkflowController', () => { }) }) + it('PATCH 已落库但响应丢失时接纳回读快照并将命令视为成功', async () => { + const run = createRun([ + setupNode({ status: 'passed', phase: 'completed' }), + templateNode({ status: 'active', phase: 'selecting' }), + ]) + const { controller, workflow } = createController(run) + const update = vi.mocked(workflow.apis.update) + const get = vi.mocked(workflow.apis.get) + const persist = update.getMockImplementation()! + const read = get.getMockImplementation()! + update.mockImplementationOnce(async (candidate) => { + await persist(candidate) + throw new WorkflowRunConflictError('执行记录版本冲突') + }) + get.mockImplementationOnce(async (id) => { + const reverseObjectKeys = (value: unknown): unknown => { + if (Array.isArray(value)) return value.map(reverseObjectKeys) + if (value === null || typeof value !== 'object') return value + return Object.fromEntries( + Object.entries(value) + .reverse() + .map(([key, item]) => [key, reverseObjectKeys(item)]), + ) + } + return reverseObjectKeys(await read(id)) as WorkflowRun + }) + + await controller.confirmCharacterTemplate('template-1', 'https://img/knight.png', 'character-1') + + expect(controller.getWorkflow()).toMatchObject({ + version: 2, + nodes: expect.arrayContaining([ + expect.objectContaining({ + id: 'setup-1', + input: expect.objectContaining({ characterId: 'character-1' }), + }), + expect.objectContaining({ id: 'template-1', status: 'passed' }), + ]), + }) + }) + it('生成任务创建成功但引用保存失败时,重试复用同一个任务', async () => { const run = createRun([...completedCharacterNodes(), ...actionNodes()]) const { controller, workflow, generation } = createController(run) diff --git a/frontend/src/features/workflow-controller/controller.ts b/frontend/src/features/workflow-controller/controller.ts index c9fbe637..ad14106c 100644 --- a/frontend/src/features/workflow-controller/controller.ts +++ b/frontend/src/features/workflow-controller/controller.ts @@ -88,8 +88,6 @@ export interface WorkflowController { nodeId: CharacterSetupWorkflowNode['id'], options: GenerateCharacterTemplateOptions, ): Promise - /** 将已创建的 Character 绑定到入口节点;一条 Run 不允许改绑到另一角色。 */ - bindCharacter(nodeId: CharacterSetupWorkflowNode['id'], characterId: string): Promise /** 仅在入口节点尚未提交时修改角色描述和参考媒体。 */ updateCharacterSetup( nodeId: CharacterSetupWorkflowNode['id'], @@ -99,10 +97,12 @@ export interface WorkflowController { acceptUploadedCharacterTemplate( nodeId: CharacterSetupWorkflowNode['id'], selectedImageUrl: string, + characterId: string, ): Promise confirmCharacterTemplate( nodeId: CharacterTemplateWorkflowNode['id'], selectedImageUrl: string, + characterId: string, ): Promise generateFirstFrame( nodeId: ActionFirstFrameWorkflowNode['id'], @@ -213,8 +213,19 @@ export function createWorkflowController({ const candidate = transform(before) if (candidate === before) return structuredClone(before) - // 只有后端确认保存后才替换内存快照;失败时页面不会看到“假成功”。 - const saved = await workflowRunApis.update(candidate) + // 只有更新响应或回读结果确认已落库后才替换内存快照,避免页面显示“假成功”。 + let saved: WorkflowRun + try { + saved = await workflowRunApis.update(candidate) + } catch (cause) { + try { + const latest = await workflowRunApis.get(candidate.id) + if (!hasSamePersistedState(candidate, latest)) throw cause + saved = latest + } catch { + throw cause + } + } current = structuredClone(saved) notifyListeners() return structuredClone(saved) @@ -395,42 +406,42 @@ export function createWorkflowController({ function confirmCharacterTemplate( nodeId: CharacterTemplateWorkflowNode['id'], selectedImageUrl: string, + characterId: string, ) { ensureRunning() const imageUrl = nonEmpty(selectedImageUrl, 'selectedImageUrl') - return persist((run) => - updateNode(run, nodeId, (node) => { - if (node.type !== 'character-template') throw new Error('目标节点不是角色母版') - if (node.status !== 'active' || node.phase !== 'selecting') { - throw new Error('角色母版节点当前不能确认候选图') - } - return unlockReadyNodes({ - ...run, - nodes: run.nodes.map((item) => - item.id === node.id - ? { ...node, selectedImageUrl: imageUrl, phase: 'completed', status: 'passed' } - : item, - ), - }) - }), - ) - } - - function bindCharacter(nodeId: CharacterSetupWorkflowNode['id'], characterId: string) { - ensureRunning() const normalizedCharacterId = nonEmpty(characterId, 'characterId') - return persist((run) => - updateNode(run, nodeId, (node) => { - if (node.type !== 'character-setup') throw new Error('目标节点不是角色设定') - if (node.input.characterId && node.input.characterId !== normalizedCharacterId) { - throw new Error('WorkflowRun 已绑定到另一角色,不能改绑') - } - return replaceNode(run, { - ...node, - input: { ...node.input, characterId: normalizedCharacterId }, - }) - }), - ) + return persist((run) => { + const templateNode = findNode(run, nodeId) + if (templateNode.type !== 'character-template') throw new Error('目标节点不是角色母版') + if (templateNode.status !== 'active' || templateNode.phase !== 'selecting') { + throw new Error('角色母版节点当前不能确认候选图') + } + const setupNode = findSingleDependencyNode(run, templateNode, 'character-setup') + if (setupNode.input.characterId && setupNode.input.characterId !== normalizedCharacterId) { + throw new Error('WorkflowRun 已绑定到另一角色,不能改绑') + } + return unlockReadyNodes({ + ...run, + nodes: run.nodes.map((node) => { + if (node.id === setupNode.id) { + return { + ...setupNode, + input: { ...setupNode.input, characterId: normalizedCharacterId }, + } + } + if (node.id === templateNode.id) { + return { + ...templateNode, + selectedImageUrl: imageUrl, + phase: 'completed', + status: 'passed', + } + } + return node + }), + }) + }) } function updateCharacterSetup( @@ -460,9 +471,11 @@ export function createWorkflowController({ function acceptUploadedCharacterTemplate( nodeId: CharacterSetupWorkflowNode['id'], selectedImageUrl: string, + characterId: string, ) { ensureRunning() const imageUrl = nonEmpty(selectedImageUrl, 'selectedImageUrl') + const normalizedCharacterId = nonEmpty(characterId, 'characterId') return persist((run) => { const setupNode = findNode(run, nodeId) if (setupNode.type !== 'character-setup') throw new Error('目标节点不是角色设定') @@ -473,11 +486,20 @@ export function createWorkflowController({ if (templateNode.status !== 'locked' || templateNode.phase !== 'ready') { throw new Error('角色母版节点当前不能使用上传图片') } + if (setupNode.input.characterId && setupNode.input.characterId !== normalizedCharacterId) { + throw new Error('WorkflowRun 已绑定到另一角色,不能改绑') + } return unlockReadyNodes({ ...run, nodes: run.nodes.map((node) => { if (node.id === setupNode.id) { - return { ...setupNode, status: 'passed', phase: 'completed', error: null } + return { + ...setupNode, + status: 'passed', + phase: 'completed', + error: null, + input: { ...setupNode.input, characterId: normalizedCharacterId }, + } } if (node.id === templateNode.id) { return { @@ -980,7 +1002,6 @@ export function createWorkflowController({ setCharacterName: asCommand(setCharacterName), addAction: asCommand(addAction), generateCharacterTemplate: asCommand(generateCharacterTemplate), - bindCharacter: asCommand(bindCharacter), updateCharacterSetup: asCommand(updateCharacterSetup), acceptUploadedCharacterTemplate: asCommand(acceptUploadedCharacterTemplate), confirmCharacterTemplate: asCommand(confirmCharacterTemplate), @@ -999,6 +1020,26 @@ export function createWorkflowController({ } } +function hasSamePersistedState(expected: WorkflowRun, actual: WorkflowRun) { + return ( + expected.id === actual.id && + expected.projectId === actual.projectId && + expected.storageStatus === actual.storageStatus && + JSON.stringify(canonicalizeJson(expected.nodes)) === + JSON.stringify(canonicalizeJson(actual.nodes)) + ) +} + +function canonicalizeJson(value: unknown): unknown { + if (Array.isArray(value)) return value.map(canonicalizeJson) + if (value === null || typeof value !== 'object') return value + return Object.fromEntries( + Object.entries(value) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, item]) => [key, canonicalizeJson(item)]), + ) +} + function asCommand( operation: (...args: TArgs) => Promise, ): (...args: TArgs) => Promise { diff --git a/frontend/src/index.css b/frontend/src/index.css index b622fe2f..b4fb77b8 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -141,6 +141,7 @@ body, --shadow-app-card: 0 18px 45px rgb(29 37 31 / 9%); --shadow-app-panel: 0 22px 60px rgb(29 37 31 / 12%); + --shadow-app-composer-focus: 0 18px 48px rgb(29 37 31 / 14%); --shadow-app-page: 0 26px 80px rgb(29 37 31 / 10%); --shadow-app-header: inset 0 1px 0 rgb(255 255 255 / 55%); --shadow-app-menu: 0 8px 24px rgb(29 37 31 / 10%); diff --git a/frontend/src/pages/quick-start/index.test.tsx b/frontend/src/pages/quick-start/index.test.tsx index 9868b59a..bfc92522 100644 --- a/frontend/src/pages/quick-start/index.test.tsx +++ b/frontend/src/pages/quick-start/index.test.tsx @@ -1,10 +1,10 @@ import { StrictMode } from 'react' import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' -import { MemoryRouter, Route, Routes, useLocation } from 'react-router' +import { MemoryRouter, Route, Routes, useLocation, useNavigate } from 'react-router' import { afterEach, describe, expect, it, vi } from 'vitest' import type { QuickStartEntryService, QuickStartSession } from './service' -import type { WorkflowRun } from '@/entities' +import { WorkflowRunConflictError, type WorkflowRun } from '@/entities' import type { ExportPackageModel } from '@/features/export-package' import { QuickStartPage } from './index' @@ -119,6 +119,7 @@ function serviceFor(run: WorkflowRun | null, overrides: Partial startAction: vi.fn(async () => service), getWorkflow: vi.fn(() => fallbackRun), subscribe: vi.fn(() => () => undefined), + subscribeErrors: vi.fn(() => () => undefined), resume: vi.fn(async () => fallbackRun), interrupt: vi.fn(async () => fallbackRun), dispose: vi.fn(), @@ -137,6 +138,16 @@ function serviceFor(run: WorkflowRun | null, overrides: Partial return service } +function deferred() { + let resolve!: (value: T) => void + let reject!: (reason?: unknown) => void + const promise = new Promise((nextResolve, nextReject) => { + resolve = nextResolve + reject = nextReject + }) + return { promise, resolve, reject } +} + function renderAt(path: string, service: QuickStartEntryService) { function PlaytestLocation() { const location = useLocation() @@ -154,6 +165,34 @@ function renderAt(path: string, service: QuickStartEntryService) { ) } +function renderWithRunSwitcher( + service: QuickStartEntryService, + initialRunId: string, + nextRunId: string, +) { + function Controls() { + const navigate = useNavigate() + const location = useLocation() + return ( + <> + + {location.pathname} + + ) + } + + return render( + + + + } /> + + , + ) +} + function renderStateFixture( state: | 'template-generating' @@ -338,8 +377,6 @@ describe('QuickStartPage', () => { expect(screen.getByRole('button', { name: /16-bit 日式 RPG/u })).toBeTruthy() expect(screen.getByRole('button', { name: /暗黑哥特像素/u })).toBeTruthy() expect(screen.getByRole('button', { name: /温暖手绘像素/u })).toBeTruthy() - expect(screen.getByRole('button', { name: '像素守夜人' })).toBeTruthy() - expect(screen.getByRole('button', { name: '轻装信使' })).toBeTruthy() fireEvent.change(screen.getByRole('textbox', { name: '创作指令' }), { target: { value: '戴银色面具的游侠' }, @@ -350,18 +387,27 @@ describe('QuickStartPage', () => { expect(screen.queryByRole('button', { name: /暗黑哥特像素/u })).toBeNull() }) - it('keeps the original Quick Start prompt shortcuts functional', () => { - const view = renderAt('/quick-start', serviceFor(null)) - fireEvent.click(screen.getByRole('button', { name: '像素守夜人' })) - expect((screen.getByRole('textbox', { name: '创作指令' }) as HTMLTextAreaElement).value).toBe( - '一位提着风灯、披深色斗篷的像素守夜人', + it('offers style prompts only, without the retired role-example shortcuts', () => { + renderAt('/quick-start', serviceFor(null)) + + // 入口只保留三张风格卡:角色样例会让人误以为这些形象是现成资产。 + expect(screen.queryByRole('button', { name: '像素守夜人' })).toBeNull() + expect(screen.queryByRole('button', { name: '轻装信使' })).toBeNull() + + fireEvent.click(screen.getByRole('button', { name: /16-bit 日式 RPG/u })) + expect((screen.getByRole('textbox', { name: '创作指令' }) as HTMLInputElement).value).toBe( + '16-bit 日式 RPG 像素风,清晰轮廓,明亮配色', ) + }) - view.unmount() + it('keeps the entry composer on a single line', () => { renderAt('/quick-start', serviceFor(null)) - fireEvent.click(screen.getByRole('button', { name: '轻装信使' })) - expect((screen.getByRole('textbox', { name: '创作指令' }) as HTMLTextAreaElement).value).toBe( - '轻装信使,侧视像素风,轮廓清晰,动作轻快', + const composer = screen.getByRole('textbox', { name: '创作指令' }) + + expect(composer.tagName).toBe('INPUT') + expect(composer.className).toContain('h-10') + expect(composer.closest('form')?.className).toContain( + 'focus-within:shadow-[var(--shadow-app-composer-focus)]', ) }) @@ -748,7 +794,7 @@ describe('QuickStartPage', () => { expect(screen.getByRole('textbox', { name: '创作指令' })).toBeTruthy() fireEvent.click(screen.getByRole('button', { name: /16-bit 日式 RPG/u })) - expect((screen.getByRole('textbox') as HTMLTextAreaElement).value).toBe( + expect((screen.getByRole('textbox') as HTMLInputElement).value).toBe( '16-bit 日式 RPG 像素风,清晰轮廓,明亮配色', ) expect(screen.queryByRole('button', { name: /暗黑哥特像素/u })).toBeNull() @@ -806,7 +852,7 @@ describe('QuickStartPage', () => { it('hands the created session to the run page under the production StrictMode lifecycle', async () => { const service = serviceFor(null) - render( + const view = render( @@ -822,7 +868,10 @@ describe('QuickStartPage', () => { await waitFor(() => expect(service.resume).toHaveBeenCalled()) expect(service.open).not.toHaveBeenCalled() - expect(service.dispose).toHaveBeenCalled() + expect(service.dispose).not.toHaveBeenCalled() + + view.unmount() + await waitFor(() => expect(service.dispose).toHaveBeenCalledOnce()) }) it('shows entry errors and supports removing an uploaded template', async () => { @@ -846,7 +895,7 @@ describe('QuickStartPage', () => { fireEvent.change(screen.getByLabelText('上传角色母版'), { target: { files: [file] } }) const composer = screen.getByLabelText('创作指令').closest('form') - expect(screen.getByLabelText('创作指令').tagName).toBe('TEXTAREA') + expect(screen.getByLabelText('创作指令').tagName).toBe('INPUT') expect(composer?.textContent).toContain('hero.png') expect(screen.getByRole('button', { name: '移除图片' }).closest('form')).toBe(composer) expect(composer?.querySelector('[data-layout="quick-start-attachment-row"]')).toBeNull() @@ -923,6 +972,304 @@ describe('QuickStartPage', () => { expect(screen.getByTestId('quick-start-run')).toBeTruthy() }) + it('freezes the current conversation and offers a full reload after a version conflict', async () => { + const run = workflow(setupAndTemplate()) + const service = serviceFor(run, { + getTemplateCandidates: vi.fn(async () => ['https://example.test/candidate.png']), + confirmCandidate: vi.fn(async () => { + throw new WorkflowRunConflictError('执行记录版本冲突,请刷新后重试') + }), + }) + renderAt('/quick-start/run-1?source=test#selection', service) + + fireEvent.click(await screen.findByRole('img', { name: '角色图候选 1' })) + fireEvent.click(screen.getByRole('button', { name: '确认选择,继续下一步' })) + + const alert = await screen.findByRole('alert') + expect(alert.textContent).toContain('工作流已在其他位置更新,请加载最新版本后继续。') + expect(screen.getByRole('link', { name: '加载最新版本' }).getAttribute('href')).toBe( + '/quick-start/run-1?source=test#selection', + ) + const confirm = screen.getByRole('button', { name: '确认选择,继续下一步' }) + expect((confirm as HTMLButtonElement).disabled).toBe(true) + fireEvent.click(confirm) + expect(service.confirmCandidate).toHaveBeenCalledTimes(1) + }) + + it('keeps a reported conflict visible when an earlier result read fails later', async () => { + const run = workflow(setupAndTemplate()) + let reportError: ((error: Error) => void) | null = null + let rejectRead: ((error: Error) => void) | null = null + const pendingRead = new Promise((_resolve, reject) => { + rejectRead = reject + }) + const service = serviceFor(run, { + getTemplateCandidates: vi.fn(() => pendingRead), + subscribeErrors: vi.fn((listener) => { + reportError = listener + return () => undefined + }), + }) + renderAt('/quick-start/run-1', service) + + await waitFor(() => expect(service.getTemplateCandidates).toHaveBeenCalled()) + act(() => { + reportError?.(new WorkflowRunConflictError('执行记录版本冲突,请刷新后重试')) + }) + expect(await screen.findByRole('link', { name: '加载最新版本' })).toBeTruthy() + + await act(async () => { + rejectRead?.(new Error('候选读取失败')) + await pendingRead.catch(() => undefined) + }) + expect(screen.getByRole('alert').textContent).toContain( + '工作流已在其他位置更新,请加载最新版本后继续。', + ) + }) + + it('does not auto-save a completed action after the session reports a conflict', async () => { + const run = workflow(setupAndTemplate()) + const completed = actionWorkflow({ fullStatus: 'passed', reviewStatus: 'active' }) + let reportError: ((error: Error) => void) | null = null + let reportRun: ((run: WorkflowRun) => void) | null = null + const service = serviceFor(run, { + subscribeErrors: vi.fn((listener) => { + reportError = listener + return () => undefined + }), + subscribe: vi.fn((listener) => { + reportRun = listener + return () => undefined + }), + }) + renderAt('/quick-start/run-1', service) + + await waitFor(() => expect(service.subscribeErrors).toHaveBeenCalledOnce()) + act(() => { + reportError?.(new WorkflowRunConflictError('执行记录版本冲突,请刷新后重试')) + reportRun?.(completed) + }) + + expect(await screen.findByRole('link', { name: '加载最新版本' })).toBeTruthy() + await waitFor(() => expect(service.approveReview).not.toHaveBeenCalled()) + }) + + it('ignores an old session resume result after navigating to another run', async () => { + const oldRun = workflow(setupAndTemplate(), 'run-old') + const newNodes = setupAndTemplate() + const newSetup = newNodes[0] + if (newSetup?.type !== 'character-setup') throw new Error('测试工作流缺少角色设定节点') + newSetup.input.prompt = '新角色' + const newRun = workflow(newNodes, 'run-new') + let resolveOldResume: ((run: WorkflowRun) => void) | null = null + const oldResume = new Promise((resolve) => { + resolveOldResume = resolve + }) + const oldSession = serviceFor(oldRun, { resume: vi.fn(() => oldResume) }) + const newSession = serviceFor(newRun) + const entryService = serviceFor(null, { + open: vi.fn(async (id) => (id === oldRun.id ? oldSession : newSession)), + }) + + renderWithRunSwitcher(entryService, oldRun.id, newRun.id) + + expect(await screen.findByText('像素骑士')).toBeTruthy() + fireEvent.click(screen.getByRole('button', { name: '切换当前运行' })) + expect(await screen.findByText('新角色')).toBeTruthy() + + await act(async () => { + resolveOldResume?.(oldRun) + await oldResume + }) + expect(screen.getByText('新角色')).toBeTruthy() + expect(screen.queryByText('像素骑士')).toBeNull() + }) + + it('ignores old session result reads and error events after switching runs', async () => { + const oldRun = workflow(setupAndTemplate(), 'run-old') + const newNodes = setupAndTemplate() + const newSetup = newNodes[0] + if (newSetup?.type !== 'character-setup') throw new Error('测试工作流缺少角色设定节点') + newSetup.input.prompt = '当前新运行' + const newRun = workflow(newNodes, 'run-new') + const oldRead = deferred() + let reportOldError: ((error: Error) => void) | null = null + const oldSession = serviceFor(oldRun, { + getTemplateCandidates: vi.fn(() => oldRead.promise), + subscribeErrors: vi.fn((listener) => { + reportOldError = listener + return () => undefined + }), + }) + const newSession = serviceFor(newRun) + const entryService = serviceFor(null, { + open: vi.fn(async (id) => (id === oldRun.id ? oldSession : newSession)), + }) + + renderWithRunSwitcher(entryService, oldRun.id, newRun.id) + + await waitFor(() => expect(oldSession.getTemplateCandidates).toHaveBeenCalled()) + fireEvent.click(screen.getByRole('button', { name: '切换当前运行' })) + expect(await screen.findByText('当前新运行')).toBeTruthy() + + await act(async () => { + reportOldError?.(new Error('旧会话错误')) + oldRead.reject(new Error('旧候选读取失败')) + await oldRead.promise.catch(() => undefined) + }) + expect(screen.queryByText(/旧会话错误|旧候选读取失败/u)).toBeNull() + }) + + it('ignores completed commands from the previous session after switching runs', async () => { + const oldRun = actionWorkflow({ fullStatus: 'passed', reviewStatus: 'passed' }) + oldRun.id = 'run-old' + const newNodes = setupAndTemplate() + const newSetup = newNodes[0] + if (newSetup?.type !== 'character-setup') throw new Error('测试工作流缺少角色设定节点') + newSetup.input.prompt = '当前新运行' + const newRun = workflow(newNodes, 'run-new') + const playtest = deferred<{ characterId: string; outfitId: string } | null>() + const failedPlaytest = deferred<{ characterId: string; outfitId: string } | null>() + const oldSession = serviceFor(oldRun, { + getCharacterInfo: vi.fn(() => null), + resolveCharacterInfo: vi + .fn() + .mockImplementationOnce(() => playtest.promise) + .mockImplementationOnce(() => failedPlaytest.promise), + getActionFrames: vi.fn(async () => [ + { index: 0, imageUrl: 'https://example.test/frame.png', durationMs: 80 }, + ]), + }) + const newSession = serviceFor(newRun) + const entryService = serviceFor(null, { + open: vi.fn(async (id) => (id === oldRun.id ? oldSession : newSession)), + }) + + renderWithRunSwitcher(entryService, oldRun.id, newRun.id) + + fireEvent.click(await screen.findByRole('button', { name: '跳转到 Play Test' })) + fireEvent.click(screen.getByRole('button', { name: '跳转到 Play Test' })) + await waitFor(() => expect(oldSession.resolveCharacterInfo).toHaveBeenCalledTimes(2)) + fireEvent.click(screen.getByRole('button', { name: '切换当前运行' })) + expect(await screen.findByText('当前新运行')).toBeTruthy() + + await act(async () => { + playtest.resolve({ characterId: 'old-character', outfitId: 'old-outfit' }) + failedPlaytest.reject(new Error('旧 Play Test 打开失败')) + await Promise.allSettled([playtest.promise, failedPlaytest.promise]) + }) + expect(screen.getByRole('status', { name: '当前位置' }).textContent).toBe( + '/quick-start/run-new', + ) + expect(screen.queryByText('旧 Play Test 打开失败')).toBeNull() + }) + + it('ignores automatic publishing completed by the previous session after switching runs', async () => { + const oldRun = actionWorkflow({ fullStatus: 'passed', reviewStatus: 'active' }) + oldRun.id = 'run-old' + const newNodes = setupAndTemplate() + const newSetup = newNodes[0] + if (newSetup?.type !== 'character-setup') throw new Error('测试工作流缺少角色设定节点') + newSetup.input.prompt = '当前新运行' + const newRun = workflow(newNodes, 'run-new') + const publish = deferred() + const oldSession = serviceFor(oldRun, { + approveReview: vi.fn(() => publish.promise), + getActionFrames: vi.fn(async () => [ + { index: 0, imageUrl: 'https://example.test/frame.png', durationMs: 80 }, + ]), + }) + const newSession = serviceFor(newRun) + const entryService = serviceFor(null, { + open: vi.fn(async (id) => (id === oldRun.id ? oldSession : newSession)), + }) + + renderWithRunSwitcher(entryService, oldRun.id, newRun.id) + + await waitFor(() => expect(oldSession.approveReview).toHaveBeenCalled()) + fireEvent.click(screen.getByRole('button', { name: '切换当前运行' })) + expect(await screen.findByText('当前新运行')).toBeTruthy() + + await act(async () => { + publish.resolve(actionWorkflow({ fullStatus: 'passed', reviewStatus: 'passed' })) + await publish.promise + }) + expect(screen.getByText('当前新运行')).toBeTruthy() + }) + + it('disposes a regenerated session that resolves after navigating to another run', async () => { + const oldRun = workflow(setupAndTemplate(), 'run-old') + const newNodes = setupAndTemplate() + const newSetup = newNodes[0] + if (newSetup?.type !== 'character-setup') throw new Error('测试工作流缺少角色设定节点') + newSetup.input.prompt = '当前新运行' + const newRun = workflow(newNodes, 'run-new') + const regeneratedRun = workflow(setupAndTemplate(), 'run-regenerated') + const regeneratedSession = serviceFor(regeneratedRun) + let resolveRegeneration: ((session: QuickStartSession) => void) | null = null + const pendingRegeneration = new Promise((resolve) => { + resolveRegeneration = resolve + }) + const oldSession = serviceFor(oldRun, { + getTemplateCandidates: vi.fn(async () => ['https://example.test/candidate.png']), + }) + const newSession = serviceFor(newRun) + const entryService = serviceFor(null, { + start: vi.fn(() => pendingRegeneration), + open: vi.fn(async (id) => (id === oldRun.id ? oldSession : newSession)), + }) + + renderWithRunSwitcher(entryService, oldRun.id, newRun.id) + + fireEvent.click(await screen.findByRole('button', { name: '重新生成' })) + await waitFor(() => expect(entryService.start).toHaveBeenCalledWith('像素骑士')) + fireEvent.click(screen.getByRole('button', { name: '切换当前运行' })) + expect(await screen.findByText('当前新运行')).toBeTruthy() + + await act(async () => { + resolveRegeneration?.(regeneratedSession) + await pendingRegeneration + }) + expect(screen.getByRole('status', { name: '当前位置' }).textContent).toBe( + '/quick-start/run-new', + ) + expect(regeneratedSession.dispose).toHaveBeenCalledOnce() + }) + + it('ignores a regeneration error after navigating to another run', async () => { + const oldRun = workflow(setupAndTemplate(), 'run-old') + const newNodes = setupAndTemplate() + const newSetup = newNodes[0] + if (newSetup?.type !== 'character-setup') throw new Error('测试工作流缺少角色设定节点') + newSetup.input.prompt = '当前新运行' + const newRun = workflow(newNodes, 'run-new') + const regeneration = deferred() + const oldSession = serviceFor(oldRun, { + getTemplateCandidates: vi.fn(async () => ['https://example.test/candidate.png']), + }) + const newSession = serviceFor(newRun) + const entryService = serviceFor(null, { + start: vi.fn(() => regeneration.promise), + open: vi.fn(async (id) => (id === oldRun.id ? oldSession : newSession)), + }) + + renderWithRunSwitcher(entryService, oldRun.id, newRun.id) + + fireEvent.click(await screen.findByRole('button', { name: '重新生成' })) + await waitFor(() => expect(entryService.start).toHaveBeenCalled()) + fireEvent.click(screen.getByRole('button', { name: '切换当前运行' })) + expect(await screen.findByText('当前新运行')).toBeTruthy() + + await act(async () => { + regeneration.reject(new Error('旧重新生成失败')) + await regeneration.promise.catch(() => undefined) + }) + expect(screen.getByRole('status', { name: '当前位置' }).textContent).toBe( + '/quick-start/run-new', + ) + expect(screen.queryByText('旧重新生成失败')).toBeNull() + }) + it('keeps the original regenerate and new-creation controls reachable', async () => { const run = workflow(setupAndTemplate()) const service = serviceFor(run, { diff --git a/frontend/src/pages/quick-start/index.tsx b/frontend/src/pages/quick-start/index.tsx index 7cf3e2ae..ddd86922 100644 --- a/frontend/src/pages/quick-start/index.tsx +++ b/frontend/src/pages/quick-start/index.tsx @@ -10,12 +10,13 @@ import { type ReactNode, } from 'react' import { ArrowUp, ImageSquare, X } from '@phosphor-icons/react' -import { Link, useNavigate, useParams, useSearchParams } from 'react-router' +import { Link, useLocation, useNavigate, useParams, useSearchParams } from 'react-router' import { type ActionFirstFrameWorkflowNode, type CharacterTemplateWorkflowNode, type WorkflowRun, + WorkflowRunConflictError, } from '@/entities' import { ExportButton, type ExportPackageModel } from '@/features/export-package' import { KineticCopyCycle, type KineticCopyMessage } from './kinetic-copy-cycle' @@ -128,14 +129,19 @@ export function QuickStartPage({ service }: QuickStartPageProps) { return service ?? quickStartService }, [service]) const [createdSession, setCreatedSession] = useState(null) + const consumeCreatedSession = useCallback((consumed: QuickStartSession) => { + setCreatedSession((current) => (current === consumed ? null : current)) + }, []) const characterId = searchParams.get('characterId') const outfitId = searchParams.get('outfitId') return runId ? ( ) : characterId && outfitId ? ( () => { submitAbortController.current?.abort() @@ -361,47 +356,25 @@ function QuickStartInput({ ))}
-
- {originalPromptShortcuts.map((shortcut) => ( - - ))} -
void submit(event)} - className="grid items-center gap-1.5 rounded-xl border border-app-line-strong bg-app-surface-raised p-1.5 shadow-app-panel transition-shadow focus-within:border-app-accent sm:grid-cols-[1fr_auto_auto]" + className="grid items-center gap-1.5 rounded-xl border border-app-line-strong bg-app-surface-raised p-1.5 shadow-app-panel transition-shadow focus-within:border-app-accent focus-within:shadow-[var(--shadow-app-composer-focus)] sm:grid-cols-[1fr_auto_auto]" >