Skip to content

feat(generation): align validated SSE adapter with backend tasks - #110

Merged
xyh202131 merged 5 commits into
1024XEngineer:mainfrom
xyh202131:feat/generation-sse-adapter
Aug 11, 2026
Merged

feat(generation): align validated SSE adapter with backend tasks#110
xyh202131 merged 5 commits into
1024XEngineer:mainfrom
xyh202131:feat/generation-sse-adapter

Conversation

@xyh202131

@xyh202131 xyh202131 commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

本次更新

  • Generation 类型严格对齐后端:character_imagecharacter_action;首帧和完整动画通过 num_frames(1 / 32)区分,不再另造任务类型。
  • 创建任务时携带当前认证用户的 user_id,并校验返回任务的用户、项目、任务 ID、类型、状态和终态结果。
  • 使用带鉴权的 fetch SSE 接收状态,兼容后端事件字段 task_idid,统一转换成前端 taskId
  • SSE 路由未部署时才回退任务查询;其他错误明确上报,并支持取消订阅和 401 会话恢复。
  • 动作结果保留后端帧序号、图片地址与时长,不在前端重新猜测。

依赖说明

当前 main 中已合并的旧 WorkflowController 仍使用旧 Generation 名称。本 PR 保持后端命名,不在适配器内重新引入旧概念;Controller 调用方需按新的 character_image / character_action 契约同步更新。

验证

  • Generation、SSE 与统一 API 相关测试:42 条通过。
  • TypeScript 类型检查、oxlint、格式检查通过。
  • 生产构建通过。

@vercel

vercel Bot commented Aug 4, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
windup Ready Ready Preview Aug 11, 2026 8:11am

@fennoai fennoai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review summary: two issues stand out.

Findings without inline locations

  • frontend/src/entities/generation/index.ts:102: The complete-animation result type only preserves URLs, but the backend action frame DTO already carries duration_ms and the frontend Frame model can store durationMs. Once this adapter maps the task into GenerationResult, that timing data is gone and downstream character writes will have to invent defaults.

Comment thread frontend/src/entities/generation/api.ts Outdated
@johnnyzhang-eng

Copy link
Copy Markdown
Contributor

实测反馈:契约对得上,但终态不关流会无限重连;另外目前零 UI 消费者

