73 lines
4.5 KiB
TypeScript
73 lines
4.5 KiB
TypeScript
|
|
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;
|