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:
+25
-25
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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+)-(.+)$/;
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user