在集成分支(main + #117 + #119 + #111 + 本 PR)上验证,并对一台真实部署的后端订阅了一个已完成的任务。

1. 契约是对的,两处可以放心

  • 事件名:服务端实际只吐一种事件名 task_update,靠 payload 里的 status 区分状态,payload 五个键 task_id / task_type / status / result / error_message。本 PR 只监听 task_update 是对的。
    ⚠️ 注意另有一份设计文档写的是 status / progress / completed / failed 四个事件名,与线上实际行为不符,别照那份改。
  • URL 拼接带了 project_id,与后端强制要求的 query 参数一致。

2. 终态之后 EventSource 会每 3 秒重连一次,需要显式 close

服务端发完终态事件就关流,但响应里带了 retry: 3000。浏览器原生 EventSource 把「连接关闭」当成断线,于是按 retry 间隔重连——实测 45 秒内重连 15 次,每次都重新收到同一条 completed 事件,测试结束时强制 close,readyState 仍停在 CONNECTING。

也就是说:任务早就结束了,前端还在每 3 秒打一次请求,且业务回调会被重复触发。

修法二选一(前者更稳):

  • 前端在收到 status 为终态(completed / failed)时显式 es.close()
  • 或服务端在关流前不下发 retry,改用一个明确的终止事件。

3. 目前全仓没有调用方

createGenerationApis 的调用点,除测试外为 0。要接进界面还差组合根注入四样:baseUrluserIdtransport: { request, stream }(需要把 #117 的 client 与 shared/api/stream.tssubscribeToEventStream 适配进来)、resolveImageSize(projectId)。这一层目前没人认领,本 PR 合了之后界面上仍然摸不到。

@xyh202131
xyh202131 force-pushed the feat/generation-sse-adapter branch from b0bc0ce to 33b4863 Compare August 7, 2026 10:37
@xyh202131
xyh202131 force-pushed the feat/generation-sse-adapter branch from 33b4863 to d8d171b Compare August 8, 2026 08:11
@xyh202131 xyh202131 changed the title feat(generation): add validated SSE task adapter feat(generation): align validated SSE adapter with backend tasks Aug 8, 2026
@xyh202131
xyh202131 force-pushed the feat/generation-sse-adapter branch from f1042ab to 3fcdceb Compare August 11, 2026 06:57
@codecov

codecov Bot commented Aug 11, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 83.95722% with 60 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
frontend/src/entities/generation/api.ts 84.27% 13 Missing and 26 partials ⚠️
frontend/src/shared/api/stream.ts 80.21% 8 Missing and 10 partials ⚠️
.../packages/app/src/windup_app/web/api/generation.py 91.17% 3 Missing ⚠️

📢 Thoughts on this report? Let us know!

@xyh202131

Copy link
Copy Markdown
Contributor Author

@fennoai 审核评价

@fennoai

fennoai Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

审核完成

已针对固定提交范围 1f47667327e3ad91b152de2c91df1c88cc7d19fb...275b97e89a1ebec15bad33af66c2de509dab213c 完成代码质量、性能、安全与文档审查,并提交了一次非阻塞 PR 评审。

发现 4 个问题:

  • SSE 事件仅读取 id,未兼容要求中的 task_id
  • SSE 解析强制要求 project_id/user_id,与同 PR 声明的精简事件契约不一致。
  • 完整动画结果丢失后端帧 indexduration_ms,类型也无法暴露这些字段。
  • SSE 路由返回 404 等不可用错误时没有查询轮询兜底,运行中的任务可能永久卡住。

验证:尝试运行 Generation/SSE 测试与类型检查,但工作区未安装可执行的 vitest/tsc,命令均因 Permission denied 无法启动;未修改代码。

View job run

@fennoai fennoai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

发现 4 个需要处理的问题,主要集中在 SSE 事件契约、结果字段保真和 SSE 不可用时的任务收敛。由于工作区未安装可执行的前端依赖,测试与类型检查无法启动。

Comment thread frontend/src/entities/generation/api.ts Outdated
Comment thread frontend/src/entities/generation/api.ts Outdated
Comment thread frontend/src/entities/generation/api.ts Outdated
Comment thread frontend/src/entities/generation/api.ts Outdated
@huyanxius

Copy link
Copy Markdown
Collaborator

LGTM👍

@xyh202131
xyh202131 merged commit 3d08f76 into 1024XEngineer:main Aug 11, 2026
7 checks passed
johnnyzhang-eng added a commit to johnnyzhang-eng/game-asset-character that referenced this pull request Aug 11, 2026
**先更正我上一版的方向。** 上一版删掉了 `project_id`、改用任务自己的 `user_id` 做归属
校验,并把 EventBus 改成单键。那是错的:主线 1024XEngineer#110 里 `project_id` 正是归属校验的依据
(`_get_project_or_raise`),且 EventBus 按 `(project_id, task_id)` 双键隔离同一 task_id
在不同项目下的流。删掉它会退化主线已有的能力。本版改为**在主线骨架上补齐它的 TODO**。

一、归属补成两道。主线已校验「项目属于当前用户」,缺「任务属于那个项目」。缺这一道,
   任意已认证用户拿**自己的** project_id 配上别人的 task_id 就能订阅到别人的流,而事件
   体带 result,即最终帧的对象存储 URL。两道都在 `subscribe` 之前 —— 放之后的话越权请求
   仍会在 EventBus 上挂一个订阅者(照样收事件、只是响应体被丢弃),订阅表还会因为没人
   unsubscribe 而增长。

二、终态预检落地(原先是一行 TODO,而 docstring 已经承诺了该行为)。实际表现是客户端要
   先挂满一次心跳超时才拿到终态。

三、跨线程投递:`publish` 改成**同 loop 直接入队、跨 loop 才 call_soon_threadsafe**。
   一律走 marshal 是错的 —— 那是异步调度,要等 loop 下一次迭代才真入队,于是
   「publish 完立刻 get_nowait」会拿到空队列,主线 1024XEngineer#110 的项目隔离用例正是这么写的。
   跨 loop 分支保留是因为 executor 在 daemon thread 里跑,而 asyncio.Queue 不是线程安全的。

四、`task.project_id` 为空时记 warning 并早退,不再 publish 到一个没人听的键上。
   静默发出去的现象是「任务确实在跑、状态也在落库,但前端进度条一动不动」,日志里一行
   异常都没有。

顺带:事件 payload 抽成公开的 `task_event_payload()`。终态预检是第二个发送点,在 API 层
再抄一份字段列表就是第二个真相源,加字段时漏一处会让客户端拿到两种形状的同名事件。

测试 10 条,7 条变异全部杀掉(去掉「任务属于项目」/ 去掉终态预检 / 校验挪到订阅之后 /
payload 少字段 / 终态映射把 failed 当 completed / project_id 为空时静默 publish /
publish 一律走异步 marshal)。

其中「payload 同形状」那条第一版是摆设:期望键集也用 task_event_payload 反算,两边同源、
删字段一起变、断言永远成立。已改成把 SSE 事件体键集写死为契约清单。
johnnyzhang-eng added a commit to johnnyzhang-eng/game-asset-character that referenced this pull request Aug 11, 2026
**先更正我上一版的方向。** 上一版删掉了 `project_id`、改用任务自己的 `user_id` 做归属
校验,并把 EventBus 改成单键。那是错的:主线 1024XEngineer#110 里 `project_id` 正是归属校验的依据
(`_get_project_or_raise`),且 EventBus 按 `(project_id, task_id)` 双键隔离同一 task_id
在不同项目下的流。删掉它会退化主线已有的能力。本版改为**在主线骨架上补齐它的 TODO**。

一、归属补成两道。主线已校验「项目属于当前用户」,缺「任务属于那个项目」。缺这一道,
   任意已认证用户拿**自己的** project_id 配上别人的 task_id 就能订阅到别人的流,而事件
   体带 result,即最终帧的对象存储 URL。两道都在 `subscribe` 之前 —— 放之后的话越权请求
   仍会在 EventBus 上挂一个订阅者(照样收事件、只是响应体被丢弃),订阅表还会因为没人
   unsubscribe 而增长。

二、终态预检落地(原先是一行 TODO,而 docstring 已经承诺了该行为)。实际表现是客户端要
   先挂满一次心跳超时才拿到终态。

三、跨线程投递:`publish` 改成**同 loop 直接入队、跨 loop 才 call_soon_threadsafe**。
   一律走 marshal 是错的 —— 那是异步调度,要等 loop 下一次迭代才真入队,于是
   「publish 完立刻 get_nowait」会拿到空队列,主线 1024XEngineer#110 的项目隔离用例正是这么写的。
   跨 loop 分支保留是因为 executor 在 daemon thread 里跑,而 asyncio.Queue 不是线程安全的。

四、`task.project_id` 为空时记 warning 并早退,不再 publish 到一个没人听的键上。
   静默发出去的现象是「任务确实在跑、状态也在落库,但前端进度条一动不动」,日志里一行
   异常都没有。

顺带:事件 payload 抽成公开的 `task_event_payload()`。终态预检是第二个发送点,在 API 层
再抄一份字段列表就是第二个真相源,加字段时漏一处会让客户端拿到两种形状的同名事件。

测试 10 条,7 条变异全部杀掉(去掉「任务属于项目」/ 去掉终态预检 / 校验挪到订阅之后 /
payload 少字段 / 终态映射把 failed 当 completed / project_id 为空时静默 publish /
publish 一律走异步 marshal)。

其中「payload 同形状」那条第一版是摆设:期望键集也用 task_event_payload 反算,两边同源、
删字段一起变、断言永远成立。已改成把 SSE 事件体键集写死为契约清单。
johnnyzhang-eng added a commit to johnnyzhang-eng/game-asset-character that referenced this pull request Aug 11, 2026
**先更正我上一版的方向。** 上一版删掉了 `project_id`、改用任务自己的 `user_id` 做归属
校验,并把 EventBus 改成单键。那是错的:主线 1024XEngineer#110 里 `project_id` 正是归属校验的依据
(`_get_project_or_raise`),且 EventBus 按 `(project_id, task_id)` 双键隔离同一 task_id
在不同项目下的流。删掉它会退化主线已有的能力。本版改为**在主线骨架上补齐它的 TODO**。

一、归属补成两道。主线已校验「项目属于当前用户」,缺「任务属于那个项目」。缺这一道,
   任意已认证用户拿**自己的** project_id 配上别人的 task_id 就能订阅到别人的流,而事件
   体带 result,即最终帧的对象存储 URL。两道都在 `subscribe` 之前 —— 放之后的话越权请求
   仍会在 EventBus 上挂一个订阅者(照样收事件、只是响应体被丢弃),订阅表还会因为没人
   unsubscribe 而增长。

二、终态预检落地(原先是一行 TODO,而 docstring 已经承诺了该行为)。实际表现是客户端要
   先挂满一次心跳超时才拿到终态。

三、跨线程投递:`publish` 改成**同 loop 直接入队、跨 loop 才 call_soon_threadsafe**。
   一律走 marshal 是错的 —— 那是异步调度,要等 loop 下一次迭代才真入队,于是
   「publish 完立刻 get_nowait」会拿到空队列,主线 1024XEngineer#110 的项目隔离用例正是这么写的。
   跨 loop 分支保留是因为 executor 在 daemon thread 里跑,而 asyncio.Queue 不是线程安全的。

四、`task.project_id` 为空时记 warning 并早退,不再 publish 到一个没人听的键上。
   静默发出去的现象是「任务确实在跑、状态也在落库,但前端进度条一动不动」,日志里一行
   异常都没有。

顺带:事件 payload 抽成公开的 `task_event_payload()`。终态预检是第二个发送点,在 API 层
再抄一份字段列表就是第二个真相源,加字段时漏一处会让客户端拿到两种形状的同名事件。

测试 10 条,7 条变异全部杀掉(去掉「任务属于项目」/ 去掉终态预检 / 校验挪到订阅之后 /
payload 少字段 / 终态映射把 failed 当 completed / project_id 为空时静默 publish /
publish 一律走异步 marshal)。

其中「payload 同形状」那条第一版是摆设:期望键集也用 task_event_payload 反算,两边同源、
删字段一起变、断言永远成立。已改成把 SSE 事件体键集写死为契约清单。
johnnyzhang-eng added a commit to johnnyzhang-eng/game-asset-character that referenced this pull request Aug 12, 2026
**先更正我上一版的方向。** 上一版删掉了 `project_id`、改用任务自己的 `user_id` 做归属
校验,并把 EventBus 改成单键。那是错的:主线 1024XEngineer#110 里 `project_id` 正是归属校验的依据
(`_get_project_or_raise`),且 EventBus 按 `(project_id, task_id)` 双键隔离同一 task_id
在不同项目下的流。删掉它会退化主线已有的能力。本版改为**在主线骨架上补齐它的 TODO**。

一、归属补成两道。主线已校验「项目属于当前用户」,缺「任务属于那个项目」。缺这一道,
   任意已认证用户拿**自己的** project_id 配上别人的 task_id 就能订阅到别人的流,而事件
   体带 result,即最终帧的对象存储 URL。两道都在 `subscribe` 之前 —— 放之后的话越权请求
   仍会在 EventBus 上挂一个订阅者(照样收事件、只是响应体被丢弃),订阅表还会因为没人
   unsubscribe 而增长。

二、终态预检落地(原先是一行 TODO,而 docstring 已经承诺了该行为)。实际表现是客户端要
   先挂满一次心跳超时才拿到终态。

三、跨线程投递:`publish` 改成**同 loop 直接入队、跨 loop 才 call_soon_threadsafe**。
   一律走 marshal 是错的 —— 那是异步调度,要等 loop 下一次迭代才真入队,于是
   「publish 完立刻 get_nowait」会拿到空队列,主线 1024XEngineer#110 的项目隔离用例正是这么写的。
   跨 loop 分支保留是因为 executor 在 daemon thread 里跑,而 asyncio.Queue 不是线程安全的。

四、`task.project_id` 为空时记 warning 并早退,不再 publish 到一个没人听的键上。
   静默发出去的现象是「任务确实在跑、状态也在落库,但前端进度条一动不动」,日志里一行
   异常都没有。

顺带:事件 payload 抽成公开的 `task_event_payload()`。终态预检是第二个发送点,在 API 层
再抄一份字段列表就是第二个真相源,加字段时漏一处会让客户端拿到两种形状的同名事件。

测试 10 条,7 条变异全部杀掉(去掉「任务属于项目」/ 去掉终态预检 / 校验挪到订阅之后 /
payload 少字段 / 终态映射把 failed 当 completed / project_id 为空时静默 publish /
publish 一律走异步 marshal)。

其中「payload 同形状」那条第一版是摆设:期望键集也用 task_event_payload 反算,两边同源、
删字段一起变、断言永远成立。已改成把 SSE 事件体键集写死为契约清单。
johnnyzhang-eng added a commit to johnnyzhang-eng/game-asset-character that referenced this pull request Aug 12, 2026
**先更正我上一版的方向。** 上一版删掉了 `project_id`、改用任务自己的 `user_id` 做归属
校验,并把 EventBus 改成单键。那是错的:主线 1024XEngineer#110 里 `project_id` 正是归属校验的依据
(`_get_project_or_raise`),且 EventBus 按 `(project_id, task_id)` 双键隔离同一 task_id
在不同项目下的流。删掉它会退化主线已有的能力。本版改为**在主线骨架上补齐它的 TODO**。

一、归属补成两道。主线已校验「项目属于当前用户」,缺「任务属于那个项目」。缺这一道,
   任意已认证用户拿**自己的** project_id 配上别人的 task_id 就能订阅到别人的流,而事件
   体带 result,即最终帧的对象存储 URL。两道都在 `subscribe` 之前 —— 放之后的话越权请求
   仍会在 EventBus 上挂一个订阅者(照样收事件、只是响应体被丢弃),订阅表还会因为没人
   unsubscribe 而增长。

二、终态预检落地(原先是一行 TODO,而 docstring 已经承诺了该行为)。实际表现是客户端要
   先挂满一次心跳超时才拿到终态。

三、跨线程投递:`publish` 改成**同 loop 直接入队、跨 loop 才 call_soon_threadsafe**。
   一律走 marshal 是错的 —— 那是异步调度,要等 loop 下一次迭代才真入队,于是
   「publish 完立刻 get_nowait」会拿到空队列,主线 1024XEngineer#110 的项目隔离用例正是这么写的。
   跨 loop 分支保留是因为 executor 在 daemon thread 里跑,而 asyncio.Queue 不是线程安全的。

四、`task.project_id` 为空时记 warning 并早退,不再 publish 到一个没人听的键上。
   静默发出去的现象是「任务确实在跑、状态也在落库,但前端进度条一动不动」,日志里一行
   异常都没有。

顺带:事件 payload 抽成公开的 `task_event_payload()`。终态预检是第二个发送点,在 API 层
再抄一份字段列表就是第二个真相源,加字段时漏一处会让客户端拿到两种形状的同名事件。

测试 10 条,7 条变异全部杀掉(去掉「任务属于项目」/ 去掉终态预检 / 校验挪到订阅之后 /
payload 少字段 / 终态映射把 failed 当 completed / project_id 为空时静默 publish /
publish 一律走异步 marshal)。

其中「payload 同形状」那条第一版是摆设:期望键集也用 task_event_payload 反算,两边同源、
删字段一起变、断言永远成立。已改成把 SSE 事件体键集写死为契约清单。
johnnyzhang-eng added a commit to johnnyzhang-eng/game-asset-character that referenced this pull request Aug 12, 2026
**先更正我上一版的方向。** 上一版删掉了 `project_id`、改用任务自己的 `user_id` 做归属
校验,并把 EventBus 改成单键。那是错的:主线 1024XEngineer#110 里 `project_id` 正是归属校验的依据
(`_get_project_or_raise`),且 EventBus 按 `(project_id, task_id)` 双键隔离同一 task_id
在不同项目下的流。删掉它会退化主线已有的能力。本版改为**在主线骨架上补齐它的 TODO**。

一、归属补成两道。主线已校验「项目属于当前用户」,缺「任务属于那个项目」。缺这一道,
   任意已认证用户拿**自己的** project_id 配上别人的 task_id 就能订阅到别人的流,而事件
   体带 result,即最终帧的对象存储 URL。两道都在 `subscribe` 之前 —— 放之后的话越权请求
   仍会在 EventBus 上挂一个订阅者(照样收事件、只是响应体被丢弃),订阅表还会因为没人
   unsubscribe 而增长。

二、终态预检落地(原先是一行 TODO,而 docstring 已经承诺了该行为)。实际表现是客户端要
   先挂满一次心跳超时才拿到终态。

三、跨线程投递:`publish` 改成**同 loop 直接入队、跨 loop 才 call_soon_threadsafe**。
   一律走 marshal 是错的 —— 那是异步调度,要等 loop 下一次迭代才真入队,于是
   「publish 完立刻 get_nowait」会拿到空队列,主线 1024XEngineer#110 的项目隔离用例正是这么写的。
   跨 loop 分支保留是因为 executor 在 daemon thread 里跑,而 asyncio.Queue 不是线程安全的。

四、`task.project_id` 为空时记 warning 并早退,不再 publish 到一个没人听的键上。
   静默发出去的现象是「任务确实在跑、状态也在落库,但前端进度条一动不动」,日志里一行
   异常都没有。

顺带:事件 payload 抽成公开的 `task_event_payload()`。终态预检是第二个发送点,在 API 层
再抄一份字段列表就是第二个真相源,加字段时漏一处会让客户端拿到两种形状的同名事件。

测试 10 条,7 条变异全部杀掉(去掉「任务属于项目」/ 去掉终态预检 / 校验挪到订阅之后 /
payload 少字段 / 终态映射把 failed 当 completed / project_id 为空时静默 publish /
publish 一律走异步 marshal)。

其中「payload 同形状」那条第一版是摆设:期望键集也用 task_event_payload 反算,两边同源、
删字段一起变、断言永远成立。已改成把 SSE 事件体键集写死为契约清单。
johnnyzhang-eng added a commit to johnnyzhang-eng/game-asset-character that referenced this pull request Aug 12, 2026
**先更正我上一版的方向。** 上一版删掉了 `project_id`、改用任务自己的 `user_id` 做归属
校验,并把 EventBus 改成单键。那是错的:主线 1024XEngineer#110 里 `project_id` 正是归属校验的依据
(`_get_project_or_raise`),且 EventBus 按 `(project_id, task_id)` 双键隔离同一 task_id
在不同项目下的流。删掉它会退化主线已有的能力。本版改为**在主线骨架上补齐它的 TODO**。

一、归属补成两道。主线已校验「项目属于当前用户」,缺「任务属于那个项目」。缺这一道,
   任意已认证用户拿**自己的** project_id 配上别人的 task_id 就能订阅到别人的流,而事件
   体带 result,即最终帧的对象存储 URL。两道都在 `subscribe` 之前 —— 放之后的话越权请求
   仍会在 EventBus 上挂一个订阅者(照样收事件、只是响应体被丢弃),订阅表还会因为没人
   unsubscribe 而增长。

二、终态预检落地(原先是一行 TODO,而 docstring 已经承诺了该行为)。实际表现是客户端要
   先挂满一次心跳超时才拿到终态。

三、跨线程投递:`publish` 改成**同 loop 直接入队、跨 loop 才 call_soon_threadsafe**。
   一律走 marshal 是错的 —— 那是异步调度,要等 loop 下一次迭代才真入队,于是
   「publish 完立刻 get_nowait」会拿到空队列,主线 1024XEngineer#110 的项目隔离用例正是这么写的。
   跨 loop 分支保留是因为 executor 在 daemon thread 里跑,而 asyncio.Queue 不是线程安全的。

四、`task.project_id` 为空时记 warning 并早退,不再 publish 到一个没人听的键上。
   静默发出去的现象是「任务确实在跑、状态也在落库,但前端进度条一动不动」,日志里一行
   异常都没有。

顺带:事件 payload 抽成公开的 `task_event_payload()`。终态预检是第二个发送点,在 API 层
再抄一份字段列表就是第二个真相源,加字段时漏一处会让客户端拿到两种形状的同名事件。

测试 10 条,7 条变异全部杀掉(去掉「任务属于项目」/ 去掉终态预检 / 校验挪到订阅之后 /
payload 少字段 / 终态映射把 failed 当 completed / project_id 为空时静默 publish /
publish 一律走异步 marshal)。

其中「payload 同形状」那条第一版是摆设:期望键集也用 task_event_payload 反算,两边同源、
删字段一起变、断言永远成立。已改成把 SSE 事件体键集写死为契约清单。
johnnyzhang-eng added a commit to johnnyzhang-eng/game-asset-character that referenced this pull request Aug 12, 2026
**先更正我上一版的方向。** 上一版删掉了 `project_id`、改用任务自己的 `user_id` 做归属
校验,并把 EventBus 改成单键。那是错的:主线 1024XEngineer#110 里 `project_id` 正是归属校验的依据
(`_get_project_or_raise`),且 EventBus 按 `(project_id, task_id)` 双键隔离同一 task_id
在不同项目下的流。删掉它会退化主线已有的能力。本版改为**在主线骨架上补齐它的 TODO**。

一、归属补成两道。主线已校验「项目属于当前用户」,缺「任务属于那个项目」。缺这一道,
   任意已认证用户拿**自己的** project_id 配上别人的 task_id 就能订阅到别人的流,而事件
   体带 result,即最终帧的对象存储 URL。两道都在 `subscribe` 之前 —— 放之后的话越权请求
   仍会在 EventBus 上挂一个订阅者(照样收事件、只是响应体被丢弃),订阅表还会因为没人
   unsubscribe 而增长。

二、终态预检落地(原先是一行 TODO,而 docstring 已经承诺了该行为)。实际表现是客户端要
   先挂满一次心跳超时才拿到终态。

三、跨线程投递:`publish` 改成**同 loop 直接入队、跨 loop 才 call_soon_threadsafe**。
   一律走 marshal 是错的 —— 那是异步调度,要等 loop 下一次迭代才真入队,于是
   「publish 完立刻 get_nowait」会拿到空队列,主线 1024XEngineer#110 的项目隔离用例正是这么写的。
   跨 loop 分支保留是因为 executor 在 daemon thread 里跑,而 asyncio.Queue 不是线程安全的。

四、`task.project_id` 为空时记 warning 并早退,不再 publish 到一个没人听的键上。
   静默发出去的现象是「任务确实在跑、状态也在落库,但前端进度条一动不动」,日志里一行
   异常都没有。

顺带:事件 payload 抽成公开的 `task_event_payload()`。终态预检是第二个发送点,在 API 层
再抄一份字段列表就是第二个真相源,加字段时漏一处会让客户端拿到两种形状的同名事件。

测试 10 条,7 条变异全部杀掉(去掉「任务属于项目」/ 去掉终态预检 / 校验挪到订阅之后 /
payload 少字段 / 终态映射把 failed 当 completed / project_id 为空时静默 publish /
publish 一律走异步 marshal)。

其中「payload 同形状」那条第一版是摆设:期望键集也用 task_event_payload 反算,两边同源、
删字段一起变、断言永远成立。已改成把 SSE 事件体键集写死为契约清单。
huyanxius pushed a commit that referenced this pull request Aug 12, 2026
* feat(framework): 补 provider 抽象接口与抠图/视频实现

providers/ 此前只有三个 create_*_client 工厂,没有可供上层依赖的抽象类型,
ai_engine 无法在不 import 具体实现的前提下声明它需要什么能力。

- interfaces.py:ImageProvider / VideoProvider / MatteProvider 三个 Protocol,
  零依赖,供上层按能力而非按厂商声明依赖。
- matte.py:OnnxU2NetMatteProvider,onnxruntime 直跑 u2netp。不用 rembg:其底层
  同样依赖 onnxruntime,且 numba 老链在 3.12 无轮子。onnxruntime 导入失败时降级
  到 Pillow 兜底而非崩溃。
- sufy.py:SufyImageProvider / SufyVideoProvider。视频成品下载加三次退避重试与
  长度校验 —— 该步发生在提交任务、轮询、等待全部成功之后,此时费用已产生、视频
  已生成好,只差取回数据,连接断一次整单作废。实测同一角色连续两单死在这里各烧
  一次费用。test_sufy_video_download 的四条断言拿修复前的旧实现做过对照,确认其中
  三条在修复前会失败。

依赖声明:
- qiniu>=7.14 —— 此前未声明,镜像能起、/docs 也 200,只有第一次 POST /media/upload
  才 ModuleNotFoundError。
- onnxruntime>=1.17,<1.24 —— 1.24 起不再发布 macOS Intel(x86_64) wheel,Intel Mac
  装不上。1.23.x 仍覆盖 Intel/arm64/Linux + py3.12,API 一致,抠图代码零改动。

本 PR 不依赖其他未合分支:providers 不 import windup_common.models。

* feat(framework): 补 provider 抽象接口与抠图/视频实现

providers/ 此前只有三个 create_*_client 工厂,没有可供上层依赖的抽象类型,
ai_engine 无法在不 import 具体实现的前提下声明它需要什么能力。

- interfaces.py:ImageProvider / VideoProvider / MatteProvider 三个 Protocol,
  零依赖,供上层按能力而非按厂商声明依赖。
- matte.py:OnnxU2NetMatteProvider,onnxruntime 直跑 u2netp。不用 rembg:其底层
  同样依赖 onnxruntime,且 numba 老链在 3.12 无轮子。onnxruntime 导入失败时降级
  到 Pillow 兜底而非崩溃。
- sufy.py:SufyImageProvider / SufyVideoProvider。视频成品下载加三次退避重试与
  长度校验 —— 该步发生在提交任务、轮询、等待全部成功之后,此时费用已产生、视频
  已生成好,只差取回数据,连接断一次整单作废。实测同一角色连续两单死在这里各烧
  一次费用。test_sufy_video_download 的四条断言拿修复前的旧实现做过对照,确认其中
  三条在修复前会失败。

依赖声明:
- qiniu>=7.14 —— 此前未声明,镜像能起、/docs 也 200,只有第一次 POST /media/upload
  才 ModuleNotFoundError。
- onnxruntime>=1.17,<1.24 —— 1.24 起不再发布 macOS Intel(x86_64) wheel,Intel Mac
  装不上。1.23.x 仍覆盖 Intel/arm64/Linux + py3.12,API 一致,抠图代码零改动。

本 PR 不依赖其他未合分支:providers 不 import windup_common.models。

* feat(ai_engine): 对外契约 ports + 动作分流 strategy + 串联 CharacterGenerator

server 与生成引擎之间的唯一边界,以及"哪个动作走哪条生成路线"这个架构决策。

ports/  server 只 import 这里,由 CI 的 import-linter 分层门禁强制。
  CharacterGeneratorPort.generate(card, action, master, progress) -> GeneratedAction
  边界:ai_engine 只产出帧 bytes + 逐帧时长,不碰存储 / 数据库 / 任务状态。母版由
  server 从 Character.reference_image_url 取好以 bytes 传入;产出的帧由 server 上传
  对象存储、写 character_data。依据是"谁掌握租户与配额上下文"——bucket、路径规则、
  归属项目、配额全在 server;ai_engine 自持存储等于把租户概念下沉到一个只做图像计算
  的层。代价是帧 bytes 在内存过一次(16 帧 512×512 RGBA ≈ 16MB,可接受)。

strategy/  ROUTE_MATRIX 是实测挣得的架构契约,改它 = 改产线。
  walk / run / jump / attack / idle -> VIDEO_I2V;hit -> PER_FRAME
  依据:逐帧独立生成锁不住"哪条腿在前"(踢踏舞),视频天生连贯、腿自然交替;
  hit 这类离散姿势单帧可编辑价值高、无连续步态。Refs #35 #53。

impl/CharacterGenerator  选路线 -> strategy.derive 出帧 -> 脚线对齐 -> GeneratedAction。

与 #53 原设计的两处差异:

1) idle 从 PROC_IDLE 改走 VIDEO_I2V,GenRoute.PROC_IDLE 与 ProcIdleStrategy 一并移除。
   #53 原设计 idle 走 ¥0 的程序化局部呼吸(Idle-B),实测做不出可用效果,放弃,认这份
   i2v 的钱。不留没有实现的枚举值。

2) 未实现的路线抛错,不返回空帧。
   旧桩实现 return [b""] * n_frames,调用方拿到的 GeneratedAction 帧数对、时长对、
   无异常——完全像一次成功的生成。server 会把 N 个 0 字节文件传上对象存储、写进
   character_data,用户看到 N 张裂图,排查时不会想到是路线没实现。
   现在 PerFrameStrategy 调用即抛 NotImplementedError;装配表缺该路线时抛错并报出
   已装配了哪些;strategy 吐出空帧时抛 ValueError。四条回归测试拿旧实现对照过,
   确认在修复前全部失败。

