diff --git a/web/src/components/chat/deliverableSync.test.ts b/web/src/components/chat/deliverableSync.test.ts index 78923eba..ba40a4c2 100644 --- a/web/src/components/chat/deliverableSync.test.ts +++ b/web/src/components/chat/deliverableSync.test.ts @@ -99,5 +99,34 @@ describe('mergeWorkflowDocumentsFromServer', () => { const asA = mergeWorkflowDocumentsFromServer([], [serverDoc], 56, { id: 9, name: '编制人A' }); expect(asA[0].author).toBe('编制人A'); expect(asA[0].authorId).toBe(9); + expect(asA[0].dutiesAuthoritative).toBe(true); + }); + + it('marks server controlled docs authoritative even when duty ids are 0', () => { + const merged = mergeWorkflowDocumentsFromServer( + [], + [ + { + doc_id: 'doc_proj56_intake', + project_id: 56, + task_key: 'intake', + title: '用户初始需求收集表', + lifecycle_state: 'drafting', + is_template_only: false, + author_id: 0, + author_name: '编制人A', + reviewer_id: 0, + reviewer_name: '审核人B', + approver_id: 0, + approver_name: '批准人C', + }, + ], + 56, + { id: 99, name: '编制人A' }, + ); + expect(merged[0].dutiesAuthoritative).toBe(true); + expect(merged[0].governanceMode).toBe('controlled'); + expect(merged[0].authorId).toBe(0); + expect(merged[0].author).toBe('编制人A'); }); }); diff --git a/web/src/components/user/UserProjectRightsModal.tsx b/web/src/components/user/UserProjectRightsModal.tsx index 33cd934c..ab2810b2 100644 --- a/web/src/components/user/UserProjectRightsModal.tsx +++ b/web/src/components/user/UserProjectRightsModal.tsx @@ -63,7 +63,12 @@ export const UserProjectRightsModal: FC = ({ reviewer: item.reviewer, approver: item.approver, projectPermission: permissions[item.docId], - isLegacyLocalDoc: !item.authorId && !item.reviewerId && !item.approverId, + dutiesAuthoritative: item.dutiesAuthoritative, + governanceMode: item.governanceMode, + isLegacyLocalDoc: + item.dutiesAuthoritative !== true && + item.governanceMode !== 'controlled' && + /^doc-p\d+-/.test(item.docId), }); deliverables.forEach((item) => { diff --git a/web/src/engine/markdownToBlock.ts b/web/src/engine/markdownToBlock.ts index b905728a..635e52d7 100644 --- a/web/src/engine/markdownToBlock.ts +++ b/web/src/engine/markdownToBlock.ts @@ -268,6 +268,7 @@ export function markdownToBlockAST( reviewerId: meta?.reviewerId, approver: meta?.approver || '王总监', approverId: meta?.approverId, + dutiesAuthoritative: meta?.dutiesAuthoritative, revisionEtag: meta?.revisionEtag, summary: meta?.summary || '由项目助手自动生成的受控交付物', updatedAt: meta?.updatedAt || new Date().toISOString().replace('T', ' ').substring(0, 16), diff --git a/web/src/lib/currentDocRole.test.ts b/web/src/lib/currentDocRole.test.ts index e461129c..7b53e81e 100644 --- a/web/src/lib/currentDocRole.test.ts +++ b/web/src/lib/currentDocRole.test.ts @@ -58,4 +58,30 @@ describe('resolveCurrentDocRole', () => { 'viewer', ); }); + + it('does not name-promote when server controlled duties are zero ids', () => { + assert.equal( + resolveCurrentDocRole({ + currentUser: { id: 99, name: '编制人A' }, + authorId: 0, + reviewerId: 0, + approverId: 0, + author: '编制人A', + reviewer: '审核人B', + approver: '批准人C', + dutiesAuthoritative: true, + governanceMode: 'controlled', + isLegacyLocalDoc: true, + }), + 'viewer', + ); + assert.equal( + resolveCurrentDocRole({ + currentUser: { id: 99, name: '编制人A' }, + author: '编制人A', + governanceMode: 'controlled', + }), + 'viewer', + ); + }); }); diff --git a/web/src/lib/currentDocRole.ts b/web/src/lib/currentDocRole.ts index ad69a5e3..561570cd 100644 --- a/web/src/lib/currentDocRole.ts +++ b/web/src/lib/currentDocRole.ts @@ -10,19 +10,25 @@ export type ResolveCurrentDocRoleInput = { approver?: string; projectPermission?: string; isLegacyLocalDoc?: boolean; + dutiesAuthoritative?: boolean; + governanceMode?: 'controlled' | 'freeform'; }; function hasAuthoritativeDutyId(input: ResolveCurrentDocRoleInput): boolean { return Boolean(input.authorId || input.reviewerId || input.approverId); } +function isAuthoritativeDutySource(input: ResolveCurrentDocRoleInput): boolean { + return input.dutiesAuthoritative === true || input.governanceMode === 'controlled'; +} + /** * 按权威 user id 解析文档职责。未知身份 fail-closed 为 viewer。 - * name 匹配仅用于无职责 id 的旧本地文档。 + * name 匹配仅用于真正旧本地文档;服务端受控即使 duty id 为 0 也不得按 name 提权。 */ export function resolveCurrentDocRole(input: ResolveCurrentDocRoleInput): UserRole { const uid = input.currentUser.id; - if (hasAuthoritativeDutyId(input)) { + if (isAuthoritativeDutySource(input) || 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'; diff --git a/web/src/lib/deliverableSync.ts b/web/src/lib/deliverableSync.ts index 47be830f..e0efd9f5 100644 --- a/web/src/lib/deliverableSync.ts +++ b/web/src/lib/deliverableSync.ts @@ -90,6 +90,7 @@ export function mergeWorkflowDocumentsFromServer( }; applyAuthoritativeDuties(base, s, slotIdx < 0); + base.dutiesAuthoritative = true; base.docId = s.doc_id; base.title = s.title || base.title; diff --git a/web/src/types/block.ts b/web/src/types/block.ts index cde3dde7..68c1d7c2 100644 --- a/web/src/types/block.ts +++ b/web/src/types/block.ts @@ -65,6 +65,8 @@ export interface DocumentAST { reviewerId?: number; approver: string; approverId?: number; + /** 服务端受控职责为权威来源;即使 id 为 0 也不得按 name 提权。 */ + dutiesAuthoritative?: boolean; /** 打开受控文档时取得的 revision-state ETag,人工 PATCH 必须使用该基线。 */ revisionEtag?: string; summary?: string; diff --git a/web/src/types/workbench.ts b/web/src/types/workbench.ts index 76b3473e..077312bb 100644 --- a/web/src/types/workbench.ts +++ b/web/src/types/workbench.ts @@ -313,6 +313,8 @@ export interface DeliverableItem { reviewerId?: number; approver: string; approverId?: number; + /** 来自服务端 summary 的职责;true 时禁止 name fallback 提权。 */ + dutiesAuthoritative?: boolean; updatedAt: string; summary?: string; sourceMessageSnippet?: string;