feat(review): 重构项目级单方案验收协作
This commit is contained in:
@@ -1,5 +1,4 @@
|
||||
import { Router, type Response } from 'express';
|
||||
import { annotationsRepository } from '../repositories/annotationsRepository.js';
|
||||
import { canWriteProject, requireWriter, type AuthRequest } from '../auth.js';
|
||||
import { database } from '../database.js';
|
||||
|
||||
@@ -8,10 +7,13 @@ const router = Router();
|
||||
router.delete('/:annotationId', requireWriter, async (req: AuthRequest, res: Response) => {
|
||||
const id = Number(req.params.annotationId);
|
||||
if (!Number.isFinite(id)) { res.status(400).json({ error: '无效的批注 ID' }); return; }
|
||||
const context = await database.one<{ project_id: number }>('SELECT c.project_id FROM annotations a JOIN images i ON i.id = a.image_id JOIN notes n ON n.id = i.note_id JOIN collections c ON c.id = n.collection_id WHERE a.id = ?', [id]);
|
||||
const context = await database.one<{ project_id: number; version_number: number; current_version: number; author_name: string; author_role: string; round_status: string; project_status: string; project_review_status: string }>(`SELECT n.project_id,i.version_number,n.version_number AS current_version,a.author_name,a.author_role,r.status AS round_status,p.status AS project_status,p.review_status AS project_review_status
|
||||
FROM annotations a JOIN images i ON i.id=a.image_id JOIN notes n ON n.id=i.note_id JOIN projects p ON p.id=n.project_id JOIN review_rounds r ON r.id=n.active_round_id WHERE a.id=?`, [id]);
|
||||
if (!context) { res.status(404).json({ error: '批注不存在' }); return; }
|
||||
if (!await canWriteProject(req, context.project_id)) { res.status(403).json({ error: '无权操作该批注' }); return; }
|
||||
await annotationsRepository.remove(id);
|
||||
if (context.project_status !== 'active' || context.project_review_status === 'completed' || context.round_status !== 'reviewing' || Number(context.version_number) !== Number(context.current_version)) { res.status(409).json({ error: '历史轮次、已关闭或已完成项目为只读状态' }); return; }
|
||||
if (context.author_role !== 'operator' || context.author_name !== (req.authUser?.display_name || 'API')) { res.status(403).json({ error: '只能撤回自己提交的批注' }); return; }
|
||||
await database.execute('UPDATE annotations SET withdrawn_at=CURRENT_TIMESTAMP WHERE id=? AND withdrawn_at IS NULL', [id]);
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
|
||||
@@ -7,20 +7,22 @@ const router = Router();
|
||||
|
||||
router.post('/notes/:noteId/comments', requireWriter, async (req: AuthRequest, res: Response) => {
|
||||
const noteId = Number(req.params.noteId); const content = String(req.body?.content ?? '').trim();
|
||||
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 = ?', [noteId]);
|
||||
const context = await database.one<{ project_id: number; version_number: number; round_status: string; project_status: string; project_review_status: string }>('SELECT n.project_id,n.version_number,r.status AS round_status,p.status AS project_status,p.review_status AS project_review_status FROM notes n JOIN projects p ON p.id=n.project_id JOIN review_rounds r ON r.id=n.active_round_id WHERE n.id = ?', [noteId]);
|
||||
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' || context.project_review_status === 'completed' || context.round_status !== 'reviewing') { res.status(409).json({ error: '当前轮次、已关闭或已完成项目为只读状态' }); return; }
|
||||
if (!content || content.length > 2000) { res.status(400).json({ error: '回复内容须为 1–2000 个字符' }); return; }
|
||||
const id = await database.insertId("INSERT INTO work_comments (note_id, content, author_name, author_role) VALUES (?, ?, ?, 'operator')", [noteId, content, req.authUser?.display_name || 'API']);
|
||||
const id = await database.insertId("INSERT INTO work_comments (note_id, version_number, content, author_name, author_role) VALUES (?, ?, ?, ?, 'operator')", [noteId, context.version_number, content, req.authUser?.display_name || 'API']);
|
||||
res.status(201).json(await database.one<WorkComment>('SELECT * FROM work_comments WHERE id = ?', [id]));
|
||||
});
|
||||
|
||||
router.patch('/comments/:commentId', requireWriter, async (req: AuthRequest, res: Response) => {
|
||||
const id = Number(req.params.commentId); const status = req.body?.status;
|
||||
if (!['open', 'resolved', 'confirmed'].includes(status)) { res.status(400).json({ error: '无效状态' }); return; }
|
||||
const context = await database.one<{ project_id: number }>('SELECT c.project_id FROM work_comments wc JOIN notes n ON n.id = wc.note_id JOIN collections c ON c.id = n.collection_id WHERE wc.id = ?', [id]);
|
||||
const context = await database.one<{ project_id: number; version_number: number; current_version: number; round_status: string; project_status: string; project_review_status: string }>('SELECT n.project_id,wc.version_number,n.version_number AS current_version,r.status AS round_status,p.status AS project_status,p.review_status AS project_review_status FROM work_comments wc JOIN notes n ON n.id=wc.note_id JOIN projects p ON p.id=n.project_id JOIN review_rounds r ON r.id=n.active_round_id WHERE wc.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' || context.project_review_status === 'completed' || context.round_status !== 'reviewing' || Number(context.version_number) !== Number(context.current_version)) { res.status(409).json({ error: '历史轮次、已关闭或已完成项目为只读状态' }); return; }
|
||||
await database.execute('UPDATE work_comments SET status = ? WHERE id = ?', [status, id]);
|
||||
res.json(await database.one<WorkComment>('SELECT * FROM work_comments WHERE id = ?', [id]));
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@ import { database } from '../database.js';
|
||||
const router = Router();
|
||||
|
||||
async function imageProjectId(imageId: number): Promise<number | undefined> {
|
||||
return (await database.one<{ project_id: number }>('SELECT c.project_id FROM images i JOIN notes n ON n.id = i.note_id JOIN collections c ON c.id = n.collection_id WHERE i.id = ?', [imageId]))?.project_id;
|
||||
return (await database.one<{ project_id: number }>('SELECT n.project_id FROM images i JOIN notes n ON n.id = i.note_id WHERE i.id = ?', [imageId]))?.project_id;
|
||||
}
|
||||
|
||||
router.get('/:imageId/annotations', requireWriter, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
@@ -26,14 +26,14 @@ router.post('/:imageId/annotations', requireWriter, async (req: AuthRequest, res
|
||||
const imageId = Number(req.params.imageId);
|
||||
if (!Number.isFinite(imageId)) { res.status(400).json({ error: '无效的图片 ID' }); return; }
|
||||
if (!await imagesRepository.findById(imageId)) { res.status(404).json({ error: '图片不存在' }); 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 images i JOIN notes n ON n.id=i.note_id JOIN collections c ON c.id=n.collection_id JOIN work_versions v ON v.note_id=i.note_id AND v.version_number=i.version_number JOIN review_rounds r ON r.id=v.review_round_id WHERE i.id=?', [imageId]);
|
||||
const context = await database.one<{ project_id: number; review_round_id: number; active_round_id: number | null; round_status: string; project_review_status: string; project_status: string }>('SELECT n.project_id,v.review_round_id,n.active_round_id,r.status AS round_status,p.review_status AS project_review_status,p.status AS project_status FROM images i JOIN notes n ON n.id=i.note_id JOIN projects p ON p.id=n.project_id JOIN work_versions v ON v.note_id=i.note_id AND v.version_number=i.version_number JOIN review_rounds r ON r.id=v.review_round_id WHERE i.id=?', [imageId]);
|
||||
if (!context || !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; }
|
||||
if (context.project_status !== 'active' || context.project_review_status === 'completed' || context.round_status !== 'reviewing' || Number(context.review_round_id) !== Number(context.active_round_id)) { res.status(409).json({ error: '历史轮次、已关闭或已完成项目为只读状态' }); return; }
|
||||
const { x, y } = req.body ?? {};
|
||||
const content = String(req.body?.content ?? '').trim();
|
||||
if (typeof x !== 'number' || typeof y !== 'number' || x < 0 || x > 1 || y < 0 || y > 1) { res.status(400).json({ error: '批注坐标无效' }); return; }
|
||||
if (!content) { res.status(400).json({ error: '批注内容不能为空' }); return; }
|
||||
res.status(201).json(await annotationsRepository.create(imageId, { x, y, content, author_name: req.authUser?.display_name || 'API' }));
|
||||
res.status(201).json(await annotationsRepository.create(imageId, { x, y, content, author_name: req.authUser?.display_name || 'API', author_role: 'operator' }));
|
||||
} catch (error) { next(error); }
|
||||
});
|
||||
|
||||
|
||||
@@ -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: '笔记不存在' });
|
||||
|
||||
72
api/routes/projectWorks.ts
Normal file
72
api/routes/projectWorks.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import fs from 'node:fs';
|
||||
import { Router, type NextFunction, type Response } from 'express';
|
||||
import { canWriteProject, requireWriter, audit, type AuthRequest } from '../auth.js';
|
||||
import { database } from '../database.js';
|
||||
import { notesService } from '../services/notesService.js';
|
||||
import { upload } from '../upload.js';
|
||||
|
||||
const router = Router({ mergeParams: true });
|
||||
|
||||
function parseTags(value: unknown): string[] {
|
||||
if (Array.isArray(value)) return value.map((tag) => String(tag).trim()).filter(Boolean);
|
||||
const raw = String(value ?? '').trim();
|
||||
if (!raw) return [];
|
||||
try { const parsed = JSON.parse(raw); if (Array.isArray(parsed)) return parsed.map((tag) => String(tag).trim()).filter(Boolean); }
|
||||
catch { /* comma-separated form input */ }
|
||||
return raw.split(',').map((tag) => tag.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function parseImageUrls(value: unknown): string[] | null {
|
||||
if (value === undefined) return [];
|
||||
if (!Array.isArray(value) || value.length < 1 || value.length > 30) return null;
|
||||
const urls = value.map((item) => String(item).trim());
|
||||
return urls.every((url) => {
|
||||
try { return url.length <= 2048 && ['http:', 'https:'].includes(new URL(url).protocol); }
|
||||
catch { return false; }
|
||||
}) ? urls : null;
|
||||
}
|
||||
|
||||
router.get('/', requireWriter, async (req: AuthRequest, res: Response) => {
|
||||
const projectId = Number(req.params.projectId);
|
||||
if (!Number.isInteger(projectId) || !await canWriteProject(req, projectId)) { res.status(403).json({ error: '无权查看该项目' }); return; }
|
||||
const { sort, order, q, status, tag, externalId } = req.query as Record<string, string | undefined>;
|
||||
res.json(await notesService.list({ projectId, sort, order, q, status: status as Parameters<typeof notesService.list>[0]['status'], tag, externalId }));
|
||||
});
|
||||
|
||||
router.post('/', requireWriter, upload.array('images', 30), async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
const files = (req.files as Express.Multer.File[] | undefined) ?? [];
|
||||
const cleanup = () => files.forEach((file) => { try { if (fs.existsSync(file.path)) fs.unlinkSync(file.path); } catch { /* noop */ } });
|
||||
try {
|
||||
const projectId = Number(req.params.projectId);
|
||||
if (!Number.isInteger(projectId) || !await canWriteProject(req, projectId)) { cleanup(); res.status(403).json({ error: '无权向该项目上传作品' }); return; }
|
||||
const project = await database.one<{ status: string }>('SELECT status FROM projects WHERE id = ?', [projectId]);
|
||||
if (!project) { cleanup(); res.status(404).json({ error: '项目不存在' }); return; }
|
||||
if (project.status !== 'active') { cleanup(); res.status(409).json({ error: '已关闭或归档项目为只读状态' }); return; }
|
||||
const title = String(req.body?.title ?? '').trim();
|
||||
const description = String(req.body?.description ?? '').trim();
|
||||
const tags = parseTags(req.body?.tags);
|
||||
const externalId = String(req.body?.externalId ?? req.body?.external_id ?? '').trim() || null;
|
||||
const imageUrls = parseImageUrls(req.body?.images);
|
||||
if (!title) { cleanup(); res.status(400).json({ error: '标题不能为空' }); return; }
|
||||
if (externalId && (externalId.length > 128 || !/^[A-Za-z0-9._:-]+$/.test(externalId))) { cleanup(); res.status(400).json({ error: 'externalId 格式无效' }); return; }
|
||||
if (imageUrls === null || (!files.length && !imageUrls.length)) { cleanup(); res.status(400).json({ error: '请提供 1–30 张上传图片或公开图片 URL' }); return; }
|
||||
if (externalId) {
|
||||
const existing = await notesService.findByProjectExternalId(projectId, externalId);
|
||||
if (existing) { cleanup(); res.json({ ...existing, idempotent: true }); return; }
|
||||
}
|
||||
let work;
|
||||
try {
|
||||
work = files.length
|
||||
? await notesService.createInProject(projectId, title, description, files.map((file) => ({ filename: file.filename, originalname: file.originalname, mimetype: file.mimetype, path: file.path })), tags, externalId)
|
||||
: await notesService.createInProjectFromUrls(projectId, title, description, imageUrls, tags, externalId);
|
||||
} catch (error) {
|
||||
const existing = externalId ? await notesService.findByProjectExternalId(projectId, externalId) : null;
|
||||
if (existing) { cleanup(); res.json({ ...existing, idempotent: true }); return; }
|
||||
throw error;
|
||||
}
|
||||
await audit(req, 'work.create', 'work', work.id, { projectId, imageCount: files.length || imageUrls.length, imageSource: files.length ? 'upload' : 'external_url' });
|
||||
res.status(201).json(work);
|
||||
} catch (error) { cleanup(); next(error); }
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -5,22 +5,29 @@ import type { Project, WorkCollection } from '../../shared/types.js';
|
||||
|
||||
const router = Router();
|
||||
const reader = requireWriter;
|
||||
type ProjectRow = Project & { customer_access_enabled: boolean | number; has_access_password: boolean | number; collection_count: number | string; work_count: number | string };
|
||||
type ProjectRow = Project & { customer_access_enabled: boolean | number; has_access_password: boolean | number; collection_count: number | string; work_count: number | string; pending_count: number | string; changes_requested_count: number | string; approved_count: number | string };
|
||||
type CollectionRow = WorkCollection & { work_count: number | string; approved_count: number | string };
|
||||
|
||||
function projectSelect(where: string) {
|
||||
return `SELECT p.id, p.group_id, g.name AS group_name, p.name, p.slug, p.client_description, p.status, p.created_at,
|
||||
return `SELECT p.id, p.group_id, g.name AS group_name, p.name, p.slug, p.client_description, p.status, p.review_status, p.review_completed_at, p.created_at,
|
||||
p.customer_access_enabled, p.access_expires_at,
|
||||
CASE WHEN p.access_password_hash != '' THEN 1 ELSE 0 END AS has_access_password,
|
||||
COALESCE(cc.collection_count, 0) AS collection_count,
|
||||
COALESCE(wc.work_count, 0) AS work_count
|
||||
COALESCE(wc.work_count, 0) AS work_count,
|
||||
COALESCE(wc.pending_count, 0) AS pending_count,
|
||||
COALESCE(wc.changes_requested_count, 0) AS changes_requested_count,
|
||||
COALESCE(wc.approved_count, 0) AS approved_count
|
||||
FROM projects p
|
||||
JOIN operation_groups g ON g.id = p.group_id
|
||||
LEFT JOIN (SELECT project_id, COUNT(*) AS collection_count FROM collections GROUP BY project_id) cc ON cc.project_id = p.id
|
||||
LEFT JOIN (SELECT c.project_id, COUNT(n.id) AS work_count FROM collections c LEFT JOIN notes n ON n.collection_id = c.id GROUP BY c.project_id) wc ON wc.project_id = p.id
|
||||
LEFT JOIN (SELECT project_id, COUNT(*) AS work_count,
|
||||
SUM(CASE WHEN review_status = 'pending' THEN 1 ELSE 0 END) AS pending_count,
|
||||
SUM(CASE WHEN review_status = 'changes_requested' THEN 1 ELSE 0 END) AS changes_requested_count,
|
||||
SUM(CASE WHEN review_status = 'approved' THEN 1 ELSE 0 END) AS approved_count
|
||||
FROM notes GROUP BY project_id) wc ON wc.project_id = p.id
|
||||
${where}`;
|
||||
}
|
||||
function projectJson(row: ProjectRow) { return { ...row, id: Number(row.id), group_id: Number(row.group_id), customer_access_enabled: Boolean(row.customer_access_enabled), has_access_password: Boolean(row.has_access_password), collection_count: Number(row.collection_count), work_count: Number(row.work_count) }; }
|
||||
function projectJson(row: ProjectRow) { return { ...row, id: Number(row.id), group_id: Number(row.group_id), customer_access_enabled: Boolean(row.customer_access_enabled), has_access_password: Boolean(row.has_access_password), collection_count: Number(row.collection_count), work_count: Number(row.work_count), pending_count: Number(row.pending_count), changes_requested_count: Number(row.changes_requested_count), approved_count: Number(row.approved_count) }; }
|
||||
function collectionJson(row: CollectionRow) { return { ...row, id: Number(row.id), project_id: Number(row.project_id), work_count: Number(row.work_count), approved_count: Number(row.approved_count) }; }
|
||||
|
||||
router.get('/', reader, async (req: AuthRequest, res: Response) => {
|
||||
|
||||
@@ -4,30 +4,173 @@ import { createCustomerSession, customerSessionCookie, optionalCustomer, require
|
||||
import { verifyPassword } from '../auth.js';
|
||||
import { notesService } from '../services/notesService.js';
|
||||
import { annotationsRepository } from '../repositories/annotationsRepository.js';
|
||||
import { decideCandidate, ReviewDecisionError } from '../services/reviewService.js';
|
||||
import type { TextAnnotation, WorkComment } from '../../shared/types.js';
|
||||
import { decideRound, ReviewDecisionError } from '../services/reviewService.js';
|
||||
import { addFeedbackReply, findFeedbackTarget, withdrawFeedback } from '../services/feedbackService.js';
|
||||
import type { FeedbackType, TextAnnotation, WorkComment } from '../../shared/types.js';
|
||||
|
||||
const router=Router();router.use(optionalCustomer);
|
||||
type ProjectAccess={id:number;name:string;slug:string;client_description:string;status:string;customer_access_enabled:boolean|number;access_password_hash:string;access_expires_at:string|Date|null};
|
||||
const projectBySlug=(slug:string)=>database.one<ProjectAccess>(`SELECT id,name,slug,client_description,status,customer_access_enabled,access_password_hash,access_expires_at FROM projects WHERE slug=?`,[slug]);
|
||||
const expired=(value:string|Date|null)=>Boolean(value&&new Date(value).getTime()<=Date.now());
|
||||
const router = Router();
|
||||
router.use(optionalCustomer);
|
||||
|
||||
router.get('/:slug/access',async(req:CustomerRequest,res:Response)=>{const project=await projectBySlug(req.params.slug);if(!project||project.status==='archived'){res.status(404).json({error:'项目不存在'});return}res.json({project_name:project.name,client_description:project.client_description,enabled:Boolean(project.customer_access_enabled),expired:expired(project.access_expires_at),authenticated:Boolean(req.customer?.project_id===Number(project.id)),reviewer_name:req.customer?.project_id===Number(project.id)?req.customer.reviewer_name:null})});
|
||||
type ProjectAccess = {
|
||||
id: number; name: string; slug: string; client_description: string; status: string; review_status: string;
|
||||
customer_access_enabled: boolean | number; access_password_hash: string; access_expires_at: string | Date | null;
|
||||
};
|
||||
|
||||
router.post('/:slug/login',async(req:CustomerRequest,res:Response)=>{const project=await projectBySlug(req.params.slug);const reviewerName=String(req.body?.reviewer_name??'').trim();const password=String(req.body?.password??'');if(!project||project.status==='archived'){res.status(404).json({error:'项目不存在'});return}if(!project.customer_access_enabled){res.status(403).json({error:'该项目暂未开放客户访问'});return}if(expired(project.access_expires_at)){res.status(403).json({error:'项目访问链接已到期'});return}if(reviewerName.length<2||reviewerName.length>30){res.status(400).json({error:'请填写 2–30 个字符的姓名'});return}if(!project.access_password_hash||!verifyPassword(password,project.access_password_hash)){res.status(401).json({error:'访问密码错误'});return}const token=await createCustomerSession(Number(project.id),reviewerName);res.setHeader('Set-Cookie',customerSessionCookie(token));res.json({success:true,reviewer_name:reviewerName})});
|
||||
const projectBySlug = (slug: string) => database.one<ProjectAccess>(
|
||||
'SELECT id,name,slug,client_description,status,review_status,customer_access_enabled,access_password_hash,access_expires_at FROM projects WHERE slug=?',
|
||||
[slug],
|
||||
);
|
||||
const expired = (value: string | Date | null) => Boolean(value && new Date(value).getTime() <= Date.now());
|
||||
const feedbackType = (value: string): FeedbackType | null => ['image_annotation', 'text_annotation', 'comment'].includes(value) ? value as FeedbackType : null;
|
||||
const storedTagsText = (value: string) => { try { const parsed = JSON.parse(value || '[]'); return Array.isArray(parsed) ? parsed.map(String).join(' ') : ''; } catch { return ''; } };
|
||||
|
||||
router.get('/:slug/project',requireCustomerProject,async(req:CustomerRequest,res:Response)=>{const project=(await projectBySlug(req.params.slug))!;const collections=await database.all<Record<string,unknown>>(`SELECT c.id,c.project_id,c.name,c.client_description,c.status,c.completed_at,c.created_at,COUNT(n.id) AS work_count,COUNT(CASE WHEN n.review_status='approved' THEN 1 END) AS approved_count FROM collections c LEFT JOIN notes n ON n.collection_id=c.id AND n.review_status!='draft' WHERE c.project_id=? AND c.status IN ('reviewing','completed') GROUP BY c.id,c.project_id,c.name,c.client_description,c.status,c.completed_at,c.created_at ORDER BY c.id DESC`,[project.id]);res.json({project:{id:Number(project.id),name:project.name,slug:project.slug,client_description:project.client_description,status:project.status},collections:collections.map((item)=>({...item,id:Number(item.id),project_id:Number(item.project_id),work_count:Number(item.work_count),approved_count:Number(item.approved_count)})),reviewer_name:req.customer!.reviewer_name})});
|
||||
router.get('/:slug/access', async (req: CustomerRequest, res: Response) => {
|
||||
const project = await projectBySlug(req.params.slug);
|
||||
if (!project || project.status === 'archived') { res.status(404).json({ error: '项目不存在' }); return; }
|
||||
res.json({ project_name: project.name, client_description: project.client_description, enabled: Boolean(project.customer_access_enabled), expired: expired(project.access_expires_at), authenticated: Boolean(req.customer?.project_id === Number(project.id)), reviewer_name: req.customer?.project_id === Number(project.id) ? req.customer.reviewer_name : null });
|
||||
});
|
||||
|
||||
router.get('/:slug/collections/:collectionId/works',requireCustomerProject,async(req:CustomerRequest,res:Response)=>{const project=(await projectBySlug(req.params.slug))!;const collectionId=Number(req.params.collectionId);const collection=await database.one<Record<string,unknown>>(`SELECT c.*,COUNT(n.id) AS work_count,COUNT(CASE WHEN n.review_status='approved' THEN 1 END) AS approved_count FROM collections c LEFT JOIN notes n ON n.collection_id=c.id AND n.review_status!='draft' WHERE c.id=? AND c.project_id=? AND c.status IN ('reviewing','completed') GROUP BY c.id,c.project_id,c.name,c.client_description,c.status,c.completed_at,c.created_at`,[collectionId,project.id]);if(!collection){res.status(404).json({error:'作品交付集不存在或尚未发布'});return}const works=(await notesService.list({collectionId})).filter((work)=>work.review_status!=='draft');res.json({collection:{...collection,id:Number(collection.id),project_id:Number(collection.project_id),work_count:Number(collection.work_count),approved_count:Number(collection.approved_count)},works})});
|
||||
router.post('/:slug/login', async (req: CustomerRequest, res: Response) => {
|
||||
const project = await projectBySlug(req.params.slug);
|
||||
const reviewerName = String(req.body?.reviewer_name ?? '').trim();
|
||||
const password = String(req.body?.password ?? '');
|
||||
if (!project || project.status === 'archived') { res.status(404).json({ error: '项目不存在' }); return; }
|
||||
if (!project.customer_access_enabled) { res.status(403).json({ error: '该项目暂未开放客户访问' }); return; }
|
||||
if (expired(project.access_expires_at)) { res.status(403).json({ error: '项目访问链接已到期' }); return; }
|
||||
if (reviewerName.length < 2 || reviewerName.length > 30) { res.status(400).json({ error: '请填写 2–30 个字符的姓名' }); return; }
|
||||
if (!project.access_password_hash || !verifyPassword(password, project.access_password_hash)) { res.status(401).json({ error: '访问密码错误' }); return; }
|
||||
const token = await createCustomerSession(Number(project.id), reviewerName);
|
||||
res.setHeader('Set-Cookie', customerSessionCookie(token));
|
||||
res.json({ success: true, reviewer_name: reviewerName });
|
||||
});
|
||||
|
||||
router.get('/:slug/works/:noteId',requireCustomerProject,async(req:CustomerRequest,res:Response)=>{const project=(await projectBySlug(req.params.slug))!;const noteId=Number(req.params.noteId);const belongs=await database.one<{review_status:string}>('SELECT n.review_status FROM notes n JOIN collections c ON c.id=n.collection_id WHERE n.id=? AND c.project_id=?',[noteId,project.id]);if(!belongs||belongs.review_status==='draft'){res.status(404).json({error:'作品不存在或尚未提交'});return}const version=req.query.version?Number(req.query.version):undefined;const detail=await notesService.getDetail(noteId,version);if(!detail){res.status(404).json({error:'作品版本不存在'});return}res.json(detail)});
|
||||
router.get('/:slug/project', requireCustomerProject, async (req: CustomerRequest, res: Response) => {
|
||||
const project = (await projectBySlug(req.params.slug))!;
|
||||
const works = (await notesService.list({ projectId: Number(project.id) })).filter((work) => work.review_status !== 'draft');
|
||||
res.json({
|
||||
project: { id: Number(project.id), name: project.name, slug: project.slug, client_description: project.client_description, status: project.status, review_status: project.review_status },
|
||||
works,
|
||||
reviewer_name: req.customer!.reviewer_name,
|
||||
});
|
||||
});
|
||||
|
||||
router.post('/:slug/works/:noteId/comments',requireCustomerProject,async(req:CustomerRequest,res:Response)=>{const project=(await projectBySlug(req.params.slug))!;const noteId=Number(req.params.noteId);const content=String(req.body?.content??'').trim();const belongs=await database.one<{status:string;round_status:string}>('SELECT c.status,r.status AS round_status FROM notes n JOIN collections c ON c.id=n.collection_id LEFT JOIN review_rounds r ON r.id=n.active_round_id WHERE n.id=? AND c.project_id=? AND n.review_status!=?',[noteId,project.id,'draft']);if(!belongs){res.status(404).json({error:'作品不存在'});return}if(belongs.status==='completed'||belongs.round_status!=='reviewing'){res.status(409).json({error:'当前验收轮次为只读状态'});return}if(!content||content.length>2000){res.status(400).json({error:'反馈内容须为 1–2000 个字符'});return}const id=await database.insertId("INSERT INTO work_comments (note_id,content,author_name,author_role) VALUES (?,?,?,'client')",[noteId,content,req.customer!.reviewer_name]);res.status(201).json(await database.one<WorkComment>('SELECT * FROM work_comments WHERE id=?',[id]))});
|
||||
router.get('/:slug/collections/:collectionId/works', requireCustomerProject, async (req: CustomerRequest, res: Response) => {
|
||||
const project = (await projectBySlug(req.params.slug))!;
|
||||
const works = (await notesService.list({ projectId: Number(project.id) })).filter((work) => work.review_status !== 'draft');
|
||||
res.setHeader('Deprecation', 'true');
|
||||
res.json({ redirect_to: `/review/${project.slug}`, works });
|
||||
});
|
||||
|
||||
router.post('/:slug/works/:noteId/text-annotations',requireCustomerProject,async(req:CustomerRequest,res:Response)=>{const project=(await projectBySlug(req.params.slug))!;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(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 belongs=await database.one<{status:string;round_status:string;review_round_id:number;active_round_id:number|null}>('SELECT c.status,r.status AS round_status,v.review_round_id,n.active_round_id 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=? AND c.project_id=? AND n.review_status!=?',[noteId,versionNumber,project.id,'draft']);if(!belongs){res.status(404).json({error:'作品版本不存在'});return}if(belongs.status==='completed'||belongs.round_status!=='reviewing'||Number(belongs.review_round_id)!==Number(belongs.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.customer!.reviewer_name]);res.status(201).json(await database.one<TextAnnotation>('SELECT * FROM text_annotations WHERE id=?',[id]))});
|
||||
router.get('/:slug/works/:workId', requireCustomerProject, async (req: CustomerRequest, res: Response) => {
|
||||
const project = (await projectBySlug(req.params.slug))!;
|
||||
const workId = Number(req.params.workId);
|
||||
const belongs = await database.one<{ review_status: string }>('SELECT review_status FROM notes WHERE id=? AND project_id=?', [workId, project.id]);
|
||||
if (!belongs || belongs.review_status === 'draft') { res.status(404).json({ error: '作品不存在或尚未提交' }); return; }
|
||||
const round = req.query.round ? Number(req.query.round) : undefined;
|
||||
const version = req.query.version ? Number(req.query.version) : undefined;
|
||||
const detail = await notesService.getDetail(workId, version, round);
|
||||
if (!detail) { res.status(404).json({ error: '验收轮次不存在' }); return; }
|
||||
res.json(detail);
|
||||
});
|
||||
|
||||
router.post('/:slug/images/:imageId/annotations',requireCustomerProject,async(req:CustomerRequest,res:Response)=>{const project=(await projectBySlug(req.params.slug))!;const imageId=Number(req.params.imageId);const{x,y}=req.body??{};const content=String(req.body?.content??'').trim();const belongs=await database.one<{status:string;round_status:string;review_round_id:number;active_round_id:number|null}>(`SELECT c.status,r.status AS round_status,v.review_round_id,n.active_round_id FROM images i JOIN notes n ON n.id=i.note_id JOIN collections c ON c.id=n.collection_id JOIN work_versions v ON v.note_id=i.note_id AND v.version_number=i.version_number JOIN review_rounds r ON r.id=v.review_round_id WHERE i.id=? AND c.project_id=? AND n.review_status!='draft'`,[imageId,project.id]);if(!belongs){res.status(404).json({error:'图片不存在'});return}if(belongs.status==='completed'||belongs.round_status!=='reviewing'||Number(belongs.review_round_id)!==Number(belongs.active_round_id)){res.status(409).json({error:'历史验收轮次为只读状态'});return}if(typeof x!=='number'||typeof y!=='number'||x<0||x>1||y<0||y>1){res.status(400).json({error:'批注坐标无效'});return}if(!content||content.length>1000){res.status(400).json({error:'批注内容须为 1–1000 个字符'});return}res.status(201).json(await annotationsRepository.create(imageId,{x,y,content,author_name:req.customer!.reviewer_name}))});
|
||||
router.get('/:slug/works/:workId/annotations', requireCustomerProject, async (req: CustomerRequest, res: Response) => {
|
||||
const project = (await projectBySlug(req.params.slug))!;
|
||||
const workId = Number(req.params.workId);
|
||||
if (!await database.one('SELECT id FROM notes WHERE id=? AND project_id=? AND review_status!=?', [workId, project.id, 'draft'])) { res.status(404).json({ error: '作品不存在' }); return; }
|
||||
res.json(await notesService.getFeedback(workId));
|
||||
});
|
||||
|
||||
router.post('/:slug/works/:noteId/decision',requireCustomerProject,async(req:CustomerRequest,res:Response)=>{const project=(await projectBySlug(req.params.slug))!;const noteId=Number(req.params.noteId);const versionNumber=Number(req.body?.version_number);const decision=req.body?.decision;const reason=String(req.body?.reason??'').trim();if(!Number.isInteger(versionNumber)||versionNumber<1){res.status(400).json({error:'验收决定必须明确指定候选稿版本'});return}if(!['approved','changes_requested'].includes(decision)){res.status(400).json({error:'验收决定无效'});return}if(reason.length>2000){res.status(400).json({error:'验收原因不能超过 2000 个字符'});return}if(decision==='changes_requested'&&!reason){res.status(400).json({error:'要求修改时必须填写原因'});return}try{res.json(await decideCandidate({noteId,versionNumber,projectId:Number(project.id),decision,reason,actorName:req.customer!.reviewer_name,actorRole:'client'}))}catch(error){if(error instanceof ReviewDecisionError){res.status(error.statusCode).json({error:error.message});return}throw error}});
|
||||
router.post('/:slug/works/:workId/comments', requireCustomerProject, async (req: CustomerRequest, res: Response) => {
|
||||
const project = (await projectBySlug(req.params.slug))!;
|
||||
const workId = Number(req.params.workId);
|
||||
const content = String(req.body?.content ?? '').trim();
|
||||
const work = await database.one<{ version_number: number; round_status: string }>('SELECT n.version_number,r.status AS round_status FROM notes n JOIN review_rounds r ON r.id=n.active_round_id WHERE n.id=? AND n.project_id=? AND n.review_status!=?', [workId, project.id, 'draft']);
|
||||
if (!work) { res.status(404).json({ error: '作品不存在' }); return; }
|
||||
if (project.status !== 'active' || project.review_status === 'completed' || work.round_status !== 'reviewing') { res.status(409).json({ error: '当前轮次、已关闭或已完成项目为只读状态' }); return; }
|
||||
if (!content || content.length > 2000) { res.status(400).json({ error: '反馈内容须为 1–2000 个字符' }); return; }
|
||||
const id = await database.insertId("INSERT INTO work_comments (note_id,version_number,content,author_name,author_role) VALUES (?,?,?,?,'client')", [workId, work.version_number, content, req.customer!.reviewer_name]);
|
||||
res.status(201).json(await database.one<WorkComment>('SELECT * FROM work_comments WHERE id=?', [id]));
|
||||
});
|
||||
|
||||
router.post('/:slug/works/:workId/text-annotations', requireCustomerProject, async (req: CustomerRequest, res: Response) => {
|
||||
const project = (await projectBySlug(req.params.slug))!;
|
||||
const workId = Number(req.params.workId);
|
||||
const roundNumber = Number(req.body?.round_number);
|
||||
const target = req.body?.target as 'title' | 'description' | 'tags';
|
||||
const startOffset = Number(req.body?.start_offset);
|
||||
const endOffset = Number(req.body?.end_offset);
|
||||
const selectedText = String(req.body?.selected_text ?? '');
|
||||
const content = String(req.body?.content ?? '').trim();
|
||||
const version = await database.one<{ version_number: number; title: string; description: string; tags: string; review_round_id: number; active_round_id: number | null; round_status: string }>(`SELECT v.version_number,v.title,v.description,v.tags,v.review_round_id,n.active_round_id,r.status AS round_status
|
||||
FROM work_versions v JOIN review_rounds r ON r.id=v.review_round_id JOIN notes n ON n.id=v.note_id
|
||||
WHERE v.note_id=? AND r.round_number=? AND n.project_id=?`, [workId, roundNumber, project.id]);
|
||||
if (!version) { res.status(404).json({ error: '验收轮次不存在' }); return; }
|
||||
if (project.status !== 'active' || project.review_status === 'completed' || version.round_status !== 'reviewing' || Number(version.review_round_id) !== Number(version.active_round_id)) { res.status(409).json({ error: '历史轮次、已关闭或已完成项目为只读状态' }); return; }
|
||||
const source = target === 'title' ? version.title : target === 'description' ? version.description : target === 'tags' ? storedTagsText(version.tags) : '';
|
||||
if (!source || !Number.isInteger(startOffset) || !Number.isInteger(endOffset) || startOffset < 0 || endOffset <= startOffset || endOffset > source.length || source.slice(startOffset, endOffset) !== selectedText) { res.status(400).json({ error: '请选择标题或正文中的有效文字区域' }); return; }
|
||||
if (!content || content.length > 1000) { res.status(400).json({ error: '批注内容须为 1–1000 个字符' }); return; }
|
||||
const id = await database.insertId(`INSERT INTO text_annotations (note_id,version_number,target,start_offset,end_offset,selected_text,prefix_text,suffix_text,content,author_name,author_role)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,'client')`, [workId, version.version_number, target, startOffset, endOffset, selectedText, source.slice(Math.max(0, startOffset - 24), startOffset), source.slice(endOffset, endOffset + 24), content, req.customer!.reviewer_name]);
|
||||
res.status(201).json(await database.one<TextAnnotation>('SELECT * FROM text_annotations WHERE id=?', [id]));
|
||||
});
|
||||
|
||||
router.post('/:slug/images/:imageId/annotations', requireCustomerProject, async (req: CustomerRequest, res: Response) => {
|
||||
const project = (await projectBySlug(req.params.slug))!;
|
||||
const imageId = Number(req.params.imageId);
|
||||
const { x, y } = req.body ?? {};
|
||||
const content = String(req.body?.content ?? '').trim();
|
||||
const context = await database.one<{ review_round_id: number; active_round_id: number | null; round_status: string }>(`SELECT v.review_round_id,n.active_round_id,r.status AS round_status FROM images i JOIN notes n ON n.id=i.note_id
|
||||
JOIN work_versions v ON v.note_id=i.note_id AND v.version_number=i.version_number JOIN review_rounds r ON r.id=v.review_round_id
|
||||
WHERE i.id=? AND n.project_id=? AND n.review_status!='draft'`, [imageId, project.id]);
|
||||
if (!context) { res.status(404).json({ error: '图片不存在' }); return; }
|
||||
if (project.status !== 'active' || project.review_status === 'completed' || context.round_status !== 'reviewing' || Number(context.review_round_id) !== Number(context.active_round_id)) { res.status(409).json({ error: '历史轮次、已关闭或已完成项目为只读状态' }); return; }
|
||||
if (typeof x !== 'number' || typeof y !== 'number' || x < 0 || x > 1 || y < 0 || y > 1) { res.status(400).json({ error: '批注坐标无效' }); return; }
|
||||
if (!content || content.length > 1000) { res.status(400).json({ error: '批注内容须为 1–1000 个字符' }); return; }
|
||||
res.status(201).json(await annotationsRepository.create(imageId, { x, y, content, author_name: req.customer!.reviewer_name, author_role: 'client' }));
|
||||
});
|
||||
|
||||
router.post('/:slug/works/:workId/feedback/:type/:feedbackId/replies', requireCustomerProject, async (req: CustomerRequest, res: Response) => {
|
||||
const project = (await projectBySlug(req.params.slug))!; const workId = Number(req.params.workId);
|
||||
const type = feedbackType(req.params.type); const feedbackId = Number(req.params.feedbackId); const content = String(req.body?.content ?? '').trim();
|
||||
if (!type || !Number.isInteger(feedbackId)) { res.status(400).json({ error: '反馈类型或编号无效' }); return; }
|
||||
if (!await database.one('SELECT id FROM notes WHERE id=? AND project_id=?', [workId, project.id])) { res.status(404).json({ error: '作品不存在' }); return; }
|
||||
const target = await findFeedbackTarget(workId, type, feedbackId);
|
||||
const state = await database.one<{ version_number: number; round_status: string }>('SELECT n.version_number,r.status AS round_status FROM notes n JOIN review_rounds r ON r.id=n.active_round_id WHERE n.id=?', [workId]);
|
||||
if (!target) { res.status(404).json({ error: '反馈不存在' }); return; }
|
||||
if (project.status !== 'active' || project.review_status === 'completed' || state?.round_status !== 'reviewing' || Number(target.version_number) !== Number(state?.version_number)) { res.status(409).json({ error: '历史轮次、已关闭或已完成项目为只读状态' }); return; }
|
||||
if (!content || content.length > 1000) { res.status(400).json({ error: '回复内容须为 1–1000 个字符' }); return; }
|
||||
res.status(201).json(await addFeedbackReply(target, type, feedbackId, content, req.customer!.reviewer_name, 'client'));
|
||||
});
|
||||
|
||||
router.post('/:slug/works/:workId/feedback/:type/:feedbackId/withdraw', requireCustomerProject, async (req: CustomerRequest, res: Response) => {
|
||||
const project = (await projectBySlug(req.params.slug))!; const workId = Number(req.params.workId);
|
||||
const type = feedbackType(req.params.type); const feedbackId = Number(req.params.feedbackId);
|
||||
if (!type || !Number.isInteger(feedbackId)) { res.status(400).json({ error: '反馈类型或编号无效' }); return; }
|
||||
if (!await database.one('SELECT id FROM notes WHERE id=? AND project_id=?', [workId, project.id])) { res.status(404).json({ error: '作品不存在' }); return; }
|
||||
const target = await findFeedbackTarget(workId, type, feedbackId);
|
||||
if (!target) { res.status(404).json({ error: '反馈不存在' }); return; }
|
||||
const state = await database.one<{ version_number: number; round_status: string }>('SELECT n.version_number,r.status AS round_status FROM notes n JOIN review_rounds r ON r.id=n.active_round_id WHERE n.id=?', [workId]);
|
||||
if (project.status !== 'active' || project.review_status === 'completed' || state?.round_status !== 'reviewing' || Number(target.version_number) !== Number(state?.version_number)) { res.status(409).json({ error: '历史轮次、已关闭或已完成项目为只读状态' }); return; }
|
||||
if (target.withdrawn_at) { res.json({ success: true }); return; }
|
||||
if (target.author_role !== 'client' || target.author_name !== req.customer!.reviewer_name) { res.status(403).json({ error: '只能撤回自己提交的反馈' }); return; }
|
||||
await withdrawFeedback(type, feedbackId); res.json({ success: true });
|
||||
});
|
||||
|
||||
router.post('/:slug/works/:workId/decision', requireCustomerProject, async (req: CustomerRequest, res: Response) => {
|
||||
const project = (await projectBySlug(req.params.slug))!;
|
||||
const workId = Number(req.params.workId);
|
||||
const roundNumber = Number(req.body?.round_number);
|
||||
const legacyVersion = Number(req.body?.version_number);
|
||||
const round = Number.isInteger(roundNumber) ? await database.one<{ version_number: number }>('SELECT v.version_number FROM review_rounds r JOIN work_versions v ON v.review_round_id=r.id WHERE r.note_id=? AND r.round_number=?', [workId, roundNumber]) : undefined;
|
||||
const versionNumber = round ? Number(round.version_number) : legacyVersion;
|
||||
const decision = req.body?.decision;
|
||||
const reason = String(req.body?.reason ?? '').trim();
|
||||
if (!Number.isInteger(versionNumber) || versionNumber < 1) { res.status(400).json({ error: '验收决定必须明确指定轮次' }); return; }
|
||||
if (!['approved', 'changes_requested'].includes(decision)) { res.status(400).json({ error: '验收决定无效' }); return; }
|
||||
if (reason.length > 2000) { res.status(400).json({ error: '验收原因不能超过 2000 个字符' }); return; }
|
||||
if (decision === 'changes_requested' && !reason) { res.status(400).json({ error: '要求修改时必须填写原因' }); return; }
|
||||
try { res.json(await decideRound({ noteId: workId, versionNumber, projectId: Number(project.id), decision, reason, actorName: req.customer!.reviewer_name, actorRole: 'client' })); }
|
||||
catch (error) { if (error instanceof ReviewDecisionError) { res.status(error.statusCode).json({ error: error.message }); return; } throw error; }
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
182
api/routes/works.ts
Normal file
182
api/routes/works.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import fs from 'node:fs';
|
||||
import { Router, type NextFunction, type Response } from 'express';
|
||||
import { audit, canWriteProject, requireWriter, type AuthRequest } from '../auth.js';
|
||||
import { database } from '../database.js';
|
||||
import { notesService } from '../services/notesService.js';
|
||||
import { addFeedbackReply, findFeedbackTarget, withdrawFeedback } from '../services/feedbackService.js';
|
||||
import { upload } from '../upload.js';
|
||||
import type { FeedbackType, TextAnnotation, WorkComment } from '../../shared/types.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
function parseTags(value: unknown): string[] {
|
||||
if (Array.isArray(value)) return value.map((tag) => String(tag).trim()).filter(Boolean);
|
||||
const raw = String(value ?? '').trim();
|
||||
if (!raw) return [];
|
||||
try { const parsed = JSON.parse(raw); if (Array.isArray(parsed)) return parsed.map((tag) => String(tag).trim()).filter(Boolean); }
|
||||
catch { /* comma-separated form input */ }
|
||||
return raw.split(',').map((tag) => tag.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function parseImageUrls(value: unknown): string[] | null {
|
||||
if (value === undefined) return [];
|
||||
if (!Array.isArray(value) || value.length < 1 || value.length > 30) return null;
|
||||
const urls = value.map((item) => String(item).trim());
|
||||
return urls.every((url) => { try { return url.length <= 2048 && ['http:', 'https:'].includes(new URL(url).protocol); } catch { return false; } }) ? urls : null;
|
||||
}
|
||||
|
||||
async function workProjectId(workId: number): Promise<number | undefined> {
|
||||
return (await database.one<{ project_id: number }>('SELECT project_id FROM notes WHERE id = ?', [workId]))?.project_id;
|
||||
}
|
||||
|
||||
const feedbackType = (value: string): FeedbackType | null => ['image_annotation', 'text_annotation', 'comment'].includes(value) ? value as FeedbackType : null;
|
||||
|
||||
router.get('/:workId', requireWriter, async (req: AuthRequest, res: Response) => {
|
||||
const workId = Number(req.params.workId);
|
||||
const projectId = await workProjectId(workId);
|
||||
if (!projectId) { res.status(404).json({ error: '作品不存在' }); return; }
|
||||
if (!await canWriteProject(req, projectId)) { res.status(403).json({ error: '无权查看该作品' }); return; }
|
||||
const round = req.query.round ? Number(req.query.round) : undefined;
|
||||
const detail = await notesService.getDetail(workId, undefined, round);
|
||||
if (!detail) { res.status(404).json({ error: '验收轮次不存在' }); return; }
|
||||
res.json(detail);
|
||||
});
|
||||
|
||||
router.get('/:workId/annotations', requireWriter, async (req: AuthRequest, res: Response) => {
|
||||
const workId = Number(req.params.workId);
|
||||
const projectId = await workProjectId(workId);
|
||||
if (!projectId) { res.status(404).json({ error: '作品不存在' }); return; }
|
||||
if (!await canWriteProject(req, projectId)) { res.status(403).json({ error: '无权查看该作品反馈' }); return; }
|
||||
res.json(await notesService.getFeedback(workId));
|
||||
});
|
||||
|
||||
router.get('/:workId/optimization-context', requireWriter, async (req: AuthRequest, res: Response) => {
|
||||
const workId = Number(req.params.workId);
|
||||
const roundNumber = Number(req.query.round);
|
||||
const includeHistory = req.query.include_history === 'true';
|
||||
if (!Number.isInteger(roundNumber) || roundNumber < 1) { res.status(400).json({ error: '请指定有效的验收轮次' }); return; }
|
||||
const projectId = await workProjectId(workId);
|
||||
if (!projectId) { res.status(404).json({ error: '作品不存在' }); return; }
|
||||
if (!await canWriteProject(req, projectId)) { res.status(403).json({ error: '无权查看该作品反馈' }); return; }
|
||||
|
||||
const [detail, feedback] = await Promise.all([
|
||||
notesService.getDetail(workId, undefined, roundNumber),
|
||||
notesService.getFeedback(workId),
|
||||
]);
|
||||
const round = feedback?.rounds.find((item) => item.round_number === roundNumber);
|
||||
if (!detail || !round) { res.status(404).json({ error: '验收轮次不存在' }); return; }
|
||||
|
||||
const visible = (item: { status: string; withdrawn_at: string | null }) => includeHistory || (item.status === 'open' && !item.withdrawn_at);
|
||||
const repliesFor = (type: FeedbackType, id: number) => round.feedback_replies.filter((reply) => reply.feedback_type === type && reply.feedback_id === id && (includeHistory || !reply.withdrawn_at));
|
||||
const withReplies = <T extends { id: number }>(type: FeedbackType, item: T) => ({ ...item, replies: repliesFor(type, item.id) });
|
||||
|
||||
res.json({
|
||||
project: detail.project,
|
||||
work_id: workId,
|
||||
work_label: `Work ${String(workId).padStart(3, '0')}`,
|
||||
round_number: roundNumber,
|
||||
version_number: round.version_number,
|
||||
content: {
|
||||
title: detail.title,
|
||||
description: detail.description,
|
||||
tags: detail.tags,
|
||||
images: detail.images.map(({ id, url, width, height, order_index }) => ({ image_id: id, url, width, height, order_index })),
|
||||
},
|
||||
feedback: {
|
||||
image_annotations: round.image_annotations.filter(visible).map((item) => withReplies('image_annotation', item)),
|
||||
text_annotations: round.text_annotations.filter(visible).map((item) => withReplies('text_annotation', item)),
|
||||
general_comments: round.comments.filter(visible).map((item) => withReplies('comment', item)),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
router.post('/:workId/rounds', requireWriter, upload.array('images', 30), async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
const files = (req.files as Express.Multer.File[] | undefined) ?? [];
|
||||
const cleanup = () => files.forEach((file) => { try { if (fs.existsSync(file.path)) fs.unlinkSync(file.path); } catch { /* noop */ } });
|
||||
try {
|
||||
const workId = Number(req.params.workId);
|
||||
const projectId = await workProjectId(workId);
|
||||
if (!projectId) { cleanup(); res.status(404).json({ error: '作品不存在' }); return; }
|
||||
if (!await canWriteProject(req, projectId)) { cleanup(); res.status(403).json({ error: '无权操作该作品' }); return; }
|
||||
const projectState = await database.one<{ status: string }>('SELECT status FROM projects WHERE id=?', [projectId]);
|
||||
if (!projectState || projectState.status !== 'active') { cleanup(); res.status(409).json({ error: '已关闭或归档项目为只读状态' }); return; }
|
||||
const current = await notesService.getDetail(workId);
|
||||
if (!current) { cleanup(); res.status(404).json({ error: '作品不存在' }); return; }
|
||||
const title = String(req.body?.title ?? current.title).trim();
|
||||
const description = String(req.body?.description ?? current.description).trim();
|
||||
const tags = parseTags(req.body?.tags ?? current.tags);
|
||||
const imageUrls = parseImageUrls(req.body?.images);
|
||||
if (!title) { cleanup(); res.status(400).json({ error: '标题不能为空' }); return; }
|
||||
if (imageUrls === null || (!files.length && !imageUrls.length)) { cleanup(); res.status(400).json({ error: '每轮必须提供 1–30 张图片' }); return; }
|
||||
const work = files.length
|
||||
? await notesService.createRound(workId, { title, description, tags, files: files.map((file) => ({ filename: file.filename, originalname: file.originalname, mimetype: file.mimetype, path: file.path })) }, req.authUser?.id)
|
||||
: await notesService.createRoundFromUrls(workId, { title, description, tags, images: imageUrls }, req.authUser?.id);
|
||||
await audit(req, 'work.round_create', 'work', workId, { roundNumber: work.version_number, imageCount: files.length || imageUrls.length });
|
||||
res.status(201).json(work);
|
||||
} catch (error) { cleanup(); next(error); }
|
||||
});
|
||||
|
||||
router.post('/:workId/text-annotations', requireWriter, async (req: AuthRequest, res: Response) => {
|
||||
const workId = Number(req.params.workId);
|
||||
const roundNumber = Number(req.body?.round_number);
|
||||
const target = req.body?.target as 'title' | 'description' | 'tags';
|
||||
const startOffset = Number(req.body?.start_offset);
|
||||
const endOffset = Number(req.body?.end_offset);
|
||||
const selectedText = String(req.body?.selected_text ?? '');
|
||||
const content = String(req.body?.content ?? '').trim();
|
||||
const version = await database.one<{ version_number: number; title: string; description: string; tags: string; review_round_id: number; active_round_id: number | null; round_status: string; project_id: number; project_review_status: string; project_status: string }>(`SELECT v.version_number,v.title,v.description,v.tags,v.review_round_id,n.active_round_id,n.project_id,r.status AS round_status,p.review_status AS project_review_status,p.status AS project_status
|
||||
FROM work_versions v JOIN review_rounds r ON r.id=v.review_round_id JOIN notes n ON n.id=v.note_id JOIN projects p ON p.id=n.project_id
|
||||
WHERE v.note_id=? AND r.round_number=?`, [workId, roundNumber]);
|
||||
if (!version) { res.status(404).json({ error: '验收轮次不存在' }); return; }
|
||||
if (!await canWriteProject(req, Number(version.project_id))) { res.status(403).json({ error: '无权批注该作品' }); return; }
|
||||
if (version.project_status !== 'active' || version.project_review_status === 'completed' || version.round_status !== 'reviewing' || Number(version.review_round_id) !== Number(version.active_round_id)) { res.status(409).json({ error: '历史轮次、已关闭或已完成项目为只读状态' }); return; }
|
||||
const source = target === 'title' ? version.title : target === 'description' ? version.description : target === 'tags' ? parseTags(version.tags).join(' ') : '';
|
||||
if (!source || !Number.isInteger(startOffset) || !Number.isInteger(endOffset) || startOffset < 0 || endOffset <= startOffset || endOffset > source.length || source.slice(startOffset, endOffset) !== selectedText) { res.status(400).json({ error: '请选择标题或正文中的有效文字区域' }); return; }
|
||||
if (!content || content.length > 1000) { res.status(400).json({ error: '批注内容须为 1–1000 个字符' }); return; }
|
||||
const prefix = source.slice(Math.max(0, startOffset - 24), startOffset);
|
||||
const suffix = source.slice(endOffset, endOffset + 24);
|
||||
const id = await database.insertId(`INSERT INTO text_annotations (note_id,version_number,target,start_offset,end_offset,selected_text,prefix_text,suffix_text,content,author_name,author_role)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?, 'operator')`, [workId, version.version_number, target, startOffset, endOffset, selectedText, prefix, suffix, content, req.authUser?.display_name || 'API']);
|
||||
await audit(req, 'text_annotation.create', 'text_annotation', id, { workId, roundNumber, target, startOffset, endOffset });
|
||||
res.status(201).json(await database.one<TextAnnotation>('SELECT * FROM text_annotations WHERE id = ?', [id]));
|
||||
});
|
||||
|
||||
router.post('/:workId/comments', requireWriter, async (req: AuthRequest, res: Response) => {
|
||||
const workId = Number(req.params.workId);
|
||||
const content = String(req.body?.content ?? '').trim();
|
||||
const context = await database.one<{ project_id: number; version_number: number; review_status: string; project_status: string; round_status: string }>('SELECT n.project_id,n.version_number,p.review_status,p.status AS project_status,r.status AS round_status FROM notes n JOIN projects p ON p.id=n.project_id JOIN review_rounds r ON r.id=n.active_round_id WHERE n.id=?', [workId]);
|
||||
if (!context) { res.status(404).json({ error: '作品不存在' }); return; }
|
||||
if (!await canWriteProject(req, Number(context.project_id))) { res.status(403).json({ error: '无权操作该作品' }); return; }
|
||||
if (context.project_status !== 'active' || context.review_status === 'completed' || context.round_status !== 'reviewing') { res.status(409).json({ error: '当前轮次、已关闭或已完成项目为只读状态' }); return; }
|
||||
if (!content || content.length > 2000) { res.status(400).json({ error: '反馈内容须为 1–2000 个字符' }); return; }
|
||||
const id = await database.insertId("INSERT INTO work_comments (note_id,version_number,content,author_name,author_role) VALUES (?,?,?,?, 'operator')", [workId, context.version_number, content, req.authUser?.display_name || 'API']);
|
||||
res.status(201).json(await database.one<WorkComment>('SELECT * FROM work_comments WHERE id = ?', [id]));
|
||||
});
|
||||
|
||||
router.post('/:workId/feedback/:type/:feedbackId/replies', requireWriter, async (req: AuthRequest, res: Response) => {
|
||||
const workId = Number(req.params.workId); const type = feedbackType(req.params.type); const feedbackId = Number(req.params.feedbackId);
|
||||
const content = String(req.body?.content ?? '').trim(); const projectId = await workProjectId(workId);
|
||||
if (!type || !Number.isInteger(feedbackId)) { res.status(400).json({ error: '反馈类型或编号无效' }); return; }
|
||||
if (!projectId || !await canWriteProject(req, projectId)) { res.status(projectId ? 403 : 404).json({ error: projectId ? '无权操作该作品' : '作品不存在' }); return; }
|
||||
const target = await findFeedbackTarget(workId, type, feedbackId);
|
||||
const state = await database.one<{ version_number: number; review_status: string; project_status: string; round_status: string }>('SELECT n.version_number,p.review_status,p.status AS project_status,r.status AS round_status FROM notes n JOIN projects p ON p.id=n.project_id JOIN review_rounds r ON r.id=n.active_round_id WHERE n.id=?', [workId]);
|
||||
if (!target) { res.status(404).json({ error: '反馈不存在' }); return; }
|
||||
if (!state || state.project_status !== 'active' || state.review_status === 'completed' || state.round_status !== 'reviewing' || Number(target.version_number) !== Number(state.version_number)) { res.status(409).json({ error: '历史轮次、已关闭或已完成项目为只读状态' }); return; }
|
||||
if (!content || content.length > 1000) { res.status(400).json({ error: '回复内容须为 1–1000 个字符' }); return; }
|
||||
res.status(201).json(await addFeedbackReply(target, type, feedbackId, content, req.authUser?.display_name || 'API', 'operator'));
|
||||
});
|
||||
|
||||
router.post('/:workId/feedback/:type/:feedbackId/withdraw', requireWriter, async (req: AuthRequest, res: Response) => {
|
||||
const workId = Number(req.params.workId); const type = feedbackType(req.params.type); const feedbackId = Number(req.params.feedbackId); const projectId = await workProjectId(workId);
|
||||
if (!type || !Number.isInteger(feedbackId)) { res.status(400).json({ error: '反馈类型或编号无效' }); return; }
|
||||
if (!projectId || !await canWriteProject(req, projectId)) { res.status(projectId ? 403 : 404).json({ error: projectId ? '无权操作该作品' : '作品不存在' }); return; }
|
||||
const target = await findFeedbackTarget(workId, type, feedbackId);
|
||||
if (!target) { res.status(404).json({ error: '反馈不存在' }); return; }
|
||||
const state = await database.one<{ version_number: number; review_status: string; project_status: string; round_status: string }>('SELECT n.version_number,p.review_status,p.status AS project_status,r.status AS round_status FROM notes n JOIN projects p ON p.id=n.project_id JOIN review_rounds r ON r.id=n.active_round_id WHERE n.id=?', [workId]);
|
||||
if (!state || state.project_status !== 'active' || state.review_status === 'completed' || state.round_status !== 'reviewing' || Number(target.version_number) !== Number(state.version_number)) { res.status(409).json({ error: '历史轮次、已关闭或已完成项目为只读状态' }); return; }
|
||||
if (target.withdrawn_at) { res.json({ success: true }); return; }
|
||||
if (target.author_role !== 'operator' || target.author_name !== (req.authUser?.display_name || 'API')) { res.status(403).json({ error: '只能撤回自己提交的反馈' }); return; }
|
||||
await withdrawFeedback(type, feedbackId); res.json({ success: true });
|
||||
});
|
||||
|
||||
export default router;
|
||||
Reference in New Issue
Block a user