另记录 ROUTE_MATRIX 形状的已知边界:它是「动作类型 → 路线」一对一映射,隐含前提是
"路线由动作的物理性质唯一决定"。该前提对逐帧 / 视频成立,但对渲染出帧路线不成立——
同一个 walk 走 i2v 还是走渲染,取决于该角色有没有 3D 模型,那是 server 才知道的事。
接入第三条路线前须先定「路线选择由谁决定」。

本分支 stack 在 feat/character-domain-models、feat/provider-interfaces-and-matte、
feat/ai-engine-frame-toolkit 之上,那三个合并后 rebase。

* fix(generation): 修机器审报的五处 P1

`_get_generator()` 里还留着 `GenRoute.PROC_IDLE: ProcIdleStrategy(...)`,而这两个都已
随「程序化待机放弃」删除。注入 generator 的测试走不到这条装配路径,所以测试全绿而真实
调用全崩。改为只装当前 GenRoute 真有的路线,并加一条漏装断言——将来新增枚举成员时会
在装配处立刻暴露,而不是等某个动作第一次被请求。

`_download_master` / `_download` 直接 `httpx.get(input.reference_image_urls[0])`,
而那个 URL 来自已认证请求的请求体。等于把服务器当跳板:打 loopback 绕过鉴权中间件、
读云实例元数据服务的临时凭证、探测私网拓扑;重定向还能把合法域名换成上述任意一种。

