diff --git a/AGENTS.md b/AGENTS.md index 09bc336..de6a6df 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,7 @@ # Delivery Desk 开发约定 -- 包管理器使用 pnpm;提交前运行 `pnpm check`、`pnpm lint`、`pnpm build` 和 `pnpm test:postgres-runtime`。 -- 业务术语统一为“运营组 → 项目 → 作品交付集 → 作品 → 版本”。`collections` 只是内部数据库与路由标识,用户界面和文档不再称“作品集”或“阶段任务”。 +- 包管理器使用 pnpm;提交前运行 `pnpm check`、`pnpm lint`、`pnpm build`、`pnpm test:review-rounds`、`pnpm test:collection-status`、`pnpm test:postgres-runtime` 和 `pnpm db:postgres:validate`。 +- 业务术语统一为“运营组 → 项目 → 作品交付集 → 作品 → 验收轮次 → 候选稿”。`collections` 和 `work_versions` 只是内部数据库与路由标识,用户界面和文档不再称“作品集”“阶段任务”或把候选稿称为版本。 - 数据库结构变更必须同时更新 `api/db.ts`、`db/postgres/schema.sql` 和相关迁移验证。 - `data/`、`uploads/`、`.env*`、COS 凭证、数据库文件及用户上传内容不得提交。 - 本地开发可使用 SQLite;正式部署使用 PostgreSQL。COS 配置只通过平台管理界面或部署密钥注入,不写入源码。 diff --git a/README.md b/README.md index 84766d3..dfae45f 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,11 @@ # 交付工作台(Delivery Desk) -面向图文作品交付与客户验收的响应式 Web 工作台。业务层级为“运营组 → 项目 → 作品交付集 → 作品 → 版本”。运营人员负责上传和处理反馈,客户通过项目链接完成查看、批注与验收。 +面向图文作品交付与客户验收的响应式 Web 工作台。业务层级为“运营组 → 项目 → 作品交付集 → 作品 → 验收轮次 → 候选稿”。运营人员负责上传和处理反馈,客户通过项目链接完成查看、批注与验收。 ## 当前能力 - 平台管理员、组管理员、光影叙事三类账号及运营组数据隔离 -- 项目、作品交付集、作品和作品版本管理 +- 项目、作品交付集、作品和多候选稿验收轮次管理 - 多图上传、封面预览、图片排序和腾讯云 COS 存储 - 图片坐标批注、标题/正文批注、总体反馈和验收记录 - 客户项目密码、访问期限和独立验收入口 @@ -88,5 +88,8 @@ docker compose up -d --build pnpm check pnpm lint pnpm build +pnpm test:review-rounds +pnpm test:collection-status pnpm test:postgres-runtime +pnpm db:postgres:validate ``` diff --git a/api/database.ts b/api/database.ts index 499b8ba..f17bfa8 100644 --- a/api/database.ts +++ b/api/database.ts @@ -28,7 +28,8 @@ if (databaseUrl) { if (databaseUrl === 'pg-mem://') { schema = schema .replace(/CREATE UNIQUE INDEX IF NOT EXISTS one_active_storage_config[^;]+;/, '') - .replace(/-- COLLECTION_STATUS_REPAIR_START[\s\S]+?-- COLLECTION_STATUS_REPAIR_END/, ''); + .replace(/-- COLLECTION_STATUS_REPAIR_START[\s\S]+?-- COLLECTION_STATUS_REPAIR_END/, '') + .replace(/-- REVIEW_ROUND_REPAIR_START[\s\S]+?-- REVIEW_ROUND_REPAIR_END/, ''); } await pool.query(schema); const userCount = Number((await pool.query('SELECT COUNT(*)::int AS count FROM users')).rows[0].count); diff --git a/api/db.ts b/api/db.ts index 596c9f0..0435acb 100644 --- a/api/db.ts +++ b/api/db.ts @@ -174,6 +174,19 @@ db.exec(` FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE, FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL ); + CREATE TABLE IF NOT EXISTS review_rounds ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + note_id INTEGER NOT NULL, + round_number INTEGER NOT NULL, + status TEXT NOT NULL DEFAULT 'reviewing', + selected_version_number INTEGER, + created_by INTEGER, + completed_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(note_id, round_number), + FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE, + FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL + ); CREATE TABLE IF NOT EXISTS review_events ( id INTEGER PRIMARY KEY AUTOINCREMENT, note_id INTEGER NOT NULL, @@ -202,6 +215,8 @@ addColumn('notes', 'external_id TEXT'); addColumn('notes', "tags TEXT NOT NULL DEFAULT '[]'"); addColumn('notes', "review_status TEXT NOT NULL DEFAULT 'pending'"); addColumn('notes', 'version_number INTEGER NOT NULL DEFAULT 1'); +addColumn('notes', 'active_round_id INTEGER'); +addColumn('notes', 'approved_version_number INTEGER'); addColumn('annotations', "author_name TEXT NOT NULL DEFAULT '客户'"); addColumn('annotations', "status TEXT NOT NULL DEFAULT 'open'"); addColumn('projects', 'group_id INTEGER'); @@ -213,6 +228,9 @@ addColumn('images', "storage_key TEXT NOT NULL DEFAULT ''"); addColumn('images', 'version_number INTEGER NOT NULL DEFAULT 1'); addColumn('users', 'last_login_at TEXT'); addColumn('collections', 'completed_at TEXT'); +addColumn('work_versions', 'review_round_id INTEGER'); +addColumn('work_versions', "candidate_name TEXT NOT NULL DEFAULT '方案 A'"); +addColumn('work_versions', "candidate_status TEXT NOT NULL DEFAULT 'pending'"); db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_notes_collection_external_id ON notes(collection_id, external_id) WHERE external_id IS NOT NULL AND external_id != ''"); @@ -256,6 +274,32 @@ db.prepare(`UPDATE collections SET name = '2026 年 7 月任务', client_descrip WHERE project_id = (SELECT id FROM projects WHERE slug = 'light-notes') AND (name LIKE '%?%' OR client_description LIKE '%?%')`).run(); db.prepare(`INSERT OR IGNORE INTO work_versions (note_id, version_number, title, description, tags, review_status) SELECT id, version_number, title, description, tags, review_status FROM notes`).run(); +db.exec(` + INSERT OR IGNORE INTO review_rounds (note_id, round_number, status, selected_version_number, completed_at, created_at) + SELECT v.note_id, v.version_number, + CASE WHEN v.version_number = n.version_number AND n.review_status != 'approved' THEN 'reviewing' ELSE 'completed' END, + CASE WHEN v.review_status = 'approved' THEN v.version_number ELSE NULL END, + CASE WHEN v.version_number = n.version_number AND n.review_status != 'approved' THEN NULL ELSE v.created_at END, + v.created_at + FROM work_versions v JOIN notes n ON n.id = v.note_id; + + UPDATE work_versions + SET review_round_id = (SELECT r.id FROM review_rounds r WHERE r.note_id = work_versions.note_id AND r.round_number = work_versions.version_number), + candidate_name = COALESCE(NULLIF(candidate_name, ''), '方案 A'), + candidate_status = CASE + WHEN review_status = 'approved' THEN 'selected' + WHEN version_number = (SELECT version_number FROM notes WHERE id = work_versions.note_id) AND review_status = 'changes_requested' THEN 'changes_requested' + WHEN version_number = (SELECT version_number FROM notes WHERE id = work_versions.note_id) AND review_status = 'pending' THEN 'pending' + WHEN version_number = (SELECT version_number FROM notes WHERE id = work_versions.note_id) AND review_status = 'draft' THEN 'draft' + ELSE 'not_selected' + END + WHERE review_round_id IS NULL; + + UPDATE notes + SET active_round_id = (SELECT r.id FROM review_rounds r WHERE r.note_id = notes.id AND r.round_number = notes.version_number), + approved_version_number = (SELECT MAX(v.version_number) FROM work_versions v WHERE v.note_id = notes.id AND v.review_status = 'approved') + WHERE active_round_id IS NULL; +`); db.exec(` UPDATE collections SET status = CASE @@ -288,6 +332,7 @@ db.exec(` CREATE INDEX IF NOT EXISTS idx_api_keys_hash ON api_keys(key_hash); CREATE INDEX IF NOT EXISTS idx_storage_configs_status ON storage_configs(status); CREATE INDEX IF NOT EXISTS idx_work_versions_note_id ON work_versions(note_id, version_number); + CREATE INDEX IF NOT EXISTS idx_review_rounds_note_id ON review_rounds(note_id, round_number); CREATE INDEX IF NOT EXISTS idx_review_events_note_id ON review_events(note_id, version_number); `); diff --git a/api/repositories/notesRepository.ts b/api/repositories/notesRepository.ts index 9d285c2..8c12f43 100644 --- a/api/repositories/notesRepository.ts +++ b/api/repositories/notesRepository.ts @@ -3,7 +3,7 @@ import type { Note, NoteListQuery, ReviewStatus } from '../../shared/types.js'; interface NoteRow { id: number; collection_id: number; external_id: string | null; title: string; description: string; tags: string; - review_status: ReviewStatus; version_number: number; created_at: string; + review_status: ReviewStatus; version_number: number; active_round_id: number | null; approved_version_number: number | null; created_at: string; image_count: number | string; annotation_count: number | string; comment_count: number | string; cover_url: string | null; } @@ -11,6 +11,7 @@ function toNote(row: NoteRow): Note { return { ...row, id: Number(row.id), collection_id: Number(row.collection_id), version_number: Number(row.version_number), + active_round_id: row.active_round_id == null ? null : Number(row.active_round_id), approved_version_number: row.approved_version_number == null ? null : Number(row.approved_version_number), image_count: Number(row.image_count), annotation_count: Number(row.annotation_count), comment_count: Number(row.comment_count), tags: JSON.parse(row.tags || '[]') as string[], cover_image: row.cover_url ? (/^https?:\/\//.test(row.cover_url) || row.cover_url.startsWith('/') ? row.cover_url : `/uploads/${row.cover_url}`) : '', @@ -18,7 +19,7 @@ function toNote(row: NoteRow): Note { } const select = ` - SELECT n.id, n.collection_id, n.external_id, n.title, n.description, n.tags, n.review_status, n.version_number, n.created_at, + SELECT n.id, n.collection_id, n.external_id, n.title, n.description, n.tags, n.review_status, n.version_number, n.active_round_id, n.approved_version_number, n.created_at, COALESCE(ic.image_count, 0) AS image_count, COALESCE(ac.annotation_count, 0) AS annotation_count, COALESCE(cc.comment_count, 0) AS comment_count, diff --git a/api/routes/images.ts b/api/routes/images.ts index d4fd70a..9f42155 100644 --- a/api/routes/images.ts +++ b/api/routes/images.ts @@ -26,8 +26,9 @@ 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 projectId = await imageProjectId(imageId); - if (!projectId || !await canWriteProject(req, projectId)) { res.status(403).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]); + 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; } 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; } diff --git a/api/routes/notes.ts b/api/routes/notes.ts index a8c5678..b12c3fb 100644 --- a/api/routes/notes.ts +++ b/api/routes/notes.ts @@ -30,6 +30,15 @@ function parseImageUrls(value: unknown): { valid: boolean; urls: string[] } { 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 { @@ -76,9 +85,10 @@ router.post('/:noteId/text-annotations', requireWriter, async (req: AuthRequest, 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 }>('SELECT c.project_id FROM work_versions v JOIN notes n ON n.id=v.note_id JOIN collections c ON c.id=n.collection_id WHERE v.note_id=? AND v.version_number=?', [noteId, versionNumber]); + 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('SELECT * FROM text_annotations WHERE id=?', [id])); @@ -175,6 +185,50 @@ router.post('/:noteId/versions', requireWriter, upload.array('images', 30), asyn } 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 }>('SELECT c.project_id FROM notes n JOIN collections c ON c.id=n.collection_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; } + const rawCandidates = parseCandidates(req.body?.candidates); + if (!rawCandidates || rawCandidates.length < 1 || rawCandidates.length > 5) { cleanupFiles(); res.status(400).json({ error: '每轮需要提交 1–5 个候选稿' }); 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 })) }; + }); + note = await notesService.createReviewRound(id, uploadCandidates, 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); + } + await audit(req, 'work.review_round_create', 'work', id, { candidateCount: normalized.length, versionNumber: note.version_number }); + 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; @@ -196,14 +250,16 @@ 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 }>('SELECT n.review_status, n.version_number, n.collection_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 }>('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]); if (!note) { res.status(404).json({ error: '作品不存在' }); return; } if (!await canWriteProject(req, note.project_id)) { res.status(403).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 work_versions SET review_status = 'pending' WHERE note_id = ? AND version_number = ?", [id, note.version_number]); + 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); }); diff --git a/api/routes/review.ts b/api/routes/review.ts index f0ab220..b1e1359 100644 --- a/api/routes/review.ts +++ b/api/routes/review.ts @@ -1,10 +1,10 @@ import { Router, type Response } from 'express'; -import { database, withTransaction } from '../database.js'; +import { database } from '../database.js'; import { createCustomerSession, customerSessionCookie, optionalCustomer, requireCustomerProject, type CustomerRequest } from '../customerAuth.js'; import { verifyPassword } from '../auth.js'; import { notesService } from '../services/notesService.js'; import { annotationsRepository } from '../repositories/annotationsRepository.js'; -import { recalculateCollectionStatus } from '../services/collectionsService.js'; +import { decideCandidate, ReviewDecisionError } from '../services/reviewService.js'; import type { TextAnnotation, WorkComment } from '../../shared/types.js'; const router=Router();router.use(optionalCustomer); @@ -22,12 +22,12 @@ router.get('/:slug/collections/:collectionId/works',requireCustomerProject,async 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.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}>('SELECT c.status FROM notes n JOIN collections c ON c.id=n.collection_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'){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('SELECT * FROM work_comments WHERE id=?',[id]))}); +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('SELECT * FROM work_comments WHERE id=?',[id]))}); -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}>('SELECT c.status FROM work_versions v JOIN notes n ON n.id=v.note_id JOIN collections c ON c.id=n.collection_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'){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('SELECT * FROM text_annotations WHERE id=?',[id]))}); +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('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 belongs=await database.one<{status:string}>(`SELECT c.status FROM images i JOIN notes n ON n.id=i.note_id JOIN collections c ON c.id=n.collection_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'){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.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.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 decision=req.body?.decision;const reason=String(req.body?.reason??'').trim();if(!['approved','changes_requested'].includes(decision)){res.status(400).json({error:'验收决定无效'});return}if(decision==='changes_requested'&&!reason){res.status(400).json({error:'要求修改时必须填写原因'});return}const current=await database.one<{version_number:number;review_status:string;collection_id:number;collection_status:string}>(`SELECT n.version_number,n.review_status,n.collection_id,c.status AS collection_status FROM notes n JOIN collections c ON c.id=n.collection_id WHERE n.id=? AND n.review_status!='draft' AND c.project_id=?`,[noteId,project.id]);if(!current){res.status(404).json({error:'作品不存在或尚未提交'});return}if(current.collection_status==='completed'){res.status(409).json({error:'该作品交付集已验收完毕,当前为只读状态'});return}await withTransaction(async(tx)=>{await tx.execute('UPDATE notes SET review_status=? WHERE id=?',[decision,noteId]);await tx.execute('UPDATE work_versions SET review_status=? WHERE note_id=? AND version_number=?',[decision,noteId,current.version_number]);await tx.execute('INSERT INTO review_events (note_id,version_number,event_type,from_status,to_status,reason,actor_name,actor_role) VALUES (?,?,?,?,?,?,?,?)',[noteId,current.version_number,decision,current.review_status,decision,reason,req.customer!.reviewer_name,'client']);if(reason)await tx.execute("INSERT INTO work_comments (note_id,content,author_name,author_role) VALUES (?,?,?,'client')",[noteId,reason,req.customer!.reviewer_name]);await recalculateCollectionStatus(Number(current.collection_id),tx)});res.json({success:true,status:decision})}); +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}}); export default router; diff --git a/api/services/notesService.ts b/api/services/notesService.ts index b27eb41..fd56ec3 100644 --- a/api/services/notesService.ts +++ b/api/services/notesService.ts @@ -1,23 +1,90 @@ import sharp from 'sharp'; -import type { Note, NoteDetail, ImageWithAnnotations, ReviewStatus } from '../../shared/types.js'; +import type { ImageWithAnnotations, Note, NoteDetail, ReviewStatus } from '../../shared/types.js'; import { notesRepository } from '../repositories/notesRepository.js'; import { imagesRepository } from '../repositories/imagesRepository.js'; import { annotationsRepository } from '../repositories/annotationsRepository.js'; -import { database, withTransaction } from '../database.js'; +import { database, databaseDialect, withTransaction, type QueryContext } from '../database.js'; import { storeUploadedFile } from '../storage.js'; import { recalculateCollectionStatus } from './collectionsService.js'; export interface UploadedFile { filename: string; originalname?: string; mimetype?: string; path: string } +export interface UploadCandidate { candidate_name: string; title: string; description: string; tags: string[]; files: UploadedFile[] } +export interface UrlCandidate { candidate_name: string; title: string; description: string; tags: string[]; images: string[] } + +type StoredImage = { url: string; width: number; height: number; storageProvider: 'local' | 'tencent_cos' | 'external'; storageKey: string }; +type PreparedCandidate = { candidate_name: string; title: string; description: string; tags: string[]; images: StoredImage[] }; async function readImageSize(filePath: string): Promise<{ width: number; height: number }> { try { const meta = await sharp(filePath).metadata(); return { width: meta.width ?? 0, height: meta.height ?? 0 }; } catch { return { width: 0, height: 0 }; } } -async function prepareFiles(files: UploadedFile[]) { +async function prepareFiles(files: UploadedFile[]): Promise { return Promise.all(files.map(async (file) => ({ ...(await readImageSize(file.path)), ...(await storeUploadedFile(file)) }))); } +function externalImages(images: string[]): StoredImage[] { + return images.map((url) => ({ url, width: 0, height: 0, storageProvider: 'external', storageKey: '' })); +} + +async function createRoundInTransaction( + tx: QueryContext, + noteId: number, + collectionId: number, + candidates: PreparedCandidate[], + createdBy: number | undefined, + fromStatus: ReviewStatus, +): Promise<{ roundId: number; roundNumber: number; firstVersion: number }> { + const note = await tx.one<{ active_round_id: number | null }>('SELECT active_round_id FROM notes WHERE id = ?' + (databaseDialect === 'postgres' ? ' FOR NO KEY UPDATE' : ''), [noteId]); + if (note?.active_round_id) { + await tx.execute("UPDATE work_versions SET candidate_status = 'not_selected', review_status = 'draft' WHERE review_round_id = ? AND candidate_status = 'pending'", [note.active_round_id]); + await tx.execute("UPDATE review_rounds SET status = 'completed', completed_at = COALESCE(completed_at, ?) WHERE id = ? AND status = 'reviewing'", [new Date().toISOString(), note.active_round_id]); + } + const maxima = await tx.one<{ max_version: number | string | null; max_round: number | string | null }>( + `SELECT (SELECT MAX(version_number) FROM work_versions WHERE note_id = ?) AS max_version, + (SELECT MAX(round_number) FROM review_rounds WHERE note_id = ?) AS max_round`, + [noteId, noteId], + ); + const firstVersion = Number(maxima?.max_version ?? 0) + 1; + const roundNumber = Number(maxima?.max_round ?? 0) + 1; + const roundId = await tx.insertId( + "INSERT INTO review_rounds (note_id, round_number, status, created_by) VALUES (?, ?, 'reviewing', ?)", + [noteId, roundNumber, createdBy ?? null], + ); + + for (let index = 0; index < candidates.length; index += 1) { + const candidate = candidates[index]; + const versionNumber = firstVersion + index; + await tx.execute( + "INSERT INTO work_versions (note_id, version_number, title, description, tags, review_status, review_round_id, candidate_name, candidate_status, created_by) VALUES (?, ?, ?, ?, ?, 'pending', ?, ?, 'pending', ?)", + [noteId, versionNumber, candidate.title, candidate.description, JSON.stringify(candidate.tags), roundId, candidate.candidate_name, createdBy ?? null], + ); + await imagesRepository.createMany(noteId, candidate.images, versionNumber, tx); + await tx.execute( + "INSERT INTO review_events (note_id, version_number, event_type, from_status, to_status, actor_name, actor_role) VALUES (?, ?, 'submitted', ?, 'pending', ?, 'operator')", + [noteId, versionNumber, fromStatus, '工作台'], + ); + } + + const first = candidates[0]; + await tx.execute( + "UPDATE notes SET title = ?, description = ?, tags = ?, version_number = ?, active_round_id = ?, approved_version_number = NULL, review_status = 'pending' WHERE id = ?", + [first.title, first.description, JSON.stringify(first.tags), firstVersion, roundId, noteId], + ); + await recalculateCollectionStatus(collectionId, tx); + return { roundId, roundNumber, firstVersion }; +} + +async function prepareUploadCandidates(candidates: UploadCandidate[]): Promise { + return Promise.all(candidates.map(async (candidate) => ({ + candidate_name: candidate.candidate_name, + title: candidate.title, + description: candidate.description, + tags: candidate.tags, + images: await prepareFiles(candidate.files), + }))); +} + export const notesService = { async list(query: { sort?: string; order?: string; q?: string; collectionId?: number; status?: ReviewStatus; tag?: string; projectId?: number; groupId?: number; externalId?: string }) { return notesRepository.list({ sort: query.sort === 'annotations' ? 'annotations' : 'created_at', order: query.order === 'asc' ? 'asc' : 'desc', q: query.q, collectionId: query.collectionId, status: query.status, tag: query.tag, projectId: query.projectId, groupId: query.groupId, externalId: query.externalId }); @@ -34,13 +101,16 @@ export const notesService = { const images = await imagesRepository.listByNote(id, note.version_number); const workContext = await database.one<{ project_id: number; project_name: string; slug: string; collection_id: number; collection_name: string; collection_status: NoteDetail['collection']['status'] }>(`SELECT p.id AS project_id, p.name AS project_name, p.slug, c.id AS collection_id, c.name AS collection_name, c.status AS collection_status FROM notes n JOIN collections c ON c.id = n.collection_id JOIN projects p ON p.id = c.project_id WHERE n.id = ?`, [id]); if (!workContext) return null; - const versionRows = await database.all & { tags: string }>[number]>('SELECT version_number, title, description, tags, review_status, created_at FROM work_versions WHERE note_id = ? ORDER BY version_number DESC', [id]); + const versionRows = await database.all & { tags: string }>[number]>(`SELECT v.version_number, v.title, v.description, v.tags, v.review_status, v.review_round_id, + v.candidate_name, v.candidate_status, v.created_at, r.round_number, r.status AS round_status, r.selected_version_number + FROM work_versions v JOIN review_rounds r ON r.id = v.review_round_id + WHERE v.note_id = ? ORDER BY r.round_number DESC, v.version_number ASC`, [id]); const result: NoteDetail = { ...note, images: [] as ImageWithAnnotations[], text_annotations: await database.all('SELECT id, note_id, version_number, target, content, author_name, status, created_at FROM text_annotations WHERE note_id = ? AND version_number = ? ORDER BY id ASC', [id, note.version_number]), comments: await database.all('SELECT * FROM work_comments WHERE note_id = ? ORDER BY id ASC', [id]), - versions: versionRows.map((item) => ({ ...item, version_number: Number(item.version_number), tags: JSON.parse(item.tags || '[]') as string[] })), + versions: versionRows.map((item) => ({ ...item, version_number: Number(item.version_number), review_round_id: Number(item.review_round_id), round_number: Number(item.round_number), selected_version_number: item.selected_version_number == null ? null : Number(item.selected_version_number), tags: JSON.parse(item.tags || '[]') as string[] })), review_events: await database.all('SELECT * FROM review_events WHERE note_id = ? ORDER BY id DESC', [id]), project: { id: Number(workContext.project_id), name: workContext.project_name, slug: workContext.slug }, collection: { id: Number(workContext.collection_id), name: workContext.collection_name, status: workContext.collection_status }, @@ -52,10 +122,8 @@ export const notesService = { async create(title: string, description: string, files: UploadedFile[], collectionId: number, tags: string[], externalId: string | null = null): Promise { const prepared = await prepareFiles(files); const noteId = await withTransaction(async (tx) => { - const id = await tx.insertId('INSERT INTO notes (external_id, title, description, collection_id, tags, review_status) VALUES (?, ?, ?, ?, ?, ?)', [externalId, title, description, collectionId, JSON.stringify(tags), 'pending']); - await imagesRepository.createMany(id, prepared, 1, tx); - await tx.execute("INSERT INTO work_versions (note_id, version_number, title, description, tags, review_status) VALUES (?, 1, ?, ?, ?, 'pending')", [id, title, description, JSON.stringify(tags)]); - await recalculateCollectionStatus(collectionId, tx); + const id = await tx.insertId("INSERT INTO notes (external_id, title, description, collection_id, tags, review_status) VALUES (?, ?, ?, ?, ?, 'pending')", [externalId, title, description, collectionId, JSON.stringify(tags)]); + await createRoundInTransaction(tx, id, collectionId, [{ candidate_name: '方案 A', title, description, tags, images: prepared }], undefined, 'draft'); return id; }); return (await notesRepository.findById(noteId))!; @@ -63,10 +131,8 @@ export const notesService = { async createFromUrls(title: string, description: string, imageUrls: string[], collectionId: number, tags: string[], externalId: string | null = null): Promise { const noteId = await withTransaction(async (tx) => { - const id = await tx.insertId('INSERT INTO notes (external_id, title, description, collection_id, tags, review_status) VALUES (?, ?, ?, ?, ?, ?)', [externalId, title, description, collectionId, JSON.stringify(tags), 'pending']); - await imagesRepository.createMany(id, imageUrls.map((url) => ({ url, width: 0, height: 0, storageProvider: 'external' as const, storageKey: '' })), 1, tx); - await tx.execute("INSERT INTO work_versions (note_id, version_number, title, description, tags, review_status) VALUES (?, 1, ?, ?, ?, 'pending')", [id, title, description, JSON.stringify(tags)]); - await recalculateCollectionStatus(collectionId, tx); + const id = await tx.insertId("INSERT INTO notes (external_id, title, description, collection_id, tags, review_status) VALUES (?, ?, ?, ?, ?, 'pending')", [externalId, title, description, collectionId, JSON.stringify(tags)]); + await createRoundInTransaction(tx, id, collectionId, [{ candidate_name: '方案 A', title, description, tags, images: externalImages(imageUrls) }], undefined, 'draft'); return id; }); return (await notesRepository.findById(noteId))!; @@ -76,35 +142,30 @@ export const notesService = { return notesRepository.findByExternalId(collectionId, externalId); }, - async createVersion(id: number, title: string, description: string, files: UploadedFile[], tags: string[], createdBy?: number): Promise { + async createReviewRound(id: number, candidates: UploadCandidate[], createdBy?: number): Promise { const current = await notesRepository.findById(id); if (!current) throw new Error('作品不存在'); - const nextVersion = current.version_number + 1; - const prepared = await prepareFiles(files); - await withTransaction(async (tx) => { - await tx.execute("UPDATE notes SET title = ?, description = ?, tags = ?, version_number = ?, review_status = 'pending' WHERE id = ?", [title, description, JSON.stringify(tags), nextVersion, id]); - await tx.execute("INSERT INTO work_versions (note_id, version_number, title, description, tags, review_status, created_by) VALUES (?, ?, ?, ?, ?, 'pending', ?)", [id, nextVersion, title, description, JSON.stringify(tags), createdBy ?? null]); - await imagesRepository.createMany(id, prepared, nextVersion, tx); - await tx.execute("INSERT INTO review_events (note_id, version_number, event_type, from_status, to_status, actor_name, actor_role) VALUES (?, ?, 'submitted', ?, 'pending', ?, 'operator')", [id, nextVersion, current.review_status, '工作台']); - await recalculateCollectionStatus(current.collection_id, tx); - }); + const prepared = await prepareUploadCandidates(candidates); + await withTransaction((tx) => createRoundInTransaction(tx, id, current.collection_id, prepared, createdBy, current.review_status)); return (await notesRepository.findById(id))!; }, - async createVersionFromUrls(id: number, title: string, description: string, imageUrls: string[], tags: string[], createdBy?: number): Promise { + async createReviewRoundFromUrls(id: number, candidates: UrlCandidate[], createdBy?: number): Promise { const current = await notesRepository.findById(id); if (!current) throw new Error('作品不存在'); - const nextVersion = current.version_number + 1; - await withTransaction(async (tx) => { - await tx.execute("UPDATE notes SET title = ?, description = ?, tags = ?, version_number = ?, review_status = 'pending' WHERE id = ?", [title, description, JSON.stringify(tags), nextVersion, id]); - await tx.execute("INSERT INTO work_versions (note_id, version_number, title, description, tags, review_status, created_by) VALUES (?, ?, ?, ?, ?, 'pending', ?)", [id, nextVersion, title, description, JSON.stringify(tags), createdBy ?? null]); - await imagesRepository.createMany(id, imageUrls.map((url) => ({ url, width: 0, height: 0, storageProvider: 'external' as const, storageKey: '' })), nextVersion, tx); - await tx.execute("INSERT INTO review_events (note_id, version_number, event_type, from_status, to_status, actor_name, actor_role) VALUES (?, ?, 'submitted', ?, 'pending', ?, 'operator')", [id, nextVersion, current.review_status, '工作台']); - await recalculateCollectionStatus(current.collection_id, tx); - }); + const prepared = candidates.map((candidate) => ({ ...candidate, images: externalImages(candidate.images) })); + await withTransaction((tx) => createRoundInTransaction(tx, id, current.collection_id, prepared, createdBy, current.review_status)); return (await notesRepository.findById(id))!; }, + async createVersion(id: number, title: string, description: string, files: UploadedFile[], tags: string[], createdBy?: number): Promise { + return this.createReviewRound(id, [{ candidate_name: '方案 A', title, description, tags, files }], createdBy); + }, + + async createVersionFromUrls(id: number, title: string, description: string, imageUrls: string[], tags: string[], createdBy?: number): Promise { + return this.createReviewRoundFromUrls(id, [{ candidate_name: '方案 A', title, description, tags, images: imageUrls }], createdBy); + }, + async remove(id: number) { return withTransaction(async (tx) => { const note = await tx.one<{ collection_id: number }>('SELECT collection_id FROM notes WHERE id = ?', [id]); @@ -114,12 +175,17 @@ export const notesService = { return removed; }); }, + async setStatus(id: number, status: ReviewStatus) { return withTransaction(async (tx) => { - const note = await tx.one<{ collection_id: number; version_number: number }>('SELECT collection_id, version_number FROM notes WHERE id = ?', [id]); + const note = await tx.one<{ collection_id: number; active_round_id: number | null }>('SELECT collection_id, active_round_id FROM notes WHERE id = ?', [id]); if (!note) return false; await tx.execute('UPDATE notes SET review_status = ? WHERE id = ?', [status, id]); - await tx.execute('UPDATE work_versions SET review_status = ? WHERE note_id = ? AND version_number = ?', [status, id, note.version_number]); + if (note.active_round_id) { + const candidateStatus = status === 'draft' ? 'draft' : 'pending'; + await tx.execute("UPDATE work_versions SET review_status = ?, candidate_status = ? WHERE review_round_id = ? AND candidate_status NOT IN ('selected', 'not_selected')", [status, candidateStatus, note.active_round_id]); + await tx.execute('UPDATE review_rounds SET status = ?, selected_version_number = NULL, completed_at = NULL WHERE id = ?', [status === 'draft' ? 'draft' : 'reviewing', note.active_round_id]); + } await recalculateCollectionStatus(Number(note.collection_id), tx); return true; }); diff --git a/api/services/reviewService.ts b/api/services/reviewService.ts new file mode 100644 index 0000000..223c61a --- /dev/null +++ b/api/services/reviewService.ts @@ -0,0 +1,58 @@ +import type { ReviewStatus } from '../../shared/types.js'; +import { databaseDialect, withTransaction, type QueryContext } from '../database.js'; +import { recalculateCollectionStatus } from './collectionsService.js'; + +export class ReviewDecisionError extends Error { + constructor(public statusCode: number, message: string) { super(message); } +} + +export interface CandidateDecisionInput { + noteId: number; + versionNumber: number; + projectId: number; + decision: 'approved' | 'changes_requested'; + reason: string; + actorName: string; + actorRole: 'client'; +} + +export async function decideCandidateInTransaction(tx: QueryContext, input: CandidateDecisionInput) { + const candidate = await tx.one<{ + review_round_id: number; candidate_status: string; review_status: ReviewStatus; + collection_id: number; active_round_id: number | null; round_status: string; + title: string; description: string; tags: string; + }>(`SELECT v.review_round_id,v.candidate_status,v.review_status,v.title,v.description,v.tags, + n.collection_id,n.active_round_id,r.status AS round_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=? AND c.project_id=?${databaseDialect === 'postgres' ? ' FOR NO KEY UPDATE' : ''}`, + [input.noteId, input.versionNumber, input.projectId]); + if (!candidate) throw new ReviewDecisionError(404, '候选稿不存在'); + if (Number(candidate.active_round_id) !== Number(candidate.review_round_id) || candidate.round_status !== 'reviewing') { + throw new ReviewDecisionError(409, '历史验收轮次为只读状态'); + } + if (!['pending', 'changes_requested'].includes(candidate.candidate_status)) { + throw new ReviewDecisionError(409, '该候选稿当前不能重复验收'); + } + + if (input.decision === 'approved') { + await tx.execute("UPDATE work_versions SET candidate_status='not_selected', review_status='draft' WHERE review_round_id=? AND version_number!=?", [candidate.review_round_id, input.versionNumber]); + await tx.execute("UPDATE work_versions SET candidate_status='selected', review_status='approved' WHERE note_id=? AND version_number=?", [input.noteId, input.versionNumber]); + await tx.execute("UPDATE review_rounds SET status='completed', selected_version_number=?, completed_at=? WHERE id=?", [input.versionNumber, new Date().toISOString(), candidate.review_round_id]); + await tx.execute("UPDATE notes SET title=?,description=?,tags=?,version_number=?,approved_version_number=?,review_status='approved' WHERE id=?", [candidate.title, candidate.description, candidate.tags, input.versionNumber, input.versionNumber, input.noteId]); + } else { + await tx.execute("UPDATE work_versions SET candidate_status='changes_requested', review_status='changes_requested' WHERE note_id=? AND version_number=?", [input.noteId, input.versionNumber]); + const remaining = await tx.one<{ count: number | string }>("SELECT COUNT(*) AS count FROM work_versions WHERE review_round_id=? AND candidate_status='pending'", [candidate.review_round_id]); + const workStatus: ReviewStatus = Number(remaining?.count ?? 0) > 0 ? 'pending' : 'changes_requested'; + await tx.execute('UPDATE notes SET title=?,description=?,tags=?,version_number=?,review_status=? WHERE id=?', [candidate.title, candidate.description, candidate.tags, input.versionNumber, workStatus, input.noteId]); + } + + await tx.execute('INSERT INTO review_events (note_id,version_number,event_type,from_status,to_status,reason,actor_name,actor_role) VALUES (?,?,?,?,?,?,?,?)', [input.noteId, input.versionNumber, input.decision, candidate.review_status, input.decision, input.reason, input.actorName, input.actorRole]); + if (input.reason) await tx.execute("INSERT INTO work_comments (note_id,content,author_name,author_role) VALUES (?,?,?,'client')", [input.noteId, input.reason, input.actorName]); + await recalculateCollectionStatus(Number(candidate.collection_id), tx); + return { success: true as const, status: input.decision, version_number: input.versionNumber }; +} + +export async function decideCandidate(input: CandidateDecisionInput) { + return withTransaction((tx) => decideCandidateInTransaction(tx, input)); +} diff --git a/db/postgres/schema.sql b/db/postgres/schema.sql index 0bf5648..05811aa 100644 --- a/db/postgres/schema.sql +++ b/db/postgres/schema.sql @@ -57,6 +57,8 @@ CREATE TABLE IF NOT EXISTS notes ( tags TEXT NOT NULL DEFAULT '[]', review_status TEXT NOT NULL DEFAULT 'pending' CHECK (review_status IN ('draft', 'pending', 'changes_requested', 'approved')), version_number INTEGER NOT NULL DEFAULT 1 CHECK (version_number > 0), + active_round_id BIGINT, + approved_version_number INTEGER, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); @@ -117,11 +119,32 @@ CREATE TABLE IF NOT EXISTS work_versions ( description TEXT NOT NULL DEFAULT '', tags TEXT NOT NULL DEFAULT '[]', review_status TEXT NOT NULL DEFAULT 'pending' CHECK (review_status IN ('draft', 'pending', 'changes_requested', 'approved')), + review_round_id BIGINT, + candidate_name TEXT NOT NULL DEFAULT '方案 A', + candidate_status TEXT NOT NULL DEFAULT 'pending' CHECK (candidate_status IN ('draft', 'pending', 'changes_requested', 'selected', 'not_selected')), created_by BIGINT REFERENCES users(id) ON DELETE SET NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE (note_id, version_number) ); +CREATE TABLE IF NOT EXISTS review_rounds ( + id BIGSERIAL PRIMARY KEY, + note_id BIGINT NOT NULL REFERENCES notes(id) ON DELETE CASCADE, + round_number INTEGER NOT NULL CHECK (round_number > 0), + status TEXT NOT NULL DEFAULT 'reviewing' CHECK (status IN ('draft', 'reviewing', 'completed')), + selected_version_number INTEGER, + created_by BIGINT REFERENCES users(id) ON DELETE SET NULL, + completed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(note_id, round_number) +); + +ALTER TABLE notes ADD COLUMN IF NOT EXISTS active_round_id BIGINT; +ALTER TABLE notes ADD COLUMN IF NOT EXISTS approved_version_number INTEGER; +ALTER TABLE work_versions ADD COLUMN IF NOT EXISTS review_round_id BIGINT; +ALTER TABLE work_versions ADD COLUMN IF NOT EXISTS candidate_name TEXT NOT NULL DEFAULT '方案 A'; +ALTER TABLE work_versions ADD COLUMN IF NOT EXISTS candidate_status TEXT NOT NULL DEFAULT 'pending'; + CREATE TABLE IF NOT EXISTS review_events ( id BIGSERIAL PRIMARY KEY, note_id BIGINT NOT NULL REFERENCES notes(id) ON DELETE CASCADE, @@ -205,6 +228,7 @@ CREATE INDEX IF NOT EXISTS annotations_image_id_idx ON annotations(image_id); CREATE INDEX IF NOT EXISTS comments_note_id_idx ON work_comments(note_id); CREATE INDEX IF NOT EXISTS customer_sessions_project_id_idx ON customer_sessions(project_id); CREATE INDEX IF NOT EXISTS review_events_note_version_idx ON review_events(note_id, version_number); +CREATE INDEX IF NOT EXISTS review_rounds_note_id_idx ON review_rounds(note_id, round_number); CREATE INDEX IF NOT EXISTS audit_logs_group_id_idx ON audit_logs(group_id); -- COLLECTION_STATUS_REPAIR_START @@ -227,4 +251,34 @@ FROM ( WHERE c.id = s.collection_id AND c.status != 'archived'; -- COLLECTION_STATUS_REPAIR_END +-- REVIEW_ROUND_REPAIR_START +INSERT INTO review_rounds (note_id, round_number, status, selected_version_number, completed_at, created_at) +SELECT v.note_id, v.version_number, + CASE WHEN v.version_number = n.version_number AND n.review_status != 'approved' THEN 'reviewing' ELSE 'completed' END, + CASE WHEN v.review_status = 'approved' THEN v.version_number ELSE NULL END, + CASE WHEN v.version_number = n.version_number AND n.review_status != 'approved' THEN NULL ELSE v.created_at END, + v.created_at +FROM work_versions v JOIN notes n ON n.id = v.note_id +ON CONFLICT (note_id, round_number) DO NOTHING; + +UPDATE work_versions v +SET review_round_id = r.id, + candidate_name = COALESCE(NULLIF(v.candidate_name, ''), '方案 A'), + candidate_status = CASE + WHEN v.review_status = 'approved' THEN 'selected' + WHEN v.version_number = n.version_number AND v.review_status = 'changes_requested' THEN 'changes_requested' + WHEN v.version_number = n.version_number AND v.review_status = 'pending' THEN 'pending' + WHEN v.version_number = n.version_number AND v.review_status = 'draft' THEN 'draft' + ELSE 'not_selected' + END +FROM review_rounds r, notes n +WHERE r.note_id = v.note_id AND r.round_number = v.version_number AND n.id = v.note_id AND v.review_round_id IS NULL; + +UPDATE notes n +SET active_round_id = r.id, + approved_version_number = (SELECT MAX(v.version_number) FROM work_versions v WHERE v.note_id = n.id AND v.review_status = 'approved') +FROM review_rounds r +WHERE r.note_id = n.id AND r.round_number = n.version_number AND n.active_round_id IS NULL; +-- REVIEW_ROUND_REPAIR_END + COMMIT; diff --git a/docs/architecture.md b/docs/architecture.md index a2fa694..fe45918 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -21,7 +21,8 @@ flowchart LR └── 项目 └── 作品交付集 └── 作品 - └── 版本 + └── 验收轮次 + └── 候选稿(1–5 个) ``` - 一个运营组只能有一位组管理员,可以有多位光影叙事。 @@ -52,9 +53,10 @@ flowchart LR | `customer_sessions` | 客户项目级验收会话 | | `projects` | 项目、客户访问密码和访问期限 | | `collections` | 项目下的作品交付集 | -| `notes` | 作品当前状态和当前版本 | -| `work_versions` | 各版本标题、正文、标签和状态快照 | -| `images` | 版本图片、顺序、存储提供方和对象 Key | +| `notes` | 作品当前状态、活动轮次和选中稿 | +| `review_rounds` | 验收轮次、完成状态和选中候选稿 | +| `work_versions` | 各候选稿的标题、正文、标签和验收状态快照 | +| `images` | 候选稿图片、顺序、存储提供方和对象 Key | | `annotations` | 图片坐标批注 | | `text_annotations` | 标题或正文的版本级批注 | | `work_comments` | 作品总体反馈与回复 | @@ -77,6 +79,6 @@ flowchart LR ## 验收状态 -作品状态为 `draft`、`pending`、`changes_requested`、`approved`。客户只能看到非草稿作品;客户可通过或要求修改,要求修改必须填写原因。已通过作品只能由平台管理员或所属组管理员填写原因后重新打开,历史事件保留。 +作品状态为 `draft`、`pending`、`changes_requested`、`approved`。一个验收轮次可包含 1–5 个候选稿;单稿退修时,其他待验收稿仍可继续验收。客户选中并通过任意一稿后,作品即通过,同轮其他稿标记为未选用,历史轮次只读。已通过作品只能由平台管理员或所属组管理员填写原因后重新打开,历史事件保留。 -作品交付集状态由其中非草稿作品自动计算:没有已提交作品时为 `draft`,存在未通过作品时为 `reviewing`,全部已提交作品通过时为 `completed`。`archived` 是人工状态,自动计算不会覆盖。完成后客户页面只读;新增作品、新版本或重新打开作品会自动恢复为验收中。 +作品交付集状态由其中非草稿作品自动计算:没有已提交作品时为 `draft`,存在未通过作品时为 `reviewing`,全部已提交作品通过时为 `completed`。`archived` 是人工状态,自动计算不会覆盖。完成后客户页面只读;新增作品、新验收轮次或重新打开作品会自动恢复为验收中。 diff --git a/docs/handoff.md b/docs/handoff.md index e01c416..243d122 100644 --- a/docs/handoff.md +++ b/docs/handoff.md @@ -3,8 +3,8 @@ ## 已完成 - 三类工作台角色、运营组隔离、账号管理和 7 天会话 -- 项目、作品交付集、作品、版本和验收状态 -- 手动多图上传、封面、上传前拖拽排序及新版本 +- 项目、作品交付集、作品、多候选稿验收轮次和验收状态 +- 手动多图上传、封面、上传前拖拽排序及新验收轮次 - 图片坐标批注、标题/正文批注、总体反馈和验收记录 - 客户项目链接、密码、姓名、期限和验收决定 - API Key、审计日志、COS 前端配置及连接测试 diff --git a/docs/integration-guide.md b/docs/integration-guide.md index 5d27f36..b005c52 100644 --- a/docs/integration-guide.md +++ b/docs/integration-guide.md @@ -23,11 +23,13 @@ API Key 明文只在创建时返回一次,数据库仅保存 SHA-256 哈希。 | `/api/projects` | 项目创建、查询和编辑 | | `/api/projects/:projectId/collections` | 作品交付集创建、查询和编辑 | | `/api/notes` | 作品查询与创建 | -| `/api/notes/:noteId/versions` | 创建作品新版本 | +| `/api/notes/:noteId/review-rounds` | 创建包含 1–5 个候选稿的验收轮次 | +| `/api/notes/:noteId/versions` | 兼容接口:创建单候选稿验收轮次 | | `/api/notes/:noteId/status` | 草稿与待验收状态切换 | | `/api/notes/:noteId/text-annotations` | 标题/正文批注 | | `/api/images/:imageId/annotations` | 图片坐标批注 | -| `/api/review/:slug/*` | 客户登录、浏览、反馈与验收 | +| `/api/review/:slug/*` | 客户登录、浏览和反馈 | +| `/api/review/:slug/works/:noteId/decision` | 客户对指定候选稿作出验收决定 | | `/api/health` | 数据库就绪检查 | ## 查询运营组、项目、作品交付集和作品 @@ -72,7 +74,7 @@ curl -X POST http://localhost:3010/api/projects/1/collections \ -d '{"name":"2026 年 7 月交付","client_description":"本月交付内容"}' ``` -新建作品交付集的 `status` 为 `draft`。上传首件作品后自动变为 `reviewing`;全部非草稿作品通过后自动变为 `completed`。响应中的 `work_count`、`approved_count` 和 `completed_at` 分别表示已提交作品数、已通过作品数和本次完成时间。调用方不应直接维护作品交付集状态;创建作品、新版本、修改验收状态和删除作品都会触发服务端重算。 +新建作品交付集的 `status` 为 `draft`。上传首件作品后自动变为 `reviewing`;全部非草稿作品通过后自动变为 `completed`。响应中的 `work_count`、`approved_count` 和 `completed_at` 分别表示已提交作品数、已通过作品数和本次完成时间。调用方不应直接维护作品交付集状态;创建作品、新验收轮次、修改验收状态和删除作品都会触发服务端重算。 ## 上传作品 @@ -99,7 +101,7 @@ curl -X POST http://localhost:3010/api/notes \ URL 图片不会进入当前配置的 COS,也不会由服务检查其内容或长期可用性,因此调用方需要保证链接公开、稳定且确实指向图片。工作台手动上传仍接受 JPEG、PNG、GIF、WebP 和 AVIF。标签按原文保存和展示,不会自动添加 `#` 或拆分为标签库。 -## 创建新版本 +## 创建单候选稿验收轮次(兼容接口) ```bash curl -X POST http://localhost:3010/api/notes/12/versions \ @@ -116,7 +118,44 @@ curl -X POST http://localhost:3010/api/notes/12/versions \ }' ``` -批注绑定作品版本或具体图片,不会因新版本覆盖历史验收证据。 +该接口保留给只提交一个方案的现有调用方。批注绑定候选稿或具体图片,不会被新验收轮次覆盖。 + +## 提交多候选稿验收轮次 + +`POST /api/notes/:noteId/review-rounds` 可在同一轮中提交 1–5 个候选稿。JSON 请求中每个候选稿使用公开图片 URL: + +```json +{ + "candidates": [ + { + "candidate_name": "暖色方案", + "title": "夏日新品", + "description": "暖色调正文", + "tags": ["#夏日", "#新品"], + "images": ["https://cdn.example.com/warm-01.jpg"] + }, + { + "candidate_name": "冷色方案", + "title": "夏日新品", + "description": "冷色调正文", + "tags": ["#夏日", "#新品"], + "images": ["https://cdn.example.com/cool-01.jpg"] + } + ] +} +``` + +客户验收决定必须带上候选稿的 `version_number`。选中并通过某稿后,同轮其他稿自动标记为 `not_selected`,历史轮次变为只读。提交新轮次时,尚未结束的上一轮会自动关闭,其中仍在等待验收的候选稿会标记为 `not_selected`。旧的 `/versions` 接口继续可用,等价于创建只有一个候选稿的新轮次。决定接口使用客户登录后获得的 Cookie,不能使用工作台 API Key 代替。 + +```bash +curl -X POST http://localhost:3010/api/review/july-content/works/12/decision \ + -b cookies.txt \ + -H "Content-Type: application/json" \ + -d '{ + "version_number": 5, + "decision": "approved" + }' +``` ## Python 冒烟脚本 @@ -126,7 +165,7 @@ curl -X POST http://localhost:3010/api/notes/12/versions \ $env:DELIVERY_DESK_API_KEY = 'dd_live_xxx' python tests/api_create_work.py --project-id 1 --collection-id 1 -# 为已有作品创建新版本 +# 为已有作品创建单候选稿验收轮次 python tests/api_create_work.py --project-id 1 --collection-id 1 --work-id 12 ``` diff --git a/docs/operator-runbook.md b/docs/operator-runbook.md index 29716f0..df177da 100644 --- a/docs/operator-runbook.md +++ b/docs/operator-runbook.md @@ -63,7 +63,10 @@ pnpm install --frozen-lockfile pnpm check pnpm lint pnpm build +pnpm test:review-rounds +pnpm test:collection-status pnpm test:postgres-runtime +pnpm db:postgres:validate ``` 正式切换前还应验证:管理员首次改密、客户访问门禁、COS 上传、客户批注与验收、数据库备份及 HTTPS Cookie。 diff --git a/package.json b/package.json index 18d54f2..0c69576 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "db:postgres:validate": "tsx scripts/validate-postgres-migration.ts", "test:postgres-runtime": "tsx scripts/test-postgres-runtime.ts", "test:collection-status": "tsx scripts/test-sqlite-collection-status.ts", + "test:review-rounds": "tsx scripts/test-sqlite-review-rounds.ts", "dev": "concurrently \"npm run client:dev\" \"npm run server:dev\"" }, "dependencies": { diff --git a/scripts/migrate-sqlite-to-postgres.ts b/scripts/migrate-sqlite-to-postgres.ts index 3f6aad8..ff4fbc2 100644 --- a/scripts/migrate-sqlite-to-postgres.ts +++ b/scripts/migrate-sqlite-to-postgres.ts @@ -13,7 +13,7 @@ if (!fs.existsSync(sqlitePath)) throw new Error(`SQLite 数据库不存在:${s const tables = [ 'operation_groups', 'users', 'projects', 'collections', 'notes', 'images', 'annotations', - 'work_comments', 'work_versions', 'review_events', 'sessions', 'customer_sessions', + 'work_comments', 'work_versions', 'review_rounds', 'review_events', 'sessions', 'customer_sessions', 'audit_logs', 'api_keys', 'storage_configs', ] as const; const booleanColumns: Record> = { @@ -47,7 +47,10 @@ try { if (rows.length) await client.query(`SELECT setval(pg_get_serial_sequence('${table}', 'id'), (SELECT MAX(id) FROM "${table}"), true)`); } const collectionStatusRepair = schema.match(/-- COLLECTION_STATUS_REPAIR_START([\s\S]+?)-- COLLECTION_STATUS_REPAIR_END/)?.[1]; + const reviewRoundRepair = schema.match(/-- REVIEW_ROUND_REPAIR_START([\s\S]+?)-- REVIEW_ROUND_REPAIR_END/)?.[1]; if (!collectionStatusRepair) throw new Error('PostgreSQL schema 缺少作品交付集状态修复脚本'); + if (!reviewRoundRepair) throw new Error('PostgreSQL schema 缺少验收轮次修复脚本'); + await client.query(reviewRoundRepair); await client.query(collectionStatusRepair); await client.query('COMMIT'); process.stdout.write(`迁移完成:${tables.length} 张表已从 ${sqlitePath} 导入 PostgreSQL\n`); diff --git a/scripts/test-postgres-runtime.ts b/scripts/test-postgres-runtime.ts index 74b7757..919c693 100644 --- a/scripts/test-postgres-runtime.ts +++ b/scripts/test-postgres-runtime.ts @@ -119,6 +119,8 @@ try { if(Number((newVersion.body as {version_number:number}).version_number)!==2)throw new Error('作品版本号未递增'); const workDetail=await request(`/api/notes/${workId}`,{headers:bearerHeaders}); expectStatus(workDetail.response.status,200,'读取新版本作品',workDetail.body); + const firstRound=(workDetail.body as {versions:Array<{version_number:number;round_status:string;candidate_status:string}>}).versions.find((item)=>item.version_number===1); + if(firstRound?.round_status!=='completed'||firstRound.candidate_status!=='not_selected')throw new Error('新验收轮次未自动收口旧轮次'); const currentImages=(workDetail.body as {images:Array<{url:string;storage_provider:string}>}).images; if(currentImages.length!==1||currentImages[0].url!=='https://cdn.example.com/runtime-v2.jpg'||currentImages[0].storage_provider!=='external')throw new Error('新版本图片没有按外部 URL 保存'); const reviewLogin = await request('/api/review/postgres-runtime-test/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ reviewer_name: '客户测试', password: 'Review123!' }) }); @@ -127,7 +129,7 @@ try { const reviewProject = await request('/api/review/postgres-runtime-test/project', {}, reviewCookie); expectStatus(reviewProject.response.status, 200, '客户项目读取'); - const approveV2=await request(`/api/review/postgres-runtime-test/works/${workId}/decision`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({decision:'approved'})},reviewCookie); + const approveV2=await request(`/api/review/postgres-runtime-test/works/${workId}/decision`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({version_number:2,decision:'approved'})},reviewCookie); expectStatus(approveV2.response.status,200,'客户通过作品',approveV2.body); const completedCollections=await request(`/api/projects/${projectId}/collections`,{headers:bearerHeaders}); const completedCollection=(completedCollections.body as Array<{id:number;status:string;completed_at:string|null;approved_count:number}>).find((item)=>Number(item.id)===collectionId); @@ -142,7 +144,7 @@ try { expectStatus(reopenApproved.response.status,200,'组管理员重新打开已通过作品',reopenApproved.body); const reopenedByAdmin=await request(`/api/projects/${projectId}/collections`,{headers:bearerHeaders}); if((reopenedByAdmin.body as Array<{id:number;status:string}>).find((item)=>Number(item.id)===collectionId)?.status!=='reviewing')throw new Error('管理员重新打开作品后,作品交付集未回到验收中'); - const approveReopened=await request(`/api/review/postgres-runtime-test/works/${workId}/decision`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({decision:'approved'})},reviewCookie); + const approveReopened=await request(`/api/review/postgres-runtime-test/works/${workId}/decision`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({version_number:2,decision:'approved'})},reviewCookie); expectStatus(approveReopened.response.status,200,'客户通过重新打开的作品',approveReopened.body); const versionThree=await request(`/api/notes/${workId}/versions`,{method:'POST',headers:{...bearerHeaders,'Content-Type':'application/json'},body:JSON.stringify({title:'接口作品 V3',description:'完成后追加版本',tags:['API 测试'],images:['https://cdn.example.com/runtime-v3.jpg']})}); @@ -150,17 +152,44 @@ try { const reopenedCollections=await request(`/api/projects/${projectId}/collections`,{headers:bearerHeaders}); const reopenedCollection=(reopenedCollections.body as Array<{id:number;status:string;completed_at:string|null}>).find((item)=>Number(item.id)===collectionId); if(reopenedCollection?.status!=='reviewing'||reopenedCollection.completed_at!==null)throw new Error('新版本未将作品交付集重新打开为验收中'); - const requestChanges=await request(`/api/review/postgres-runtime-test/works/${workId}/decision`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({decision:'changes_requested',reason:'请调整第三版'})},reviewCookie); + const requestChanges=await request(`/api/review/postgres-runtime-test/works/${workId}/decision`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({version_number:3,decision:'changes_requested',reason:'请调整第三版'})},reviewCookie); expectStatus(requestChanges.response.status,200,'客户要求修改',requestChanges.body); - const approveV3=await request(`/api/review/postgres-runtime-test/works/${workId}/decision`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({decision:'approved'})},reviewCookie); + const approveV3=await request(`/api/review/postgres-runtime-test/works/${workId}/decision`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({version_number:3,decision:'approved'})},reviewCookie); expectStatus(approveV3.response.status,200,'客户再次通过',approveV3.body); + const candidateRound=await request(`/api/notes/${workId}/review-rounds`,{method:'POST',headers:{...bearerHeaders,'Content-Type':'application/json'},body:JSON.stringify({candidates:[ + {candidate_name:'暖色方案',title:'候选稿 A',description:'暖色方向',tags:['A'],images:['https://cdn.example.com/runtime-candidate-a.jpg']}, + {candidate_name:'冷色方案',title:'候选稿 B',description:'冷色方向',tags:['B'],images:['https://cdn.example.com/runtime-candidate-b.jpg']} + ]})}); + expectStatus(candidateRound.response.status,201,'创建多候选稿验收轮次',candidateRound.body); + const candidateDetail=await request(`/api/notes/${workId}`,{headers:bearerHeaders}); + expectStatus(candidateDetail.response.status,200,'读取多候选稿',candidateDetail.body); + const latestCandidates=(candidateDetail.body as {versions:Array<{version_number:number;round_number:number;candidate_name:string;candidate_status:string}>}).versions.filter((item)=>item.round_number===4); + if(latestCandidates.length!==2||latestCandidates.map((item)=>item.version_number).join(',')!=='4,5')throw new Error('同一验收轮次未生成两个独立候选稿'); + const historicalDecision=await request(`/api/review/postgres-runtime-test/works/${workId}/decision`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({version_number:3,decision:'approved'})},reviewCookie); + expectStatus(historicalDecision.response.status,409,'历史验收轮次不可重复决策',historicalDecision.body); + const historicalClientAnnotation=await request(`/api/review/postgres-runtime-test/works/${workId}/text-annotations`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({version_number:3,target:'title',content:'历史轮次不应写入'})},reviewCookie); + expectStatus(historicalClientAnnotation.response.status,409,'客户不可批注历史轮次',historicalClientAnnotation.body); + const historicalOperatorAnnotation=await request(`/api/notes/${workId}/text-annotations`,{method:'POST',headers:{...bearerHeaders,'Content-Type':'application/json'},body:JSON.stringify({version_number:3,target:'title',content:'历史轮次不应写入'})}); + expectStatus(historicalOperatorAnnotation.response.status,409,'工作台不可批注历史轮次',historicalOperatorAnnotation.body); + const oversizedRound=await request(`/api/notes/${workId}/review-rounds`,{method:'POST',headers:{...bearerHeaders,'Content-Type':'application/json'},body:JSON.stringify({candidates:[{candidate_name:'超量方案',title:'超量方案',images:Array.from({length:31},(_,index)=>`https://cdn.example.com/oversized-${index}.jpg`)}]})}); + expectStatus(oversizedRound.response.status,400,'验收轮次总图片不可超过 30 张',oversizedRound.body); + const changesA=await request(`/api/review/postgres-runtime-test/works/${workId}/decision`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({version_number:4,decision:'changes_requested',reason:'A 方案需调整'})},reviewCookie); + expectStatus(changesA.response.status,200,'单个候选稿要求修改',changesA.body); + const afterChanges=await request(`/api/notes/${workId}`,{headers:bearerHeaders}); + if((afterChanges.body as {review_status:string}).review_status!=='pending')throw new Error('仍有待验收候选稿时作品不应整体进入需修改'); + const chooseB=await request(`/api/review/postgres-runtime-test/works/${workId}/decision`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({version_number:5,decision:'approved'})},reviewCookie); + expectStatus(chooseB.response.status,200,'选择并通过候选稿 B',chooseB.body); + const selectedDetail=await request(`/api/notes/${workId}`,{headers:bearerHeaders}); + const selectedBody=selectedDetail.body as {version_number:number;approved_version_number:number;review_status:string;versions:Array<{version_number:number;candidate_status:string}>}; + if(selectedBody.version_number!==5||selectedBody.approved_version_number!==5||selectedBody.review_status!=='approved')throw new Error('作品未指向客户选中的候选稿'); + if(selectedBody.versions.find((item)=>item.version_number===4)?.candidate_status!=='not_selected'||selectedBody.versions.find((item)=>item.version_number===5)?.candidate_status!=='selected')throw new Error('选中候选稿后同轮状态未正确收口'); const secondWork=await request('/api/notes',{method:'POST',headers:{...bearerHeaders,'Content-Type':'application/json'},body:JSON.stringify({collectionId,externalId:'runtime-client-work-002',title:'完成后新增作品',description:'验证部分通过',tags:['API 测试'],images:['https://cdn.example.com/runtime-second.jpg']})}); expectStatus(secondWork.response.status,201,'完成后新增作品',secondWork.body); const secondWorkId=Number((secondWork.body as {id:number}).id); const partialCollections=await request(`/api/projects/${projectId}/collections`,{headers:bearerHeaders}); const partialCollection=(partialCollections.body as Array<{id:number;status:string;work_count:number;approved_count:number}>).find((item)=>Number(item.id)===collectionId); if(partialCollection?.status!=='reviewing'||Number(partialCollection.work_count)!==2||Number(partialCollection.approved_count)!==1)throw new Error('完成后新增作品未恢复验收中或进度统计错误'); - const approveSecond=await request(`/api/review/postgres-runtime-test/works/${secondWorkId}/decision`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({decision:'approved'})},reviewCookie); + const approveSecond=await request(`/api/review/postgres-runtime-test/works/${secondWorkId}/decision`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({version_number:1,decision:'approved'})},reviewCookie); expectStatus(approveSecond.response.status,200,'客户通过新增作品',approveSecond.body); const forbiddenDraft=await request(`/api/notes/${workId}/status`,{method:'PATCH',headers:{...bearerHeaders,'Content-Type':'application/json'},body:JSON.stringify({status:'draft'})}); expectStatus(forbiddenDraft.response.status,409,'普通写入不能绕过重新打开规则',forbiddenDraft.body); diff --git a/scripts/test-sqlite-review-rounds.ts b/scripts/test-sqlite-review-rounds.ts new file mode 100644 index 0000000..8d38442 --- /dev/null +++ b/scripts/test-sqlite-review-rounds.ts @@ -0,0 +1,45 @@ +process.env.NODE_ENV = 'test'; +delete process.env.DATABASE_URL; + +const { db } = await import('../api/db.js'); +const { database, closeDatabase } = await import('../api/database.js'); +const { decideCandidateInTransaction, ReviewDecisionError } = await import('../api/services/reviewService.js'); + +function assert(condition: unknown, message: string): asserts condition { + if (!condition) throw new Error(message); +} + +db.exec('BEGIN IMMEDIATE'); +try { + const project = await database.one<{ id: number }>('SELECT id FROM projects ORDER BY id LIMIT 1'); + assert(project, 'SQLite 测试需要至少一个项目'); + const collectionId = await database.insertId("INSERT INTO collections (project_id, name, status) VALUES (?, ?, 'reviewing')", [project.id, `候选稿测试 ${Date.now()}`]); + const noteId = await database.insertId("INSERT INTO notes (collection_id, title, review_status) VALUES (?, ?, 'pending')", [collectionId, '候选稿 A']); + const roundId = await database.insertId("INSERT INTO review_rounds (note_id, round_number, status) VALUES (?, 1, 'reviewing')", [noteId]); + await database.execute('UPDATE notes SET active_round_id = ? WHERE id = ?', [roundId, noteId]); + await database.execute("INSERT INTO work_versions (note_id, version_number, title, review_status, review_round_id, candidate_name, candidate_status) VALUES (?, 1, ?, 'pending', ?, ?, 'pending')", [noteId, '候选稿 A', roundId, '方案 A']); + await database.execute("INSERT INTO work_versions (note_id, version_number, title, review_status, review_round_id, candidate_name, candidate_status) VALUES (?, 2, ?, 'pending', ?, ?, 'pending')", [noteId, '候选稿 B', roundId, '方案 B']); + + await decideCandidateInTransaction(database, { noteId, versionNumber: 1, projectId: project.id, decision: 'changes_requested', reason: 'A 需调整', actorName: '测试客户', actorRole: 'client' }); + assert((await database.one<{ review_status: string }>('SELECT review_status FROM notes WHERE id = ?', [noteId]))?.review_status === 'pending', '仍有待验收候选稿时作品应保持待验收'); + + await decideCandidateInTransaction(database, { noteId, versionNumber: 2, projectId: project.id, decision: 'approved', reason: '', actorName: '测试客户', actorRole: 'client' }); + const note = await database.one<{ version_number: number; approved_version_number: number; review_status: string }>('SELECT version_number, approved_version_number, review_status FROM notes WHERE id = ?', [noteId]); + assert(note?.version_number === 2 && note.approved_version_number === 2 && note.review_status === 'approved', '作品未指向选中候选稿'); + const candidates = await database.all[number]>('SELECT version_number, candidate_status FROM work_versions WHERE review_round_id = ? ORDER BY version_number', [roundId]); + assert(candidates[0]?.candidate_status === 'not_selected' && candidates[1]?.candidate_status === 'selected', '同轮候选稿结果未正确收口'); + assert((await database.one<{ status: string }>('SELECT status FROM collections WHERE id = ?', [collectionId]))?.status === 'completed', '作品通过后作品交付集未自动完成'); + + let historicalRejected = false; + try { + await decideCandidateInTransaction(database, { noteId, versionNumber: 1, projectId: project.id, decision: 'approved', reason: '', actorName: '测试客户', actorRole: 'client' }); + } catch (error) { + historicalRejected = error instanceof ReviewDecisionError && error.statusCode === 409; + } + assert(historicalRejected, '已完成轮次的候选稿不应被重复验收'); + + process.stdout.write('SQLite 多候选稿验证通过:单稿退修、其他候选继续验收、选中收口与历史只读\n'); +} finally { + db.exec('ROLLBACK'); + await closeDatabase(); +} diff --git a/scripts/validate-postgres-migration.ts b/scripts/validate-postgres-migration.ts index c85f399..034dede 100644 --- a/scripts/validate-postgres-migration.ts +++ b/scripts/validate-postgres-migration.ts @@ -5,7 +5,7 @@ import { newDb } from 'pg-mem'; const tables = [ 'operation_groups', 'users', 'projects', 'collections', 'notes', 'images', 'annotations', - 'work_comments', 'work_versions', 'review_events', 'sessions', 'customer_sessions', + 'work_comments', 'work_versions', 'review_rounds', 'review_events', 'sessions', 'customer_sessions', 'audit_logs', 'api_keys', 'storage_configs', ] as const; const booleanColumns: Record> = { @@ -22,7 +22,8 @@ try { const schema = fs.readFileSync(path.resolve('db/postgres/schema.sql'), 'utf8') .replace(/^BEGIN;|COMMIT;$/gm, '') .replace(/CREATE UNIQUE INDEX IF NOT EXISTS one_active_storage_config[^;]+;/, '') - .replace(/-- COLLECTION_STATUS_REPAIR_START[\s\S]+?-- COLLECTION_STATUS_REPAIR_END/, ''); + .replace(/-- COLLECTION_STATUS_REPAIR_START[\s\S]+?-- COLLECTION_STATUS_REPAIR_END/, '') + .replace(/-- REVIEW_ROUND_REPAIR_START[\s\S]+?-- REVIEW_ROUND_REPAIR_END/, ''); await client.query(schema); for (const table of tables) { const exists = sqlite.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?").get(table); @@ -38,6 +39,9 @@ try { } const current = (await client.query(`SELECT n.version_number,n.review_status,i.storage_provider FROM notes n JOIN images i ON i.note_id=n.id AND i.version_number=n.version_number WHERE n.id=1 LIMIT 1`)).rows[0]; if (!current || Number(current.version_number) < 1) throw new Error('作品版本关系未正确迁移'); + const invalidRoundLinks = Number((await client.query('SELECT COUNT(*)::int AS count FROM work_versions v LEFT JOIN review_rounds r ON r.id=v.review_round_id WHERE r.id IS NULL')).rows[0].count); + const invalidActiveRounds = Number((await client.query('SELECT COUNT(*)::int AS count FROM notes n LEFT JOIN review_rounds r ON r.id=n.active_round_id WHERE r.id IS NULL OR r.note_id!=n.id')).rows[0].count); + if (invalidRoundLinks || invalidActiveRounds) throw new Error(`验收轮次迁移关联无效:versions=${invalidRoundLinks}, notes=${invalidActiveRounds}`); const activeStorage = Number((await client.query("SELECT COUNT(*)::int AS count FROM storage_configs WHERE status='active'")).rows[0].count); if (activeStorage > 1) throw new Error('活动对象存储配置超过一个'); process.stdout.write(`PostgreSQL schema 与迁移映射验证通过:${tables.length} 张表,当前作品 V${current.version_number},存储=${current.storage_provider}\n`); diff --git a/shared/types.ts b/shared/types.ts index c3801cb..1951154 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -1,4 +1,6 @@ export type ReviewStatus = 'draft' | 'pending' | 'changes_requested' | 'approved'; +export type CandidateStatus = 'draft' | 'pending' | 'changes_requested' | 'selected' | 'not_selected'; +export type ReviewRoundStatus = 'draft' | 'reviewing' | 'completed'; export type CollectionStatus = 'draft' | 'reviewing' | 'completed' | 'archived'; export type UserRole = 'platform_admin' | 'group_admin' | 'operator'; @@ -132,6 +134,8 @@ export interface Note { tags: string[]; review_status: ReviewStatus; version_number: number; + active_round_id: number | null; + approved_version_number: number | null; cover_image: string; image_count: number; annotation_count: number; @@ -198,6 +202,12 @@ export interface NoteDetail extends Note { export interface WorkVersion { version_number: number; + review_round_id: number; + round_number: number; + candidate_name: string; + candidate_status: CandidateStatus; + round_status: ReviewRoundStatus; + selected_version_number: number | null; title: string; description: string; tags: string[]; @@ -205,6 +215,16 @@ export interface WorkVersion { created_at: string; } +export interface ReviewRound { + id: number; + note_id: number; + round_number: number; + status: ReviewRoundStatus; + selected_version_number: number | null; + completed_at: string | null; + created_at: string; +} + export interface ReviewEvent { id: number; version_number: number; diff --git a/src/api/client.ts b/src/api/client.ts index f927dd9..20a00ee 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -68,6 +68,12 @@ export const api = { payload.images.forEach((file) => form.append('images', file)); return request(`/api/notes/${noteId}/versions`, { method: 'POST', body: form }); }, + createReviewRound: (noteId: number, candidates: Array<{ candidate_name: string; title: string; description: string; tags: string[]; images: File[] }>) => { + const form = new FormData(); + form.append('candidates', JSON.stringify(candidates.map((candidate) => ({ ...candidate, images: undefined, image_count: candidate.images.length })))); + candidates.forEach((candidate) => candidate.images.forEach((file) => form.append('images', file))); + return request(`/api/notes/${noteId}/review-rounds`, { method: 'POST', body: form }); + }, setReviewStatus: (id: number, status: ReviewStatus) => request<{ success: true; status: ReviewStatus }>(`/api/notes/${id}/status`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ status }) }), reopenWork: (id: number, reason: string) => request<{ success: true; status: ReviewStatus }>(`/api/notes/${id}/reopen`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ reason }) }), addAnnotation: (imageId: number, data: { x: number; y: number; content: string; author_name?: string }) => request(`/api/images/${imageId}/annotations`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }), @@ -82,5 +88,5 @@ export const api = { addCustomerComment: (slug: string, noteId: number, content: string) => request(`/api/review/${slug}/works/${noteId}/comments`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content }) }), addCustomerAnnotation: (slug: string, imageId: number, data: { x: number; y: number; content: string }) => request(`/api/review/${slug}/images/${imageId}/annotations`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }), addCustomerTextAnnotation: (slug: string, noteId: number, data: { version_number: number; target: 'title' | 'description'; content: string }) => request(`/api/review/${slug}/works/${noteId}/text-annotations`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }), - submitCustomerDecision: (slug: string, noteId: number, decision: 'approved' | 'changes_requested', reason?: string) => request<{ success: true; status: ReviewStatus }>(`/api/review/${slug}/works/${noteId}/decision`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ decision, reason }) }), + submitCustomerDecision: (slug: string, noteId: number, versionNumber: number, decision: 'approved' | 'changes_requested', reason?: string) => request<{ success: true; status: ReviewStatus; version_number: number }>(`/api/review/${slug}/works/${noteId}/decision`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ version_number: versionNumber, decision, reason }) }), }; diff --git a/src/components/CandidateStatusBadge.tsx b/src/components/CandidateStatusBadge.tsx new file mode 100644 index 0000000..2feaa5d --- /dev/null +++ b/src/components/CandidateStatusBadge.tsx @@ -0,0 +1,13 @@ +import type { CandidateStatus } from '@shared/types'; + +const labels: Record = { + draft: '草稿', pending: '待选择', changes_requested: '需修改', selected: '已选用', not_selected: '未选用', +}; +const styles: Record = { + draft: 'bg-black/5 text-black/45', pending: 'bg-amber-50 text-amber-700', changes_requested: 'bg-red-50 text-red-700', + selected: 'bg-emerald-50 text-emerald-700', not_selected: 'bg-black/5 text-black/35', +}; + +export default function CandidateStatusBadge({ status }: { status: CandidateStatus }) { + return {labels[status]}; +} diff --git a/src/pages/CustomerReview.tsx b/src/pages/CustomerReview.tsx index f68467d..18a47bd 100644 --- a/src/pages/CustomerReview.tsx +++ b/src/pages/CustomerReview.tsx @@ -7,6 +7,7 @@ import AnnotatableImage from '@/components/AnnotatableImage'; import AnnotatableText from '@/components/AnnotatableText'; import StatusBadge from '@/components/StatusBadge'; import CollectionStatusBadge from '@/components/CollectionStatusBadge'; +import CandidateStatusBadge from '@/components/CandidateStatusBadge'; type ProjectPayload = { project: Pick; collections: WorkCollection[]; reviewer_name: string }; @@ -72,11 +73,19 @@ function CollectionReview({ slug, data }: { slug: string; data: { collection: Wo } function WorkReview({ slug, work, reviewer, reload }: { slug: string; work: NoteDetail; reviewer: string; reload: () => Promise }) { - const [comment,setComment]=useState(''); const [reason,setReason]=useState(''); const [busy,setBusy]=useState(false); - const readOnly=work.collection.status==='completed'; - const send=async()=>{if(!comment.trim())return;setBusy(true);await api.addCustomerComment(slug,work.id,comment.trim());setComment('');await reload();setBusy(false)}; - const decide=async(decision:'approved'|'changes_requested')=>{if(decision==='changes_requested'&&!reason.trim())return;if(decision==='approved'&&!window.confirm('确认通过这个版本吗?通过后将记录你的验收决定。'))return;setBusy(true);await api.submitCustomerDecision(slug,work.id,decision,reason.trim());setReason('');await reload();setBusy(false)}; - return
{work.collection.name}
{work.versions.map((item)=>V{item.version_number})}
{readOnly&&
验收已经完成。你仍可查看全部作品与历史批注,但不能继续添加反馈。
}

{work.project.name} / {work.collection.name} / WORK {String(work.id).padStart(3,'0')}

{work.images.map((img)=>{await api.addCustomerAnnotation(slug,img.id,{x,y,content});await reload()}}/>)}
annotation.target==='title')} readOnly={readOnly} onAdd={async(content)=>{await api.addCustomerTextAnnotation(slug,work.id,{version_number:work.version_number,target:'title',content});await reload()}}>

{work.title}

{work.description&&
annotation.target==='description')} readOnly={readOnly} onAdd={async(content)=>{await api.addCustomerTextAnnotation(slug,work.id,{version_number:work.version_number,target:'description',content});await reload()}}>

{work.description}

}{work.tags.length>0&&

{work.tags.join(' ')}

}