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; res.json(await notesService.list({ projectId, sort, order, q, status: status as Parameters[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;