新增 `_fetch.fetch_own_media`:白名单(必须是 `storage_settings.download_base` 前缀)
+ 禁跟随重定向 + 响应体上限 16 MiB(边读边计数,不信 Content-Length)。

取白名单而非黑名单:黑名单要穷举 127/8、10/8、172.16/12、192.168/16、169.254/16、
::1、fc00::/7 以及各种十进制/八进制/IPv6-mapped 写法,漏一条等于没做。而本业务只需拉
自家 bucket 的图(母版与参考图都先经 /media/upload 传上去)。代价是不能再传外部图床
链接——真要支持该走一个显式的「导入外部素材」入口,在那里做完整校验与配额。

`task_repo` 一律发 `task_update`,而 stream 的 `_TERMINAL_EVENTS = {"completed","failed"}`
永不匹配,于是终态 break 走不到。端点带 `retry: 3000`,浏览器原生 EventSource 每 3 秒
重连、每次重收同一条 completed。按状态映射事件名。

`publish` 被后台 daemon thread 调用(executor → task_repo),而队列属于处理 SSE 请求
的那个 loop。`asyncio.Queue` 不是线程安全的。订阅时记下所属 loop,发布时经
`call_soon_threadsafe` 回到那个 loop 再入队;loop 已关闭时静默丢弃(任务状态已落库,
重连后靠 GET /tasks/{id} 取,抛异常会把后台任务整个带崩)。

