286 lines
17 KiB
TypeScript
286 lines
17 KiB
TypeScript
/**
|
||
* 笔记路由
|
||
*/
|
||
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';
|
||
|
||
const router = Router();
|
||
|
||
function parseTags(value: unknown): string[] {
|
||
return Array.isArray(value)
|
||
? value.map((tag) => String(tag).trim()).filter(Boolean)
|
||
: String(value || '').trim() ? [String(value).trim()] : [];
|
||
}
|
||
|
||
function parseImageUrls(value: unknown): { valid: boolean; urls: string[] } {
|
||
if (value === undefined) return { valid: true, urls: [] };
|
||
if (!Array.isArray(value)) return { valid: false, urls: [] };
|
||
const urls = value.map((item) => String(item).trim());
|
||
const valid = urls.length <= 30 && urls.every((url) => {
|
||
if (!url || url.length > 2048) return false;
|
||
try { return ['http:', 'https:'].includes(new URL(url).protocol); }
|
||
catch { return false; }
|
||
});
|
||
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 {
|
||
sort?: string;
|
||
order?: string;
|
||
q?: string;
|
||
collectionId?: string;
|
||
status?: 'draft' | 'pending' | 'changes_requested' | 'approved';
|
||
tag?: string;
|
||
externalId?: string;
|
||
};
|
||
const groupId = req.authUser?.role === 'platform_admin' || req.apiKey?.scope === 'platform' ? undefined : req.authUser?.group_id ?? undefined;
|
||
const projectId = req.apiKey?.scope === 'project' ? req.apiKey.project_id ?? undefined : undefined;
|
||
const list = await notesService.list({ sort, order, q, collectionId: collectionId ? Number(collectionId) : undefined, status, tag, groupId, projectId, externalId });
|
||
res.json(list);
|
||
});
|
||
|
||
// GET /api/notes/:noteId - 笔记详情
|
||
router.get('/:noteId', requireWriter, async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||
try {
|
||
const id = Number(req.params.noteId);
|
||
if (!Number.isFinite(id)) {
|
||
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]);
|
||
if (context && !await canWriteProject(req, context.project_id)) { res.status(403).json({ error: '无权查看该作品' }); return; }
|
||
const version = req.query.version ? Number(req.query.version) : undefined;
|
||
const detail = await notesService.getDetail(id, version);
|
||
if (!detail) {
|
||
res.status(404).json({ error: '笔记不存在' });
|
||
return;
|
||
}
|
||
res.json(detail);
|
||
} catch (err) {
|
||
next(err);
|
||
}
|
||
});
|
||
|
||
router.post('/:noteId/text-annotations', requireWriter, async (req: AuthRequest, res: Response) => {
|
||
res.setHeader('Deprecation', 'true');
|
||
res.status(410).json({ error: '该接口已停用,请使用 /api/works/:workId/text-annotations 并提交明确的文字选区' });
|
||
});
|
||
|
||
// POST /api/notes - 上传新笔记 (multipart/form-data)
|
||
router.post(
|
||
'/',
|
||
requireWriter,
|
||
upload.array('images', 30),
|
||
async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||
try {
|
||
const title = (req.body.title || '').toString().trim();
|
||
const description = (req.body.description || '').toString().trim();
|
||
const collectionId = Number(req.body.collectionId);
|
||
const tags = parseTags(req.body.tags);
|
||
const { valid: validImageUrls, urls: imageUrls } = parseImageUrls(req.body.images);
|
||
const externalId = String(req.body.externalId ?? req.body.external_id ?? '').trim() || null;
|
||
if (!validImageUrls) {
|
||
res.status(400).json({ error: 'images 需要包含 1–30 个有效的 HTTP/HTTPS 图片 URL' });
|
||
return;
|
||
}
|
||
if (externalId && (externalId.length > 128 || !/^[A-Za-z0-9._:-]+$/.test(externalId))) {
|
||
res.status(400).json({ error: 'externalId 仅支持 1–128 位字母、数字、点、下划线、冒号和横线' });
|
||
return;
|
||
}
|
||
if (!title) {
|
||
res.status(400).json({ error: '标题不能为空' });
|
||
return;
|
||
}
|
||
if (!Number.isFinite(collectionId)) {
|
||
res.status(400).json({ error: '请选择作品交付集' });
|
||
return;
|
||
}
|
||
const files = (req.files as Express.Multer.File[] | undefined) ?? [];
|
||
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; }
|
||
}
|
||
if (files.length === 0 && imageUrls.length === 0) {
|
||
res.status(400).json({ error: '请至少上传一张图片' });
|
||
return;
|
||
}
|
||
let note;
|
||
try {
|
||
note = files.length
|
||
? await notesService.create(
|
||
title,
|
||
description,
|
||
files.map((f) => ({ filename: f.filename, originalname: f.originalname, mimetype: f.mimetype, path: f.path })),
|
||
collectionId,
|
||
tags,
|
||
externalId,
|
||
)
|
||
: await notesService.createFromUrls(title, description, imageUrls, collectionId, tags, externalId);
|
||
} catch (error) {
|
||
const existing = externalId ? await notesService.findByExternalId(collectionId, externalId) : null;
|
||
if (existing) { res.status(200).json({ ...existing, idempotent: true }); return; }
|
||
throw error;
|
||
}
|
||
await audit(req, 'work.create', 'work', note.id, { collectionId, imageCount: files.length || imageUrls.length, imageSource: files.length ? 'upload' : 'external_url' });
|
||
res.status(201).json(note);
|
||
} catch (err) {
|
||
next(err);
|
||
}
|
||
},
|
||
);
|
||
|
||
router.post('/:noteId/versions', requireWriter, upload.array('images', 30), async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||
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; 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; }
|
||
const title = String(req.body?.title ?? context.title).trim();
|
||
const description = String(req.body?.description ?? context.description).trim();
|
||
const tags = parseTags(req.body?.tags);
|
||
if (!title) { res.status(400).json({ error: '标题不能为空' }); return; }
|
||
const note = files.length
|
||
? await notesService.createVersion(id, title, description, files.map((file) => ({ filename: file.filename, originalname: file.originalname, mimetype: file.mimetype, path: file.path })), tags, req.authUser?.id)
|
||
: await notesService.createVersionFromUrls(id, title, description, imageUrls, tags, req.authUser?.id);
|
||
await audit(req, 'work.version_create', 'work', id, { versionNumber: note.version_number, imageCount: files.length || imageUrls.length, imageSource: files.length ? 'upload' : 'external_url' });
|
||
res.status(201).json(note);
|
||
} 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; 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) { cleanupFiles(); res.status(400).json({ error: '每个验收轮次只能提交一个方案' }); 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 })) };
|
||
});
|
||
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; }
|
||
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: 1, versionNumber: note.version_number, deprecatedRoute: true });
|
||
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;
|
||
if (!['draft', 'pending'].includes(status)) {
|
||
res.status(400).json({ error: '无效的验收状态' });
|
||
return;
|
||
}
|
||
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: '作品不存在' });
|
||
return;
|
||
}
|
||
res.json({ success: true, status });
|
||
});
|
||
|
||
router.post('/:noteId/reopen', requireRole('platform_admin', 'group_admin'), async (req: AuthRequest, res: Response) => {
|
||
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; 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) => {
|
||
await tx.execute("UPDATE notes SET review_status = 'pending' WHERE id = ?", [id]);
|
||
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);
|
||
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' });
|
||
});
|
||
|
||
// DELETE /api/notes/:noteId - 删除笔记
|
||
router.delete('/:noteId', requireWriter, async (req: AuthRequest, res: Response) => {
|
||
const id = Number(req.params.noteId);
|
||
if (!Number.isFinite(id)) {
|
||
res.status(400).json({ error: '无效的笔记 ID' });
|
||
return;
|
||
}
|
||
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: '笔记不存在' });
|
||
return;
|
||
}
|
||
res.status(204).end();
|
||
});
|
||
|
||
export default router;
|