feat(review): 支持多候选稿验收轮次
- 支持每轮提交 1–5 个候选稿并按指定稿验收 - 保留历史轮次只读并兼容单候选稿版本接口 - 同步 SQLite/PostgreSQL schema、迁移验证、测试与项目文档
This commit is contained in:
@@ -30,6 +30,15 @@ function parseImageUrls(value: unknown): { valid: boolean; urls: string[] } {
|
||||
return { valid, urls };
|
||||
}
|
||||
|
||||
type CandidateBody = { candidate_name?: unknown; title?: unknown; description?: unknown; tags?: unknown; images?: unknown; image_count?: unknown };
|
||||
|
||||
function parseCandidates(value: unknown): CandidateBody[] | null {
|
||||
try {
|
||||
const parsed = typeof value === 'string' ? JSON.parse(value) : value;
|
||||
return Array.isArray(parsed) ? parsed as CandidateBody[] : null;
|
||||
} catch { return null; }
|
||||
}
|
||||
|
||||
// GET /api/notes - 笔记列表
|
||||
router.get('/', requireWriter, async (req: AuthRequest, res: Response) => {
|
||||
const { sort, order, q, collectionId, status, tag, externalId } = req.query as {
|
||||
@@ -76,9 +85,10 @@ router.post('/:noteId/text-annotations', requireWriter, async (req: AuthRequest,
|
||||
const content = String(req.body?.content ?? '').trim();
|
||||
if (!Number.isFinite(noteId) || !Number.isFinite(versionNumber) || !['title', 'description'].includes(target)) { res.status(400).json({ error: '批注目标无效' }); return; }
|
||||
if (!content || content.length > 1000) { res.status(400).json({ error: '批注内容须为 1–1000 个字符' }); return; }
|
||||
const context = await database.one<{ project_id: number }>('SELECT c.project_id FROM work_versions v JOIN notes n ON n.id=v.note_id JOIN collections c ON c.id=n.collection_id WHERE v.note_id=? AND v.version_number=?', [noteId, versionNumber]);
|
||||
const context = await database.one<{ project_id: number; review_round_id: number; active_round_id: number | null; round_status: string; collection_status: string }>('SELECT c.project_id,v.review_round_id,n.active_round_id,r.status AS round_status,c.status AS collection_status FROM work_versions v JOIN notes n ON n.id=v.note_id JOIN collections c ON c.id=n.collection_id JOIN review_rounds r ON r.id=v.review_round_id WHERE v.note_id=? AND v.version_number=?', [noteId, versionNumber]);
|
||||
if (!context) { res.status(404).json({ error: '作品版本不存在' }); return; }
|
||||
if (!await canWriteProject(req, context.project_id)) { res.status(403).json({ error: '无权批注该作品' }); return; }
|
||||
if (context.collection_status === 'completed' || context.round_status !== 'reviewing' || Number(context.review_round_id) !== Number(context.active_round_id)) { res.status(409).json({ error: '历史验收轮次为只读状态' }); return; }
|
||||
const id = await database.insertId('INSERT INTO text_annotations (note_id, version_number, target, content, author_name) VALUES (?, ?, ?, ?, ?)', [noteId, versionNumber, target, content, req.authUser?.display_name || 'API']);
|
||||
await audit(req, 'text_annotation.create', 'text_annotation', id, { noteId, versionNumber, target });
|
||||
res.status(201).json(await database.one<TextAnnotation>('SELECT * FROM text_annotations WHERE id=?', [id]));
|
||||
@@ -175,6 +185,50 @@ router.post('/:noteId/versions', requireWriter, upload.array('images', 30), asyn
|
||||
} catch (error) { files.forEach((file) => { try { if (fs.existsSync(file.path)) fs.unlinkSync(file.path); } catch { /* noop */ } }); next(error); }
|
||||
});
|
||||
|
||||
router.post('/:noteId/review-rounds', requireWriter, upload.array('images', 30), async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
const files = (req.files as Express.Multer.File[] | undefined) ?? [];
|
||||
const cleanupFiles = () => files.forEach((file) => { try { if (fs.existsSync(file.path)) fs.unlinkSync(file.path); } catch { /* noop */ } });
|
||||
try {
|
||||
const id = Number(req.params.noteId);
|
||||
const context = await database.one<{ project_id: number }>('SELECT c.project_id FROM notes n JOIN collections c ON c.id=n.collection_id WHERE n.id=?', [id]);
|
||||
if (!context) { cleanupFiles(); res.status(404).json({ error: '作品不存在' }); return; }
|
||||
if (!await canWriteProject(req, context.project_id)) { cleanupFiles(); res.status(403).json({ error: '无权操作该作品' }); return; }
|
||||
const rawCandidates = parseCandidates(req.body?.candidates);
|
||||
if (!rawCandidates || rawCandidates.length < 1 || rawCandidates.length > 5) { cleanupFiles(); res.status(400).json({ error: '每轮需要提交 1–5 个候选稿' }); return; }
|
||||
|
||||
const normalized = rawCandidates.map((candidate, index) => ({
|
||||
candidate_name: String(candidate.candidate_name ?? `方案 ${String.fromCharCode(65 + index)}`).trim(),
|
||||
title: String(candidate.title ?? '').trim(),
|
||||
description: String(candidate.description ?? '').trim(),
|
||||
tags: parseTags(candidate.tags),
|
||||
image_count: Number(candidate.image_count ?? 0),
|
||||
imageUrls: parseImageUrls(candidate.images),
|
||||
}));
|
||||
if (normalized.some((candidate) => !candidate.candidate_name || candidate.candidate_name.length > 30 || !candidate.title)) { cleanupFiles(); res.status(400).json({ error: '候选稿名称须为 1–30 个字符,标题不能为空' }); return; }
|
||||
|
||||
let note;
|
||||
if (files.length) {
|
||||
const expected = normalized.reduce((sum, candidate) => sum + candidate.image_count, 0);
|
||||
if (expected !== files.length || normalized.some((candidate) => candidate.image_count < 1 || candidate.image_count > 30)) { cleanupFiles(); res.status(400).json({ error: '候选稿图片数量与上传文件不一致' }); return; }
|
||||
let offset = 0;
|
||||
const uploadCandidates = normalized.map((candidate) => {
|
||||
const candidateFiles = files.slice(offset, offset + candidate.image_count); offset += candidate.image_count;
|
||||
return { ...candidate, files: candidateFiles.map((file) => ({ filename: file.filename, originalname: file.originalname, mimetype: file.mimetype, path: file.path })) };
|
||||
});
|
||||
note = await notesService.createReviewRound(id, uploadCandidates, req.authUser?.id);
|
||||
} else {
|
||||
const totalImages = normalized.reduce((sum, candidate) => sum + candidate.imageUrls.urls.length, 0);
|
||||
if (totalImages > 30 || normalized.some((candidate) => !candidate.imageUrls.valid || candidate.imageUrls.urls.length < 1)) { cleanupFiles(); res.status(400).json({ error: '每个候选稿至少需要 1 个有效公开图片 URL,本轮总计不超过 30 张' }); return; }
|
||||
note = await notesService.createReviewRoundFromUrls(id, normalized.map((candidate) => ({ ...candidate, images: candidate.imageUrls.urls })), req.authUser?.id);
|
||||
}
|
||||
await audit(req, 'work.review_round_create', 'work', id, { candidateCount: normalized.length, versionNumber: note.version_number });
|
||||
res.status(201).json(note);
|
||||
} catch (error) {
|
||||
cleanupFiles();
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
router.patch('/:noteId/status', requireWriter, async (req: AuthRequest, res: Response) => {
|
||||
const id = Number(req.params.noteId);
|
||||
const status = req.body?.status;
|
||||
@@ -196,14 +250,16 @@ router.post('/:noteId/reopen', requireRole('platform_admin', 'group_admin'), asy
|
||||
const id = Number(req.params.noteId);
|
||||
const reason = String(req.body?.reason ?? '').trim();
|
||||
if (!reason) { res.status(400).json({ error: '重新打开验收时必须填写原因' }); return; }
|
||||
const note = await database.one<{ review_status: string; version_number: number; project_id: number; collection_id: number }>('SELECT n.review_status, n.version_number, n.collection_id, c.project_id FROM notes n JOIN collections c ON c.id = n.collection_id WHERE n.id = ?', [id]);
|
||||
const note = await database.one<{ review_status: string; version_number: number; project_id: number; collection_id: number; active_round_id: number | null }>('SELECT n.review_status, n.version_number, n.collection_id, n.active_round_id, c.project_id FROM notes n JOIN collections c ON c.id = n.collection_id WHERE n.id = ?', [id]);
|
||||
if (!note) { res.status(404).json({ error: '作品不存在' }); return; }
|
||||
if (!await canWriteProject(req, note.project_id)) { res.status(403).json({ error: '无权操作该作品' }); return; }
|
||||
if (note.review_status !== 'approved') { res.status(409).json({ error: '只有已通过作品可以重新打开' }); return; }
|
||||
const actor = req.authUser!;
|
||||
await withTransaction(async (tx) => {
|
||||
await tx.execute("UPDATE notes SET review_status = 'pending' WHERE id = ?", [id]);
|
||||
await tx.execute("UPDATE work_versions SET review_status = 'pending' WHERE note_id = ? AND version_number = ?", [id, note.version_number]);
|
||||
await tx.execute("UPDATE notes SET approved_version_number = NULL WHERE id = ?", [id]);
|
||||
await tx.execute("UPDATE work_versions SET review_status = 'pending', candidate_status = 'pending' WHERE note_id = ? AND version_number = ?", [id, note.version_number]);
|
||||
if (note.active_round_id) await tx.execute("UPDATE review_rounds SET status = 'reviewing', selected_version_number = NULL, completed_at = NULL WHERE id = ?", [note.active_round_id]);
|
||||
await tx.execute("INSERT INTO review_events (note_id, version_number, event_type, from_status, to_status, reason, actor_name, actor_role) VALUES (?, ?, 'reopened', 'approved', 'pending', ?, ?, ?)", [id, note.version_number, reason, actor.display_name, actor.role]);
|
||||
await recalculateCollectionStatus(Number(note.collection_id), tx);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user