`num_images` 直通 provider 调用循环,请求模型不设上限——一个已认证请求填个大数就能绕过
按请求计的限流、把成本拉到无上限。加 `ge=1, le=4`;`num_frames` 加 `le=64`;宽高加
`64..2048`。

顺带删掉请求体里的 `user_id: int = Field(gt=0)`:端点已改从 `request.state.current_user`
取归属,这个字段既不被读、又让调用方以为能指定归属者——填别人的 id 不报错也不生效。

20 条回归用例。变异验证中**逮到自己两条摆设测试**并已重写:
- 终态事件名那条原先直接读 `_STATUS_EVENT` 字典,而变异改的是 `_publish_task_update`
  里的用法 → 改为注入假 bus、走真实调用路径断言发出的事件名;
- 跨线程那条证不出 `call_soon_threadsafe` 的必要性(实测裸 `put_nowait` 在单队列场景
  也能被 `get()` 取到,CPython 有元素时走快路径)→ 如实在 docstring 写明本用例强度,
  另补一条「订阅必须记下所属 loop」的结构断言,那条能杀死变异。

漏装断言那条变异存活是**预期**:当前两个路线都装满,`missing` 恒为空集,它是防未来
回归的守卫而非当前行为,测试锁的是「装满」这个事实。

* fix(generation): 请求里的图片尺寸真的生效,并同步上游 provider 两处修复

