From ede6cc1e707addceff03c2a0ad4bfe1767afa23e Mon Sep 17 00:00:00 2001 From: xupengchong-del Date: Tue, 13 Oct 2026 12:13:27 +0800 Subject: [PATCH] =?UTF-8?q?fix(create):=20=E5=88=9B=E4=BD=9C=E9=A1=B5=20-?= =?UTF-8?q?=20=E4=B8=8A=E4=B8=80=E6=9D=A1=E8=BF=98=E5=9C=A8=E7=94=9F?= =?UTF-8?q?=E6=88=90=E6=97=B6=E6=97=A0=E6=B3=95=E6=8F=90=E4=BA=A4=E4=B8=8B?= =?UTF-8?q?=E4=B8=80=E6=9D=A1=20(#754)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 提交闸门覆盖了整个生成过程:`submit()` 会一直等到任务出结果才在 finally 里放闸。 于是上一条视频还在跑时点发送会被 `tryAcquire()` 静默拒绝——没有提示、没有反应, 用户只看到按钮在转,以为坏了;重试走的是同一条 submit 路径,一并被挡住。 闸门本来只需要防住「同一次创建任务被重复触发」(连点、回车连击),不该跨越整个 生成过程: - 闸门改为令牌制:`tryAcquire()` 返回令牌,`release(token)` 只对持有者生效, 避免并发提交互相解锁; - 任务一旦建成(bindTask)立刻放闸,与已有的 `busy` 释放时机对齐,因此上一条在 生成时也能继续提交下一条; - 被拒绝时给出提示,而不是静默丢弃(这条以前完全没反馈); - 同步更新闸门单测,补上持有者令牌的并发用例。 验证:`tsc --noEmit`、ESLint、`bun run build` 通过;`bun test` 2204 通过, 唯一失败项 agent-api-reliability 在纯净上游基线上同样失败,与本改动无关。 --- docs/content/docs/progress/pending-test.mdx | 11 ++++++++ web/src/pages/create/creation-submit-gate.ts | 23 +++++++++++----- web/src/pages/create/index.tsx | 10 +++++-- web/test/creation-submit-gate.test.ts | 29 +++++++++++++++++--- 4 files changed, 60 insertions(+), 13 deletions(-) diff --git a/docs/content/docs/progress/pending-test.mdx b/docs/content/docs/progress/pending-test.mdx index a4d894ada..9bc8e456d 100644 --- a/docs/content/docs/progress/pending-test.mdx +++ b/docs/content/docs/progress/pending-test.mdx @@ -5,6 +5,16 @@ description: 当前项目已实现功能的手工验证清单与未确认边界 # 待测试 +## 创作页提交闸门只覆盖提交阶段 + +- 已修复:`submit()` 的提交闸门原来一直持有到整条生成结束(只在 `finally` 释放,而 `finally` 要等任务出结果才执行),因此上一条还在生成时点发送会被 `tryAcquire()` **静默拒绝**——没有提示也没有反应,表现为「按钮在转、点不动」。重试走同一条 submit 路径,一并被挡住。 +- 已实现:闸门改为令牌制(`tryAcquire()` 返回令牌,`release(token)` 只对持有者生效,避免并发提交互相解锁);任务一旦建成(`bindTask`)立刻放闸,与已有的 `busy` 释放时机对齐,因此上一条生成中也能继续提交下一条;被拒绝时改为给出提示,不再静默丢弃。 +- 说明:该闸门在 `4f18367`(v1.6.5)就已存在,不是本次改动引入;它只需要防住同一次创建任务被重复触发(连点、回车连击),不该跨越整个生成过程。 +- 实现位置:`web/src/pages/create/creation-submit-gate.ts`、`web/src/pages/create/index.tsx`,单测 `web/test/creation-submit-gate.test.ts`。 +- [x] 验证:`tsc --noEmit`、ESLint、`bun run build` 通过;`bun test` 2204 通过(唯一失败项 `agent-api-reliability` 在纯净上游基线上同样失败,与本改动无关);闸门单测含「持有者令牌」并发用例。 +- [ ] 用户验收:真实登录态下先提交一条慢任务(视频),在它「生成中」时输入新提示词再提交一条,确认**第二条能正常创建**、两条并行推进、按钮在输入后恢复为「发送」;确认快速连点仍只会创建一条。 +- [ ] 未验证:`busy` 仍是布尔量,两条并发时按钮可能提前约 100ms 变可点(闸门仍会拦住并提示);提交阶段本身卡住时闸门会一直被占,此时只给提示。 + ## 用户登录日志 - 已实现:管理员菜单 `/admin/login-logs`、筛选分页详情、后端认证事件记录、schema v53、失败脱敏和会话/日志原子提交。 @@ -37,6 +47,7 @@ description: 当前项目已实现功能的手工验证清单与未确认边界 - [x] 验证:`bun test test/channel-model-import-selection.test.ts` 9 项通过(空关键字返回全部、大小写不敏感、首尾空白、无命中返回空、全选并入去重、关键字外勾选保留、无关键字时与旧行为一致);`tsc --noEmit`、ESLint、`bun run build` 均通过。 - [ ] 用户验收:需要管理员登录且上游能返回大量模型才能打开该弹窗,因此**未做真实界面验收**。请在真实后台点「拉取模型」确认:搜索框位置与自动聚焦、输入关键字后列表实时收窄、全选/取消选中只影响匹配项、清空后恢复全部、以及几百个模型时的滚动与性能。 + ## 灵感页路由与导航 - 已修复:Nginx 对 `/inspirations` 与 `/inspirations/` 精确返回 SPA 入口,避免同名静态目录触发带容器端口的 301;目录下的 JSON 仍按静态文件提供。 diff --git a/web/src/pages/create/creation-submit-gate.ts b/web/src/pages/create/creation-submit-gate.ts index 02c5b83bc..58e1be844 100644 --- a/web/src/pages/create/creation-submit-gate.ts +++ b/web/src/pages/create/creation-submit-gate.ts @@ -1,14 +1,23 @@ +/** + * 创作页的提交闸门:只防止「同一次创建任务」被重复触发(快速连点、回车连击)。 + * + * 它必须只覆盖提交阶段,不能覆盖整个生成过程——否则上一条还在生成时, + * 后面的提交会被静默丢弃。释放用令牌确认持有者,避免并发提交互相解锁。 + */ export function createCreationSubmitGate() { - let locked = false; + let owner: symbol | null = null; return { - tryAcquire() { - if (locked) return false; - locked = true; - return true; + /** 取到提交权时返回令牌;已被占用返回 null。 */ + tryAcquire(): symbol | null { + if (owner) return null; + const token = Symbol("creation-submit"); + owner = token; + return token; }, - release() { - locked = false; + /** 只有仍持有该令牌的调用方才能解锁。 */ + release(token: symbol) { + if (owner === token) owner = null; }, }; } diff --git a/web/src/pages/create/index.tsx b/web/src/pages/create/index.tsx index 3de406922..d0d0c8513 100644 --- a/web/src/pages/create/index.tsx +++ b/web/src/pages/create/index.tsx @@ -588,11 +588,14 @@ export default function CreatePage() { const releaseRetryLock = () => { if (retryLockKey) retryPreparingRef.current.delete(retryLockKey); }; - if (!submitGateRef.current.tryAcquire()) { + const submitToken = submitGateRef.current.tryAcquire(); + if (!submitToken) { + // 以前这里是静默返回:上一条还在生成时点发送毫无反应,只能靠猜。 releaseRetryLock(); + toast.warning("上一条还在提交中,稍等片刻再发送"); return; } - const releaseSubmitGate = () => submitGateRef.current.release(); + const releaseSubmitGate = () => submitGateRef.current.release(submitToken); const text = prompt.trim(); if (!text || busy || !activeConversation) { releaseRetryLock(); @@ -680,6 +683,9 @@ export default function CreatePage() { if (abortRef.current === controller) { abortRef.current = null; setBusy(false); + // 任务已经建出来了:提交阶段到此结束。立刻放闸,这样上一条还在生成时 + // 也能继续提交下一条,不必等它跑完。 + releaseSubmitGate(); } }; updateActive((conversation) => ({ diff --git a/web/test/creation-submit-gate.test.ts b/web/test/creation-submit-gate.test.ts index 44e36e5c7..6d09202db 100644 --- a/web/test/creation-submit-gate.test.ts +++ b/web/test/creation-submit-gate.test.ts @@ -5,10 +5,31 @@ import { createCreationSubmitGate } from "../src/pages/create/creation-submit-ga test("creation submit gate rejects a second submit until the first submit releases it", () => { const gate = createCreationSubmitGate(); - expect(gate.tryAcquire()).toBe(true); - expect(gate.tryAcquire()).toBe(false); + const token = gate.tryAcquire(); + expect(token).not.toBeNull(); + expect(gate.tryAcquire()).toBeNull(); - gate.release(); + gate.release(token!); - expect(gate.tryAcquire()).toBe(true); + expect(gate.tryAcquire()).not.toBeNull(); +}); + +test("释放令牌只对持有者生效,并发提交不会互相解锁", () => { + const gate = createCreationSubmitGate(); + + const first = gate.tryAcquire(); + expect(first).not.toBeNull(); + // 第二个提交拿不到锁;它释放自己的“空令牌”不该解开第一个提交。 + const second = gate.tryAcquire(); + expect(second).toBeNull(); + gate.release(second as unknown as symbol); + expect(gate.tryAcquire()).toBeNull(); + + // 持有者释放后才轮得到下一次提交。 + gate.release(first!); + const third = gate.tryAcquire(); + expect(third).not.toBeNull(); + // 已释放过的旧令牌不能解开新持有者的锁。 + gate.release(first!); + expect(gate.tryAcquire()).toBeNull(); });