修复完美像素对账请求的绝对截止
为 requestJson 增加覆盖鉴权、重试和响应体读取的可选绝对 deadline。 让完美像素项目对账按 75 秒窗口和单次 10 秒上界读取权威快照。 补充鉴权等待、响应体读取、客户端透传和对账边界的定向测试与文档。
This commit is contained in:
@@ -6228,3 +6228,11 @@
|
||||
- 决策:快照 helper 返回全部同 ID 原始记录。通用 queued completion 只要任一记录未收口就执行既有第二次 GET;完美像素要求 operation dialog 唯一,命中多条时失败关闭为 `conflict`,不按其中任意一条猜测结果。无需改变 hydrate、删除、后端或 OpenAPI。
|
||||
- 验证:两条 `duplicate` 定向用例通过;`npm run typecheck`、改动文件级 ESLint 与 `npm run check:encoding` 通过,未运行目录或全仓测试套件。
|
||||
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。
|
||||
|
||||
## 2026-08-04 完美像素对账读取改用请求全生命周期绝对截止
|
||||
|
||||
- 缺陷:`loadEditorProject` 的 `timeoutMs` 只包住业务 `fetch` 等响应头。缺 token 时的登录恢复发生在该 timer 建立前,401 后的共享 refresh 等待也不受它限制;收到响应头后 timer 已清理,成功和错误响应的 `response.text()` 又可继续永久挂起。任一环节不 settle,完美像素对账都无法重新检查 75 秒窗口,首次提交的 dialog ownership 与图层锁也无法进入 `finally` 释放。
|
||||
- 决策:`requestJson` 新增 opt-in `deadlineAt`,从函数入口建立一次 lifecycle signal,覆盖缺 token 补票、业务 fetch、401 refresh 等待、所有 GET attempt、退避和成功 / 错误响应体读取。等待共享 refresh 只取消当前调用者,不把该 signal 传入共享 refresh 请求,避免一次图片对账超时取消 AuthGate 或其它请求正在复用的刷新;signal 到期也不得被鉴权 catch 吞掉后继续发业务请求、清 token 或广播登录态变化。未传 `deadlineAt` 的调用保持既有 `timeoutMs` 行为。
|
||||
- 对账边界:窗口内每次 GET 的 deadline 为 `min(reconcileUntil, readStartedAt + 10 秒)`;operation 已过期但从未读取时仍执行一次即时 GET,该例外最多 10 秒。deadline 只把本次读取视为失败,不穿透成未处理 rejection;轮询随后返回 `pending-confirmation` 并让首次提交 / hydrate 恢复的既有清理链执行。
|
||||
- 验证范围:`apiClient` 定向覆盖缺 token 与 401 refresh 永久等待、响应体永久等待;`editorProjectClient` 钉住 deadline 透传且普通读取仍保留 60 秒默认 timeout;generation workflow 钉住窗口内和过期单次读取的 deadline。未扩大为全站请求超时迁移。
|
||||
- 关联文档:`docs/technical/【前端架构】图片画布编辑器MVP接入方案-2026-06-11.md`。
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
- 前端提交前先创建关闭 composer 的右侧生成占位,再解析或上传源图以取得稳定引用,随后把版本化 `perfectPixelOperation` 请求快照写入该占位并 flush 当前项目布局,最后才发送 POST。`canvasCompletion.dialogId` 同时作为 operation identity、稳定 task identity 的输入和本地源图上传 ID;同一 operation 的上传路径与后续 POST 请求都不得随机漂移。`sourceImageSrc` 优先由当前图层已有的 `objectKey / resourceId / sourceAssetId` 解析;尚未登记的浏览器本地图片只执行 `ticket → OSS PUT → confirm → objectKey`,不为这条持久化输入换取 signed URL。一个 `AbortSignal` 必须贯穿源文件 fetch / 图片解析边界、ticket、PUT、confirm,完整上传 helper 的可选换签也必须透传同一 signal。正式请求不得包含 `data:` / `blob:`、signed URL 或普通外链。后端在读取源图前必须把该字段解析为当前 owner 已登记的私有 OSS object key,并核对 project / resource / asset 归属。
|
||||
- 该已有图片入口使用 strict 语义:只接受静态 PNG / JPEG / WebP,GIF、APNG、动画 WebP、图片序列及其它非静态媒体必须在处理前拒绝。strict 与生成风格复用完全相同的 legacy profile、峰值估算、单轴步长补全、walker、采样和编码;仅当横纵两轴都未检测到步长、legacy 即将使用 `min(width,height)/64` 统一网格兜底时拒绝。任一轴已检测到步长时,两条路径行为和输出必须一致。源图读取、解码、尺寸校验、排队、像素规整或 PNG 编码任一步失败 / 超时 / 不适用时,请求失败,不保留原图副本冒充成功,不执行最终 OSS PUT,也不创建 project resource、账号素材或结果图层。成功时只对最终 PNG 执行一次 OSS PUT,并至多各创建一个 `editor_project_resource` 和一个 `editor_asset`,再按 `canvasCompletion` 写回一个派生图层;不得保存逻辑低分辨率图、诊断图或前后对比图。
|
||||
- strict 的本次结果事实零写入边界截至首个最终 PNG PUT:所有可预判的引用、归属、类型、静态编码、元数据、网格适用性和 CPU 处理错误必须在此前失败;前置 owner-scoped 项目 / 素材读取仍可能按既有语义懒建默认 canvas / folder,这些基础记录不属于本次完美像素结果。最终 PNG 的 OSS PUT / HEAD 位于数据库事务外;验证上传结果后,asset object、project resource、账号素材与可选 canvas completion 由单个受 runtime service identity 保护的 SpacetimeDB procedure 在一次事务中原子提交。operation 以 `owner + project + canvasCompletion.dialogId` 为作用域,task / object / resource / asset ID 稳定派生,object key 携带规范请求与输入 / 输出摘要形成的 fingerprint;同内容重放只返回原结果,输入漂移或部分既有事实失败关闭。HTTP timeout/drop 不能撤销已发往远端的 procedure,客户端仍须按稳定 `taskId / objectKey / resourceId` 对账,不能把未收到回包等同于未提交。
|
||||
- `POST /api/editor/images/pixel-art-snaps` 是有副作用的 unsafe POST。客户端不得为它配置 `EDITOR_REQUEST_RETRY_OPTIONS`,请求字节可能已发出后不因 transport 异常或 `408 / 425 / 429 / 502 / 503 / 504` 自动重放;Bearer 中间件在 handler 前以 `401` 拒绝、刷新 token 后的既有认证恢复不属于业务副作用重放,保持通用行为。POST 回包中的 `project / resource / asset` 不是结果 verdict;首次成功回包、未知异常、人工 exact replay 和刷新恢复都只读取项目 GET。`perfectPixelOperation.submittedAt / reconcileUntil` 从稳定请求快照写入时建立统一 75 秒绝对窗口,POST 回包不能续期;读取必须立即执行一次,随后退避间隔不超过 5 秒,窗口已过期时仍执行一次即时 GET。固定判据为:匹配 task 的唯一 resource 加已收口 dialog / 关联图层才是画布成功;dialog 不存在但存在匹配 task resource 才是 asset-only 成功;dialog 仍 generating、dialog 不存在且无匹配 resource、项目始终不可读或窗口耗尽均保持 unknown。素材库刷新只在项目终态后 fire-and-forget,同步抛错、异步拒绝或永久挂起都不得阻塞 verdict、项目快照应用和执行锁释放。
|
||||
- `POST /api/editor/images/pixel-art-snaps` 是有副作用的 unsafe POST。客户端不得为它配置 `EDITOR_REQUEST_RETRY_OPTIONS`,请求字节可能已发出后不因 transport 异常或 `408 / 425 / 429 / 502 / 503 / 504` 自动重放;Bearer 中间件在 handler 前以 `401` 拒绝、刷新 token 后的既有认证恢复不属于业务副作用重放,保持通用行为。POST 回包中的 `project / resource / asset` 不是结果 verdict;首次成功回包、未知异常、人工 exact replay 和刷新恢复都只读取项目 GET。`perfectPixelOperation.submittedAt / reconcileUntil` 从稳定请求快照写入时建立统一 75 秒绝对窗口,POST 回包不能续期;读取必须立即执行一次,随后退避间隔不超过 5 秒,窗口已过期时仍执行一次即时 GET。每次项目读取使用 `requestJson.deadlineAt` 覆盖缺 token 补票、业务 fetch、401 refresh、重试退避与响应体读取;窗口内单次最多 10 秒且不得越过 `reconcileUntil`,过期后的唯一即时读取最多额外 10 秒。固定判据为:匹配 task 的唯一 resource 加已收口 dialog / 关联图层才是画布成功;dialog 不存在但存在匹配 task resource 才是 asset-only 成功;dialog 仍 generating、dialog 不存在且无匹配 resource、项目始终不可读或窗口耗尽均保持 unknown。素材库刷新只在项目终态后 fire-and-forget,同步抛错、异步拒绝或永久挂起都不得阻塞 verdict、项目快照应用和执行锁释放。
|
||||
- unknown 状态持久化为原 generation dialog 上的 `pending-confirmation + perfectPixelOperation`,普通删除和随源图层清理不得移除该 operation;用户只能继续 GET 对账或显式按原 identity 重放。人工重试只刷新观察窗口,POST JSON 必须与持久请求 byte-for-byte 一致,不得按当前画布、目录、类型或标题重建,也不得创建第二个 dialog / task / object / resource / asset。hydrate 后只做 GET,不自动 POST、上传或重建请求。处理成功但事务内权威 dialog 已删除时,后端保留 object / resource / asset 并返回 asset-only 事实,canvas / revision 不变;前端只有在项目 GET 看见匹配 task resource 后才能提示“已保存到素材库”。现有布局 CAS 没有 deletion tombstone,completion 与其它已持久化布局编辑冲突时继续按权威 revision 守卫收口;尚未防抖落库的本地编辑合并不在本批范围。
|
||||
- 完美像素并发闸回归测试不得通过进程级队列 Atomic 的 before/after 判断“本用例未入队”。过期 deadline 用例只断言 `504`;queue guard 的 Drop 归还由独立用例覆盖,不引入 `--test-threads=1`、全局串行锁或其它串行化兜底。
|
||||
- 项目快照对账生成占位时必须检查全部同 ID 原始记录,不得用首项短路:通用 queued completion 只要任一记录未收口就执行既有第二次 GET;完美像素要求 operation dialog 唯一,命中多条时失败关闭为 `conflict`,不得按首条记录猜测成功。
|
||||
@@ -176,7 +176,7 @@
|
||||
- 裁扩通过画布边界拖拉完成,不再展示四边数值输入;默认自由比例,选择固定比例后拖拉边界保持对应比例,完成后在原素材旁边新增裁扩结果图层,扩展区域透明填充。去除背景调用同源 BFF `POST /api/editor/images/background-removals`;父流程解析并校验私有 OSS object key 后只调用一次唯一内部 `bgfilter-worker` 的 complex 链路,子 worker 负责签发 600 秒 URL、`N / Q` 限流和最多两次顺序 provider attempt,complex 失败不接入 fallback,成功二进制返回后仍由父流程完成最终持久化。有项目上下文时先在画布创建关闭面板的去背景生成占位,完成后由后端通过 `canvasCompletion` 把新 project resource 写入该占位并返回快照,无占位上下文时才用新的 project resource 引用替换当前图层。画布任务侧栏按“排队/生成中”和“已完成”分页,生成中排在排队前,生成中耗时从任务开始时间戳实时计算,排队中不计时;进行中任务只显示阶段文本和已用时,不显示百分比;完成态生成任务副标题显示用户提示词并单行截断;点击任务只聚焦对应画布内容,不激活生成面板或改变任务顺序,聚焦时必须预留图片上方工具栏、底部工具栏和可见生成对话框空间。UI设计图的提取素材必须先进入红框素材框选状态,默认启用矩形框选,右侧框选工具与快速编辑统一且可再次点击取消启用态,当前启用工具按钮必须保持高亮。素材提取面板必须在素材下方,使用与生成新素材一致的面板宽度和底部模型 / 按钮样式,提示语显示 `使用框选工具框选你希望从画面中提取的素材`,并展示按原图坐标准确裁剪的框选区域截图预览、固定模型 `gpt-image-2`、左下角计划规格 `1:1·1K/2K` 和 `提取 · N泥点` 按钮,不显示额外取消按钮;点击素材和面板以外的画布区域即退出 UI 素材提取。至少框选一个区域后才可提交,前端把红色轮廓绘入原图后固定走 `gpt-image-2` 和自动决策纯色背景素材提取提示词。透明处理及拆分正常完成时,透明 spritesheet 和拆分素材都按后端快照保留为画布图层;透明处理失败时仅原图作为主结果,既不要求透明图也不要求切片;透明图成功但拆分失败时保留整张透明图并展示拆分告警。三种完成结果都以后端项目快照为准。
|
||||
- 2026-08-04 修订:完美像素前端已经让素材刷新退出 verdict,持久化 operation 请求快照与 `pending-confirmation`,并接入刷新后的 GET-only 恢复;是否成功只能由下面的项目 GET 正向证据判定。
|
||||
- 完美像素前端第二批以 durable operation 为提交边界:生成占位必须持久化 `perfectPixelOperation = { version: 1, kind: "perfect-pixel", operationId, taskId, request, submittedAt, reconcileUntil }`,其中 `operationId` 等于规范化 dialog id、`taskId` 固定为 `pixel-art-snap-{operationId}`,`request` 是稳定源引用解析完成后的完整 `EditorPixelArtSnapInput`,`submittedAt / reconcileUntil` 构成从快照写入起算、不得被 POST 回包续期的 75 秒整链绝对窗口。完美像素 dialog id 使用跨标签随机 identity,不能复用每个标签页都会从 1 开始的局部计数器。inline 源图以该 identity 作为稳定 upload ID,只执行 object-only 上传,不等待 signed URL;快照不得包含 Data URL、Blob URL 或 signed URL。POST 前必须取得包含该 dialog 与 operation 快照的布局保存成功确认,保存冲突、鉴权失败、重试耗尽或无法确认时 POST 必须为零。人工重试只能原样重放该快照与同一 operation,不得重新 placement、上传、读取当前图层字段或暗中换 identity;快照缺失、损坏或与 dialog / project / task / completion 不匹配时失败关闭。首次提交或人工重试在途期间若 owner、project 或组件生命周期已经变化,旧响应的素材写入、项目应用、提示与对账副作用必须全部忽略,不能把前一账号的结果写入当前账号状态。
|
||||
- 完美像素 unknown-result 的 verdict 只来自项目 GET,POST 响应体不得直接判成功:找到唯一稳定 task resource 且 dialog 已收口、结果层精确指向该 resource 时为 `Applied`;resource 存在且 dialog 不存在时为 `DialogMissing`,结果只在素材库;dialog 仍 generating(包括匹配 resource 已先可见)或 dialog 不存在且无匹配 resource 时继续有界轮询;resource 与 dialog / layer 出现原子事务不可能产生的错配时保持待确认并提示冲突,禁止自动 POST。首个 GET 立即执行,此后退避不超过 5 秒;即使绝对窗口已过期也必须读取一次。`refreshAssetLibrary` 只在终态后 best-effort 触发,不进入轮询 deadline、`Promise.all` 或成功判断,同步 throw、异步 reject 和永久挂起均不得阻塞。轮询到期或 GET 失败后 dialog 转 `pending-confirmation`,保留 operation 与请求快照并释放页面 busy,不得伪装成普通失败或声称素材已保存。
|
||||
- 完美像素 unknown-result 的 verdict 只来自项目 GET,POST 响应体不得直接判成功:找到唯一稳定 task resource 且 dialog 已收口、结果层精确指向该 resource 时为 `Applied`;resource 存在且 dialog 不存在时为 `DialogMissing`,结果只在素材库;dialog 仍 generating(包括匹配 resource 已先可见)或 dialog 不存在且无匹配 resource 时继续有界轮询;resource 与 dialog / layer 出现原子事务不可能产生的错配时保持待确认并提示冲突,禁止自动 POST。首个 GET 立即执行,此后退避不超过 5 秒;即使绝对窗口已过期也必须读取一次。GET 的绝对 deadline 从进入 `requestJson` 起覆盖鉴权恢复、所有 attempt 和响应体读取;不能把只覆盖响应头的 `timeoutMs` 当成整次读取上界。单次 deadline 到期按一次读取失败处理,随后由轮询返回 `pending`,首次提交必须进入 `finally` 释放 dialog ownership 与图层锁,hydrate 恢复必须清理 recovery controller。`refreshAssetLibrary` 只在终态后 best-effort 触发,不进入轮询 deadline、`Promise.all` 或成功判断,同步 throw、异步 reject 和永久挂起均不得阻塞。轮询到期或 GET 失败后 dialog 转 `pending-confirmation`,保留 operation 与请求快照并释放页面 busy,不得伪装成普通失败或声称素材已保存。
|
||||
- 完美像素恢复只对账、不重新执行:项目 hydrate 后识别带有效 operation 快照的 `generating` / `pending-confirmation` dialog,只按稳定 task/resource 做 GET-only 轮询,绝不 POST、重新上传、重新准备来源或为了恢复而先写布局;owner/project 切换、卸载或更高 revision 到来时旧轮询结果不得生效。新写入的 v1 operation 固定使用 75 秒跨度;为兼容第一批和滚动升级中的旧标签页,hydrate 仍接受跨度及未来时钟偏差不超过 240 秒的旧 v1 journal。若旧 `submittedAt` 位于可接受的未来区间,先把它规范化到当前时间,再把 `reconcileUntil` 压到 `min(持久截止, 规范化 submittedAt + 75 秒, 当前时间 + 75 秒)`;写回形状必须继续满足 `reconcileUntil >= submittedAt`,确保下次 hydrate 仍保留同一 identity。新 durable operation 无论在哪个标签页、是否超过 legacy TTL 都不得被 `requiresLiveSession` 或普通删除路径清理;TTL 只兼容完全没有 operation journal 字段的历史 inline 孤儿,字段存在但内容损坏时必须保留并失败关闭,清理 legacy 孤儿时必须同时更新 `project.layers` 与 `project.canvas.layers`。恢复到期仍持久保持 `pending-confirmation`,只有用户明确点击重试才进入 exact replay。
|
||||
- legacy inline 占位的本会话归属必须由同一份封装 ownership 管理:同步 Set 在首个 await 前完成 `claim`,保证到期判定即时可见;`claim / release` 仅在 membership 真变化时推进 React 可观察的 version,`release` 即使发生时 dialogs 与 callbacks identity 都不变,也必须立即唤醒到期 effect 重新判定。禁止重新暴露可变 Set ref 或直接修改 `.current`,React 不会因为 ref 内容变化而重跑 effect。
|
||||
- 重绘生成资源后,右侧出现新生成结果图层,并自动 fit 原图 + 新图,且重绘面板保持打开。
|
||||
|
||||
@@ -2710,7 +2710,7 @@ describe('useImageCanvasGenerationWorkflow', () => {
|
||||
expect(loadEditorProjectMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'project-1',
|
||||
expect.objectContaining({ timeoutMs: 10_000 }),
|
||||
expect.objectContaining({ deadlineAt: expect.any(Number) }),
|
||||
);
|
||||
expect(snapImageToPerfectPixelsMock).toHaveBeenCalledTimes(1);
|
||||
expect(applyProjectSnapshot).toHaveBeenCalledWith(reconciledProject, {
|
||||
@@ -3197,7 +3197,7 @@ describe('useImageCanvasGenerationWorkflow', () => {
|
||||
});
|
||||
expect(loadEditorProjectMock).toHaveBeenCalledWith(
|
||||
'project-1',
|
||||
expect.objectContaining({ timeoutMs: 10_000 }),
|
||||
expect.objectContaining({ deadlineAt: expect.any(Number) }),
|
||||
);
|
||||
expect(refreshAssetLibrary).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByTestId('dialog').textContent).not.toContain('failed');
|
||||
@@ -3862,9 +3862,16 @@ describe('useImageCanvasGenerationWorkflow', () => {
|
||||
'project-1',
|
||||
expect.objectContaining({
|
||||
signal: expect.any(AbortSignal),
|
||||
timeoutMs: 10_000,
|
||||
deadlineAt: expect.any(Number),
|
||||
}),
|
||||
);
|
||||
const readOptions = loadEditorProjectMock.mock.calls[0]?.[1] as
|
||||
| { deadlineAt?: number }
|
||||
| undefined;
|
||||
expect(readOptions?.deadlineAt).toBeLessThanOrEqual(Date.now() + 10_000);
|
||||
expect(readOptions?.deadlineAt).toBeLessThanOrEqual(
|
||||
hydratedDialog.perfectPixelOperation!.reconcileUntil,
|
||||
);
|
||||
expect(snapImageToPerfectPixelsMock).not.toHaveBeenCalled();
|
||||
expect(applyProjectSnapshotWithoutHistory).toHaveBeenCalledTimes(1);
|
||||
expect(applyProjectSnapshot).not.toHaveBeenCalled();
|
||||
@@ -3902,6 +3909,7 @@ describe('useImageCanvasGenerationWorkflow', () => {
|
||||
|
||||
it('observes an expired hydrated operation at least once and leaves it pending when no result exists', async () => {
|
||||
const operationId = 'perfect-pixel-hydrated-expired';
|
||||
const readStartedAt = Date.now();
|
||||
const hydratedDialog = createHydratedPerfectPixelDialog({
|
||||
operationId,
|
||||
reconcileUntil: Date.now() - 1_000,
|
||||
@@ -3930,6 +3938,11 @@ describe('useImageCanvasGenerationWorkflow', () => {
|
||||
);
|
||||
});
|
||||
expect(loadEditorProjectMock).toHaveBeenCalledTimes(1);
|
||||
const readOptions = loadEditorProjectMock.mock.calls[0]?.[1] as
|
||||
| { deadlineAt?: number }
|
||||
| undefined;
|
||||
expect(readOptions?.deadlineAt).toBeGreaterThanOrEqual(readStartedAt);
|
||||
expect(readOptions?.deadlineAt).toBeLessThanOrEqual(Date.now() + 10_000);
|
||||
expect(snapImageToPerfectPixelsMock).not.toHaveBeenCalled();
|
||||
expect(applyProjectSnapshotWithoutHistory).not.toHaveBeenCalled();
|
||||
expect(screen.getByTestId('dialog-error').textContent).toBe(
|
||||
|
||||
@@ -391,17 +391,18 @@ async function reconcilePerfectPixelProject(
|
||||
}
|
||||
try {
|
||||
hasAttemptedRead = true;
|
||||
const readStartedAt = Date.now();
|
||||
const remainingAtReadMs = operation.reconcileUntil - readStartedAt;
|
||||
const readDeadlineAt =
|
||||
remainingAtReadMs > 0
|
||||
? Math.min(
|
||||
operation.reconcileUntil,
|
||||
readStartedAt + PERFECT_PIXEL_PROJECT_READ_TIMEOUT_MS,
|
||||
)
|
||||
: readStartedAt + PERFECT_PIXEL_PROJECT_READ_TIMEOUT_MS;
|
||||
latestProject = await loadEditorProject(projectId, {
|
||||
signal: options.signal,
|
||||
timeoutMs: Math.max(
|
||||
1,
|
||||
Math.min(
|
||||
PERFECT_PIXEL_PROJECT_READ_TIMEOUT_MS,
|
||||
remainingAfterDelayMs > 0
|
||||
? remainingAfterDelayMs
|
||||
: PERFECT_PIXEL_PROJECT_READ_TIMEOUT_MS,
|
||||
),
|
||||
),
|
||||
deadlineAt: readDeadlineAt,
|
||||
});
|
||||
const verdict = inspectPerfectPixelProjectSnapshot(
|
||||
latestProject,
|
||||
|
||||
@@ -55,6 +55,16 @@ function createResponseMock(params: {
|
||||
};
|
||||
}
|
||||
|
||||
function createDeferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise;
|
||||
reject = rejectPromise;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
describe('apiClient', () => {
|
||||
const fetchMock = vi.fn();
|
||||
const dispatchEventMock = vi.fn();
|
||||
@@ -687,6 +697,83 @@ describe('apiClient', () => {
|
||||
expect(capturedError).toBeInstanceOf(Error);
|
||||
});
|
||||
|
||||
it.each(['missing-token', 'unauthorized'] as const)(
|
||||
'bounds the %s refresh wait with an absolute request deadline',
|
||||
async (refreshMode) => {
|
||||
const refreshResponse = createDeferred<
|
||||
ReturnType<typeof createResponseMock>
|
||||
>();
|
||||
if (refreshMode === 'unauthorized') {
|
||||
setStoredAccessToken('expired-token', { emit: false });
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(createResponseMock({ status: 401 }))
|
||||
.mockImplementationOnce(() => refreshResponse.promise);
|
||||
} else {
|
||||
fetchMock.mockImplementationOnce(() => refreshResponse.promise);
|
||||
}
|
||||
|
||||
const request = requestJson(
|
||||
'/api/runtime/protected',
|
||||
{ method: 'GET' },
|
||||
'读取受保护数据失败',
|
||||
{ deadlineAt: Date.now() + 20 },
|
||||
);
|
||||
|
||||
await expect(request).rejects.toMatchObject({ name: 'TimeoutError' });
|
||||
expect(fetchMock).toHaveBeenCalledTimes(
|
||||
refreshMode === 'unauthorized' ? 2 : 1,
|
||||
);
|
||||
expect(fetchMock.mock.calls.at(-1)?.[0]).toBe('/api/auth/refresh');
|
||||
if (refreshMode === 'unauthorized') {
|
||||
expect(getStoredAccessToken()).toBe('expired-token');
|
||||
} else {
|
||||
expect(getStoredAccessToken()).toBe('');
|
||||
}
|
||||
expect(dispatchEventMock).not.toHaveBeenCalled();
|
||||
|
||||
refreshResponse.resolve(
|
||||
createResponseMock({
|
||||
status: 200,
|
||||
body: JSON.stringify({
|
||||
ok: true,
|
||||
data: { token: 'late-refresh-token' },
|
||||
error: null,
|
||||
meta: { apiVersion: '2026-06-16' },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
await vi.waitFor(() => {
|
||||
expect(getStoredAccessToken()).toBe('late-refresh-token');
|
||||
});
|
||||
expect(dispatchEventMock).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it.each([200, 400])(
|
||||
'bounds a %i response body read with the same absolute request deadline',
|
||||
async (status) => {
|
||||
setStoredAccessToken('body-timeout-token', { emit: false });
|
||||
const responseBody = createDeferred<string>();
|
||||
const response = createResponseMock({ status });
|
||||
response.text.mockImplementationOnce(() => responseBody.promise);
|
||||
fetchMock.mockResolvedValueOnce(response);
|
||||
|
||||
const request = requestJson(
|
||||
'/api/runtime/protected',
|
||||
{ method: 'GET' },
|
||||
'读取受保护数据失败',
|
||||
{ deadlineAt: Date.now() + 20 },
|
||||
);
|
||||
|
||||
await expect(request).rejects.toMatchObject({ name: 'TimeoutError' });
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(response.text).toHaveBeenCalledTimes(1);
|
||||
|
||||
responseBody.resolve('');
|
||||
await responseBody.promise;
|
||||
},
|
||||
);
|
||||
|
||||
it('surfaces response metadata through ApiClientError', async () => {
|
||||
setStoredAccessToken('metadata-token', { emit: false });
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
|
||||
+142
-14
@@ -49,6 +49,12 @@ export type ApiRequestOptions = {
|
||||
requestId?: string;
|
||||
};
|
||||
|
||||
export type ApiJsonRequestOptions = ApiRequestOptions & {
|
||||
// 从 requestJson 入口起覆盖鉴权、重试、业务请求和响应体读取的绝对截止时间。
|
||||
// 未传时保持既有 timeoutMs 仅约束单次业务 fetch 的语义。
|
||||
deadlineAt?: number;
|
||||
};
|
||||
|
||||
export const BACKGROUND_AUTH_REQUEST_OPTIONS = {
|
||||
authImpact: 'local',
|
||||
skipRefresh: true,
|
||||
@@ -317,6 +323,93 @@ function composeAbortSignal(
|
||||
};
|
||||
}
|
||||
|
||||
function composeAbsoluteDeadlineSignal(
|
||||
signal: AbortSignal | undefined,
|
||||
deadlineAt: number | undefined,
|
||||
) {
|
||||
const hasDeadline =
|
||||
typeof deadlineAt === 'number' && Number.isFinite(deadlineAt);
|
||||
if (!hasDeadline) {
|
||||
return {
|
||||
signal,
|
||||
hasDeadline: false,
|
||||
cleanup: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const remainingMs = Math.max(0, deadlineAt - Date.now());
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
const cleanup = () => {
|
||||
if (timeoutId !== undefined) {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
signal?.removeEventListener('abort', onAbort);
|
||||
};
|
||||
const onAbort = () => {
|
||||
controller.abort(signal?.reason ?? createAbortError());
|
||||
};
|
||||
|
||||
if (signal?.aborted) {
|
||||
controller.abort(signal.reason ?? createAbortError());
|
||||
} else {
|
||||
signal?.addEventListener('abort', onAbort, { once: true });
|
||||
if (remainingMs <= 0) {
|
||||
controller.abort(createTimeoutError(0));
|
||||
} else {
|
||||
timeoutId = setTimeout(() => {
|
||||
controller.abort(createTimeoutError(remainingMs));
|
||||
}, remainingMs);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
signal: controller.signal,
|
||||
hasDeadline: true,
|
||||
cleanup,
|
||||
};
|
||||
}
|
||||
|
||||
function awaitWithAbortSignal<T>(
|
||||
work: Promise<T>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<T> {
|
||||
if (!signal) {
|
||||
return work;
|
||||
}
|
||||
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
let settled = false;
|
||||
const cleanup = () => {
|
||||
signal.removeEventListener('abort', onAbort);
|
||||
};
|
||||
const settle = (callback: () => void) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
cleanup();
|
||||
callback();
|
||||
};
|
||||
const onAbort = () => {
|
||||
settle(() => reject(signal.reason ?? createAbortError()));
|
||||
};
|
||||
|
||||
signal.addEventListener('abort', onAbort, { once: true });
|
||||
work.then(
|
||||
(value) => {
|
||||
settle(() => resolve(value));
|
||||
},
|
||||
(error: unknown) => {
|
||||
settle(() => reject(error));
|
||||
},
|
||||
);
|
||||
if (signal.aborted) {
|
||||
onAbort();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForRetry(ms: number, signal?: AbortSignal) {
|
||||
if (ms <= 0) {
|
||||
return;
|
||||
@@ -702,14 +795,17 @@ export async function fetchWithApiAuth(
|
||||
try {
|
||||
// 受保护请求在本地 access token 缺失时,先尝试用 refresh cookie 静默补票,
|
||||
// 避免把后端原始 “缺少 Bearer Token” 直接暴露给业务 UI。
|
||||
await ensureStoredAccessToken();
|
||||
await awaitWithAbortSignal(ensureStoredAccessToken(), requestSignal);
|
||||
requestHeaders = withAuthorizationHeaders(init.headers, options);
|
||||
requestHeaders[REQUEST_ID_HEADER] = requestId;
|
||||
hasAuthHeader = Boolean(
|
||||
requestHeaders.Authorization?.trim() ||
|
||||
requestHeaders.authorization?.trim(),
|
||||
);
|
||||
} catch {
|
||||
} catch (error) {
|
||||
if (requestSignal?.aborted) {
|
||||
throw requestSignal.reason ?? error;
|
||||
}
|
||||
// 补票失败时继续走原始请求,让调用方按真实 401 分支处理。
|
||||
}
|
||||
}
|
||||
@@ -735,13 +831,16 @@ export async function fetchWithApiAuth(
|
||||
!refreshAttempted
|
||||
) {
|
||||
try {
|
||||
await refreshAccessToken();
|
||||
await awaitWithAbortSignal(refreshAccessToken(), requestSignal);
|
||||
refreshAttempted = true;
|
||||
// refresh 成功只代表 access token 已补票成功,
|
||||
// 不能把当前业务请求的首次 401 直接放大成全局鉴权变更,
|
||||
// 否则像 Puzzle works 这类受保护列表会把单接口失败放大成整个平台重复 hydrate。
|
||||
continue;
|
||||
} catch (refreshError) {
|
||||
if (requestSignal?.aborted) {
|
||||
throw requestSignal.reason ?? refreshError;
|
||||
}
|
||||
const shouldClearAuth =
|
||||
hasAuthHeader &&
|
||||
authFailurePolicy.clearAuthOnUnauthorized &&
|
||||
@@ -771,6 +870,9 @@ export async function fetchWithApiAuth(
|
||||
return response;
|
||||
}
|
||||
} catch (error) {
|
||||
if (requestSignal?.aborted) {
|
||||
throw requestSignal.reason ?? error;
|
||||
}
|
||||
if (!shouldRetryError(error, attempt, retry)) {
|
||||
throw error;
|
||||
}
|
||||
@@ -784,8 +886,9 @@ export async function fetchWithApiAuth(
|
||||
async function buildApiClientError(
|
||||
response: Response,
|
||||
fallbackMessage: string,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
const responseText = await response.text();
|
||||
const responseText = await awaitWithAbortSignal(response.text(), signal);
|
||||
const parsedError = parseApiErrorShape(responseText);
|
||||
const requestId =
|
||||
parsedError?.meta.requestId ??
|
||||
@@ -820,17 +923,42 @@ export async function requestJson<T>(
|
||||
url: string,
|
||||
init: RequestInit,
|
||||
fallbackMessage: string,
|
||||
options: ApiRequestOptions = {},
|
||||
options: ApiJsonRequestOptions = {},
|
||||
): Promise<T> {
|
||||
const response = await fetchWithApiAuth(url, init, options);
|
||||
const lifecycle = composeAbsoluteDeadlineSignal(
|
||||
init.signal ?? undefined,
|
||||
options.deadlineAt,
|
||||
);
|
||||
const requestOptions = lifecycle.hasDeadline
|
||||
? { ...options, timeoutMs: undefined }
|
||||
: options;
|
||||
const requestInit = lifecycle.signal
|
||||
? { ...init, signal: lifecycle.signal }
|
||||
: init;
|
||||
|
||||
if (!response.ok) {
|
||||
throw await buildApiClientError(response, fallbackMessage);
|
||||
try {
|
||||
const response = await fetchWithApiAuth(url, requestInit, requestOptions);
|
||||
|
||||
if (!response.ok) {
|
||||
throw await buildApiClientError(
|
||||
response,
|
||||
fallbackMessage,
|
||||
lifecycle.signal,
|
||||
);
|
||||
}
|
||||
|
||||
const responseText = await awaitWithAbortSignal(
|
||||
response.text(),
|
||||
lifecycle.signal,
|
||||
);
|
||||
if (lifecycle.signal?.aborted) {
|
||||
throw lifecycle.signal.reason ?? createAbortError();
|
||||
}
|
||||
|
||||
return responseText
|
||||
? unwrapApiResponse<T>(JSON.parse(responseText) as T)
|
||||
: (null as T);
|
||||
} finally {
|
||||
lifecycle.cleanup();
|
||||
}
|
||||
|
||||
const responseText = await response.text();
|
||||
|
||||
return responseText
|
||||
? unwrapApiResponse<T>(JSON.parse(responseText) as T)
|
||||
: (null as T);
|
||||
}
|
||||
|
||||
@@ -371,6 +371,33 @@ describe('editorProjectClient', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('forwards an absolute lifecycle deadline when loading a project', async () => {
|
||||
const controller = new AbortController();
|
||||
const deadlineAt = Date.now() + 10_000;
|
||||
requestJsonMock.mockResolvedValueOnce({
|
||||
project: {
|
||||
projectId: 'editor-project-1',
|
||||
title: '角色设定板',
|
||||
viewport: { x: 8, y: 9, scale: 1.5 },
|
||||
layers: [],
|
||||
resources: [],
|
||||
updatedAt: '2026-06-12T00:00:00.000Z',
|
||||
},
|
||||
});
|
||||
|
||||
await loadEditorProject('editor-project-1', {
|
||||
signal: controller.signal,
|
||||
deadlineAt,
|
||||
});
|
||||
|
||||
expect(requestJsonMock).toHaveBeenCalledWith(
|
||||
'/api/editor/projects/editor-project-1',
|
||||
{ method: 'GET', signal: controller.signal },
|
||||
'读取图片画布工程失败',
|
||||
{ deadlineAt },
|
||||
);
|
||||
});
|
||||
|
||||
it('renames and deletes an editor project', async () => {
|
||||
requestJsonMock
|
||||
.mockResolvedValueOnce({
|
||||
|
||||
@@ -580,6 +580,7 @@ export type EditorCanvasSnapshot = {
|
||||
export type EditorProjectLoadOptions = {
|
||||
signal?: AbortSignal;
|
||||
timeoutMs?: number;
|
||||
deadlineAt?: number;
|
||||
};
|
||||
|
||||
export type EditorProjectCreateInput = {
|
||||
@@ -789,6 +790,9 @@ export async function loadEditorProject(
|
||||
projectId: string,
|
||||
options: EditorProjectLoadOptions = {},
|
||||
) {
|
||||
const hasAbsoluteDeadline =
|
||||
typeof options.deadlineAt === 'number' &&
|
||||
Number.isFinite(options.deadlineAt);
|
||||
const response = await requestJson<EditorProjectResponse>(
|
||||
`${EDITOR_PROJECT_API_BASE}/${encodeURIComponent(projectId)}`,
|
||||
{
|
||||
@@ -796,10 +800,11 @@ export async function loadEditorProject(
|
||||
...(options.signal ? { signal: options.signal } : {}),
|
||||
},
|
||||
'读取图片画布工程失败',
|
||||
// 中文注释:本文件多数接口都显式写了超时,这里此前没有——而 composeAbortSignal 在
|
||||
// timeoutMs 缺失时不设任何默认值。它是未知结果对账路径上的读取,挂住会让 catch 迟迟
|
||||
// 不结束,本会话对占位的归属登记要到 finally 才释放,连带把占位拖过存活窗口。
|
||||
{ timeoutMs: options.timeoutMs ?? 60_000 },
|
||||
hasAbsoluteDeadline
|
||||
? { deadlineAt: options.deadlineAt }
|
||||
: // 中文注释:普通项目读取继续保留既有单次 fetch timeout;完美像素对账显式传
|
||||
// deadlineAt,改由 requestJson 从鉴权恢复到响应体读取约束完整生命周期。
|
||||
{ timeoutMs: options.timeoutMs ?? 60_000 },
|
||||
);
|
||||
return response.project;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user