对抗复查发现:width / height 从请求进到 CharacterImageInput、被 _validate_project_size
校验过,然后被丢掉 —— ImageProvider.gen_image 没有尺寸参数,模型出多大就返多大。调用方
要 512×512、拿到 1024×1024,而请求被接受了。性质与本轮删掉的 ActionSpec.fps / loop 完全
相同,只是这次字段在入口侧。今天还给这两个字段加了 le=2048 上界,等于替它们背书。

模型本身不吃宽高,所以在编排层落实:复用已有的 _fit_to。给它加 smooth 参数——序列帧是
像素画必须 NEAREST(插值会把硬边糊成灰边并引入调色板外的颜色),全彩角色母版反过来,
NEAREST 缩图明显锯齿,用 LANCZOS。

同步上游(改动本体在 feat/provider-interfaces-and-matte):文生图路径改读配置里的
chat_completions_path;400/404 翻译成指向 GET {base}/models 的可操作错误。

测试 +4。变异测试:再把尺寸丢掉 2 条红,smooth 参数不接线 1 条红。其中重采样那条第一版
用纯色图作源是无效仪器(纯色下两种重采样结果完全相同),已换成棋盘格。

* fix(app): 交付尺寸传给引擎出帧,不再拿到帧之后再缩一次

编排层此前对引擎交付的每一帧再做一次 _fit_to(png, sprite_w, sprite_h)。引擎恒出
256,项目要 512 时这就是二次重采样 —— 而实际后果比"糊一次"严重得多:_fit_to 用
Image.thumbnail,**thumbnail 只缩不放**。2026-08-11 复刻这段逻辑实测(喂主体高
157px、脚线 0.92 的 256 交付帧):

    目标 512×512 → 主体仍 157px(根本没放大),脚线 0.92 → 0.709
    目标 384×384 → 主体仍 157px,              脚线 0.92 → 0.779
    目标 128×128 → 主体 78px,                脚线 0.914(缩小方向正常)

