/** * 笔记路由 */ 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;