From 5724f4f74e3ea1d600b8a46f5bc445f912c5e432 Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 13 Sep 2026 10:14:07 +0000 Subject: [PATCH 01/13] docs(ops): advance document revision slot to real UI e2e --- .doc/ops/CURRENT.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.doc/ops/CURRENT.md b/.doc/ops/CURRENT.md index 8772d379..1dc19147 100644 --- a/.doc/ops/CURRENT.md +++ b/.doc/ops/CURRENT.md @@ -24,7 +24,7 @@ | holder | phase | since | scope | queue | |--------|-------|-------|-------|-------| -| Codex/Antigravity | merging | 2026-09-13 10:12 UTC | DOCREV-RT merge + dev rebuild + real UI E2E | - | +| Codex/Antigravity | e2e | 2026-09-13 10:14 UTC | DOCREV-RT real browser UI + real PI E2E | - | **phase**: `idle` | `merging` | `smoke` | `e2e` From fe979fdd21395872f48936fb08f13ab388ae212e Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 13 Sep 2026 10:53:35 +0000 Subject: [PATCH 02/13] fix(web): gate DocumentRevisionToolbar to server-backed docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Skip revision-state for template-only and legacy local mocks; show a non-error preview hint instead of 「账本未就绪」. Keep generated controlled and server freeform on the toolbar; leave IndexedDB upload orphans unmounted. Co-authored-by: Cursor --- web/src/App.tsx | 49 ++++++-- .../revisionToolbarMount.test.ts | 78 +++++++++++++ .../documentRevision/revisionToolbarMount.ts | 107 ++++++++++++++++++ 3 files changed, 227 insertions(+), 7 deletions(-) create mode 100644 web/src/features/documentRevision/revisionToolbarMount.test.ts create mode 100644 web/src/features/documentRevision/revisionToolbarMount.ts diff --git a/web/src/App.tsx b/web/src/App.tsx index 3fb5f2c0..09ddffd5 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -23,6 +23,10 @@ import { listProjectWorkflowDocuments } from './api/projectDocuments'; import { patchDocumentContent, getRevisionState } from './api/documentRevision'; import type { DiffResult, OverlayMode } from './features/documentRevision/types'; import { applyHeadBlockMap, astToClientBlockMap } from './features/documentRevision/overlayMatch'; +import { + documentRevisionInactiveHint, + revisionToolbarMountMode, +} from './features/documentRevision/revisionToolbarMount'; import { createProject, fetchProjectLineage, fetchProjects, getProject } from './api/projectWorkflow'; import { addProjectMember, @@ -3887,13 +3891,44 @@ export default function App() { onExportXlsx={handleExportXlsx} onExportMarkdown={handleExportMarkdown} /> - + {(() => { + const revDoc = + deliverables.find((d) => d.docId === activeDocId) || + visibleDeliverables.find((d) => d.docId === activeDocId); + const mode = revisionToolbarMountMode( + revDoc ?? { docId: activeDocId, isTemplateOnly: true }, + ); + if (mode === 'toolbar' && revDoc) { + return ( + + ); + } + const hint = documentRevisionInactiveHint( + revDoc ?? { docId: activeDocId, isTemplateOnly: true }, + ); + if (!hint) return null; + return ( +
+ {hint} +
+ ); + })()} {docRevConflict && (
{docRevConflict} diff --git a/web/src/features/documentRevision/revisionToolbarMount.test.ts b/web/src/features/documentRevision/revisionToolbarMount.test.ts new file mode 100644 index 00000000..9dbd472d --- /dev/null +++ b/web/src/features/documentRevision/revisionToolbarMount.test.ts @@ -0,0 +1,78 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { + LOCAL_REVISION_PREVIEW_HINT, + TEMPLATE_REVISION_PREVIEW_HINT, + documentRevisionInactiveHint, + revisionToolbarMountMode, + shouldMountDocumentRevisionToolbar, +} from './revisionToolbarMount'; + +describe('revisionToolbarMount', () => { + it('exposes clear non-error template preview hint copy', () => { + assert.equal(TEMPLATE_REVISION_PREVIEW_HINT, '模板预览:首轮编制后启用版本账本'); + }); + + it('template-only slots do not mount toolbar and show template hint', () => { + const doc = { docId: 'doc-p6059-intake', isTemplateOnly: true as const }; + assert.equal(shouldMountDocumentRevisionToolbar(doc), false); + assert.equal(revisionToolbarMountMode(doc), 'template_hint'); + assert.equal(documentRevisionInactiveHint(doc), TEMPLATE_REVISION_PREVIEW_HINT); + }); + + it('legacy local mock docs do not mount toolbar', () => { + for (const docId of ['doc-001', 'doc-002', 'doc-shared-001', 'doc-private-001', 'doc-cusa-intake']) { + assert.equal(shouldMountDocumentRevisionToolbar({ docId, isTemplateOnly: false }), false, docId); + assert.equal(revisionToolbarMountMode({ docId, isTemplateOnly: false }), 'local_hint', docId); + assert.equal( + documentRevisionInactiveHint({ docId, isTemplateOnly: false }), + LOCAL_REVISION_PREVIEW_HINT, + docId, + ); + } + }); + + it('generated controlled doc-p / doc_proj mount toolbar when not template-only', () => { + assert.equal( + shouldMountDocumentRevisionToolbar({ docId: 'doc-p6059-intake', isTemplateOnly: false }), + true, + ); + assert.equal( + shouldMountDocumentRevisionToolbar({ docId: 'doc_proj6059_intake', isTemplateOnly: false }), + true, + ); + assert.equal( + revisionToolbarMountMode({ docId: 'doc_proj56_srs', isTemplateOnly: false }), + 'toolbar', + ); + assert.equal(documentRevisionInactiveHint({ docId: 'doc_proj56_srs', isTemplateOnly: false }), null); + }); + + it('server freeform mounts toolbar; IndexedDB upload orphan does not', () => { + assert.equal( + shouldMountDocumentRevisionToolbar({ + docId: 'doc-free-1710000000000-2', + isTemplateOnly: false, + governanceMode: 'freeform', + }), + true, + ); + assert.equal( + shouldMountDocumentRevisionToolbar({ + docId: 'doc-upload-1789263145197-0', + isTemplateOnly: false, + sourceType: 'uploaded_file', + governanceMode: 'freeform', + }), + false, + ); + assert.equal( + revisionToolbarMountMode({ + docId: 'doc-upload-1789263145197-0', + isTemplateOnly: false, + sourceType: 'uploaded_file', + }), + 'local_hint', + ); + }); +}); diff --git a/web/src/features/documentRevision/revisionToolbarMount.ts b/web/src/features/documentRevision/revisionToolbarMount.ts new file mode 100644 index 00000000..81b82b60 --- /dev/null +++ b/web/src/features/documentRevision/revisionToolbarMount.ts @@ -0,0 +1,107 @@ +/** + * App 是否挂载 DocumentRevisionToolbar(会请求 revision-state)的可测判断。 + * + * 根因:对 isTemplateOnly 新项目模板与 legacy local mock(如 doc-001)无条件挂工具条, + * 导致「版本账本未就绪」假错误。模板预览只显示非错误提示,不发 revision-state。 + * + * UI-16:IndexedDB 孤儿上传(doc-upload-*)不得挂工具条冒充 V1。 + * 受控生成后(isTemplateOnly=false 的 doc-p / doc_proj)与服务端 freeform 正常挂载。 + */ + +import type { DeliverableItem } from '../../types/workbench'; + +export const TEMPLATE_REVISION_PREVIEW_HINT = '模板预览:首轮编制后启用版本账本'; +export const LOCAL_REVISION_PREVIEW_HINT = '本地预览:尚未接入服务端版本账本'; + +export type RevisionMountDoc = Pick & + Partial< + Pick< + DeliverableItem, + 'governanceMode' | 'sourceType' | 'fileMeta' | 'space' | 'isFixedWorkflow' | 'taskKey' + > + >; + +export type RevisionToolbarMountMode = 'toolbar' | 'template_hint' | 'local_hint' | 'none'; + +/** 前端种子/演示交付物 ID(无服务端账本)。 */ +export function isLegacyLocalMockDocId(docId: string): boolean { + const id = (docId || '').trim(); + if (!id) return true; + if (/^doc-\d{3}$/.test(id)) return true; + if (/^doc-shared-\d+$/.test(id)) return true; + if (/^doc-private-\d{1,3}$/.test(id)) return true; + if (id.startsWith('doc-cusa-')) return true; + return false; +} + +/** 仅 IndexedDB、从未服务端注册的上传预览(UI-16)。 */ +export function isIndexedDbUploadOrphanDocId(docId: string): boolean { + return /^doc-upload-/.test((docId || '').trim()); +} + +/** 服务端受控 / freeform 事实源 ID。 */ +export function isServerBackedRevisionDocId(docId: string): boolean { + const id = (docId || '').trim(); + if (!id || isLegacyLocalMockDocId(id) || isIndexedDbUploadOrphanDocId(id)) return false; + if (/^doc_proj\d+_/.test(id)) return true; + if (/^doc-free-\d+/.test(id)) return true; + return false; +} + +/** + * 生成后的受控槽位(isTemplateOnly 已清)可用 doc-p / doc_proj 连字符形态挂载。 + * 模板槽位由 isTemplateOnly 拦截,不会走到这里。 + */ +export function isGeneratedControlledDocId(docId: string): boolean { + const id = (docId || '').trim(); + if (!id || isLegacyLocalMockDocId(id)) return false; + if (/^doc_proj\d+_/.test(id)) return true; + if (/^doc-p\d+-/.test(id)) return true; + if (/^doc-proj\d+-/.test(id)) return true; + return false; +} + +/** 仅真实可接入账本且非模板槽位才挂工具条并发起 revision-state。 */ +export function shouldMountDocumentRevisionToolbar( + doc: RevisionMountDoc | null | undefined, +): boolean { + if (!doc?.docId) return false; + if (doc.isTemplateOnly) return false; + if (isLegacyLocalMockDocId(doc.docId)) return false; + if (isIndexedDbUploadOrphanDocId(doc.docId)) return false; + + if (isServerBackedRevisionDocId(doc.docId)) return true; + if (isGeneratedControlledDocId(doc.docId)) return true; + + // 服务端 freeform:ID 可能非 doc-free 前缀,但带 freeform 事实字段且非本地孤儿 + if (doc.governanceMode === 'freeform' && doc.sourceType !== 'uploaded_file') { + return !isLegacyLocalMockDocId(doc.docId); + } + + return false; +} + +export function revisionToolbarMountMode( + doc: RevisionMountDoc | null | undefined, +): RevisionToolbarMountMode { + if (!doc?.docId) return 'none'; + if (doc.isTemplateOnly) return 'template_hint'; + if (shouldMountDocumentRevisionToolbar(doc)) return 'toolbar'; + if (isLegacyLocalMockDocId(doc.docId) || isIndexedDbUploadOrphanDocId(doc.docId)) { + return 'local_hint'; + } + if (/^doc-p\d+-/.test(doc.docId) || /^doc-proj\d+-/.test(doc.docId)) { + return 'template_hint'; + } + return 'local_hint'; +} + +/** 未挂载工具条时的非错误提示(不得写成账本未就绪)。 */ +export function documentRevisionInactiveHint( + doc: RevisionMountDoc | null | undefined, +): string | null { + const mode = revisionToolbarMountMode(doc); + if (mode === 'toolbar' || mode === 'none') return null; + if (mode === 'template_hint') return TEMPLATE_REVISION_PREVIEW_HINT; + return LOCAL_REVISION_PREVIEW_HINT; +} From bd53984790d97ece1f75aff269a7fcca11e57eeb Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 13 Sep 2026 10:53:35 +0000 Subject: [PATCH 03/13] test(web): rewrite DOCREV UI acceptance without false PASS paths Require observed squad start before terminal status, dual high-risk confirm/reject, computed overlay diffs, real A/B contexts, UI logout, and UI_PHASE post-restart evidence. Sanitize network paths; no mocks. Co-authored-by: Cursor --- .gitignore | 5 + web/e2e/document-revision-runtime.spec.ts | 896 +++++++++++++++++++++- web/playwright.config.ts | 18 +- 3 files changed, 890 insertions(+), 29 deletions(-) diff --git a/.gitignore b/.gitignore index d249f82d..18115fbc 100644 --- a/.gitignore +++ b/.gitignore @@ -59,3 +59,8 @@ node_modules/ # Regulations Knowledge Base Data .regulations/ regulations/ + +# Playwright UI acceptance local secrets / runtime output +.doc/test-reports/assets/**/phase-secrets.local.json +.doc/test-reports/assets/**/playwright-output/ +.doc/test-reports/assets/**/playwright-report/ diff --git a/web/e2e/document-revision-runtime.spec.ts b/web/e2e/document-revision-runtime.spec.ts index ff702f44..3d06d5ff 100644 --- a/web/e2e/document-revision-runtime.spec.ts +++ b/web/e2e/document-revision-runtime.spec.ts @@ -1,40 +1,884 @@ -import { test, expect } from '@playwright/test'; +import { test, expect, type Page, type BrowserContext, type Response } from '@playwright/test'; +import * as fs from 'fs'; +import * as path from 'path'; +import { fileURLToPath } from 'url'; /** - * Document revision runtime — Ubuntu headless UI acceptance skeleton. + * Document revision runtime — real UI acceptance (UI-01..UI-16). * - * Hard rules (see `.doc/ops/checklists/2026-09-13-document-revision-runtime-ui-acceptance.md`): - * - Real browser against BASE_URL (default http://127.0.0.1:8080) - * - NO page.route / request mocking - * - NO API/DB fixtures; create project and drive squad via the page only - * - Requires RUN_UI_ACCEPTANCE=1 and a live pi-agent stack - * - * This file is a skeleton: cases are skipped unless RUN_UI_ACCEPTANCE=1 so - * normal `npm test` / CI unit gates stay green without Playwright browsers. + * Hard rules: + * - Real click/fill/upload only. No page.route / API / DB / mock / stub. + * - Unmet conditions throw (FAIL). Never fallback PASS. + * - Admin UI creates A/B accounts; no seed guest/123456. + * - UI_PHASE=post-restart: only restore checks (host docker restart between runs). */ +const __dirname = path.dirname(fileURLToPath(import.meta.url)); const runAcceptance = process.env.RUN_UI_ACCEPTANCE === '1'; +const uiPhase = (process.env.UI_PHASE || 'main').trim(); // main | post-restart -test.describe('document-revision-runtime UI acceptance', () => { - test.skip(!runAcceptance, 'set RUN_UI_ACCEPTANCE=1 with real env-service.dev + pi-agent'); +const ASSETS = path.resolve( + __dirname, + '../../../.doc/test-reports/assets/2026-09-13-document-revision-runtime-ui', +); +const SCREENSHOTS = path.join(ASSETS, 'screenshots'); +const TRACES = path.join(ASSETS, 'traces'); +const VIDEOS = path.join(ASSETS, 'videos'); +const RESULTS_FILE = path.join(ASSETS, 'ui-acceptance-results.json'); +const PHASE_STATE_FILE = path.join(ASSETS, 'phase-state.json'); +/** Local-only secrets for post-restart login — never copied into results JSON. */ +const PHASE_SECRETS_FILE = path.join(ASSETS, 'phase-secrets.local.json'); - test('UI-01 login and open/create project workbench', async ({ page }) => { - await page.goto('/'); - // Page-visible workbench chrome (selectors refined during real acceptance run). - await expect(page.locator('body')).toBeVisible(); - // TODO(acceptance): fill login → create/enter project → assert three-pane workbench. +for (const d of [SCREENSHOTS, TRACES, VIDEOS]) { + fs.mkdirSync(d, { recursive: true }); +} + +type NetLog = { method: string; path: string; status: number }; +type CaseResult = { + id: string; + name: string; + input: string; + actual: string; + networkLogs: NetLog[]; + durationMs: number; + verdict: 'PASS' | 'FAIL'; + notes?: string; +}; + +const recorded: Record = {}; + +function fail(msg: string): never { + throw new Error(msg); +} + +/** Strip credentials and query strings from evidence URLs. */ +function sanitizeUrl(raw: string): string { + try { + const u = new URL(raw, 'http://127.0.0.1'); + return u.pathname; + } catch { + return raw.split('?')[0]?.replace(/https?:\/\/[^/]+/i, '') || raw; + } +} + +function pushNet(logs: NetLog[], res: Response) { + const pathOnly = sanitizeUrl(res.url()); + if (!pathOnly.includes('/api/')) return; + logs.push({ method: res.request().method(), path: pathOnly, status: res.status() }); +} + +function saveResults() { + const scrubbed = JSON.parse(JSON.stringify(recorded)) as Record; + const blob = JSON.stringify(scrubbed, null, 2); + if (/admin-secret|Bearer\s|password|token=/i.test(blob)) { + fail('results must not contain credentials or tokens'); + } + fs.writeFileSync(RESULTS_FILE, blob, 'utf-8'); +} + +function recordPass(r: CaseResult) { + recorded[r.id] = { ...r, verdict: 'PASS' }; + saveResults(); +} + +async function shot(page: Page, name: string) { + await page.screenshot({ path: path.join(SCREENSHOTS, name), fullPage: true }); +} + +type PhaseState = { + projectName: string; + projectIdHint?: string; + controlledDocTitle?: string; + controlledDocId?: string; + versionText?: string; + revisionText?: string; + bodySnippet?: string; + userA: string; + userB: string; + userC: string; +}; + +type PhaseSecrets = { + passA: string; + passB: string; + passC: string; +}; + +function writePhaseState(s: PhaseState) { + fs.writeFileSync(PHASE_STATE_FILE, JSON.stringify(s, null, 2), 'utf-8'); +} + +function writePhaseSecrets(s: PhaseSecrets) { + fs.writeFileSync(PHASE_SECRETS_FILE, JSON.stringify(s, null, 2), 'utf-8'); +} + +function readPhaseState(): PhaseState { + if (!fs.existsSync(PHASE_STATE_FILE)) { + fail(`missing phase-state.json for UI_PHASE=${uiPhase}; run main phase first`); + } + return JSON.parse(fs.readFileSync(PHASE_STATE_FILE, 'utf-8')) as PhaseState; +} + +function readPhaseSecrets(): PhaseSecrets { + if (process.env.UI_E2E_PASS_A) { + return { + passA: process.env.UI_E2E_PASS_A, + passB: process.env.UI_E2E_PASS_B || '', + passC: process.env.UI_E2E_PASS_C || '', + }; + } + if (!fs.existsSync(PHASE_SECRETS_FILE)) { + fail('missing phase-secrets.local.json (or UI_E2E_PASS_A) for post-restart login'); + } + return JSON.parse(fs.readFileSync(PHASE_SECRETS_FILE, 'utf-8')) as PhaseSecrets; +} + +const ADMIN_USER = process.env.ADMIN_USERNAME || 'admin'; +const ADMIN_PASS = process.env.ADMIN_PASSWORD || 'admin-secret-password-123'; +const suffix = `${Date.now().toString(36)}`; +const USER_A = `uia_${suffix}`; +const USER_B = `uib_${suffix}`; +const USER_C = `uic_${suffix}`; +const INIT_PASS = `Init-${suffix}9`; +const FINAL_PASS_A = `PassA-${suffix}9`; +const FINAL_PASS_B = `PassB-${suffix}9`; +const FINAL_PASS_C = `PassC-${suffix}9`; +const PROJECT_NAME = `UI验收-超声微创系统-${suffix}`; + +async function login(page: Page, username: string, password: string) { + await page.goto('/'); + await page.fill('input[placeholder*="系统用户名"]', username); + await page.fill('input[placeholder*="登录密码"]', password); + await page.click('button:has-text("进入医疗协同工作台")'); + // First-login force change may appear for admin-created users. + const force = page.getByPlaceholder('请输入初始密码'); + if (await force.isVisible({ timeout: 2500 }).catch(() => false)) { + fail(`account ${username} still requires first-login password change — call changePasswordFirstLogin`); + } + await expect(page.getByText('新建立项会话')).toBeVisible({ timeout: 30_000 }); +} + +async function changePasswordFirstLogin(page: Page, oldPass: string, newPass: string) { + await expect(page.getByPlaceholder('请输入初始密码')).toBeVisible({ timeout: 15_000 }); + await page.fill('input[placeholder="请输入初始密码"]', oldPass); + await page.fill('input[placeholder*="至少 6 位"]', newPass); + await page.fill('input[placeholder="再次输入新密码"]', newPass); + await page.getByRole('button', { name: /确认重置并进入系统/ }).click(); + await expect(page.getByText('新建立项会话')).toBeVisible({ timeout: 30_000 }); +} + +async function logoutViaUI(page: Page) { + await page.locator('button[title="个人资料与系统偏好设置"]').click({ timeout: 15_000 }); + const logout = page.getByRole('button', { name: '退出当前登录' }); + await expect(logout).toBeVisible({ timeout: 10_000 }); + await logout.click(); + await expect(page.getByPlaceholder(/系统用户名/)).toBeVisible({ timeout: 20_000 }); +} + +async function openAdminConsole(page: Page) { + await page.locator('button[title*="系统管理与准入治理"]').click(); + await expect(page.getByText('开通受控员工账号')).toBeVisible({ timeout: 15_000 }); +} + +async function adminCreateUser(page: Page, username: string, realName: string, initPass: string) { + await page.getByText('开通受控员工账号').click(); + const form = page.locator('form').filter({ hasText: '用户名 (系统账号)' }); + await expect(form).toBeVisible({ timeout: 10_000 }); + await form.locator('input[placeholder="如 li_si"]').fill(username); + await form.locator('input[placeholder="如 李四"]').fill(realName); + // 临时初始密码:label sibling input + const passBox = form.locator('xpath=.//label[contains(.,"临时初始密码")]/following::input[1]'); + if ((await passBox.count()) === 0) fail('admin create-user missing initial password field'); + await passBox.fill(initPass); + await form.getByRole('button', { name: '确认开通账号' }).click(); + await expect(page.getByText(new RegExp(`已成功开通|${realName}`))).toBeVisible({ timeout: 15_000 }); +} + +/** + * Squad wait: MUST observe start (user bubble + running/thinking) before terminal. + * failed status → FAIL. + */ +async function waitForSquadCompletion(page: Page, userPromptSnippet: string, timeoutMs = 420_000) { + const startedAt = Date.now(); + const userBubble = page.locator('div, article, li').filter({ hasText: userPromptSnippet }).first(); + await expect(userBubble).toBeVisible({ timeout: 30_000 }); + + const running = page + .locator('text=正在思考') + .or(page.locator('text=生成中')) + .or(page.locator('.agent-pulse-active')) + .or(page.locator('text=执行中')) + .or(page.locator('text=编排中')); + await expect(running.first()).toBeVisible({ timeout: 60_000 }); + + const terminal = page + .locator('text=已完成') + .or(page.locator('text=待编制人确认')) + .or(page.locator('text=失败')) + .or(page.locator('[data-status="completed"]')) + .or(page.locator('[data-status="pending_author_confirmation"]')) + .or(page.locator('[data-status="failed"]')); + + await expect(terminal.first()).toBeVisible({ timeout: timeoutMs }); + + if (await page.locator('text=失败').or(page.locator('[data-status="failed"]')).first().isVisible().catch(() => false)) { + fail('squad terminal status is failed'); + } + // Ensure running indicator has cleared. + await expect(page.locator('text=正在思考,请稍候...')).toBeHidden({ timeout: 60_000 }).catch(() => undefined); + return Date.now() - startedAt; +} + +async function sendChat(page: Page, text: string) { + const box = page.getByPlaceholder(/向项目助手提问/); + await expect(box).toBeVisible({ timeout: 15_000 }); + await box.fill(text); + await page.getByRole('button', { name: /发送/ }).click(); +} + +async function assertNoRevisionState404(logs: NetLog[], projectDocPrefix: string) { + const hits = logs.filter((l) => l.path.includes('/revision-state')); + const bad404 = hits.filter((l) => l.status === 404); + if (bad404.length) { + fail(`revision-state 404 is FAIL: ${JSON.stringify(bad404)}`); + } + const templateHits = hits.filter((l) => l.path.includes(projectDocPrefix) || /doc-p\d+-/.test(l.path)); + if (templateHits.length) { + fail(`template/local doc must not request revision-state: ${JSON.stringify(templateHits)}`); + } +} + +async function readVersionBadge(page: Page): Promise<{ v: string; r: string }> { + const v = (await page.locator('[data-testid="doc-rev-version"]').textContent())?.trim(); + const r = (await page.locator('[data-testid="doc-rev-revision"]').textContent())?.trim(); + if (!v || !r) fail('version/revision badge missing — document must be server-backed with ledger'); + return { v, r }; +} + +async function captureOverlayFingerprint(page: Page, mode: 'pure' | 'manual' | 'all') { + await page.locator('select[aria-label="变更显示模式"]').selectOption(mode); + await page.waitForTimeout(400); + const marked = page.locator('[data-overlay-kind]'); + const target = + mode === 'pure' + ? page.locator('[data-overlay-kind], [class*="block"]').first() + : marked.first(); + if ((await target.count()) === 0) { + fail(`UI overlay mode=${mode}: no block to sample computed style`); + } + return target.evaluate((el) => { + const s = window.getComputedStyle(el as HTMLElement); + return { + backgroundColor: s.backgroundColor, + boxShadow: s.boxShadow, + className: (el as HTMLElement).className || '', + overlayKind: (el as HTMLElement).getAttribute('data-overlay-kind') || '', + }; }); +} - test('UI-05a/b high-risk pending confirm/reject via toolbar', async ({ page }) => { - await page.goto('/'); - await expect(page.locator('body')).toBeVisible(); - // TODO(acceptance): after real squad high-risk pending — - // assert 「待确认报告」 visible; confirm advances version; reject keeps head. - }); +// ─── post-restart phase (host restarted docker between runs) ─── +test.describe('document-revision-runtime UI @post-restart', () => { + test.skip(!runAcceptance || uiPhase !== 'post-restart', 'UI_PHASE=post-restart only'); - test('UI-06 save increments revision then survives hard refresh', async ({ page }) => { + test('UI-15 restore after container restart (page login only)', async ({ page }) => { + const t0 = Date.now(); + const logs: NetLog[] = []; + page.on('response', (res) => pushNet(logs, res)); + const st = readPhaseState(); + const secrets = readPhaseSecrets(); + await login(page, st.userA, secrets.passA); + // Locate project by visible name. + await page.getByText(st.projectName, { exact: false }).first().click(); + await expect(page.getByText(st.projectName)).toBeVisible({ timeout: 20_000 }); + if (st.controlledDocTitle) { + await page.getByText(st.controlledDocTitle, { exact: false }).first().click(); + } + await expect(page.locator('[data-testid="doc-rev-version"]')).toBeVisible({ timeout: 30_000 }); + const { v, r } = await readVersionBadge(page); + if (st.versionText && v !== st.versionText) fail(`version mismatch after restart: ${v} vs ${st.versionText}`); + if (st.revisionText && r !== st.revisionText) fail(`revision mismatch after restart: ${r} vs ${st.revisionText}`); + if (st.bodySnippet) { + await expect(page.getByText(st.bodySnippet, { exact: false })).toBeVisible({ timeout: 15_000 }); + } + await shot(page, 'UI-15-post-restart.png'); + recordPass({ + id: 'UI-15', + name: 'post-restart restore', + input: `UI login as A; open ${st.projectName}`, + actual: `version=${v} revision=${r}`, + networkLogs: logs, + durationMs: Date.now() - t0, + verdict: 'PASS', + }); + }); +}); + +// ─── main phase ─── +test.describe.serial('document-revision-runtime UI acceptance', () => { + test.skip(!runAcceptance || uiPhase === 'post-restart', 'set RUN_UI_ACCEPTANCE=1 UI_PHASE=main'); + + let context: BrowserContext; + let page: Page; + let logs: NetLog[] = []; + let controlledTitle = ''; + let versionBeforePending = ''; + let secondDocTitle = ''; + + test.beforeAll(async ({ browser }) => { + context = await browser.newContext(); + page = await context.newPage(); + page.on('response', (res) => pushNet(logs, res)); + }); + + test.afterAll(async () => { + saveResults(); + await context?.close(); + }); + + test('UI-00 bootstrap: admin creates A/B/C via page', async () => { + const t0 = Date.now(); + logs = []; await page.goto('/'); - await expect(page.locator('body')).toBeVisible(); - // TODO(acceptance): edit block → save → r increments → reload → body persists. + await page.fill('input[placeholder*="系统用户名"]', ADMIN_USER); + await page.fill('input[placeholder*="登录密码"]', ADMIN_PASS); + await page.click('button:has-text("进入医疗协同工作台")'); + await expect(page.getByText('新建立项会话')).toBeVisible({ timeout: 30_000 }); + await openAdminConsole(page); + await adminCreateUser(page, USER_A, `编制人A-${suffix}`, INIT_PASS); + await adminCreateUser(page, USER_B, `协作者B-${suffix}`, INIT_PASS); + await adminCreateUser(page, USER_C, `无关人C-${suffix}`, INIT_PASS); + // Close admin modal + await page.keyboard.press('Escape'); + await logoutViaUI(page); + + // A first login + password change + await page.fill('input[placeholder*="系统用户名"]', USER_A); + await page.fill('input[placeholder*="登录密码"]', INIT_PASS); + await page.click('button:has-text("进入医疗协同工作台")'); + await changePasswordFirstLogin(page, INIT_PASS, FINAL_PASS_A); + await logoutViaUI(page); + + await page.fill('input[placeholder*="系统用户名"]', USER_B); + await page.fill('input[placeholder*="登录密码"]', INIT_PASS); + await page.click('button:has-text("进入医疗协同工作台")'); + await changePasswordFirstLogin(page, INIT_PASS, FINAL_PASS_B); + await logoutViaUI(page); + + await page.fill('input[placeholder*="系统用户名"]', USER_C); + await page.fill('input[placeholder*="登录密码"]', INIT_PASS); + await page.click('button:has-text("进入医疗协同工作台")'); + await changePasswordFirstLogin(page, INIT_PASS, FINAL_PASS_C); + await logoutViaUI(page); + + writePhaseSecrets({ passA: FINAL_PASS_A, passB: FINAL_PASS_B, passC: FINAL_PASS_C }); + writePhaseState({ + projectName: PROJECT_NAME, + userA: USER_A, + userB: USER_B, + userC: USER_C, + }); + + recordPass({ + id: 'UI-00', + name: 'admin creates A/B/C', + input: 'admin UI create users + first-login password change', + actual: `users ${USER_A},${USER_B},${USER_C} ready`, + networkLogs: logs.filter((l) => !/password/i.test(l.path)), + durationMs: Date.now() - t0, + verdict: 'PASS', + }); + }); + + test('UI-01 login A → page create project → template hint, no revision-state for doc-p', async () => { + const t0 = Date.now(); + logs = []; + await login(page, USER_A, FINAL_PASS_A); + await page.getByRole('button', { name: '新建立项会话' }).click(); + await expect(page.getByText('发起医疗器械新项目立项')).toBeVisible(); + await page.fill('input[placeholder*="无线便携式"]', PROJECT_NAME); + await page.fill('textarea, input[placeholder*="临床"]', '用于骨科微创手术的双频超声吸引与止血,需符合 GB 9706.1 与相关法规要求。').catch(async () => { + const desc = page.locator('textarea').first(); + if ((await desc.count()) === 0) fail('project form missing description field'); + await desc.fill('用于骨科微创手术的双频超声吸引与止血,需符合 GB 9706.1 与相关法规要求。'); + }); + // Select B as member + const memberRow = page.locator('div').filter({ hasText: `协作者B-${suffix}` }).first(); + if ((await memberRow.count()) === 0) fail('member B not listed in create-project form'); + await memberRow.click(); + await page.getByRole('button', { name: /确认立项/ }).click(); + await expect(page.getByText(PROJECT_NAME)).toBeVisible({ timeout: 30_000 }); + await expect(page.locator('aside')).toBeVisible(); + // Template doc selected: must show non-error hint, never 账本未就绪 + await expect(page.getByTestId('doc-rev-template-hint')).toBeVisible({ timeout: 15_000 }); + await expect(page.getByTestId('doc-rev-template-hint')).toContainText('模板预览'); + await expect(page.locator('text=版本账本未就绪')).toHaveCount(0); + await shot(page, '01-project.png'); + + const revHits = logs.filter((l) => l.path.includes('/revision-state')); + const docPHits = revHits.filter((l) => /doc-p\d+-/.test(l.path)); + if (docPHits.length !== 0) { + fail(`new project template must issue 0 revision-state for doc-p*: got ${JSON.stringify(docPHits)}`); + } + assertNoRevisionState404(logs, 'doc-p'); + + writePhaseState({ + projectName: PROJECT_NAME, + userA: USER_A, + userB: USER_B, + userC: USER_C, + }); + + recordPass({ + id: 'UI-01', + name: 'create project workbench', + input: `login A; create project ${PROJECT_NAME}; assign B`, + actual: 'template hint visible; doc-p revision-state count=0; no 404', + networkLogs: logs, + durationMs: Date.now() - t0, + verdict: 'PASS', + }); + }); + + test('UI-02 send real squad task', async () => { + const t0 = Date.now(); + logs = []; + const prompt = + '请基于本项目编制首轮《用户需求》摸底文档,覆盖预期用途、关键性能与适用法规框架,直接生成受控交付物。'; + await sendChat(page, prompt); + await expect(page.getByText(prompt.slice(0, 12))).toBeVisible({ timeout: 20_000 }); + const running = page.locator('text=正在思考').or(page.locator('text=生成中')); + await expect(running.first()).toBeVisible({ timeout: 60_000 }); + await shot(page, 'UI-02-squad-started.png'); + recordPass({ + id: 'UI-02', + name: 'start real squad', + input: prompt, + actual: 'running/thinking visible after user bubble', + networkLogs: logs, + durationMs: Date.now() - t0, + verdict: 'PASS', + }); + }); + + test('UI-03 wait first round; open controlled doc with V/r', async () => { + const t0 = Date.now(); + const promptSlice = '请基于本项目编制首轮'; + await waitForSquadCompletion(page, promptSlice); + // Open a workflow doc tab that is no longer template-only (server doc_proj*). + const tab = page.locator('[role="tab"], button, div').filter({ hasText: /需求|摸底|URS|用户/ }).first(); + if ((await tab.count()) === 0) fail('no controlled deliverable tab after first squad round'); + await tab.click(); + await expect(page.locator('[data-testid="doc-rev-version"]')).toBeVisible({ timeout: 60_000 }); + await expect(page.getByTestId('doc-rev-template-hint')).toHaveCount(0); + await expect(page.locator('text=版本账本未就绪')).toHaveCount(0); + const { v, r } = await readVersionBadge(page); + controlledTitle = (await tab.textContent())?.trim() || 'controlled'; + await shot(page, '03-first-doc.png'); + const st = readPhaseState(); + writePhaseState({ + ...st, + controlledDocTitle: controlledTitle, + versionText: v, + revisionText: r, + }); + recordPass({ + id: 'UI-03', + name: 'first controlled doc ledger', + input: 'wait squad; open deliverable tab', + actual: `toolbar ${v}/${r}`, + networkLogs: logs, + durationMs: Date.now() - t0, + verdict: 'PASS', + }); + }); + + test('UI-04 open change report with full fields', async () => { + const t0 = Date.now(); + logs = []; + const reportBtn = page.getByRole('button', { name: /变更报告|待确认报告/ }); + await expect(reportBtn).toBeEnabled({ timeout: 15_000 }); + await reportBtn.click(); + const panel = page.locator('text=风险').or(page.locator('text=开放问题')).or(page.locator('text=项目影响')); + await expect(panel.first()).toBeVisible({ timeout: 20_000 }); + // Report list / body must be non-empty + const bodyText = await page.locator('body').innerText(); + if (!/风险|开放问题|摘要|影响/.test(bodyText)) fail('change report panel missing required fields'); + await shot(page, '04-first-report.png'); + // Close if possible + await page.keyboard.press('Escape').catch(() => undefined); + recordPass({ + id: 'UI-04', + name: 'change report fields', + input: 'click 变更报告', + actual: 'risk/impact/open questions visible', + networkLogs: logs, + durationMs: Date.now() - t0, + verdict: 'PASS', + }); + }); + + test('UI-05 high-risk candidate #1 pending (version must not advance)', async () => { + const t0 = Date.now(); + logs = []; + const { v: v0 } = await readVersionBadge(page); + versionBeforePending = v0; + const prompt = + '请按 YY/T、GB 9706.1 与 NMPA 注册法规要求,大幅改写当前受控文档中的风险管理与电气安全章节,引用具体条款并重写适用性结论(二轮高风险候选-确认用例)。'; + await sendChat(page, prompt); + await waitForSquadCompletion(page, '二轮高风险候选-确认用例'); + await expect(page.getByTestId('doc-rev-pending-badge')).toBeVisible({ timeout: 60_000 }); + await expect(page.getByRole('button', { name: '待确认报告' })).toBeVisible(); + const { v: vPending } = await readVersionBadge(page); + if (vPending !== v0) fail(`pending must not advance version: was ${v0} now ${vPending}`); + await shot(page, '05-second-report.png'); + recordPass({ + id: 'UI-05', + name: 'high-risk pending #1', + input: prompt, + actual: `pending badge; version stayed ${vPending}`, + networkLogs: logs, + durationMs: Date.now() - t0, + verdict: 'PASS', + }); + }); + + test('UI-05a confirm high-risk candidate → version advances', async () => { + const t0 = Date.now(); + logs = []; + const confirm = page.getByRole('button', { name: /确认提交/ }); + if ((await confirm.count()) === 0) fail('confirm button missing for pending high-risk candidate'); + const decisionWait = page.waitForResponse( + (r) => r.url().includes('/decision') && r.request().method() === 'POST', + { timeout: 60_000 }, + ); + await confirm.click(); + const decRes = await decisionWait; + if (decRes.status() >= 400) fail(`decision confirm HTTP ${decRes.status()}`); + const req = decRes.request().postDataJSON() as { confirmed?: boolean; decision?: string }; + if (req.decision !== 'commit' || req.confirmed !== true) { + fail(`decision body must be commit+confirmed: ${JSON.stringify(req)}`); + } + await expect(page.getByTestId('doc-rev-pending-badge')).toHaveCount(0, { timeout: 30_000 }); + const { v } = await readVersionBadge(page); + if (v === versionBeforePending) fail(`version must advance after confirm: still ${v}`); + await shot(page, 'UI-05a-confirmed.png'); + recordPass({ + id: 'UI-05a', + name: 'confirm pending', + input: 'click 确认提交', + actual: `decision ok; version ${v}`, + networkLogs: logs, + durationMs: Date.now() - t0, + verdict: 'PASS', + }); + }); + + test('UI-05b second high-risk candidate → reject; version stays', async () => { + const t0 = Date.now(); + logs = []; + const { v: vBefore } = await readVersionBadge(page); + const prompt = + '请再次按医疗器械临床试验与注册检验法规,重写电磁兼容与软件生存周期章节并给出条款级符合性判断(二轮高风险候选-拒绝用例)。'; + await sendChat(page, prompt); + await waitForSquadCompletion(page, '二轮高风险候选-拒绝用例'); + await expect(page.getByTestId('doc-rev-pending-badge')).toBeVisible({ timeout: 60_000 }); + const { v: vPend } = await readVersionBadge(page); + if (vPend !== vBefore) fail(`second pending must not advance version (${vBefore}→${vPend})`); + const reject = page.getByRole('button', { name: /拒绝/ }); + if ((await reject.count()) === 0) fail('reject button missing'); + const decisionWait = page.waitForResponse( + (r) => r.url().includes('/decision') && r.request().method() === 'POST', + { timeout: 60_000 }, + ); + await reject.click(); + const decRes = await decisionWait; + if (decRes.status() >= 400) fail(`decision reject HTTP ${decRes.status()}`); + const req = decRes.request().postDataJSON() as { decision?: string }; + if (req.decision !== 'reject') fail(`expected reject decision, got ${JSON.stringify(req)}`); + await expect(page.getByTestId('doc-rev-pending-badge')).toHaveCount(0, { timeout: 30_000 }); + const { v: vAfter } = await readVersionBadge(page); + if (vAfter !== vBefore) fail(`reject must not advance version (${vBefore}→${vAfter})`); + await shot(page, 'UI-05b-rejected.png'); + recordPass({ + id: 'UI-05b', + name: 'reject pending', + input: prompt, + actual: `rejected; version stayed ${vAfter}`, + networkLogs: logs, + durationMs: Date.now() - t0, + verdict: 'PASS', + }); + }); + + test('UI-06 edit block → save → hard refresh persists r bump', async () => { + const t0 = Date.now(); + logs = []; + const { r: r0 } = await readVersionBadge(page); + const block = page.locator('[data-overlay-kind], p, h1, h2').first(); + await block.dblclick({ timeout: 15_000 }); + const editor = page.locator('textarea, [contenteditable="true"]').last(); + await expect(editor).toBeVisible({ timeout: 10_000 }); + const marker = `UI06-${suffix}`; + await editor.fill(`人工修订段落 ${marker} 用于硬刷新验收。`); + await page.getByRole('button', { name: /保存/ }).click(); + await expect(page.locator('[data-testid="doc-rev-revision"]')).not.toHaveText(r0, { timeout: 30_000 }); + const { r: r1 } = await readVersionBadge(page); + await page.reload(); + await expect(page.getByText(marker)).toBeVisible({ timeout: 30_000 }); + await expect(page.locator('[data-testid="doc-rev-revision"]')).toContainText(r1.replace(/^r/, ''), { + timeout: 30_000, + }); + await shot(page, 'UI-06-after-refresh.png'); + const st = readPhaseState(); + writePhaseState({ ...st, bodySnippet: marker, revisionText: r1 }); + recordPass({ + id: 'UI-06', + name: 'save + hard refresh', + input: 'dblclick edit save reload', + actual: `r ${r0}→${r1}; marker persisted`, + networkLogs: logs, + durationMs: Date.now() - t0, + verdict: 'PASS', + }); + }); + + test('UI-07/08/09 overlay modes differ in computed styles', async () => { + const t0 = Date.now(); + logs = []; + const pure = await captureOverlayFingerprint(page, 'pure'); + await shot(page, 'UI-07-pure.png'); + const manual = await captureOverlayFingerprint(page, 'manual'); + await shot(page, 'UI-08-manual.png'); + const all = await captureOverlayFingerprint(page, 'all'); + await shot(page, 'UI-09-all.png'); + const key = (x: { backgroundColor: string; boxShadow: string; className: string }) => + `${x.backgroundColor}|${x.boxShadow}|${x.className}`; + if (key(pure) === key(manual) && key(manual) === key(all)) { + fail(`overlay modes not visually distinct: pure=${JSON.stringify(pure)} manual=${JSON.stringify(manual)} all=${JSON.stringify(all)}`); + } + if (key(pure) === key(manual) || key(pure) === key(all) || key(manual) === key(all)) { + // Require pairwise difference across the three captures. + fail(`each overlay mode must differ: pure=${key(pure)} manual=${key(manual)} all=${key(all)}`); + } + recordPass({ + id: 'UI-07', + name: 'overlay pure/manual/all', + input: 'switch three overlay modes; sample computed style', + actual: `pure≠manual≠all`, + networkLogs: logs, + durationMs: Date.now() - t0, + verdict: 'PASS', + notes: JSON.stringify({ pure, manual, all }), + }); + recorded['UI-08'] = { ...recorded['UI-07'], id: 'UI-08', name: 'overlay manual' }; + recorded['UI-09'] = { ...recorded['UI-07'], id: 'UI-09', name: 'overlay all' }; + saveResults(); + }); + + test('UI-10 version history + compare', async () => { + const t0 = Date.now(); + logs = []; + await page.getByRole('button', { name: '版本历史' }).click(); + const list = page.locator('text=/V\\d+/'); + await expect(list.first()).toBeVisible({ timeout: 15_000 }); + const n = await list.count(); + if (n < 1) fail('version history list empty'); + await page.getByRole('button', { name: '比较' }).click(); + await shot(page, 'UI-10-history-compare.png'); + recordPass({ + id: 'UI-10', + name: 'history + compare', + input: 'open 版本历史 / 比较', + actual: `history entries=${n}`, + networkLogs: logs, + durationMs: Date.now() - t0, + verdict: 'PASS', + }); + }); + + test('UI-11 chat second controlled doc + page upload attachment', async () => { + const t0 = Date.now(); + logs = []; + const prompt = + '请另行生成一份《系统设计输入》受控文档(第二份),不要覆盖上一份需求文档,并给出可打开的交付物。'; + await sendChat(page, prompt); + await waitForSquadCompletion(page, '系统设计输入'); + const secondTab = page.locator('[role="tab"], button, div').filter({ hasText: /设计输入|系统设计/ }).first(); + if ((await secondTab.count()) === 0) fail('second controlled document tab not found after chat'); + await secondTab.click(); + secondDocTitle = (await secondTab.textContent())?.trim() || ''; + await expect(page.locator('[data-testid="doc-rev-version"]')).toBeVisible({ timeout: 45_000 }); + const states: string[] = []; + for (const label of [controlledTitle, secondDocTitle]) { + if (!label) continue; + await page.getByText(label, { exact: false }).first().click(); + const { v, r } = await readVersionBadge(page); + states.push(`${label}:${v}/${r}`); + } + // Page upload attachment + const fileInput = page.locator('input[type="file"]'); + if ((await fileInput.count()) === 0) fail('no file input for page upload'); + const tmp = path.join(ASSETS, `upload-${suffix}.txt`); + fs.writeFileSync(tmp, `attachment ${suffix}\n`, 'utf-8'); + await fileInput.first().setInputFiles(tmp); + await expect(page.getByText(`upload-${suffix}`).or(page.getByText('attachment'))).toBeVisible({ + timeout: 30_000, + }); + await shot(page, 'UI-11-multi-doc.png'); + recordPass({ + id: 'UI-11', + name: 'multi doc + upload', + input: 'chat second doc; upload file', + actual: states.join('; '), + networkLogs: logs, + durationMs: Date.now() - t0, + verdict: 'PASS', + }); + }); + + test('UI-12 user C (not a member) must not see project body', async () => { + const t0 = Date.now(); + const guestLogs: NetLog[] = []; + const ctx = await page.context().browser()!.newContext(); + const gp = await ctx.newPage(); + gp.on('response', (res) => pushNet(guestLogs, res)); + await login(gp, USER_C, FINAL_PASS_C); + const leaked = await gp.getByText(PROJECT_NAME, { exact: false }).isVisible().catch(() => false); + if (leaked) { + await gp.getByText(PROJECT_NAME, { exact: false }).first().click(); + const bodyLeak = await gp.getByText(/人工修订段落|UI06-/).isVisible().catch(() => false); + if (bodyLeak) fail('unauthorized user C can read controlled body'); + } + await shot(gp, '12-unauthorized-access.png'); + await ctx.close(); + recordPass({ + id: 'UI-12', + name: 'unauthorized isolation', + input: `login ${USER_C} (not member)`, + actual: leaked ? 'project listed but body blocked' : 'project not visible', + networkLogs: guestLogs, + durationMs: Date.now() - t0, + verdict: 'PASS', + }); + }); + + test('UI-13 A/B dual context conflict on same revision', async () => { + const t0 = Date.now(); + logs = []; + // Ensure A is on controlled doc + await page.getByText(controlledTitle || PROJECT_NAME, { exact: false }).first().click(); + await expect(page.locator('[data-testid="doc-rev-version"]')).toBeVisible({ timeout: 20_000 }); + + const ctxB = await page.context().browser()!.newContext(); + const pageB = await ctxB.newPage(); + await login(pageB, USER_B, FINAL_PASS_B); + await pageB.getByText(PROJECT_NAME, { exact: false }).first().click(); + await pageB.getByText(controlledTitle || /需求/, { exact: false }).first().click(); + await expect(pageB.locator('[data-testid="doc-rev-version"]')).toBeVisible({ timeout: 30_000 }); + + // Both open same old revision — A saves first + const blockA = page.locator('p, h2, [data-overlay-kind]').first(); + await blockA.dblclick(); + await page.locator('textarea, [contenteditable="true"]').last().fill(`A-win-${suffix}`); + await page.getByRole('button', { name: /保存/ }).click(); + await page.waitForTimeout(1000); + + const blockB = pageB.locator('p, h2, [data-overlay-kind]').first(); + await blockB.dblclick(); + await pageB.locator('textarea, [contenteditable="true"]').last().fill(`B-conflict-${suffix}`); + await pageB.getByRole('button', { name: /保存/ }).click(); + const conflict = pageB.getByText(/冲突|刷新后重做|版本冲突/); + await expect(conflict.first()).toBeVisible({ timeout: 30_000 }); + await shot(pageB, 'UI-13-conflict-B.png'); + await ctxB.close(); + recordPass({ + id: 'UI-13', + name: 'A/B concurrent edit conflict', + input: 'A save then B save on same base revision', + actual: 'B sees conflict / 刷新后重做', + networkLogs: logs, + durationMs: Date.now() - t0, + verdict: 'PASS', + }); + }); + + test('UI-14 logout via UI then login again', async () => { + const t0 = Date.now(); + logs = []; + const { v, r } = await readVersionBadge(page); + await logoutViaUI(page); + await login(page, USER_A, FINAL_PASS_A); + await page.getByText(PROJECT_NAME, { exact: false }).first().click(); + await page.getByText(controlledTitle || /需求/, { exact: false }).first().click(); + await expect(page.locator('[data-testid="doc-rev-version"]')).toBeVisible({ timeout: 30_000 }); + const again = await readVersionBadge(page); + if (again.v !== v) fail(`version changed across re-login unexpectedly ${v}→${again.v}`); + await shot(page, 'UI-14-relogin.png'); + recordPass({ + id: 'UI-14', + name: 'UI logout + login', + input: 'click 退出当前登录; login A', + actual: `ledger ${again.v}/${again.r}`, + networkLogs: logs, + durationMs: Date.now() - t0, + verdict: 'PASS', + }); + }); + + test('UI-15 phase-main: write evidence then exit (host restarts docker before post-restart run)', async () => { + const t0 = Date.now(); + logs = []; + const { v, r } = await readVersionBadge(page); + const st = readPhaseState(); + writePhaseState({ + ...st, + projectName: PROJECT_NAME, + controlledDocTitle: controlledTitle, + versionText: v, + revisionText: r, + userA: USER_A, + userB: USER_B, + userC: USER_C, + }); + await shot(page, 'UI-15-pre-restart-evidence.png'); + // Do NOT mark full UI-15 PASS here — only evidence for post-restart phase. + recorded['UI-15-pre'] = { + id: 'UI-15-pre', + name: 'pre-restart evidence written', + input: 'capture project/state; exit suite', + actual: `state file written version=${v} revision=${r}`, + networkLogs: logs, + durationMs: Date.now() - t0, + verdict: 'PASS', + notes: 'Full UI-15 PASS only after UI_PHASE=post-restart run', + }; + saveResults(); + }); + + test('UI-16 local upload orphan must not fake V1 ledger', async () => { + const t0 = Date.now(); + logs = []; + // Switch to uploaded private tab if present + const uploadTab = page.getByText(`upload-${suffix}`).or(page.getByText(/attachment/)); + if ((await uploadTab.count()) === 0) fail('upload orphan tab missing (UI-11 must have uploaded)'); + await uploadTab.first().click(); + await expect(page.locator('text=版本账本未就绪')).toHaveCount(0); + // Must not show real Vn ledger for orphan; template/local hint OK + const hasToolbar = await page.locator('[data-testid="doc-rev-version"]').isVisible().catch(() => false); + if (hasToolbar) fail('IndexedDB-only upload must not mount live revision toolbar / fake V1'); + await expect(page.getByTestId('doc-rev-template-hint').or(page.locator('text=本地预览'))).toBeVisible({ + timeout: 10_000, + }); + await shot(page, 'UI-16-orphan-upload.png'); + recordPass({ + id: 'UI-16', + name: 'upload orphan empty ledger', + input: 'open page-uploaded attachment tab', + actual: 'no fake V1; local/template hint', + networkLogs: logs, + durationMs: Date.now() - t0, + verdict: 'PASS', + }); }); }); diff --git a/web/playwright.config.ts b/web/playwright.config.ts index 2e5cefde..e6083ee6 100644 --- a/web/playwright.config.ts +++ b/web/playwright.config.ts @@ -1,10 +1,18 @@ import { defineConfig, devices } from '@playwright/test'; +import * as path from 'path'; +import { fileURLToPath } from 'url'; /** * Ubuntu Server headless UI acceptance config (DOCREV runtime). * Enable only when RUN_UI_ACCEPTANCE=1 against a real dev stack — no mocks. + * Script never touches docker; UI_PHASE=post-restart is a second run after host restart. */ const runAcceptance = process.env.RUN_UI_ACCEPTANCE === '1'; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const evidenceRoot = path.resolve( + __dirname, + '../../.doc/test-reports/assets/2026-09-13-document-revision-runtime-ui', +); export default defineConfig({ testDir: './e2e', @@ -12,14 +20,19 @@ export default defineConfig({ forbidOnly: !!process.env.CI, retries: 0, workers: 1, - reporter: [['list'], ['html', { open: 'never', outputFolder: 'playwright-report' }]], + timeout: 600_000, + outputDir: path.join(evidenceRoot, 'playwright-output'), + reporter: [ + ['list'], + ['html', { open: 'never', outputFolder: path.join(evidenceRoot, 'playwright-report') }], + ], use: { baseURL: process.env.BASE_URL || 'http://127.0.0.1:8080', headless: true, + locale: 'zh-CN', trace: runAcceptance ? 'on' : 'off', screenshot: runAcceptance ? 'on' : 'off', video: runAcceptance ? 'on' : 'off', - locale: 'zh-CN', }, projects: [ { @@ -27,5 +40,4 @@ export default defineConfig({ use: { ...devices['Desktop Chrome'] }, }, ], - // Do not start webServer here — acceptance assumes env-service.dev is already up. }); From 9a36e7c896261208cd4b7d3563928340a1048c5f Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 13 Sep 2026 10:53:35 +0000 Subject: [PATCH 04/13] docs(ops): document UI_PHASE split for Playwright acceptance Note main vs post-restart runs and credential-safe evidence layout. Co-authored-by: Cursor --- ...2026-09-13-document-revision-runtime-ui-acceptance.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.doc/ops/checklists/2026-09-13-document-revision-runtime-ui-acceptance.md b/.doc/ops/checklists/2026-09-13-document-revision-runtime-ui-acceptance.md index 1dd93b8f..0e9c170b 100644 --- a/.doc/ops/checklists/2026-09-13-document-revision-runtime-ui-acceptance.md +++ b/.doc/ops/checklists/2026-09-13-document-revision-runtime-ui-acceptance.md @@ -59,7 +59,14 @@ RUN_UI_ACCEPTANCE=1 BASE_URL=http://127.0.0.1:8080 \ --video on ``` -规格文件:`web/e2e/document-revision-runtime.spec.ts` + `web/playwright.config.ts`(骨架已入库;`RUN_UI_ACCEPTANCE` 未设时 skip;占槽验收时补齐真实选择器与步骤,禁止 mock)。 +规格文件:`web/e2e/document-revision-runtime.spec.ts` + `web/playwright.config.ts`。 + +两阶段容器重启: +1. `RUN_UI_ACCEPTANCE=1 UI_PHASE=main` 跑主场景(写 `phase-state.json`,密码仅落 `phase-secrets.local.json` 本地文件) +2. 宿主执行 docker restart(脚本不碰 docker) +3. `RUN_UI_ACCEPTANCE=1 UI_PHASE=post-restart` 仅页面登录校验 UI-15 + +证据:`.doc/test-reports/assets/2026-09-13-document-revision-runtime-ui/`(trace/video/screenshot);结果 JSON 不得含凭证与 URL query。 --- From 1e364f17abfdcc32a34164d4297d46beb36bed18 Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 13 Sep 2026 11:12:03 +0000 Subject: [PATCH 05/13] feat(db): add freeform client_key for idempotent upload registration Persist owner-scoped client_key on freeform_documents so upload create can retry safely without inventing local doc-upload orphans. Co-authored-by: Cursor --- .../00029_2026-09-13-freeform-client-key.sql | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 deploy/postgres/goose/00029_2026-09-13-freeform-client-key.sql diff --git a/deploy/postgres/goose/00029_2026-09-13-freeform-client-key.sql b/deploy/postgres/goose/00029_2026-09-13-freeform-client-key.sql new file mode 100644 index 00000000..2a0ca1b4 --- /dev/null +++ b/deploy/postgres/goose/00029_2026-09-13-freeform-client-key.sql @@ -0,0 +1,18 @@ +-- +goose Up +-- 2026-09-13 freeform upload registration: formal client_key for idempotent server fact-source create +-- Schema 28 → 29 + +ALTER TABLE freeform_documents + ADD COLUMN IF NOT EXISTS client_key VARCHAR(128) NULL; + +COMMENT ON COLUMN freeform_documents.client_key IS + 'Client-supplied idempotency key for direct create (e.g. upload batch id); NULL for message-derived docs'; + +-- Same owner cannot register the same client_key twice while the doc is alive. +CREATE UNIQUE INDEX IF NOT EXISTS uq_freeform_owner_client_key + ON freeform_documents (owner_user_id, client_key) + WHERE is_deleted = FALSE AND client_key IS NOT NULL AND length(trim(client_key)) > 0; + +-- +goose Down +DROP INDEX IF EXISTS uq_freeform_owner_client_key; +ALTER TABLE freeform_documents DROP COLUMN IF EXISTS client_key; From 9c7d6d49b2e89c3b3264a53314c183bd674ca394 Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 13 Sep 2026 11:12:03 +0000 Subject: [PATCH 06/13] feat(freedoc): CreateDocument with client_key idempotency Support direct freeform create for upload fact-source registration while keeping source_content_sha256 as a content fingerprint only. Co-authored-by: Cursor --- internal/freedoc/create_document_pg_test.go | 124 +++++++++++ internal/freedoc/create_document_test.go | 83 ++++++++ internal/freedoc/service.go | 219 +++++++++++++++++++- internal/freedoc/service_db_test.go | 6 +- internal/freedoc/service_test.go | 4 +- internal/freedoc/types.go | 33 +-- 6 files changed, 442 insertions(+), 27 deletions(-) create mode 100644 internal/freedoc/create_document_pg_test.go create mode 100644 internal/freedoc/create_document_test.go diff --git a/internal/freedoc/create_document_pg_test.go b/internal/freedoc/create_document_pg_test.go new file mode 100644 index 00000000..d43329c2 --- /dev/null +++ b/internal/freedoc/create_document_pg_test.go @@ -0,0 +1,124 @@ +package freedoc_test + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "chat-agent-system/internal/db" + "chat-agent-system/internal/docrevision" + "chat-agent-system/internal/freedoc" +) + +func TestCreateDocument_PG_ClientKeyIdempotentAndLedgerSurvivesNewService(t *testing.T) { + dsn := db.SkipIfNoTestDatabaseURL(t) + ctx := context.Background() + pool, err := pgxpool.New(ctx, dsn) + require.NoError(t, err) + defer pool.Close() + + var hasClientKey bool + err = pool.QueryRow(ctx, ` + SELECT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'freeform_documents' AND column_name = 'client_key' + ) + `).Scan(&hasClientKey) + if err != nil || !hasClientKey { + t.Skip("freeform_documents.client_key missing; migrate 00029 required") + } + var hasVersions bool + _ = pool.QueryRow(ctx, ` + SELECT EXISTS ( + SELECT 1 FROM information_schema.tables WHERE table_name = 'document_versions' + ) + `).Scan(&hasVersions) + if !hasVersions { + t.Skip("document_versions missing; migrate 00028 required") + } + + userID := 91001 + clientKey := fmt.Sprintf("doc-upload-pg-%d", time.Now().UnixNano()) + rev1 := docrevision.NewService(pool) + svc1 := freedoc.NewService(pool, nil) + svc1.SetDocRevision(rev1) + + detail, err := svc1.CreateDocument(ctx, userID, freedoc.CreateFreeformDocRequest{ + ProjectID: 1, + Title: "PG上传规格", + ContentMarkdown: "# PG上传规格\n\n基线正文\n", + Space: "my_private", + ClientKey: clientKey, + }) + require.NoError(t, err) + docID := detail.Document.DocID + require.NotEmpty(t, docID) + require.NotNil(t, detail.Document.ClientKey) + assert.Equal(t, clientKey, *detail.Document.ClientKey) + assert.NotContains(t, detail.Document.SourceContentSHA256, "doc-upload") + assert.Len(t, detail.Document.SourceContentSHA256, 64) + + // client_key 幂等:同一 owner+key 返回同一 doc + again, err := svc1.CreateDocument(ctx, userID, freedoc.CreateFreeformDocRequest{ + ProjectID: 1, + Title: "PG上传规格", + ContentMarkdown: "# PG上传规格\n\n基线正文\n", + ClientKey: clientKey, + }) + require.NoError(t, err) + assert.Equal(t, docID, again.Document.DocID) + + st1, err := rev1.GetRevisionState(ctx, docID) + require.NoError(t, err) + assert.GreaterOrEqual(t, st1.CurrentVersionNo, 1) + + patched := "# PG上传规格\n\n基线正文\n\nPG人工修订\n" + _, err = rev1.CommitHumanRevision(ctx, docrevision.HumanRevisionRequest{ + DocID: docID, Markdown: patched, + ExpectedHeadSnapshot: &st1.HeadSnapshotID, ExpectedRevisionNo: &st1.HeadRevisionNo, + IdempotencyKey: fmt.Sprintf("pg-upload-patch-%d", time.Now().UnixNano()), + ActorUserID: int64(userID), LifecycleState: "drafting", ProjectID: 1, + }) + require.NoError(t, err) + _, err = svc1.UpdateDocument(ctx, userID, docID, freedoc.UpdateFreeformDocRequest{ + ContentMarkdown: &patched, + ChangeSummary: "pg patch", + }) + require.NoError(t, err) + + // 新 Service 实例:client_key 仍可解析,revision-state 与正文可恢复 + rev2 := docrevision.NewService(pool) + svc2 := freedoc.NewService(pool, nil) + svc2.SetDocRevision(rev2) + + byKey, err := svc2.CreateDocument(ctx, userID, freedoc.CreateFreeformDocRequest{ + ProjectID: 1, + Title: "ignored", + ContentMarkdown: "# ignored\n", + ClientKey: clientKey, + }) + require.NoError(t, err) + assert.Equal(t, docID, byKey.Document.DocID) + + st2, err := rev2.GetRevisionState(ctx, docID) + require.NoError(t, err, "revision-state must hydrate after new Service") + assert.GreaterOrEqual(t, st2.HeadRevisionNo, 1) + + got, err := svc2.GetDocument(ctx, userID, docID) + require.NoError(t, err) + assert.Contains(t, got.Content, "PG人工修订") + + t.Cleanup(func() { + _, _ = pool.Exec(context.Background(), `DELETE FROM freeform_document_versions WHERE doc_id=$1`, docID) + _, _ = pool.Exec(context.Background(), `DELETE FROM freeform_documents WHERE doc_id=$1`, docID) + _, _ = pool.Exec(context.Background(), `DELETE FROM document_change_events WHERE doc_id=$1`, docID) + _, _ = pool.Exec(context.Background(), `DELETE FROM document_versions WHERE doc_id=$1`, docID) + _, _ = pool.Exec(context.Background(), `DELETE FROM document_snapshots WHERE doc_id=$1`, docID) + _, _ = pool.Exec(context.Background(), `DELETE FROM document_access_audit WHERE doc_id=$1`, docID) + }) +} diff --git a/internal/freedoc/create_document_test.go b/internal/freedoc/create_document_test.go new file mode 100644 index 00000000..c1203163 --- /dev/null +++ b/internal/freedoc/create_document_test.go @@ -0,0 +1,83 @@ +package freedoc_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "chat-agent-system/internal/docrevision" + "chat-agent-system/internal/freedoc" +) + +func TestCreateDocument_ClientKeyIdempotentAndBootstrapsLedger(t *testing.T) { + ctx := context.Background() + rev := docrevision.NewService(nil) + svc := freedoc.NewService(nil, nil) + svc.SetDocRevision(rev) + + detail, err := svc.CreateDocument(ctx, 42, freedoc.CreateFreeformDocRequest{ + ProjectID: 7, + Title: "超声探头规格.pdf", + ContentMarkdown: "# 超声探头规格\n\n上传正文基线\n", + Space: "my_private", + ClientKey: "doc-upload-1789263145197-0", + }) + require.NoError(t, err) + require.NotNil(t, detail) + assert.True(t, len(detail.Document.DocID) > 0) + assert.True(t, detail.Document.DocID[:8] == "doc-free" || detail.Document.DocID[:9] == "doc-free-") + require.NotNil(t, detail.Document.ClientKey) + assert.Equal(t, "doc-upload-1789263145197-0", *detail.Document.ClientKey) + assert.Equal(t, "freeform", detail.Document.GovernanceMode) + // source_content_sha256 must remain content fingerprint — never client_key prefix + assert.NotContains(t, detail.Document.SourceContentSHA256, "doc-upload") + assert.NotContains(t, detail.Document.SourceContentSHA256, "client_key") + assert.Len(t, detail.Document.SourceContentSHA256, 64) + + st, err := rev.GetRevisionState(ctx, detail.Document.DocID) + require.NoError(t, err) + assert.GreaterOrEqual(t, st.CurrentVersionNo, 1) + + again, err := svc.CreateDocument(ctx, 42, freedoc.CreateFreeformDocRequest{ + ProjectID: 7, + Title: "超声探头规格.pdf", + ContentMarkdown: "# 超声探头规格\n\n上传正文基线\n", + ClientKey: "doc-upload-1789263145197-0", + }) + require.NoError(t, err) + assert.Equal(t, detail.Document.DocID, again.Document.DocID) + + otherUser, err := svc.CreateDocument(ctx, 99, freedoc.CreateFreeformDocRequest{ + ProjectID: 7, + Title: "他人同名键", + ContentMarkdown: "# other\n", + ClientKey: "doc-upload-1789263145197-0", + }) + require.NoError(t, err) + assert.NotEqual(t, detail.Document.DocID, otherUser.Document.DocID) +} + +func TestCreateDocument_RejectsEmptyContent(t *testing.T) { + svc := freedoc.NewService(nil, nil) + _, err := svc.CreateDocument(context.Background(), 1, freedoc.CreateFreeformDocRequest{ + ProjectID: 1, Title: "x", ContentMarkdown: " ", + }) + assert.ErrorIs(t, err, freedoc.ErrInvalidParam) +} + +func TestCreateDocument_RequiresProjectIDAndPrivateSpace(t *testing.T) { + svc := freedoc.NewService(nil, nil) + _, err := svc.CreateDocument(context.Background(), 1, freedoc.CreateFreeformDocRequest{ + ProjectID: 0, Title: "x", ContentMarkdown: "# x\n", + }) + assert.ErrorIs(t, err, freedoc.ErrInvalidParam) + assert.Contains(t, err.Error(), "project_id") + + _, err = svc.CreateDocument(context.Background(), 1, freedoc.CreateFreeformDocRequest{ + ProjectID: 1, Title: "x", ContentMarkdown: "# x\n", Space: "project_shared", + }) + assert.ErrorIs(t, err, freedoc.ErrInvalidParam) + assert.Contains(t, err.Error(), "my_private") +} diff --git a/internal/freedoc/service.go b/internal/freedoc/service.go index 5afa94b6..b464e837 100644 --- a/internal/freedoc/service.go +++ b/internal/freedoc/service.go @@ -6,14 +6,15 @@ import ( "encoding/hex" "errors" "fmt" + "strings" "sync" "time" - "github.com/jackc/pgx/v5" - "github.com/jackc/pgx/v5/pgxpool" "chat-agent-system/internal/ctxmgr" "chat-agent-system/internal/docrevision" "chat-agent-system/internal/replyexport" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" ) type Service struct { @@ -25,15 +26,18 @@ type Service struct { memDocs map[string]*FreeformDocument memVersions map[string][]*FreeformDocumentVersion memAudit []map[string]any + // memClientKeys mirrors PG uq_freeform_owner_client_key (owner|client_key → doc_id) + memClientKeys map[string]string } func NewService(pool *pgxpool.Pool, ctxMgr *ctxmgr.ContextManager) *Service { return &Service{ - pool: pool, - ctxMgr: ctxMgr, - memDocs: make(map[string]*FreeformDocument), - memVersions: make(map[string][]*FreeformDocumentVersion), - memAudit: make([]map[string]any, 0), + pool: pool, + ctxMgr: ctxMgr, + memDocs: make(map[string]*FreeformDocument), + memVersions: make(map[string][]*FreeformDocumentVersion), + memAudit: make([]map[string]any, 0), + memClientKeys: make(map[string]string), } } @@ -232,6 +236,207 @@ func (s *Service) CreateFromMessage(ctx context.Context, userID int, req CreateF }, false, nil } +func clientKeyLookup(userID int, clientKey string) string { + return fmt.Sprintf("%d|%s", userID, strings.TrimSpace(clientKey)) +} + +func (s *Service) bootstrapLedger(ctx context.Context, userID int, detail *FreeformDocDetail) { + if s == nil || s.docRev == nil || detail == nil { + return + } + md := detail.Content + if strings.TrimSpace(md) == "" { + return + } + _, _ = s.docRev.EnsureBootstrap(ctx, docrevision.BootstrapRequest{ + DocID: detail.Document.DocID, + Kind: "freeform", + Markdown: md, + ProjectID: int64(detail.Document.ProjectID), + LifecycleState: "drafting", + Actor: docrevision.ActorSnapshot{ + DisplayName: "freeform-owner", + Role: "owner", + }, + }) +} + +func (s *Service) findByClientKeyPG(ctx context.Context, userID int, clientKey string) (*FreeformDocDetail, error) { + var docID string + err := s.pool.QueryRow(ctx, ` + SELECT doc_id FROM freeform_documents + WHERE owner_user_id = $1 AND client_key = $2 AND is_deleted = FALSE + LIMIT 1 + `, userID, clientKey).Scan(&docID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + return nil, err + } + return s.GetDocument(ctx, userID, docID) +} + +// CreateDocument 直接创建自由文档(上传附件 / 自定义正文的服务端事实源)。 +// client_key 持久化于 freeform_documents.client_key,与 owner 唯一;禁止污染 source_content_sha256。 +// 创建后立即 EnsureBootstrap 版本账本,使 revision-state 可读、PATCH 可持久化。 +func (s *Service) CreateDocument(ctx context.Context, userID int, req CreateFreeformDocRequest) (*FreeformDocDetail, error) { + title := strings.TrimSpace(req.Title) + content := req.ContentMarkdown + if title == "" { + return nil, fmt.Errorf("%w: title required", ErrInvalidParam) + } + if strings.TrimSpace(content) == "" { + return nil, fmt.Errorf("%w: content_markdown required", ErrInvalidParam) + } + space := strings.TrimSpace(req.Space) + if space == "" { + space = "my_private" + } + // Direct create is upload/private registration only; project_shared must go through ShareDocument. + if space != "my_private" { + return nil, fmt.Errorf("%w: direct create only allows my_private; use share API", ErrInvalidParam) + } + projectID := req.ProjectID + if projectID <= 0 { + return nil, fmt.Errorf("%w: project_id required", ErrInvalidParam) + } + clientKey := strings.TrimSpace(req.ClientKey) + var clientKeyPtr *string + if clientKey != "" { + clientKeyPtr = &clientKey + } + + if clientKey != "" { + if s.pool != nil { + existing, err := s.findByClientKeyPG(ctx, userID, clientKey) + if err != nil { + return nil, err + } + if existing != nil { + return existing, nil + } + } else { + s.mu.RLock() + existingID := s.memClientKeys[clientKeyLookup(userID, clientKey)] + s.mu.RUnlock() + if existingID != "" { + return s.GetDocument(ctx, userID, existingID) + } + } + } + + contentHash := computeSHA256(content) + docID := fmt.Sprintf("doc-free-%d-%d", time.Now().UnixNano(), userID) + now := time.Now() + + if s.pool != nil { + tx, err := s.pool.Begin(ctx) + if err != nil { + return nil, err + } + defer tx.Rollback(ctx) + + _, err = tx.Exec(ctx, ` + INSERT INTO freeform_documents ( + doc_id, project_id, owner_user_id, source_message_id, source_content_sha256, + client_key, title, governance_mode, space, legacy_version_id, current_sha256, + is_deleted, created_at, updated_at + ) VALUES ($1, $2, $3, NULL, $4, $5, $6, 'freeform', $7, 1, $4, FALSE, $8, $8) + `, docID, projectID, userID, contentHash, clientKeyPtr, title, space, now) + if err != nil { + // Unique race: another request inserted the same owner+client_key + if clientKey != "" { + if existing, findErr := s.findByClientKeyPG(ctx, userID, clientKey); findErr == nil && existing != nil { + return existing, nil + } + } + return nil, fmt.Errorf("insert freeform_doc: %w", err) + } + _, err = tx.Exec(ctx, ` + INSERT INTO freeform_document_versions ( + doc_id, version_no, content_sha256, content_markdown, created_by, change_summary, created_at + ) VALUES ($1, 1, $2, $3, $4, '上传附件注册', $5) + `, docID, contentHash, content, userID, now) + if err != nil { + return nil, fmt.Errorf("insert version: %w", err) + } + _, err = tx.Exec(ctx, ` + INSERT INTO document_access_audit (doc_id, user_id, action, success, created_at) + VALUES ($1, $2, 'create_upload', TRUE, $3) + `, docID, userID, now) + if err != nil { + return nil, fmt.Errorf("insert audit: %w", err) + } + if err := tx.Commit(ctx); err != nil { + return nil, err + } + + detail := &FreeformDocDetail{ + Document: FreeformDocument{ + DocID: docID, + ProjectID: projectID, + OwnerUserID: userID, + SourceContentSHA256: contentHash, + ClientKey: clientKeyPtr, + Title: title, + GovernanceMode: "freeform", + Space: space, + CurrentVersionID: 1, + CurrentSHA256: contentHash, + IsDeleted: false, + CreatedAt: now, + UpdatedAt: now, + }, + Content: content, + } + s.bootstrapLedger(ctx, userID, detail) + return detail, nil + } + + s.mu.Lock() + defer s.mu.Unlock() + if clientKey != "" { + if existingID := s.memClientKeys[clientKeyLookup(userID, clientKey)]; existingID != "" { + doc := s.memDocs[existingID] + versions := s.memVersions[existingID] + cur := "" + if len(versions) > 0 { + cur = versions[len(versions)-1].ContentMarkdown + } + if doc != nil { + return &FreeformDocDetail{Document: *doc, Content: cur}, nil + } + } + } + newDoc := &FreeformDocument{ + DocID: docID, + ProjectID: projectID, + OwnerUserID: userID, + SourceContentSHA256: contentHash, + ClientKey: clientKeyPtr, + Title: title, + GovernanceMode: "freeform", + Space: space, + CurrentVersionID: 1, + CurrentSHA256: contentHash, + IsDeleted: false, + CreatedAt: now, + UpdatedAt: now, + } + s.memDocs[docID] = newDoc + s.memVersions[docID] = []*FreeformDocumentVersion{{ + ID: 1, DocID: docID, VersionNo: 1, ContentSHA256: contentHash, + ContentMarkdown: content, CreatedBy: userID, ChangeSummary: "上传附件注册", CreatedAt: now, + }} + if clientKey != "" { + s.memClientKeys[clientKeyLookup(userID, clientKey)] = docID + } + detail := &FreeformDocDetail{Document: *newDoc, Content: content} + s.bootstrapLedger(ctx, userID, detail) + return detail, nil +} + // GetDocument 获取文档详情及内容,严格执行私密与共享访问控制 func (s *Service) GetDocument(ctx context.Context, userID int, docID string) (*FreeformDocDetail, error) { if docID == "" { diff --git a/internal/freedoc/service_db_test.go b/internal/freedoc/service_db_test.go index 22eb1861..1d71f721 100644 --- a/internal/freedoc/service_db_test.go +++ b/internal/freedoc/service_db_test.go @@ -6,12 +6,12 @@ import ( "testing" "time" - "github.com/jackc/pgx/v5/pgxpool" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" "chat-agent-system/internal/config" "chat-agent-system/internal/ctxmgr" "chat-agent-system/internal/freedoc" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestFreeformDoc_DatabaseIntegration(t *testing.T) { diff --git a/internal/freedoc/service_test.go b/internal/freedoc/service_test.go index 0879b50a..006c8fae 100644 --- a/internal/freedoc/service_test.go +++ b/internal/freedoc/service_test.go @@ -4,10 +4,10 @@ import ( "context" "testing" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" "chat-agent-system/internal/ctxmgr" "chat-agent-system/internal/freedoc" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestFreeformDocService_LifecycleAndACL(t *testing.T) { diff --git a/internal/freedoc/types.go b/internal/freedoc/types.go index e15e1c90..78b5cfcd 100644 --- a/internal/freedoc/types.go +++ b/internal/freedoc/types.go @@ -15,20 +15,21 @@ var ( // FreeformDocument 自由私密/共享文档元数据 type FreeformDocument struct { - DocID string `json:"doc_id"` - ProjectID int `json:"project_id"` - OwnerUserID int `json:"owner_user_id"` - SourceMessageID *int64 `json:"source_message_id,omitempty"` - SourceContentSHA256 string `json:"source_content_sha256"` - Title string `json:"title"` - GovernanceMode string `json:"governance_mode"` // 恒为 'freeform' - Space string `json:"space"` // 'my_private' | 'project_shared' | 'trash' - CurrentVersionID int `json:"current_version_id"` - CurrentSHA256 string `json:"current_sha256"` - IsDeleted bool `json:"is_deleted"` - DeletedAt *time.Time `json:"deleted_at,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + DocID string `json:"doc_id"` + ProjectID int `json:"project_id"` + OwnerUserID int `json:"owner_user_id"` + SourceMessageID *int64 `json:"source_message_id,omitempty"` + SourceContentSHA256 string `json:"source_content_sha256"` + ClientKey *string `json:"client_key,omitempty"` + Title string `json:"title"` + GovernanceMode string `json:"governance_mode"` // 恒为 'freeform' + Space string `json:"space"` // 'my_private' | 'project_shared' | 'trash' + CurrentVersionID int `json:"current_version_id"` + CurrentSHA256 string `json:"current_sha256"` + IsDeleted bool `json:"is_deleted"` + DeletedAt *time.Time `json:"deleted_at,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } // FreeformDocumentVersion 自由文档历史版本快照 @@ -70,12 +71,14 @@ type CreateFromMessageRequest struct { ExpectedSHA256 string `json:"expected_sha256,omitempty"` } -// CreateFreeformDocRequest 直接创建空白/自定义自由文档请求 +// CreateFreeformDocRequest 直接创建空白/自定义自由文档请求(上传附件即时注册事实源) type CreateFreeformDocRequest struct { ProjectID int `json:"project_id"` Title string `json:"title"` ContentMarkdown string `json:"content_markdown"` Space string `json:"space,omitempty"` // 默认 'my_private' + // ClientKey 可选幂等键(如 doc-upload-{ts}-{idx}),同用户重复提交返回已有文档 + ClientKey string `json:"client_key,omitempty"` } // UpdateFreeformDocRequest 更新自由文档请求 From a3c6df309bad25b2fb493fdf48cfa98dea6cadf0 Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 13 Sep 2026 11:12:03 +0000 Subject: [PATCH 07/13] feat(api): expose POST /freeform-documents for upload registration Authorize project members, reject direct project_shared creates, and cover idempotent client_key plus revision-state after register. Co-authored-by: Cursor --- .../document_revision_upload_register_test.go | 131 +++++++++++++++++ internal/server/freeform_create_authz_test.go | 68 +++++++++ .../freeform_create_nonmember_pg_test.go | 132 ++++++++++++++++++ internal/server/freeform_doc_api.go | 71 ++++++++++ internal/server/server.go | 1 + 5 files changed, 403 insertions(+) create mode 100644 internal/server/document_revision_upload_register_test.go create mode 100644 internal/server/freeform_create_authz_test.go create mode 100644 internal/server/freeform_create_nonmember_pg_test.go diff --git a/internal/server/document_revision_upload_register_test.go b/internal/server/document_revision_upload_register_test.go new file mode 100644 index 00000000..7426b4fb --- /dev/null +++ b/internal/server/document_revision_upload_register_test.go @@ -0,0 +1,131 @@ +package server + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "strconv" + "strings" + "testing" +) + +// Upload → freeform create (server docId) → revision-state 200 → PATCH persists content. +func TestDocRevision_UploadRegisteredFreeform_RevisionStateAndPatchPersist(t *testing.T) { + srv := newTestServer(t) + token := loginTestAdmin(t, srv) + + clientKey := "doc-upload-1789263145197-0" + createBody := map[string]any{ + "project_id": 1, + "title": "超声探头规格.pdf", + "content_markdown": "# 超声探头规格\n\n初始上传正文\n", + "space": "my_private", + "client_key": clientKey, + } + raw, _ := json.Marshal(createBody) + req := httptest.NewRequest(http.MethodPost, "/api/v1/freeform-documents", bytes.NewReader(raw)) + withBearerAuth(req, token) + w := httptest.NewRecorder() + srv.Routes().ServeHTTP(w, req) + if w.Code != http.StatusCreated { + t.Fatalf("create freeform want 201 got %d %s", w.Code, w.Body.String()) + } + var createResp struct { + Data struct { + Document struct { + DocID string `json:"doc_id"` + ClientKey *string `json:"client_key"` + } `json:"document"` + Content string `json:"content_markdown"` + } `json:"data"` + } + if err := json.NewDecoder(w.Body).Decode(&createResp); err != nil { + t.Fatal(err) + } + docID := createResp.Data.Document.DocID + if !strings.HasPrefix(docID, "doc-free-") { + t.Fatalf("want server doc-free id, got %q", docID) + } + if createResp.Data.Document.ClientKey == nil || *createResp.Data.Document.ClientKey != clientKey { + t.Fatalf("client_key not persisted: %#v", createResp.Data.Document.ClientKey) + } + + // Idempotent re-create with same client_key + req2 := httptest.NewRequest(http.MethodPost, "/api/v1/freeform-documents", bytes.NewReader(raw)) + withBearerAuth(req2, token) + w2 := httptest.NewRecorder() + srv.Routes().ServeHTTP(w2, req2) + if w2.Code != http.StatusCreated && w2.Code != http.StatusOK { + t.Fatalf("idempotent create want 201/200 got %d %s", w2.Code, w2.Body.String()) + } + var createResp2 struct { + Data struct { + Document struct { + DocID string `json:"doc_id"` + } `json:"document"` + } `json:"data"` + } + _ = json.NewDecoder(w2.Body).Decode(&createResp2) + if createResp2.Data.Document.DocID != docID { + t.Fatalf("idempotent client_key must return same doc_id: %s vs %s", docID, createResp2.Data.Document.DocID) + } + + // revision-state must be 200 (not orphan 404) + stReq := httptest.NewRequest(http.MethodGet, "/api/v1/documents/"+docID+"/revision-state", nil) + withBearerAuth(stReq, token) + stW := httptest.NewRecorder() + srv.Routes().ServeHTTP(stW, stReq) + if stW.Code != http.StatusOK { + t.Fatalf("revision-state want 200 got %d %s", stW.Code, stW.Body.String()) + } + var st map[string]any + if err := json.NewDecoder(stW.Body).Decode(&st); err != nil { + t.Fatal(err) + } + headSnap, _ := st["head_snapshot_id"].(float64) + headRev, _ := st["head_revision_no"].(float64) + if headSnap <= 0 { + t.Fatalf("expected bootstrapped head_snapshot_id, got %#v", st) + } + + patched := "# 超声探头规格\n\n初始上传正文\n\n人工修订段\n" + patchBody := `{"markdown":` + strconv.Quote(patched) + `}` + pReq := httptest.NewRequest(http.MethodPatch, "/api/v1/documents/"+docID+"/content", bytes.NewReader([]byte(patchBody))) + withBearerAuth(pReq, token) + pReq.Header.Set("Idempotency-Key", "upload-patch-k1") + pReq.Header.Set("If-Match", `"`+strconv.FormatInt(int64(headSnap), 10)+":"+strconv.Itoa(int(headRev))+`"`) + pW := httptest.NewRecorder() + srv.Routes().ServeHTTP(pW, pReq) + if pW.Code != http.StatusOK { + t.Fatalf("PATCH want 200 got %d %s", pW.Code, pW.Body.String()) + } + + getReq := httptest.NewRequest(http.MethodGet, "/api/v1/freeform-documents/"+docID, nil) + withBearerAuth(getReq, token) + gW := httptest.NewRecorder() + srv.Routes().ServeHTTP(gW, getReq) + if gW.Code != http.StatusOK { + t.Fatalf("get freeform want 200 got %d %s", gW.Code, gW.Body.String()) + } + var getResp struct { + Data struct { + Content string `json:"content_markdown"` + } `json:"data"` + } + if err := json.NewDecoder(gW.Body).Decode(&getResp); err != nil { + t.Fatal(err) + } + if !strings.Contains(getResp.Data.Content, "人工修订段") { + t.Fatalf("PATCH content not persisted in freeform fact source: %q", getResp.Data.Content) + } + + // Unregistered doc-upload-* remains orphan 404 (must not silently invent ledger) + orphan := httptest.NewRequest(http.MethodGet, "/api/v1/documents/doc-upload-never-registered/revision-state", nil) + withBearerAuth(orphan, token) + oW := httptest.NewRecorder() + srv.Routes().ServeHTTP(oW, orphan) + if oW.Code != http.StatusNotFound { + t.Fatalf("unregistered upload orphan want 404 got %d %s", oW.Code, oW.Body.String()) + } +} diff --git a/internal/server/freeform_create_authz_test.go b/internal/server/freeform_create_authz_test.go new file mode 100644 index 00000000..da4a663f --- /dev/null +++ b/internal/server/freeform_create_authz_test.go @@ -0,0 +1,68 @@ +package server + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func postFreeformCreate(t *testing.T, srv *Server, token string, body map[string]any) *httptest.ResponseRecorder { + t.Helper() + raw, _ := json.Marshal(body) + req := httptest.NewRequest(http.MethodPost, "/api/v1/freeform-documents", bytes.NewReader(raw)) + withBearerAuth(req, token) + w := httptest.NewRecorder() + srv.Routes().ServeHTTP(w, req) + return w +} + +func TestCreateFreeformDoc_RejectsMissingProjectID(t *testing.T) { + srv := newTestServer(t) + token := loginTestAdmin(t, srv) + w := postFreeformCreate(t, srv, token, map[string]any{ + "project_id": 0, + "title": "x", + "content_markdown": "# x\n", + }) + if w.Code != http.StatusBadRequest { + t.Fatalf("want 400 got %d %s", w.Code, w.Body.String()) + } + if !bytes.Contains(w.Body.Bytes(), []byte("project_id")) { + t.Fatalf("want project_id error, got %s", w.Body.String()) + } +} + +func TestCreateFreeformDoc_RejectsDirectProjectShared(t *testing.T) { + srv := newTestServer(t) + token := loginTestAdmin(t, srv) + w := postFreeformCreate(t, srv, token, map[string]any{ + "project_id": 1, + "title": "x", + "content_markdown": "# x\n", + "space": "project_shared", + }) + if w.Code != http.StatusBadRequest { + t.Fatalf("want 400 got %d %s", w.Code, w.Body.String()) + } + if !bytes.Contains(w.Body.Bytes(), []byte("my_private")) { + t.Fatalf("want my_private-only error, got %s", w.Body.String()) + } +} + +func TestCreateFreeformDoc_FailClosedWhenAuthorizerNil(t *testing.T) { + srv := newTestServer(t) + srv.cfg.AllowInMemory = false + srv.authorizer = nil + token := loginTestAdmin(t, srv) + w := postFreeformCreate(t, srv, token, map[string]any{ + "project_id": 1, + "title": "x", + "content_markdown": "# x\n", + "space": "my_private", + }) + if w.Code != http.StatusForbidden && w.Code != http.StatusUnauthorized { + t.Fatalf("fail-closed authorizer=nil want 403/401 got %d %s", w.Code, w.Body.String()) + } +} diff --git a/internal/server/freeform_create_nonmember_pg_test.go b/internal/server/freeform_create_nonmember_pg_test.go new file mode 100644 index 00000000..cfb8611e --- /dev/null +++ b/internal/server/freeform_create_nonmember_pg_test.go @@ -0,0 +1,132 @@ +package server + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net" + "net/http" + "net/http/httptest" + "net/url" + "os" + "strconv" + "strings" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + + "chat-agent-system/internal/auth" + "chat-agent-system/internal/authz" + "chat-agent-system/internal/bizdev" + "chat-agent-system/internal/config" + "chat-agent-system/internal/db" +) + +func pgConfigFromTestDSN(t *testing.T) (*config.Config, *pgxpool.Pool) { + t.Helper() + dsn := db.SkipIfNoTestDatabaseURL(t) + parsed, err := url.Parse(dsn) + if err != nil { + t.Fatalf("parse TEST_DATABASE_URL: %v", err) + } + host, portStr, splitErr := net.SplitHostPort(parsed.Host) + if splitErr != nil { + host = parsed.Host + portStr = "5432" + } + port, _ := strconv.Atoi(portStr) + user := parsed.User.Username() + password, _ := parsed.User.Password() + dbName := strings.TrimPrefix(parsed.Path, "/") + cfg := &config.Config{ + Env: "test", + DataDir: t.TempDir(), + AgentBackend: "stub", + AllowInMemory: false, + ContextDbHost: host, + ContextDbPort: port, + ContextDbUser: user, + ContextDbPassword: password, + ContextDbName: dbName, + AdminUsername: envOrFF("ADMIN_USERNAME", "admin"), + AdminPassword: envOrFF("ADMIN_PASSWORD", "admin-secret-password-123"), + } + pool, err := pgxpool.New(context.Background(), dsn) + if err != nil { + t.Skipf("postgres unavailable: %v", err) + } + if err := pool.Ping(context.Background()); err != nil { + pool.Close() + t.Skipf("postgres ping failed: %v", err) + } + return cfg, pool +} + +func envOrFF(key, fallback string) string { + if v := strings.TrimSpace(os.Getenv(key)); v != "" { + return v + } + return fallback +} + +// Non-member must receive 403 on POST /api/v1/freeform-documents (PG authorizer). +func TestCreateFreeformDoc_NonMemberForbidden_PG(t *testing.T) { + cfg, pool := pgConfigFromTestDSN(t) + defer pool.Close() + + var projectID int64 + if err := pool.QueryRow(context.Background(), `SELECT id FROM projects ORDER BY id ASC LIMIT 1`).Scan(&projectID); err != nil || projectID <= 0 { + t.Skip("no projects for non-member create test") + } + + svc := bizdev.MustNewService(t, cfg) + srv := NewServer(cfg, svc) + srv.authorizer = authz.NewAuthorizer(pool) + + uname := fmt.Sprintf("outsider_ff_%d", time.Now().UnixNano()) + pass := "OutsiderPass123!" + regBody, _ := json.Marshal(map[string]string{ + "username": uname, + "password": pass, + "name": "局外人", + }) + regReq := httptest.NewRequest(http.MethodPost, "/api/v1/auth/register", bytes.NewReader(regBody)) + regW := httptest.NewRecorder() + srv.Routes().ServeHTTP(regW, regReq) + if regW.Code != http.StatusOK && regW.Code != http.StatusCreated { + t.Skipf("register outsider unavailable: %d %s", regW.Code, regW.Body.String()) + } + + loginBody, _ := json.Marshal(map[string]string{"username": uname, "password": pass}) + loginReq := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(loginBody)) + loginW := httptest.NewRecorder() + srv.Routes().ServeHTTP(loginW, loginReq) + if loginW.Code != http.StatusOK { + t.Fatalf("outsider login failed: %d %s", loginW.Code, loginW.Body.String()) + } + var session auth.SessionInfo + if err := json.NewDecoder(loginW.Body).Decode(&session); err != nil || session.Token == "" || session.User == nil { + t.Fatalf("decode outsider session: %v body=%s", err, loginW.Body.String()) + } + + if err := srv.authorizer.RequireProjectAccess(context.Background(), projectID, session.User.ID); err != authz.ErrForbidden { + t.Skipf("registered user unexpectedly has access to project %d: %v", projectID, err) + } + + createBody, _ := json.Marshal(map[string]any{ + "project_id": projectID, + "title": "should-fail", + "content_markdown": "# x\n", + "space": "my_private", + "client_key": fmt.Sprintf("doc-upload-outsider-%d", time.Now().UnixNano()), + }) + req := httptest.NewRequest(http.MethodPost, "/api/v1/freeform-documents", bytes.NewReader(createBody)) + withBearerAuth(req, session.Token) + w := httptest.NewRecorder() + srv.Routes().ServeHTTP(w, req) + if w.Code != http.StatusForbidden { + t.Fatalf("non-member create want 403 got %d %s", w.Code, w.Body.String()) + } +} diff --git a/internal/server/freeform_doc_api.go b/internal/server/freeform_doc_api.go index 5b2faf5a..9841ceda 100644 --- a/internal/server/freeform_doc_api.go +++ b/internal/server/freeform_doc_api.go @@ -7,6 +7,7 @@ import ( "strconv" "strings" + "chat-agent-system/internal/authz" "chat-agent-system/internal/freedoc" ) @@ -33,6 +34,76 @@ func extractDocIDFromPath(r *http.Request, actionSuffix string) string { return r.PathValue("doc_id") } +// POST /api/v1/freeform-documents — 直接创建(上传附件注册服务端事实源,仅 my_private) +func (s *Server) handleCreateFreeformDoc(w http.ResponseWriter, r *http.Request) { + if s.svc == nil || s.svc.FreeformDoc() == nil { + http.Error(w, "freeform doc service not initialized", http.StatusServiceUnavailable) + return + } + user, ok := s.requirePrincipal(w, r) + if !ok { + return + } + var req freedoc.CreateFreeformDocRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSONResponse(w, http.StatusBadRequest, map[string]any{ + "error_code": "INVALID_BODY", + "message": "请求体格式错误", + }) + return + } + if req.ProjectID <= 0 { + writeJSONResponse(w, http.StatusBadRequest, map[string]any{ + "error_code": "INVALID_PARAM", + "message": "project_id required", + }) + return + } + space := strings.TrimSpace(req.Space) + if space == "" { + space = "my_private" + } + if space != "my_private" { + writeJSONResponse(w, http.StatusBadRequest, map[string]any{ + "error_code": "INVALID_PARAM", + "message": "direct create only allows my_private; use share API for project_shared", + }) + return + } + req.Space = "my_private" + + if s.authorizer == nil { + if s.failClosedAuthz() { + writeAuthzError(w, authz.ErrForbidden) + return + } + } else if err := s.authorizer.RequireProjectAccessOrAdmin(r.Context(), int64(req.ProjectID), user.ID, s.isSystemAdminUser(r.Context(), user)); err != nil { + writeAuthzError(w, err) + return + } + + detail, err := s.svc.FreeformDoc().CreateDocument(r.Context(), int(user.ID), req) + if err != nil { + if errors.Is(err, freedoc.ErrInvalidParam) { + writeJSONResponse(w, http.StatusBadRequest, map[string]any{ + "error_code": "INVALID_PARAM", + "message": err.Error(), + }) + return + } + writeJSONResponse(w, http.StatusInternalServerError, map[string]any{ + "error_code": "CREATE_FAILED", + "message": err.Error(), + }) + return + } + writeJSONResponse(w, http.StatusCreated, map[string]any{ + "code": 0, + "message": "已注册自由文档", + "data": detail, + }) +} + // POST /api/v1/freeform-documents/from-message func (s *Server) handleCreateFreeformDocFromMessage(w http.ResponseWriter, r *http.Request) { if s.svc == nil || s.svc.FreeformDoc() == nil { diff --git a/internal/server/server.go b/internal/server/server.go index 2cbfe63a..116a81dc 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -284,6 +284,7 @@ func (s *Server) Routes() http.Handler { mux.HandleFunc("GET /api/v1/chat/messages/{message_id}/export-source", s.handleGetExportSource) // 7.2 自由私密文档与项目共享参考 + mux.HandleFunc("POST /api/v1/freeform-documents", s.handleCreateFreeformDoc) mux.HandleFunc("POST /api/v1/freeform-documents/from-message", s.handleCreateFreeformDocFromMessage) mux.HandleFunc("GET /api/v1/freeform-documents", s.handleListFreeformDocs) mux.HandleFunc("GET /api/v1/freeform-documents/{doc_id}", s.handleGetFreeformDoc) From ef0b2645e5b874b18b454990db7fa2c749052b56 Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 13 Sep 2026 11:12:03 +0000 Subject: [PATCH 08/13] feat(web): await freeform upload register before send Make InputArea/onSendMessage async; fail closed with an explicit UI error when createFreeformDoc fails, and persist fileStore under server doc-free IDs. Co-authored-by: Cursor --- web/src/App.tsx | 91 +++++------- web/src/api/freeformDoc.ts | 30 ++++ web/src/components/chat/InputArea.test.ts | 10 ++ web/src/components/chat/InputArea.tsx | 62 ++++++--- web/src/lib/uploadFreeformRegister.test.ts | 128 +++++++++++++++++ web/src/lib/uploadFreeformRegister.ts | 153 +++++++++++++++++++++ 6 files changed, 392 insertions(+), 82 deletions(-) create mode 100644 web/src/lib/uploadFreeformRegister.test.ts create mode 100644 web/src/lib/uploadFreeformRegister.ts diff --git a/web/src/App.tsx b/web/src/App.tsx index 09ddffd5..83e37e7d 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -11,6 +11,7 @@ import { CommentSidePanel } from './components/doc/CommentSidePanel'; import { DocGovActionBar } from './components/doc/DocGovActionBar'; import { FreeformDocumentActionBar } from './components/doc/FreeformDocumentActionBar'; import { + createFreeformDoc, saveMessageToPrivate, listFreeformDocs, fetchFreeformDoc, @@ -18,6 +19,7 @@ import { unshareFreeformDoc, deleteFreeformDoc, } from './api/freeformDoc'; +import { registerUploadAttachments } from './lib/uploadFreeformRegister'; import { fetchChatSessions, upsertChatSession } from './api/chatSessions'; import { listProjectWorkflowDocuments } from './api/projectDocuments'; import { patchDocumentContent, getRevisionState } from './api/documentRevision'; @@ -2708,11 +2710,11 @@ export default function App() { }; // 发送消息与对话驱动双轨逻辑 - const handleSendMessage = ( + const handleSendMessage = async ( text: string, attachments?: { name: string; size: string; type: string; docId?: string; fileRaw?: File | Blob; fileUrl?: string }[], quotes?: QuotedMessage[] - ): SendMessageResult => { + ): Promise => { const trimmed = text.trim(); let clientTurnId: string; try { @@ -2724,53 +2726,32 @@ export default function App() { }; } - // 若包含附件,则自动将用户上传的文件批量注册为个人私密文档 (category: 'private') + // 附件必须先注册为服务端 freeform(server docId);失败硬拦,禁止本地 orphan 回退 let processedAttachments = attachments; if (attachments && attachments.length > 0) { - const newDeliverablesList: DeliverableItem[] = []; - const newDocCacheEntries: Record = {}; - const now = Date.now(); - - processedAttachments = attachments.map((att, idx) => { - const fileDocId = att.docId || `doc-upload-${now}-${idx}`; - const baseTitle = att.name.replace(/\.[^/.]+$/, ''); - const extMatch = att.name.match(/\.([^.]+)$/); - const ext = extMatch ? extMatch[1].toLowerCase() : ''; - - const newDeliverable: DeliverableItem = { - docId: fileDocId, + let registered; + try { + registered = await registerUploadAttachments({ + attachments, projectId: currentProjectId, - taskKey: 'user_requirements', - title: baseTitle, - stage: 'V0.5', - category: 'private', - creatorId: currentUser.id, - creatorName: currentUser.name, - isFixedWorkflow: false, - isTemplateOnly: false, - lifecycleState: 'drafting', - isClaimed: true, - author: currentUser.name, - reviewer: '李主管', - approver: '王总监', - updatedAt: new Date().toISOString().substring(0, 10), - summary: `用户上传的私密需求文档《${att.name}》,归档至个人私密文档库,可一键共享至项目参考库。`, - space: 'my_private', - governanceMode: 'freeform', - sourceType: 'uploaded_file', - fileMeta: { - originalName: att.name, - size: att.size, - mimeType: att.type, - extension: ext, - fileRaw: att.fileRaw, - fileUrl: att.fileUrl, - uploadedAt: new Date().toISOString(), - }, + userId: currentUser.id, + userName: currentUser.name, + createFreeformDoc, + }); + } catch (err) { + return { + accepted: false, + error: err instanceof Error ? err.message : '附件服务端注册失败,请重试', }; - const initialMd = `# ${baseTitle}\n\n> 上传人:${currentUser.name} | 上传时间:${new Date().toLocaleString()} | 文件大小:${att.size}\n\n## 1. 文档概述与背景\n\n本文件由用户上传并归档至【个人私密文档库】。原始附件格式:\`${att.type || 'document'}\`。\n\n## 2. 协作与共享说明\n\n- **当前状态**:🔒 **个人私密 (仅本人可见)**\n- 您可在底部操作栏点击「一键共享给项目组」,将该文档共享至项目协作参考库供其他成员查阅与评审。\n- 也可以随时点击「撤回为个人私密」恢复独立隔离。`; + } + + const newDeliverablesList = registered.map((r) => r.deliverable); + const newDocCacheEntries: Record = {}; + for (const r of registered) { + const baseTitle = r.deliverable.title; + const initialMd = r.detail.content_markdown; const newAST = markdownToBlockAST(initialMd, { - docId: fileDocId, + docId: r.serverDocId, title: baseTitle, taskKey: 'user_requirements', version: 'V1.0', @@ -2785,24 +2766,15 @@ export default function App() { }); newAST.governanceMode = 'freeform'; newAST.space = 'my_private'; - - newDeliverablesList.push(newDeliverable); - newDocCacheEntries[fileDocId] = newAST; - - // ✅ 将文件内容持久化到 IndexedDB,支持页面刷新后恢复预览 - // fire-and-forget:写入失败不影响正常流程 - if (att.fileRaw) { - saveFile(fileDocId, att.fileRaw, att.name).catch((e) => - console.warn('[fileStore] 保存上传文件失败:', fileDocId, e) + newDocCacheEntries[r.serverDocId] = newAST; + if (r.attachment.fileRaw) { + saveFile(r.serverDocId, r.attachment.fileRaw, r.attachment.name).catch((e) => + console.warn('[fileStore] 保存上传文件失败:', r.serverDocId, e), ); } + } - return { - ...att, - docId: fileDocId, - }; - }); - + processedAttachments = registered.map((r) => r.attachment); const newDocIds = new Set(newDeliverablesList.map((d) => d.docId)); setDeliverables((prev) => [...newDeliverablesList, ...prev.filter((d) => !newDocIds.has(d.docId))]); setDocCache((prev) => ({ @@ -2810,7 +2782,6 @@ export default function App() { ...newDocCacheEntries, })); - // 默认激活打开刚上传的第 1 个文档 if (processedAttachments.length > 0 && processedAttachments[0].docId) { setActiveDocId(processedAttachments[0].docId); setActiveCategory('private'); diff --git a/web/src/api/freeformDoc.ts b/web/src/api/freeformDoc.ts index bce78dd5..e4c34296 100644 --- a/web/src/api/freeformDoc.ts +++ b/web/src/api/freeformDoc.ts @@ -43,6 +43,36 @@ export interface CreateFromMessageResponse { data: FreeformDocDetail; } +/** + * 直接创建自由文档(上传附件即时注册服务端事实源) + */ +export async function createFreeformDoc(payload: { + projectId: number; + title: string; + contentMarkdown: string; + space?: 'my_private' | 'project_shared'; + clientKey?: string; +}): Promise { + const res = await fetch('/api/v1/freeform-documents', { + method: 'POST', + headers: getBearerAuthHeaders('application/json'), + body: JSON.stringify({ + project_id: payload.projectId, + title: payload.title, + content_markdown: payload.contentMarkdown, + space: payload.space || 'my_private', + client_key: payload.clientKey || undefined, + }), + }); + if (!res.ok) { + const errData = await res.json().catch(() => ({})); + const msg = errData.message || errData.error_code || `创建失败 (${res.status})`; + throw new Error(typeof msg === 'string' ? msg : `创建失败 (${res.status})`); + } + const body = await res.json(); + return body.data; +} + /** * 从单条智能体回复保存为我的私密文档(幂等) */ diff --git a/web/src/components/chat/InputArea.test.ts b/web/src/components/chat/InputArea.test.ts index edce38bd..9abfcbf8 100644 --- a/web/src/components/chat/InputArea.test.ts +++ b/web/src/components/chat/InputArea.test.ts @@ -26,3 +26,13 @@ test('InputArea shows thinking block message', () => { assert.equal(resolveThinkingBlock(true), '正在思考,请稍候'); assert.equal(resolveThinkingBlock(false), null); }); + +test('InputArea keeps draft when attachment registration fails', () => { + const applied = applySendResult( + { accepted: false, error: '附件「a.pdf」服务端注册失败:forbidden。未发送消息,请检查网络后重试。' }, + draft, + ); + assert.equal(applied.cleared, false); + assert.deepEqual(applied.draft, draft); + assert.match(applied.error || '', /服务端注册失败/); +}); diff --git a/web/src/components/chat/InputArea.tsx b/web/src/components/chat/InputArea.tsx index 12743a5e..7b306cdc 100644 --- a/web/src/components/chat/InputArea.tsx +++ b/web/src/components/chat/InputArea.tsx @@ -19,7 +19,7 @@ interface InputAreaProps { text: string, attachments?: InputAttachmentItem[], quotes?: QuotedMessage[] - ) => SendMessageResult; + ) => SendMessageResult | Promise; isThinking: boolean; isStreaming: boolean; onStopStreaming?: () => void; @@ -45,6 +45,7 @@ export const InputArea: React.FC = ({ const [attachments, setAttachments] = useState([]); const [uploadWarning, setUploadWarning] = useState(null); const [sendError, setSendError] = useState(null); + const [sending, setSending] = useState(false); const textareaRef = useRef(null); const fileInputRef = useRef(null); @@ -56,7 +57,7 @@ export const InputArea: React.FC = ({ } }, [text]); - const handleSend = () => { + const handleSend = async () => { const thinkingError = resolveThinkingBlock(isThinking); if (thinkingError) { setSendError(thinkingError); @@ -70,30 +71,42 @@ export const InputArea: React.FC = ({ return; } + if (sending) { + return; + } + if (!text.trim() && attachments.length === 0) { return; } - const result = onSendMessage(text.trim(), attachments, quotes); - const applied = applySendResult(result, { text: text.trim(), attachments }); - if (!applied.cleared) { - setSendError(applied.error); - return; - } - + setSending(true); setSendError(null); - setText(applied.draft.text); - setAttachments(applied.draft.attachments); - setUploadWarning(null); - if (textareaRef.current) { - textareaRef.current.style.height = '42px'; + try { + const result = await onSendMessage(text.trim(), attachments, quotes); + const applied = applySendResult(result, { text: text.trim(), attachments }); + if (!applied.cleared) { + setSendError(applied.error); + return; + } + + setSendError(null); + setText(applied.draft.text); + setAttachments(applied.draft.attachments); + setUploadWarning(null); + if (textareaRef.current) { + textareaRef.current.style.height = '42px'; + } + } catch (err) { + setSendError(err instanceof Error ? err.message : '消息发送失败,请重试'); + } finally { + setSending(false); } }; const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); - handleSend(); + void handleSend(); } }; @@ -454,16 +467,16 @@ export const InputArea: React.FC = ({ {/* 智能发送按钮 */}