即放大方向上主体一点没变大("成品放大看很糊"的直接来源),并且引擎刚用
align_bottom_center 对齐好的脚线被整体挪高,角色不站在地上,ref_height 那套跨动作
本体尺寸一致也一并失效。

改法:把项目 sprite 尺寸作为 canvas 传给 generate,引擎一次出到位,那一步不存在了。

用归档角色「林间斥候」的真实抠图帧(1280×720,主体高中位 619px)端到端实测:

    canvas 不传        交付 256×256   主体高中位 159px
    canvas (256,256)   交付 256×256   主体高中位 159px(与不传**逐字节相同**)
    canvas (512,512)   交付 512×512   主体高中位 318px —— 倍数 2.000
    canvas (384,512)   交付 384×512   主体高与 (512,512) 完全相同(高度几何只看高)
    canvas (1024,1024) 交付 1024×1024 主体高中位 635px

顺带一条选型信息:源帧主体 619px,512 档交付 318px 仍是**下采样**(不引入插值糊),
1024 档 635px 已经越过源分辨率、是上采样,收益递减。

_fit_to 换成 _require_size:只核对、不补救。尺寸对不上说明生成侧没按 canvas 出帧,
该报错让人看见,而不是缩放补边把问题抹平、交付一批脚线错位的帧。_fit_to 本身保留,
角色母版那条路径(smooth=True)仍在用。

**接口影响**:CharacterGeneratorPort.generate 多了 canvas 入参,实现该 Protocol 的
测试替身必须跟着接。修 _SpyGenerator 时顺带发现一个假绿用例:该 spy 一直在传
GeneratedAction(fps=...),而该字段早已删除,构造直接 TypeError、任务其实被判 FAILED;
当时的用例只断言 seen_facing(在构造之前就赋了值)所以一直绿着。已一并修好,
test_project_perspective_constrains_facing 现在跑的是一次真正成功的任务。

变异测试(4 个变异逐个改坏 → 确认变红 → 还原,全部被杀):
  E1 不把项目尺寸传给引擎     → project_sprite_size_is_passed 等 2 条红
  E2 canvas 宽高接反          → non_square_sprite_size_passed 红
  E3 只传宽当成方形           → non_square_sprite_size_passed 红
  E4 尺寸不符时静默放行       → wrong_size_fails_instead_of_rescaled 红

**依赖上游分支**:canvas 入参由 feat/ai-engine-ports-and-strategy 的 013520f 提供,
后者又依赖 feat/ai-engine-frame-toolkit 的 5da358e。本分支尚未同步这两个提交,故
单独跑 pytest 会有 1 条 test_action_task_runs_end_to_end 失败(真实
CharacterGenerator 还不认 canvas)。在工作区先打上那两个提交再跑,CI 全绿:
ruff / lint-imports(2 contracts kept) / pytest 309 passed。同步后即恢复。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(generation): SSE 订阅补归属校验与终态预检,去掉无消费方的 project_id

人工评审报的三条,逐条修:

一、越权订阅。stream 端点只按 task_id 订阅、不看是谁在订阅,任意已认证用户猜到 id
   就能拿到别人任务的实时进度与最终产物 URL(事件体带 result,即帧的对象存储地址)。
   同文件的 GET /tasks/{task_id} 有校验,stream 漏了,而两者从没被放在一起测过。
   校验放在 subscribe **之前**:放之后的话越权请求仍会在 EventBus 上挂一个订阅者,
   照样收事件、只是响应体被丢弃,订阅表还会因为没人 unsubscribe 而增长。

二、终态预检。原先是一行 TODO,而上一行的 docstring 已经承诺了该行为 —— 读文档的人
   不会发现,实际表现是客户端要先挂满 30 秒心跳超时才拿到终态。

三、project_id 声明为必填但从未被使用,归属判定的依据是任务自己的 user_id,不是调用方
   声称的项目。删掉后 fastapi.Query 也变成未使用 import,反过来印证它确实没有消费方。

顺带:事件 payload 从 task_repo 抽成公开的 task_event_payload()。终态预检是第二个发送
点,在 API 层再抄一份字段列表就是第二个真相源,加字段时漏一处会让客户端拿到两种形状的
同名事件。

测试 +9,5 条变异全部杀掉(去掉归属校验 / 去掉终态预检 / 校验挪到订阅之后 /
payload 少字段 / 终态映射把 failed 当成 completed)。

其中"payload 同形状"那条第一版是摆设:期望键集也用 task_event_payload 反算,两边同源,
删字段一起变、断言永远成立。改成把 SSE 事件体的键集写死成契约清单,并另加一条直接比
两条真实发送路径产出的用例。

