feat(review): 支持多候选稿验收轮次
- 支持每轮提交 1–5 个候选稿并按指定稿验收 - 保留历史轮次只读并兼容单候选稿版本接口 - 同步 SQLite/PostgreSQL schema、迁移验证、测试与项目文档
This commit is contained in:
@@ -13,7 +13,7 @@ if (!fs.existsSync(sqlitePath)) throw new Error(`SQLite 数据库不存在:${s
|
||||
|
||||
const tables = [
|
||||
'operation_groups', 'users', 'projects', 'collections', 'notes', 'images', 'annotations',
|
||||
'work_comments', 'work_versions', 'review_events', 'sessions', 'customer_sessions',
|
||||
'work_comments', 'work_versions', 'review_rounds', 'review_events', 'sessions', 'customer_sessions',
|
||||
'audit_logs', 'api_keys', 'storage_configs',
|
||||
] as const;
|
||||
const booleanColumns: Record<string, Set<string>> = {
|
||||
@@ -47,7 +47,10 @@ try {
|
||||
if (rows.length) await client.query(`SELECT setval(pg_get_serial_sequence('${table}', 'id'), (SELECT MAX(id) FROM "${table}"), true)`);
|
||||
}
|
||||
const collectionStatusRepair = schema.match(/-- COLLECTION_STATUS_REPAIR_START([\s\S]+?)-- COLLECTION_STATUS_REPAIR_END/)?.[1];
|
||||
const reviewRoundRepair = schema.match(/-- REVIEW_ROUND_REPAIR_START([\s\S]+?)-- REVIEW_ROUND_REPAIR_END/)?.[1];
|
||||
if (!collectionStatusRepair) throw new Error('PostgreSQL schema 缺少作品交付集状态修复脚本');
|
||||
if (!reviewRoundRepair) throw new Error('PostgreSQL schema 缺少验收轮次修复脚本');
|
||||
await client.query(reviewRoundRepair);
|
||||
await client.query(collectionStatusRepair);
|
||||
await client.query('COMMIT');
|
||||
process.stdout.write(`迁移完成:${tables.length} 张表已从 ${sqlitePath} 导入 PostgreSQL\n`);
|
||||
|
||||
@@ -119,6 +119,8 @@ try {
|
||||
if(Number((newVersion.body as {version_number:number}).version_number)!==2)throw new Error('作品版本号未递增');
|
||||
const workDetail=await request(`/api/notes/${workId}`,{headers:bearerHeaders});
|
||||
expectStatus(workDetail.response.status,200,'读取新版本作品',workDetail.body);
|
||||
const firstRound=(workDetail.body as {versions:Array<{version_number:number;round_status:string;candidate_status:string}>}).versions.find((item)=>item.version_number===1);
|
||||
if(firstRound?.round_status!=='completed'||firstRound.candidate_status!=='not_selected')throw new Error('新验收轮次未自动收口旧轮次');
|
||||
const currentImages=(workDetail.body as {images:Array<{url:string;storage_provider:string}>}).images;
|
||||
if(currentImages.length!==1||currentImages[0].url!=='https://cdn.example.com/runtime-v2.jpg'||currentImages[0].storage_provider!=='external')throw new Error('新版本图片没有按外部 URL 保存');
|
||||
const reviewLogin = await request('/api/review/postgres-runtime-test/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ reviewer_name: '客户测试', password: 'Review123!' }) });
|
||||
@@ -127,7 +129,7 @@ try {
|
||||
const reviewProject = await request('/api/review/postgres-runtime-test/project', {}, reviewCookie);
|
||||
expectStatus(reviewProject.response.status, 200, '客户项目读取');
|
||||
|
||||
const approveV2=await request(`/api/review/postgres-runtime-test/works/${workId}/decision`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({decision:'approved'})},reviewCookie);
|
||||
const approveV2=await request(`/api/review/postgres-runtime-test/works/${workId}/decision`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({version_number:2,decision:'approved'})},reviewCookie);
|
||||
expectStatus(approveV2.response.status,200,'客户通过作品',approveV2.body);
|
||||
const completedCollections=await request(`/api/projects/${projectId}/collections`,{headers:bearerHeaders});
|
||||
const completedCollection=(completedCollections.body as Array<{id:number;status:string;completed_at:string|null;approved_count:number}>).find((item)=>Number(item.id)===collectionId);
|
||||
@@ -142,7 +144,7 @@ try {
|
||||
expectStatus(reopenApproved.response.status,200,'组管理员重新打开已通过作品',reopenApproved.body);
|
||||
const reopenedByAdmin=await request(`/api/projects/${projectId}/collections`,{headers:bearerHeaders});
|
||||
if((reopenedByAdmin.body as Array<{id:number;status:string}>).find((item)=>Number(item.id)===collectionId)?.status!=='reviewing')throw new Error('管理员重新打开作品后,作品交付集未回到验收中');
|
||||
const approveReopened=await request(`/api/review/postgres-runtime-test/works/${workId}/decision`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({decision:'approved'})},reviewCookie);
|
||||
const approveReopened=await request(`/api/review/postgres-runtime-test/works/${workId}/decision`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({version_number:2,decision:'approved'})},reviewCookie);
|
||||
expectStatus(approveReopened.response.status,200,'客户通过重新打开的作品',approveReopened.body);
|
||||
|
||||
const versionThree=await request(`/api/notes/${workId}/versions`,{method:'POST',headers:{...bearerHeaders,'Content-Type':'application/json'},body:JSON.stringify({title:'接口作品 V3',description:'完成后追加版本',tags:['API 测试'],images:['https://cdn.example.com/runtime-v3.jpg']})});
|
||||
@@ -150,17 +152,44 @@ try {
|
||||
const reopenedCollections=await request(`/api/projects/${projectId}/collections`,{headers:bearerHeaders});
|
||||
const reopenedCollection=(reopenedCollections.body as Array<{id:number;status:string;completed_at:string|null}>).find((item)=>Number(item.id)===collectionId);
|
||||
if(reopenedCollection?.status!=='reviewing'||reopenedCollection.completed_at!==null)throw new Error('新版本未将作品交付集重新打开为验收中');
|
||||
const requestChanges=await request(`/api/review/postgres-runtime-test/works/${workId}/decision`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({decision:'changes_requested',reason:'请调整第三版'})},reviewCookie);
|
||||
const requestChanges=await request(`/api/review/postgres-runtime-test/works/${workId}/decision`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({version_number:3,decision:'changes_requested',reason:'请调整第三版'})},reviewCookie);
|
||||
expectStatus(requestChanges.response.status,200,'客户要求修改',requestChanges.body);
|
||||
const approveV3=await request(`/api/review/postgres-runtime-test/works/${workId}/decision`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({decision:'approved'})},reviewCookie);
|
||||
const approveV3=await request(`/api/review/postgres-runtime-test/works/${workId}/decision`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({version_number:3,decision:'approved'})},reviewCookie);
|
||||
expectStatus(approveV3.response.status,200,'客户再次通过',approveV3.body);
|
||||
const candidateRound=await request(`/api/notes/${workId}/review-rounds`,{method:'POST',headers:{...bearerHeaders,'Content-Type':'application/json'},body:JSON.stringify({candidates:[
|
||||
{candidate_name:'暖色方案',title:'候选稿 A',description:'暖色方向',tags:['A'],images:['https://cdn.example.com/runtime-candidate-a.jpg']},
|
||||
{candidate_name:'冷色方案',title:'候选稿 B',description:'冷色方向',tags:['B'],images:['https://cdn.example.com/runtime-candidate-b.jpg']}
|
||||
]})});
|
||||
expectStatus(candidateRound.response.status,201,'创建多候选稿验收轮次',candidateRound.body);
|
||||
const candidateDetail=await request(`/api/notes/${workId}`,{headers:bearerHeaders});
|
||||
expectStatus(candidateDetail.response.status,200,'读取多候选稿',candidateDetail.body);
|
||||
const latestCandidates=(candidateDetail.body as {versions:Array<{version_number:number;round_number:number;candidate_name:string;candidate_status:string}>}).versions.filter((item)=>item.round_number===4);
|
||||
if(latestCandidates.length!==2||latestCandidates.map((item)=>item.version_number).join(',')!=='4,5')throw new Error('同一验收轮次未生成两个独立候选稿');
|
||||
const historicalDecision=await request(`/api/review/postgres-runtime-test/works/${workId}/decision`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({version_number:3,decision:'approved'})},reviewCookie);
|
||||
expectStatus(historicalDecision.response.status,409,'历史验收轮次不可重复决策',historicalDecision.body);
|
||||
const historicalClientAnnotation=await request(`/api/review/postgres-runtime-test/works/${workId}/text-annotations`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({version_number:3,target:'title',content:'历史轮次不应写入'})},reviewCookie);
|
||||
expectStatus(historicalClientAnnotation.response.status,409,'客户不可批注历史轮次',historicalClientAnnotation.body);
|
||||
const historicalOperatorAnnotation=await request(`/api/notes/${workId}/text-annotations`,{method:'POST',headers:{...bearerHeaders,'Content-Type':'application/json'},body:JSON.stringify({version_number:3,target:'title',content:'历史轮次不应写入'})});
|
||||
expectStatus(historicalOperatorAnnotation.response.status,409,'工作台不可批注历史轮次',historicalOperatorAnnotation.body);
|
||||
const oversizedRound=await request(`/api/notes/${workId}/review-rounds`,{method:'POST',headers:{...bearerHeaders,'Content-Type':'application/json'},body:JSON.stringify({candidates:[{candidate_name:'超量方案',title:'超量方案',images:Array.from({length:31},(_,index)=>`https://cdn.example.com/oversized-${index}.jpg`)}]})});
|
||||
expectStatus(oversizedRound.response.status,400,'验收轮次总图片不可超过 30 张',oversizedRound.body);
|
||||
const changesA=await request(`/api/review/postgres-runtime-test/works/${workId}/decision`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({version_number:4,decision:'changes_requested',reason:'A 方案需调整'})},reviewCookie);
|
||||
expectStatus(changesA.response.status,200,'单个候选稿要求修改',changesA.body);
|
||||
const afterChanges=await request(`/api/notes/${workId}`,{headers:bearerHeaders});
|
||||
if((afterChanges.body as {review_status:string}).review_status!=='pending')throw new Error('仍有待验收候选稿时作品不应整体进入需修改');
|
||||
const chooseB=await request(`/api/review/postgres-runtime-test/works/${workId}/decision`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({version_number:5,decision:'approved'})},reviewCookie);
|
||||
expectStatus(chooseB.response.status,200,'选择并通过候选稿 B',chooseB.body);
|
||||
const selectedDetail=await request(`/api/notes/${workId}`,{headers:bearerHeaders});
|
||||
const selectedBody=selectedDetail.body as {version_number:number;approved_version_number:number;review_status:string;versions:Array<{version_number:number;candidate_status:string}>};
|
||||
if(selectedBody.version_number!==5||selectedBody.approved_version_number!==5||selectedBody.review_status!=='approved')throw new Error('作品未指向客户选中的候选稿');
|
||||
if(selectedBody.versions.find((item)=>item.version_number===4)?.candidate_status!=='not_selected'||selectedBody.versions.find((item)=>item.version_number===5)?.candidate_status!=='selected')throw new Error('选中候选稿后同轮状态未正确收口');
|
||||
const secondWork=await request('/api/notes',{method:'POST',headers:{...bearerHeaders,'Content-Type':'application/json'},body:JSON.stringify({collectionId,externalId:'runtime-client-work-002',title:'完成后新增作品',description:'验证部分通过',tags:['API 测试'],images:['https://cdn.example.com/runtime-second.jpg']})});
|
||||
expectStatus(secondWork.response.status,201,'完成后新增作品',secondWork.body);
|
||||
const secondWorkId=Number((secondWork.body as {id:number}).id);
|
||||
const partialCollections=await request(`/api/projects/${projectId}/collections`,{headers:bearerHeaders});
|
||||
const partialCollection=(partialCollections.body as Array<{id:number;status:string;work_count:number;approved_count:number}>).find((item)=>Number(item.id)===collectionId);
|
||||
if(partialCollection?.status!=='reviewing'||Number(partialCollection.work_count)!==2||Number(partialCollection.approved_count)!==1)throw new Error('完成后新增作品未恢复验收中或进度统计错误');
|
||||
const approveSecond=await request(`/api/review/postgres-runtime-test/works/${secondWorkId}/decision`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({decision:'approved'})},reviewCookie);
|
||||
const approveSecond=await request(`/api/review/postgres-runtime-test/works/${secondWorkId}/decision`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({version_number:1,decision:'approved'})},reviewCookie);
|
||||
expectStatus(approveSecond.response.status,200,'客户通过新增作品',approveSecond.body);
|
||||
const forbiddenDraft=await request(`/api/notes/${workId}/status`,{method:'PATCH',headers:{...bearerHeaders,'Content-Type':'application/json'},body:JSON.stringify({status:'draft'})});
|
||||
expectStatus(forbiddenDraft.response.status,409,'普通写入不能绕过重新打开规则',forbiddenDraft.body);
|
||||
|
||||
45
scripts/test-sqlite-review-rounds.ts
Normal file
45
scripts/test-sqlite-review-rounds.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
process.env.NODE_ENV = 'test';
|
||||
delete process.env.DATABASE_URL;
|
||||
|
||||
const { db } = await import('../api/db.js');
|
||||
const { database, closeDatabase } = await import('../api/database.js');
|
||||
const { decideCandidateInTransaction, ReviewDecisionError } = await import('../api/services/reviewService.js');
|
||||
|
||||
function assert(condition: unknown, message: string): asserts condition {
|
||||
if (!condition) throw new Error(message);
|
||||
}
|
||||
|
||||
db.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
const project = await database.one<{ id: number }>('SELECT id FROM projects ORDER BY id LIMIT 1');
|
||||
assert(project, 'SQLite 测试需要至少一个项目');
|
||||
const collectionId = await database.insertId("INSERT INTO collections (project_id, name, status) VALUES (?, ?, 'reviewing')", [project.id, `候选稿测试 ${Date.now()}`]);
|
||||
const noteId = await database.insertId("INSERT INTO notes (collection_id, title, review_status) VALUES (?, ?, 'pending')", [collectionId, '候选稿 A']);
|
||||
const roundId = await database.insertId("INSERT INTO review_rounds (note_id, round_number, status) VALUES (?, 1, 'reviewing')", [noteId]);
|
||||
await database.execute('UPDATE notes SET active_round_id = ? WHERE id = ?', [roundId, noteId]);
|
||||
await database.execute("INSERT INTO work_versions (note_id, version_number, title, review_status, review_round_id, candidate_name, candidate_status) VALUES (?, 1, ?, 'pending', ?, ?, 'pending')", [noteId, '候选稿 A', roundId, '方案 A']);
|
||||
await database.execute("INSERT INTO work_versions (note_id, version_number, title, review_status, review_round_id, candidate_name, candidate_status) VALUES (?, 2, ?, 'pending', ?, ?, 'pending')", [noteId, '候选稿 B', roundId, '方案 B']);
|
||||
|
||||
await decideCandidateInTransaction(database, { noteId, versionNumber: 1, projectId: project.id, decision: 'changes_requested', reason: 'A 需调整', actorName: '测试客户', actorRole: 'client' });
|
||||
assert((await database.one<{ review_status: string }>('SELECT review_status FROM notes WHERE id = ?', [noteId]))?.review_status === 'pending', '仍有待验收候选稿时作品应保持待验收');
|
||||
|
||||
await decideCandidateInTransaction(database, { noteId, versionNumber: 2, projectId: project.id, decision: 'approved', reason: '', actorName: '测试客户', actorRole: 'client' });
|
||||
const note = await database.one<{ version_number: number; approved_version_number: number; review_status: string }>('SELECT version_number, approved_version_number, review_status FROM notes WHERE id = ?', [noteId]);
|
||||
assert(note?.version_number === 2 && note.approved_version_number === 2 && note.review_status === 'approved', '作品未指向选中候选稿');
|
||||
const candidates = await database.all<Array<{ version_number: number; candidate_status: string }>[number]>('SELECT version_number, candidate_status FROM work_versions WHERE review_round_id = ? ORDER BY version_number', [roundId]);
|
||||
assert(candidates[0]?.candidate_status === 'not_selected' && candidates[1]?.candidate_status === 'selected', '同轮候选稿结果未正确收口');
|
||||
assert((await database.one<{ status: string }>('SELECT status FROM collections WHERE id = ?', [collectionId]))?.status === 'completed', '作品通过后作品交付集未自动完成');
|
||||
|
||||
let historicalRejected = false;
|
||||
try {
|
||||
await decideCandidateInTransaction(database, { noteId, versionNumber: 1, projectId: project.id, decision: 'approved', reason: '', actorName: '测试客户', actorRole: 'client' });
|
||||
} catch (error) {
|
||||
historicalRejected = error instanceof ReviewDecisionError && error.statusCode === 409;
|
||||
}
|
||||
assert(historicalRejected, '已完成轮次的候选稿不应被重复验收');
|
||||
|
||||
process.stdout.write('SQLite 多候选稿验证通过:单稿退修、其他候选继续验收、选中收口与历史只读\n');
|
||||
} finally {
|
||||
db.exec('ROLLBACK');
|
||||
await closeDatabase();
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import { newDb } from 'pg-mem';
|
||||
|
||||
const tables = [
|
||||
'operation_groups', 'users', 'projects', 'collections', 'notes', 'images', 'annotations',
|
||||
'work_comments', 'work_versions', 'review_events', 'sessions', 'customer_sessions',
|
||||
'work_comments', 'work_versions', 'review_rounds', 'review_events', 'sessions', 'customer_sessions',
|
||||
'audit_logs', 'api_keys', 'storage_configs',
|
||||
] as const;
|
||||
const booleanColumns: Record<string, Set<string>> = {
|
||||
@@ -22,7 +22,8 @@ try {
|
||||
const schema = fs.readFileSync(path.resolve('db/postgres/schema.sql'), 'utf8')
|
||||
.replace(/^BEGIN;|COMMIT;$/gm, '')
|
||||
.replace(/CREATE UNIQUE INDEX IF NOT EXISTS one_active_storage_config[^;]+;/, '')
|
||||
.replace(/-- COLLECTION_STATUS_REPAIR_START[\s\S]+?-- COLLECTION_STATUS_REPAIR_END/, '');
|
||||
.replace(/-- COLLECTION_STATUS_REPAIR_START[\s\S]+?-- COLLECTION_STATUS_REPAIR_END/, '')
|
||||
.replace(/-- REVIEW_ROUND_REPAIR_START[\s\S]+?-- REVIEW_ROUND_REPAIR_END/, '');
|
||||
await client.query(schema);
|
||||
for (const table of tables) {
|
||||
const exists = sqlite.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?").get(table);
|
||||
@@ -38,6 +39,9 @@ try {
|
||||
}
|
||||
const current = (await client.query(`SELECT n.version_number,n.review_status,i.storage_provider FROM notes n JOIN images i ON i.note_id=n.id AND i.version_number=n.version_number WHERE n.id=1 LIMIT 1`)).rows[0];
|
||||
if (!current || Number(current.version_number) < 1) throw new Error('作品版本关系未正确迁移');
|
||||
const invalidRoundLinks = Number((await client.query('SELECT COUNT(*)::int AS count FROM work_versions v LEFT JOIN review_rounds r ON r.id=v.review_round_id WHERE r.id IS NULL')).rows[0].count);
|
||||
const invalidActiveRounds = Number((await client.query('SELECT COUNT(*)::int AS count FROM notes n LEFT JOIN review_rounds r ON r.id=n.active_round_id WHERE r.id IS NULL OR r.note_id!=n.id')).rows[0].count);
|
||||
if (invalidRoundLinks || invalidActiveRounds) throw new Error(`验收轮次迁移关联无效:versions=${invalidRoundLinks}, notes=${invalidActiveRounds}`);
|
||||
const activeStorage = Number((await client.query("SELECT COUNT(*)::int AS count FROM storage_configs WHERE status='active'")).rows[0].count);
|
||||
if (activeStorage > 1) throw new Error('活动对象存储配置超过一个');
|
||||
process.stdout.write(`PostgreSQL schema 与迁移映射验证通过:${tables.length} 张表,当前作品 V${current.version_number},存储=${current.storage_provider}\n`);
|
||||
|
||||
Reference in New Issue
Block a user