diff --git a/web/src/App.tsx b/web/src/App.tsx index 5c8e9de6..47a30470 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -22,7 +22,8 @@ import { import { registerUploadAttachments } from './lib/uploadFreeformRegister'; import { fetchChatSessions, upsertChatSession } from './api/chatSessions'; import { listProjectWorkflowDocuments } from './api/projectDocuments'; -import { patchDocumentContent, getRevisionState } from './api/documentRevision'; +import { patchDocumentContent } from './api/documentRevision'; +import { resolvePatchEtag } from './lib/revisionEtag'; import type { DiffResult, OverlayMode } from './features/documentRevision/types'; import { applyHeadBlockMap, astToClientBlockMap } from './features/documentRevision/overlayMatch'; import { @@ -48,6 +49,7 @@ import { toApiProjectRole, } from './lib/projectMemberAdapter'; import { mergeWorkflowDocumentsFromServer } from './lib/deliverableSync'; +import { resolveCurrentDocRole } from './lib/currentDocRole'; import { pollUntilPostTaskServerDoc, shouldSwitchTemplateSlotToServer, @@ -1092,8 +1094,11 @@ export default function App() { lifecycleState: target.lifecycleState || 'drafting', isClaimed: target.isClaimed ?? true, author: target.author || target.creatorName || '本人', + authorId: target.authorId, reviewer: target.reviewer || '李主管', + reviewerId: target.reviewerId, approver: target.approver || '王总监', + approverId: target.approverId, }); fallbackAST.governanceMode = target.governanceMode || 'freeform'; fallbackAST.space = target.space || (target.category === 'shared' ? 'project_shared' : 'my_private'); @@ -1218,18 +1223,18 @@ export default function App() { setDocCache((prev) => ({ ...prev, [docAST.docId]: docAST })); }, [docAST, docCache, deliverables]); - // 根据当前激活文档与用户权限矩阵,计算当前生效角色 - const currentDocRole: UserRole = (() => { - const perm = currentUser.projectPermissions?.[activeDocId]; - if (perm === 'author' || perm === 'reviewer' || perm === 'approver') { - return perm; - } - // 降级回退 - if (docAST.author === currentUser.name) return 'author'; - if (docAST.reviewer === currentUser.name) return 'reviewer'; - if (docAST.approver === currentUser.name) return 'approver'; - return 'author'; // 默认 - })(); + // 根据权威 user id 计算当前文档职责;未知身份 fail-closed 为 viewer + const currentDocRole: UserRole = resolveCurrentDocRole({ + currentUser, + authorId: docAST.authorId, + reviewerId: docAST.reviewerId, + approverId: docAST.approverId, + author: docAST.author, + reviewer: docAST.reviewer, + approver: docAST.approver, + projectPermission: currentUser.projectPermissions?.[activeDocId], + isLegacyLocalDoc: !docAST.authorId && !docAST.reviewerId && !docAST.approverId, + }); const setDocAST = (newAst: DocumentAST) => { setDocCache((prev) => ({ @@ -3336,18 +3341,10 @@ export default function App() { return; } try { - let etag = '0:0'; - try { - const st = await getRevisionState(activeDocId); - etag = st.etag; - } catch (e) { - const err = e as Error & { code?: string }; - if (err.code === 'DOCUMENT_REVISION_NEEDS_CONTENT') { - /* first PATCH bootstraps from markdown body */ - } else if (err.code && err.code !== 'DOCUMENT_REVISION_NOT_FOUND') { - throw e; - } - } + const etag = resolvePatchEtag({ + revisionEtag: nextAST.revisionEtag ?? docAST.revisionEtag, + governanceMode: nextAST.governanceMode ?? docAST.governanceMode, + }); const markdown = blockASTToMarkdown(nextAST); const idempotencyKey = (typeof crypto !== 'undefined' && 'randomUUID' in crypto && crypto.randomUUID()) || @@ -3362,6 +3359,9 @@ export default function App() { { etag, idempotencyKey }, ); const aligned = applyHeadBlockMap(nextAST, res.state?.head_block_map); + if (res.state?.etag) { + aligned.revisionEtag = res.state.etag; + } setDocAST(aligned); setDocCache((prev) => ({ ...prev, [activeDocId]: aligned })); setEditingBlock(null); diff --git a/web/src/api/projectDocuments.ts b/web/src/api/projectDocuments.ts index a9290186..98f27e6c 100644 --- a/web/src/api/projectDocuments.ts +++ b/web/src/api/projectDocuments.ts @@ -17,6 +17,12 @@ export type WorkflowDocumentSummary = { lease_holder_id?: string; lease_expires_at?: string; lease_heartbeat_at?: string; + author_id?: number; + author_name?: string; + reviewer_id?: number; + reviewer_name?: string; + approver_id?: number; + approver_name?: string; }; export type ControlledDocumentContent = { diff --git a/web/src/components/chat/deliverableSync.test.ts b/web/src/components/chat/deliverableSync.test.ts index 5650c3e1..78923eba 100644 --- a/web/src/components/chat/deliverableSync.test.ts +++ b/web/src/components/chat/deliverableSync.test.ts @@ -60,4 +60,44 @@ describe('mergeWorkflowDocumentsFromServer', () => { heartbeatAt: undefined, }); }); + + it('keeps authoritative author A and never forges B or stranger as author', () => { + const reviewerB = { id: 2, name: '审核人B' }; + const stranger = { id: 99, name: '陌生用户' }; + const serverDoc = { + doc_id: 'doc_proj56_intake', + project_id: 56, + task_key: 'intake', + title: '用户初始需求收集表', + lifecycle_state: 'drafting' as const, + is_template_only: false, + author_id: 9, + author_name: '编制人A', + reviewer_id: 2, + reviewer_name: '审核人B', + approver_id: 3, + approver_name: '批准人C', + }; + + const asB = mergeWorkflowDocumentsFromServer([], [serverDoc], 56, reviewerB); + expect(asB).toHaveLength(1); + expect(asB[0].authorId).toBe(9); + expect(asB[0].author).toBe('编制人A'); + expect(asB[0].reviewerId).toBe(2); + expect(asB[0].reviewer).toBe('审核人B'); + expect(asB[0].approverId).toBe(3); + expect(asB[0].approver).toBe('批准人C'); + expect(asB[0].author).not.toBe(reviewerB.name); + expect(asB[0].authorId).not.toBe(reviewerB.id); + + const asStranger = mergeWorkflowDocumentsFromServer([], [serverDoc], 56, stranger); + expect(asStranger[0].author).toBe('编制人A'); + expect(asStranger[0].authorId).toBe(9); + expect(asStranger[0].author).not.toBe(stranger.name); + expect(asStranger[0].authorId).not.toBe(stranger.id); + + const asA = mergeWorkflowDocumentsFromServer([], [serverDoc], 56, { id: 9, name: '编制人A' }); + expect(asA[0].author).toBe('编制人A'); + expect(asA[0].authorId).toBe(9); + }); }); diff --git a/web/src/components/user/UserProjectRightsModal.tsx b/web/src/components/user/UserProjectRightsModal.tsx index cfe83bdc..33cd934c 100644 --- a/web/src/components/user/UserProjectRightsModal.tsx +++ b/web/src/components/user/UserProjectRightsModal.tsx @@ -9,6 +9,7 @@ import { } from 'lucide-react'; import { UserProfile, DocPermissionRole } from '../../types/docgov'; import { DeliverableItem } from '../../types/workbench'; +import { resolveCurrentDocRole } from '../../lib/currentDocRole'; interface UserProjectRightsModalProps { isOpen: boolean; @@ -52,8 +53,21 @@ export const UserProjectRightsModal: FC = ({ let approverCount = 0; let viewerCount = 0; + const roleForItem = (item: DeliverableItem): DocPermissionRole => + resolveCurrentDocRole({ + currentUser, + authorId: item.authorId, + reviewerId: item.reviewerId, + approverId: item.approverId, + author: item.author, + reviewer: item.reviewer, + approver: item.approver, + projectPermission: permissions[item.docId], + isLegacyLocalDoc: !item.authorId && !item.reviewerId && !item.approverId, + }); + deliverables.forEach((item) => { - const role = permissions[item.docId] || (item.author === currentUser.name ? 'author' : 'viewer'); + const role = roleForItem(item); if (role === 'author') authorCount++; else if (role === 'reviewer') reviewerCount++; else if (role === 'approver') approverCount++; @@ -398,13 +412,7 @@ export const UserProjectRightsModal: FC = ({ {deliverables.map((item, idx) => { - const role = - permissions[item.docId] || - (item.author === currentUser.name - ? 'author' - : item.reviewer === currentUser.name - ? 'reviewer' - : 'viewer'); + const role = roleForItem(item); const badge = getRoleBadge(role); const isCurrentActive = item.docId === activeDocId; diff --git a/web/src/engine/markdownToBlock.ts b/web/src/engine/markdownToBlock.ts index 05b716ae..b905728a 100644 --- a/web/src/engine/markdownToBlock.ts +++ b/web/src/engine/markdownToBlock.ts @@ -263,8 +263,12 @@ export function markdownToBlockAST( lifecycleState: meta?.lifecycleState || 'drafting', isClaimed: meta?.isClaimed ?? false, author: meta?.author || '张工', + authorId: meta?.authorId, reviewer: meta?.reviewer || '李主管', + reviewerId: meta?.reviewerId, approver: meta?.approver || '王总监', + approverId: meta?.approverId, + revisionEtag: meta?.revisionEtag, summary: meta?.summary || '由项目助手自动生成的受控交付物', updatedAt: meta?.updatedAt || new Date().toISOString().replace('T', ' ').substring(0, 16), rootBlock, diff --git a/web/src/lib/controlledDocHydrate.test.ts b/web/src/lib/controlledDocHydrate.test.ts index b12dd7e6..966b148f 100644 --- a/web/src/lib/controlledDocHydrate.test.ts +++ b/web/src/lib/controlledDocHydrate.test.ts @@ -2,9 +2,11 @@ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { canonicalDocIdForTask, + hydrateControlledDocumentContent, resolveCanonicalDocId, shouldLoadControlledContent, } from './controlledDocHydrate'; +import { resolvePatchEtag } from './revisionEtag'; import type { DeliverableItem } from '../types/workbench'; const base: DeliverableItem = { @@ -43,4 +45,23 @@ describe('controlledDocHydrate', () => { 'doc_proj56_research', ); }); + + it('attaches hydrate ETag as PATCH baseline and ignores a later GET', async () => { + const ast = await hydrateControlledDocumentContent('doc_proj56_intake', base, { + fetchContent: async () => ({ + doc_id: 'doc_proj56_intake', + version: 1, + content_sha256: 'abc', + markdown: '# 标题\n\n正文', + is_template_only: false, + }), + fetchRevisionState: async () => ({ etag: '2:7' }), + }); + assert.ok(ast); + assert.equal(ast?.revisionEtag, '2:7'); + const latestGet = { etag: '2:8' }; + const patchEtag = resolvePatchEtag({ revisionEtag: ast?.revisionEtag }); + assert.equal(patchEtag, '2:7'); + assert.notEqual(patchEtag, latestGet.etag); + }); }); diff --git a/web/src/lib/controlledDocHydrate.ts b/web/src/lib/controlledDocHydrate.ts index 7eacc06a..e8379d05 100644 --- a/web/src/lib/controlledDocHydrate.ts +++ b/web/src/lib/controlledDocHydrate.ts @@ -1,7 +1,9 @@ import { fetchControlledDocumentContent } from '../api/projectDocuments'; +import { getRevisionState } from '../api/documentRevision'; import { markdownToBlockAST } from '../engine/markdownToBlock'; import type { DocumentAST } from '../types/block'; import type { DeliverableItem } from '../types/workbench'; +import { revisionEtagFromHydrate } from './revisionEtag'; export function isControlledWorkflowDeliverable(doc: DeliverableItem): boolean { return ( @@ -30,8 +32,11 @@ export function buildControlledLoadErrorSkeleton(doc: DeliverableItem): Document lifecycleState: doc.lifecycleState, isClaimed: doc.isClaimed, author: doc.author, + authorId: doc.authorId, reviewer: doc.reviewer, + reviewerId: doc.reviewerId, approver: doc.approver, + approverId: doc.approverId, }); ast.governanceMode = 'controlled'; ast.editLease = doc.editLease; @@ -51,8 +56,11 @@ export function buildControlledLoadingSkeleton(doc: DeliverableItem): DocumentAS lifecycleState: doc.lifecycleState, isClaimed: doc.isClaimed, author: doc.author, + authorId: doc.authorId, reviewer: doc.reviewer, + reviewerId: doc.reviewerId, approver: doc.approver, + approverId: doc.approverId, }); ast.governanceMode = 'controlled'; ast.editLease = doc.editLease; @@ -76,8 +84,11 @@ export function buildControlledDocumentAST( lifecycleState: doc.lifecycleState, isClaimed: doc.isClaimed, author: doc.author, + authorId: doc.authorId, reviewer: doc.reviewer, + reviewerId: doc.reviewerId, approver: doc.approver, + approverId: doc.approverId, }); fullAST.governanceMode = 'controlled'; fullAST.editLease = doc.editLease; @@ -87,10 +98,29 @@ export function buildControlledDocumentAST( export async function hydrateControlledDocumentContent( docId: string, doc: DeliverableItem, + deps?: { + fetchContent?: typeof fetchControlledDocumentContent; + fetchRevisionState?: (id: string) => Promise<{ etag: string }>; + }, ): Promise { - const detail = await fetchControlledDocumentContent(docId); + const loadContent = deps?.fetchContent ?? fetchControlledDocumentContent; + const loadRevision = deps?.fetchRevisionState ?? getRevisionState; + const [detail, rev] = await Promise.all([ + loadContent(docId), + loadRevision(docId).then( + (st) => ({ ok: true as const, st }), + (err: unknown) => ({ ok: false as const, err }), + ), + ]); if (!detail?.markdown) return null; - return buildControlledDocumentAST(detail.markdown, doc, detail.doc_id, detail.version); + const ast = buildControlledDocumentAST(detail.markdown, doc, detail.doc_id, detail.version); + if (rev.ok) { + ast.revisionEtag = revisionEtagFromHydrate(rev.st); + } else { + const err = rev.err as { code?: string }; + ast.revisionEtag = revisionEtagFromHydrate(null, err); + } + return ast; } const LEGACY_DOC_P = /^doc-p(\d+)-(.+)$/; diff --git a/web/src/lib/currentDocRole.test.ts b/web/src/lib/currentDocRole.test.ts new file mode 100644 index 00000000..e461129c --- /dev/null +++ b/web/src/lib/currentDocRole.test.ts @@ -0,0 +1,61 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { resolveCurrentDocRole } from './currentDocRole'; + +const authorA = { id: 9, name: '编制人A' }; +const reviewerB = { id: 2, name: '审核人B' }; +const stranger = { id: 99, name: '陌生用户' }; + +const duties = { + authorId: 9, + author: '编制人A', + reviewerId: 2, + reviewer: '审核人B', + approverId: 3, + approver: '批准人C', +}; + +describe('resolveCurrentDocRole', () => { + it('resolves A as author by authoritative user id', () => { + assert.equal(resolveCurrentDocRole({ currentUser: authorA, ...duties }), 'author'); + }); + + it('does not promote reviewer B or stranger to author', () => { + assert.equal(resolveCurrentDocRole({ currentUser: reviewerB, ...duties }), 'reviewer'); + assert.equal(resolveCurrentDocRole({ currentUser: stranger, ...duties }), 'viewer'); + }); + + it('fail-closes unknown identity to viewer even if projectPermission says author', () => { + assert.equal( + resolveCurrentDocRole({ + currentUser: stranger, + ...duties, + projectPermission: 'author', + }), + 'viewer', + ); + }); + + it('matches name only for legacy local docs without duty ids', () => { + assert.equal( + resolveCurrentDocRole({ + currentUser: { id: 1, name: '张工' }, + author: '张工', + reviewer: '李主管', + approver: '王总监', + isLegacyLocalDoc: true, + }), + 'author', + ); + assert.equal( + resolveCurrentDocRole({ + currentUser: stranger, + author: '张工', + reviewer: '李主管', + approver: '王总监', + isLegacyLocalDoc: true, + }), + 'viewer', + ); + }); +}); diff --git a/web/src/lib/currentDocRole.ts b/web/src/lib/currentDocRole.ts new file mode 100644 index 00000000..ad69a5e3 --- /dev/null +++ b/web/src/lib/currentDocRole.ts @@ -0,0 +1,39 @@ +import type { UserRole } from '../types/docgov'; + +export type ResolveCurrentDocRoleInput = { + currentUser: { id: number; name: string }; + authorId?: number; + reviewerId?: number; + approverId?: number; + author?: string; + reviewer?: string; + approver?: string; + projectPermission?: string; + isLegacyLocalDoc?: boolean; +}; + +function hasAuthoritativeDutyId(input: ResolveCurrentDocRoleInput): boolean { + return Boolean(input.authorId || input.reviewerId || input.approverId); +} + +/** + * 按权威 user id 解析文档职责。未知身份 fail-closed 为 viewer。 + * name 匹配仅用于无职责 id 的旧本地文档。 + */ +export function resolveCurrentDocRole(input: ResolveCurrentDocRoleInput): UserRole { + const uid = input.currentUser.id; + if (hasAuthoritativeDutyId(input)) { + if (input.authorId && input.authorId === uid) return 'author'; + if (input.reviewerId && input.reviewerId === uid) return 'reviewer'; + if (input.approverId && input.approverId === uid) return 'approver'; + return 'viewer'; + } + + if (input.isLegacyLocalDoc) { + if (input.author && input.author === input.currentUser.name) return 'author'; + if (input.reviewer && input.reviewer === input.currentUser.name) return 'reviewer'; + if (input.approver && input.approver === input.currentUser.name) return 'approver'; + } + + return 'viewer'; +} diff --git a/web/src/lib/deliverableSync.ts b/web/src/lib/deliverableSync.ts index ffdfa925..47be830f 100644 --- a/web/src/lib/deliverableSync.ts +++ b/web/src/lib/deliverableSync.ts @@ -2,6 +2,34 @@ import type { WorkflowDocumentSummary } from '../api/projectDocuments'; import type { DeliverableItem } from '../types/workbench'; import type { DocLifecycleState } from '../types/docgov'; +function applyAuthoritativeDuties( + base: DeliverableItem, + s: WorkflowDocumentSummary, + isNewSlot: boolean, +): void { + const hasDuties = + s.author_id != null || + s.author_name != null || + s.reviewer_id != null || + s.reviewer_name != null || + s.approver_id != null || + s.approver_name != null; + if (hasDuties) { + base.authorId = s.author_id; + base.author = s.author_name || ''; + base.reviewerId = s.reviewer_id; + base.reviewer = s.reviewer_name || ''; + base.approverId = s.approver_id; + base.approver = s.approver_name || ''; + return; + } + if (isNewSlot) { + base.author = ''; + base.reviewer = ''; + base.approver = ''; + } +} + function mapLifecycleState(raw: string): DocLifecycleState { switch (raw) { case 'reviewing': @@ -54,13 +82,15 @@ export function mergeWorkflowDocumentsFromServer( isTemplateOnly: s.is_template_only, lifecycleState: mapLifecycleState(s.lifecycle_state), isClaimed: !s.is_template_only, - author: fallbackUser.name, - reviewer: '李主管', - approver: '王总监', + author: '', + reviewer: '', + approver: '', updatedAt: (s.updated_at || '').replace('T', ' ').substring(0, 16), governanceMode: 'controlled', }; + applyAuthoritativeDuties(base, s, slotIdx < 0); + base.docId = s.doc_id; base.title = s.title || base.title; base.isTemplateOnly = s.is_template_only; diff --git a/web/src/lib/revisionEtag.test.ts b/web/src/lib/revisionEtag.test.ts new file mode 100644 index 00000000..d0485098 --- /dev/null +++ b/web/src/lib/revisionEtag.test.ts @@ -0,0 +1,29 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { + resolvePatchEtag, + revisionEtagFromHydrate, +} from './revisionEtag'; + +describe('revisionEtag', () => { + it('uses stale hydrate ETag for PATCH and never the latest GET', () => { + const hydrated = revisionEtagFromHydrate({ etag: '3:12' }); + const latestFromGet = { etag: '3:13' }; + const patchEtag = resolvePatchEtag({ revisionEtag: hydrated }); + assert.equal(patchEtag, '3:12'); + assert.notEqual(patchEtag, latestFromGet.etag); + }); + + it('keeps 0:0 bootstrap when ledger is missing', () => { + assert.equal( + revisionEtagFromHydrate(null, { code: 'DOCUMENT_REVISION_NEEDS_CONTENT' }), + '0:0', + ); + assert.equal( + revisionEtagFromHydrate(null, { code: 'DOCUMENT_REVISION_NOT_FOUND' }), + '0:0', + ); + assert.equal(resolvePatchEtag({ governanceMode: 'freeform' }), '0:0'); + assert.equal(resolvePatchEtag({}), '0:0'); + }); +}); diff --git a/web/src/lib/revisionEtag.ts b/web/src/lib/revisionEtag.ts new file mode 100644 index 00000000..3c29efb0 --- /dev/null +++ b/web/src/lib/revisionEtag.ts @@ -0,0 +1,28 @@ +export type HydrateRevisionError = { code?: string }; + +/** 打开文档时从 revision-state 取得的基线 ETag;无账本时兼容 0:0。 */ +export function revisionEtagFromHydrate( + state: { etag?: string } | null | undefined, + err?: HydrateRevisionError | null, +): string | undefined { + if (state?.etag) return state.etag; + if ( + err?.code === 'DOCUMENT_REVISION_NEEDS_CONTENT' || + err?.code === 'DOCUMENT_REVISION_NOT_FOUND' + ) { + return '0:0'; + } + return undefined; +} + +/** + * 人工 PATCH 必须使用打开时缓存的基线 ETag。 + * 禁止在点击保存时重新 GET 最新 ETag。 + */ +export function resolvePatchEtag(input: { + revisionEtag?: string; + governanceMode?: 'controlled' | 'freeform'; +}): string { + if (input.revisionEtag) return input.revisionEtag; + return '0:0'; +} diff --git a/web/src/types/block.ts b/web/src/types/block.ts index c8452134..cde3dde7 100644 --- a/web/src/types/block.ts +++ b/web/src/types/block.ts @@ -60,8 +60,13 @@ export interface DocumentAST { lifecycleState: DocLifecycleState; isClaimed: boolean; author: string; + authorId?: number; reviewer: string; + reviewerId?: number; approver: string; + approverId?: number; + /** 打开受控文档时取得的 revision-state ETag,人工 PATCH 必须使用该基线。 */ + revisionEtag?: string; summary?: string; visibilityRule?: DocVisibilityRule; updatedAt: string; diff --git a/web/src/types/docgov.ts b/web/src/types/docgov.ts index 1d7b2a6c..68a32cc1 100644 --- a/web/src/types/docgov.ts +++ b/web/src/types/docgov.ts @@ -8,7 +8,7 @@ export type DocLifecycleState = | 'approved' | 'rejected'; -export type UserRole = 'author' | 'reviewer' | 'approver'; +export type UserRole = 'author' | 'reviewer' | 'approver' | 'viewer'; export type DocPermissionRole = 'author' | 'reviewer' | 'approver' | 'viewer'; diff --git a/web/src/types/workbench.ts b/web/src/types/workbench.ts index a351c542..76b3473e 100644 --- a/web/src/types/workbench.ts +++ b/web/src/types/workbench.ts @@ -308,8 +308,11 @@ export interface DeliverableItem { lifecycleState: DocLifecycleState; isClaimed: boolean; author: string; + authorId?: number; reviewer: string; + reviewerId?: number; approver: string; + approverId?: number; updatedAt: string; summary?: string; sourceMessageSnippet?: string;