过程中三处是测试自身写错、不是代码错,记下避免再犯:按 HTTP 状态码断言越权(本仓
BizException 统一以 200 + 业务码返回,把"校验生效"误判成"越权",且成功码是 200 不是 0);
create_task 签名靠猜;conftest 的建表清单里没有本 PR 新引入的 generation_task 表。

* fix(generation): SSE 归属补齐「任务属于项目」这一道,并落终态预检

**先更正我上一版的方向。** 上一版删掉了 `project_id`、改用任务自己的 `user_id` 做归属
校验,并把 EventBus 改成单键。那是错的:主线 #110 里 `project_id` 正是归属校验的依据
(`_get_project_or_raise`),且 EventBus 按 `(project_id, task_id)` 双键隔离同一 task_id
在不同项目下的流。删掉它会退化主线已有的能力。本版改为**在主线骨架上补齐它的 TODO**。

一、归属补成两道。主线已校验「项目属于当前用户」,缺「任务属于那个项目」。缺这一道,
   任意已认证用户拿**自己的** project_id 配上别人的 task_id 就能订阅到别人的流,而事件
   体带 result,即最终帧的对象存储 URL。两道都在 `subscribe` 之前 —— 放之后的话越权请求
   仍会在 EventBus 上挂一个订阅者(照样收事件、只是响应体被丢弃),订阅表还会因为没人
   unsubscribe 而增长。

二、终态预检落地(原先是一行 TODO,而 docstring 已经承诺了该行为)。实际表现是客户端要
   先挂满一次心跳超时才拿到终态。

三、跨线程投递:`publish` 改成**同 loop 直接入队、跨 loop 才 call_soon_threadsafe**。
   一律走 marshal 是错的 —— 那是异步调度,要等 loop 下一次迭代才真入队,于是
   「publish 完立刻 get_nowait」会拿到空队列,主线 #110 的项目隔离用例正是这么写的。
   跨 loop 分支保留是因为 executor 在 daemon thread 里跑,而 asyncio.Queue 不是线程安全的。

四、`task.project_id` 为空时记 warning 并早退,不再 publish 到一个没人听的键上。
   静默发出去的现象是「任务确实在跑、状态也在落库,但前端进度条一动不动」,日志里一行
   异常都没有。

顺带:事件 payload 抽成公开的 `task_event_payload()`。终态预检是第二个发送点,在 API 层
再抄一份字段列表就是第二个真相源,加字段时漏一处会让客户端拿到两种形状的同名事件。

测试 10 条,7 条变异全部杀掉(去掉「任务属于项目」/ 去掉终态预检 / 校验挪到订阅之后 /
payload 少字段 / 终态映射把 failed 当 completed / project_id 为空时静默 publish /
publish 一律走异步 marshal)。

其中「payload 同形状」那条第一版是摆设:期望键集也用 task_event_payload 反算,两边同源、
删字段一起变、断言永远成立。已改成把 SSE 事件体键集写死为契约清单。

* fix(generation): 三个端点接回服务层,并补上能发现"没接上"的测试

评审报的是实情:这三个端点在分支上还是 TODO 桩。根因是 8-11 那次 rebase 解冲突时对
generation.py 取了基座版,把本分支的实现换成了主线的桩,CI 全绿没拦住。

修复:
- POST /generation/image、POST /generation/action 接回 generation_service,落 PENDING
  记录后返回;后台线程仍走 _dispatch_after_commit(commit 后再起,否则后台 session
  读不到未提交的行、update 静默跳过、任务永远 PENDING)
- GET /generation/tasks/{id} 接回 task_repo,归属两道:项目属于当前用户 + 任务属于该
  项目。只查项目不够,任意已认证用户拿自己的 project_id 配别人的 task_id 就能读到别人
  的产物 URL。与 stream 端点同口径。

补 5 条测试,锁住"端点确实落库"“_task_to_out 确实被调用”“跨项目任务读不到”。
之前没有这类断言,桩返回 400、测试也断言 400,两边一致所以看不出来。
变异测试:端点改回桩 4 条红、去掉任务归属校验 1 条红、绕开 _task_to_out 4 条红。

顺带:conftest 的建表清单补上 generation_task(端点接上后才会真的用到这张表);
删掉 rebase 带回来的 test_fal_queue_video_provider.py(FAL 面已随 #179 移除)。

* fix(deps): framework 依赖声明与 lock 取回 main 版

rebase 时删掉了 main 已入库的 passlib/redis/resend 三条声明,lock 也跟着丢了
bcrypt,CI 报 ModuleNotFoundError。本地 venv 恰好装着这三个包所以没暴露。

#181 昨天修过同一处,当时没顺手检查 #182。

* test(orchestrator): 补齐白名单取图的下载段 —— 三条防线此前一行没测

`codecov/patch` 在重排后掉到 83.91%(目标 84.10%,差 0.19pp)。#181 合入 main 后
#182 的 patch 只剩自己那 10 个提交,分母变了就压线掉下来。

去看未覆盖的行,最大缺口是 `_fetch.py` 的 46%(26 行里 14 行没跑),而那 14 行正是
**放行之后的三条防线**,一条都没测过:

1. **`follow_redirects=False`** —— 白名单最容易被绕开的方式:URL 本身完全合规,
   坏事发生在重定向之后。自家域名返回 302 指向 169.254.169.254,跟过去就等于白名单
   没写。新测试断言异常之外,还断言**元数据服务那个 URL 从未被请求过**。
2. **声明 Content-Length 超限** → 读 body 之前就拒。
3. **Content-Length 撒谎时边读边计数** —— 声明 1 字节实际吐 100MB,只信 header
   就能吃光 worker 内存。

四条新测试用 httpx.MockTransport,不联网。`_fetch.py` 46% → **100%**。

两处自己踩的坑,记下来:
- 补丁装在 `F.httpx.Client` 上,而工厂内部又调 `httpx.Client` → 无限递归。必须先把
  真的 Client 抓在局部变量里再打补丁。
- 重定向那条初版写的是 `pytest.raises(Exception)`,于是上面那个 RecursionError 也
  算"通过" —— 测试因为错误的原因变绿。已收紧成 `httpx.HTTPStatusError`。

变异测试验过这四条真的能咬:分别关掉重定向防护 / 废掉边读边计数 / 废掉声明超限检查,
三次都被逮到(脚本带 try/finally,结束校验 sha256 一致)。

Refs #171 · Refs #78

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: johnnyzhang-eng <johnnyzhang-eng@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants