feat(review): 重构项目级单方案验收协作
This commit is contained in:
@@ -5,10 +5,10 @@ import { Router, type Response, type NextFunction } from 'express';
|
||||
import { upload } from '../upload.js';
|
||||
import { notesService } from '../services/notesService.js';
|
||||
import { recalculateCollectionStatus } from '../services/collectionsService.js';
|
||||
import { recalculateProjectReviewStatus } from '../services/projectsService.js';
|
||||
import { audit, canWriteProject, requireRole, requireWriter, type AuthRequest } from '../auth.js';
|
||||
import { database, withTransaction } from '../database.js';
|
||||
import fs from 'fs';
|
||||
import type { TextAnnotation } from '../../shared/types.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -79,19 +79,8 @@ router.get('/:noteId', requireWriter, async (req: AuthRequest, res: Response, ne
|
||||
});
|
||||
|
||||
router.post('/:noteId/text-annotations', requireWriter, async (req: AuthRequest, res: Response) => {
|
||||
const noteId = Number(req.params.noteId);
|
||||
const versionNumber = Number(req.body?.version_number);
|
||||
const target = req.body?.target;
|
||||
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; 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]));
|
||||
res.setHeader('Deprecation', 'true');
|
||||
res.status(410).json({ error: '该接口已停用,请使用 /api/works/:workId/text-annotations 并提交明确的文字选区' });
|
||||
});
|
||||
|
||||
// POST /api/notes - 上传新笔记 (multipart/form-data)
|
||||
@@ -124,12 +113,13 @@ router.post(
|
||||
return;
|
||||
}
|
||||
const files = (req.files as Express.Multer.File[] | undefined) ?? [];
|
||||
const collection = await database.one<{ project_id: number }>('SELECT c.project_id FROM collections c WHERE c.id = ?', [collectionId]);
|
||||
const collection = await database.one<{ project_id: number; project_status: string }>('SELECT c.project_id,p.status AS project_status FROM collections c JOIN projects p ON p.id=c.project_id WHERE c.id = ?', [collectionId]);
|
||||
if (!collection || !await canWriteProject(req, collection.project_id)) {
|
||||
files.forEach((file) => { try { fs.unlinkSync(file.path); } catch { /* uploaded file may already be gone */ } });
|
||||
res.status(collection ? 403 : 404).json({ error: collection ? '无权向该作品交付集上传作品' : '作品交付集不存在' });
|
||||
return;
|
||||
}
|
||||
if (collection.project_status !== 'active') { files.forEach((file) => { try { fs.unlinkSync(file.path); } catch { /* noop */ } }); res.status(409).json({ error: '已关闭或归档项目为只读状态' }); return; }
|
||||
if (externalId) {
|
||||
const existing = await notesService.findByExternalId(collectionId, externalId);
|
||||
if (existing) { res.status(200).json({ ...existing, idempotent: true }); return; }
|
||||
@@ -167,9 +157,10 @@ router.post('/:noteId/versions', requireWriter, upload.array('images', 30), asyn
|
||||
const files = (req.files as Express.Multer.File[] | undefined) ?? [];
|
||||
try {
|
||||
const id = Number(req.params.noteId);
|
||||
const context = await database.one<{ title: string; description: string; tags: string; project_id: number }>('SELECT n.title, n.description, n.tags, c.project_id FROM notes n JOIN collections c ON c.id = n.collection_id WHERE n.id = ?', [id]);
|
||||
const context = await database.one<{ title: string; description: string; tags: string; project_id: number; project_status: string }>('SELECT n.title,n.description,n.tags,n.project_id,p.status AS project_status FROM notes n JOIN projects p ON p.id=n.project_id WHERE n.id = ?', [id]);
|
||||
if (!context) { res.status(404).json({ error: '作品不存在' }); return; }
|
||||
if (!await canWriteProject(req, context.project_id)) { res.status(403).json({ error: '无权操作该作品' }); return; }
|
||||
if (context.project_status !== 'active') { res.status(409).json({ error: '已关闭或归档项目为只读状态' }); return; }
|
||||
const { valid: validImageUrls, urls: imageUrls } = parseImageUrls(req.body.images);
|
||||
if (!validImageUrls) { res.status(400).json({ error: 'images 需要包含 1–30 个有效的 HTTP/HTTPS 图片 URL' }); return; }
|
||||
if (!files.length && !imageUrls.length) { res.status(400).json({ error: '新版本至少需要一张图片' }); return; }
|
||||
@@ -190,11 +181,12 @@ router.post('/:noteId/review-rounds', requireWriter, upload.array('images', 30),
|
||||
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]);
|
||||
const context = await database.one<{ project_id: number; project_status: string }>('SELECT n.project_id,p.status AS project_status FROM notes n JOIN projects p ON p.id=n.project_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; }
|
||||
if (context.project_status !== 'active') { cleanupFiles(); res.status(409).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; }
|
||||
if (!rawCandidates || rawCandidates.length !== 1) { cleanupFiles(); res.status(400).json({ error: '每个验收轮次只能提交一个方案' }); return; }
|
||||
|
||||
const normalized = rawCandidates.map((candidate, index) => ({
|
||||
candidate_name: String(candidate.candidate_name ?? `方案 ${String.fromCharCode(65 + index)}`).trim(),
|
||||
@@ -215,13 +207,15 @@ router.post('/:noteId/review-rounds', requireWriter, upload.array('images', 30),
|
||||
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);
|
||||
const only = uploadCandidates[0];
|
||||
note = await notesService.createRound(id, { title: only.title, description: only.description, tags: only.tags, files: only.files }, 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);
|
||||
const only = normalized[0];
|
||||
note = await notesService.createRoundFromUrls(id, { title: only.title, description: only.description, tags: only.tags, images: only.imageUrls.urls }, req.authUser?.id);
|
||||
}
|
||||
await audit(req, 'work.review_round_create', 'work', id, { candidateCount: normalized.length, versionNumber: note.version_number });
|
||||
await audit(req, 'work.review_round_create', 'work', id, { candidateCount: 1, versionNumber: note.version_number, deprecatedRoute: true });
|
||||
res.status(201).json(note);
|
||||
} catch (error) {
|
||||
cleanupFiles();
|
||||
@@ -236,8 +230,9 @@ router.patch('/:noteId/status', requireWriter, async (req: AuthRequest, res: Res
|
||||
res.status(400).json({ error: '无效的验收状态' });
|
||||
return;
|
||||
}
|
||||
const context = await database.one<{ project_id: number; review_status: string }>('SELECT c.project_id, n.review_status FROM notes n JOIN collections c ON c.id = n.collection_id WHERE n.id = ?', [id]);
|
||||
const context = await database.one<{ project_id: number; review_status: string; project_status: string }>('SELECT n.project_id,n.review_status,p.status AS project_status FROM notes n JOIN projects p ON p.id=n.project_id WHERE n.id = ?', [id]);
|
||||
if (context && !await canWriteProject(req, context.project_id)) { res.status(403).json({ error: '无权操作该作品' }); return; }
|
||||
if (context && context.project_status !== 'active') { res.status(409).json({ error: '已关闭或归档项目为只读状态' }); return; }
|
||||
if (context?.review_status === 'approved') { res.status(409).json({ error: '已通过作品只能由组管理员填写原因后重新打开' }); return; }
|
||||
if (!await notesService.setStatus(id, status)) {
|
||||
res.status(404).json({ error: '作品不存在' });
|
||||
@@ -250,9 +245,10 @@ 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; 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]);
|
||||
const note = await database.one<{ review_status: string; version_number: number; project_id: number; collection_id: number; active_round_id: number | null; project_status: string }>('SELECT n.review_status,n.version_number,n.project_id,n.collection_id,n.active_round_id,p.status AS project_status FROM notes n JOIN projects p ON p.id=n.project_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.project_status !== 'active') { res.status(409).json({ error: '已关闭或归档项目为只读状态' }); return; }
|
||||
if (note.review_status !== 'approved') { res.status(409).json({ error: '只有已通过作品可以重新打开' }); return; }
|
||||
const actor = req.authUser!;
|
||||
await withTransaction(async (tx) => {
|
||||
@@ -262,6 +258,7 @@ router.post('/:noteId/reopen', requireRole('platform_admin', 'group_admin'), asy
|
||||
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);
|
||||
await recalculateProjectReviewStatus(Number(note.project_id), tx);
|
||||
});
|
||||
await audit(req, 'work.reopen', 'work', id, { reason, versionNumber: note.version_number });
|
||||
res.json({ success: true, status: 'pending' });
|
||||
@@ -274,8 +271,9 @@ router.delete('/:noteId', requireWriter, async (req: AuthRequest, res: Response)
|
||||
res.status(400).json({ error: '无效的笔记 ID' });
|
||||
return;
|
||||
}
|
||||
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]);
|
||||
const context = await database.one<{ project_id: number; project_status: string }>('SELECT n.project_id,p.status AS project_status FROM notes n JOIN projects p ON p.id=n.project_id WHERE n.id = ?', [id]);
|
||||
if (context && !await canWriteProject(req, context.project_id)) { res.status(403).json({ error: '无权操作该作品' }); return; }
|
||||
if (context && context.project_status !== 'active') { res.status(409).json({ error: '已关闭或归档项目为只读状态' }); return; }
|
||||
const ok = await notesService.remove(id);
|
||||
if (!ok) {
|
||||
res.status(404).json({ error: '笔记不存在' });
|
||||
|
||||
Reference in New Issue
Block a user