feat(web): keep server duties and PATCH with hydrate ETag

Retain ledger author/reviewer/approver, fail-closed unknown users to
viewer, and send the opened revision ETag instead of a fresh GET.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-13 17:02:23 +00:00
co-authored by Cursor
parent 79797cab7c
commit eefdd4c4fd
15 changed files with 343 additions and 39 deletions
+25 -25
View File
@@ -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);
+6
View File
@@ -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 = {
@@ -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);
});
});
@@ -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<UserProjectRightsModalProps> = ({
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<UserProjectRightsModalProps> = ({
</thead>
<tbody>
{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;
+4
View File
@@ -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,
+21
View File
@@ -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);
});
});
+32 -2
View File
@@ -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<DocumentAST | null> {
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+)-(.+)$/;
+61
View File
@@ -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',
);
});
});
+39
View File
@@ -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';
}
+33 -3
View File
@@ -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;
+29
View File
@@ -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');
});
});
+28
View File
@@ -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';
}
+5
View File
@@ -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;
+1 -1
View File
@@ -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';
+3
View File
@@ -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;