From 721e971dd899820d4c5bd0a61809236df20078c2 Mon Sep 17 00:00:00 2001 From: yuzhe Date: Tue, 21 Jul 2026 19:23:58 +0800 Subject: [PATCH] =?UTF-8?q?feat(collections):=20=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E5=90=8C=E6=AD=A5=E4=BD=9C=E5=93=81=E4=BA=A4=E4=BB=98=E9=9B=86?= =?UTF-8?q?=E9=AA=8C=E6=94=B6=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/database.ts | 6 +- api/db.ts | 19 ++++++- api/routes/notes.ts | 7 ++- api/routes/projects.ts | 6 +- api/routes/review.ts | 13 +++-- api/services/collectionsService.ts | 46 ++++++++++++++++ api/services/notesService.ts | 30 ++++++++-- db/postgres/schema.sql | 25 ++++++++- docs/architecture.md | 1 + docs/integration-guide.md | 2 + package.json | 1 + scripts/migrate-sqlite-to-postgres.ts | 6 +- scripts/test-postgres-runtime.ts | 70 ++++++++++++++++++++++-- scripts/test-sqlite-collection-status.ts | 63 +++++++++++++++++++++ scripts/validate-postgres-migration.ts | 3 +- shared/types.ts | 3 +- src/components/AnnotatableImage.tsx | 6 +- src/components/AnnotatableText.tsx | 4 +- src/components/CollectionStatusBadge.tsx | 19 +++++++ src/pages/Collection.tsx | 3 +- src/pages/CustomerReview.tsx | 8 ++- src/pages/Project.tsx | 3 +- 22 files changed, 309 insertions(+), 35 deletions(-) create mode 100644 api/services/collectionsService.ts create mode 100644 scripts/test-sqlite-collection-status.ts create mode 100644 src/components/CollectionStatusBadge.tsx diff --git a/api/database.ts b/api/database.ts index 279b497..499b8ba 100644 --- a/api/database.ts +++ b/api/database.ts @@ -25,7 +25,11 @@ if (databaseUrl) { pool = new pg.Pool({ connectionString: databaseUrl, ssl: process.env.PGSSL === 'disable' ? false : undefined, max: Number(process.env.PG_POOL_MAX || 10) }); } let schema = fs.readFileSync(path.resolve('db/postgres/schema.sql'), 'utf8'); - if (databaseUrl === 'pg-mem://') schema = schema.replace(/CREATE UNIQUE INDEX IF NOT EXISTS one_active_storage_config[^;]+;/, ''); + 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/, ''); + } await pool.query(schema); const userCount = Number((await pool.query('SELECT COUNT(*)::int AS count FROM users')).rows[0].count); if (userCount === 0) { diff --git a/api/db.ts b/api/db.ts index 2fe3bb0..596c9f0 100644 --- a/api/db.ts +++ b/api/db.ts @@ -109,7 +109,8 @@ db.exec(` project_id INTEGER NOT NULL, name TEXT NOT NULL, client_description TEXT NOT NULL DEFAULT '', - status TEXT NOT NULL DEFAULT 'reviewing', + status TEXT NOT NULL DEFAULT 'draft', + completed_at TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE ); @@ -211,6 +212,7 @@ addColumn('images', "storage_provider TEXT NOT NULL DEFAULT 'local'"); 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'); 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 != ''"); @@ -254,6 +256,21 @@ 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(` + UPDATE collections + SET status = CASE + WHEN NOT EXISTS (SELECT 1 FROM notes n WHERE n.collection_id = collections.id AND n.review_status != 'draft') THEN 'draft' + WHEN NOT EXISTS (SELECT 1 FROM notes n WHERE n.collection_id = collections.id AND n.review_status != 'draft' AND n.review_status != 'approved') THEN 'completed' + ELSE 'reviewing' + END, + completed_at = CASE + WHEN EXISTS (SELECT 1 FROM notes n WHERE n.collection_id = collections.id AND n.review_status != 'draft') + AND NOT EXISTS (SELECT 1 FROM notes n WHERE n.collection_id = collections.id AND n.review_status != 'draft' AND n.review_status != 'approved') + THEN COALESCE(completed_at, datetime('now')) + ELSE NULL + END + WHERE status != 'archived'; +`); db.exec(` CREATE INDEX IF NOT EXISTS idx_collections_project_id ON collections(project_id); diff --git a/api/routes/notes.ts b/api/routes/notes.ts index 8058d9f..a8c5678 100644 --- a/api/routes/notes.ts +++ b/api/routes/notes.ts @@ -4,6 +4,7 @@ import { Router, type Response, type NextFunction } from 'express'; import { upload } from '../upload.js'; import { notesService } from '../services/notesService.js'; +import { recalculateCollectionStatus } from '../services/collectionsService.js'; import { audit, canWriteProject, requireRole, requireWriter, type AuthRequest } from '../auth.js'; import { database, withTransaction } from '../database.js'; import fs from 'fs'; @@ -181,8 +182,9 @@ router.patch('/:noteId/status', requireWriter, async (req: AuthRequest, res: Res res.status(400).json({ error: '无效的验收状态' }); return; } - const context = await database.one<{ project_id: number }>('SELECT c.project_id FROM notes n JOIN collections c ON c.id = n.collection_id WHERE n.id = ?', [id]); + const context = await database.one<{ project_id: number; review_status: string }>('SELECT c.project_id, n.review_status FROM notes n JOIN collections c ON c.id = n.collection_id WHERE n.id = ?', [id]); if (context && !await canWriteProject(req, context.project_id)) { res.status(403).json({ error: '无权操作该作品' }); return; } + if (context?.review_status === 'approved') { res.status(409).json({ error: '已通过作品只能由组管理员填写原因后重新打开' }); return; } if (!await notesService.setStatus(id, status)) { res.status(404).json({ error: '作品不存在' }); return; @@ -194,7 +196,7 @@ 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 }>('SELECT n.review_status, n.version_number, 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 }>('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]); 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; } @@ -203,6 +205,7 @@ router.post('/:noteId/reopen', requireRole('platform_admin', 'group_admin'), asy 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("INSERT INTO review_events (note_id, version_number, event_type, from_status, to_status, reason, actor_name, actor_role) VALUES (?, ?, 'reopened', 'approved', 'pending', ?, ?, ?)", [id, note.version_number, reason, actor.display_name, actor.role]); + await recalculateCollectionStatus(Number(note.collection_id), tx); }); await audit(req, 'work.reopen', 'work', id, { reason, versionNumber: note.version_number }); res.json({ success: true, status: 'pending' }); diff --git a/api/routes/projects.ts b/api/routes/projects.ts index 7416bc2..d1ed1ba 100644 --- a/api/routes/projects.ts +++ b/api/routes/projects.ts @@ -69,7 +69,7 @@ router.patch('/:projectId',requireWriter,async(req:AuthRequest,res:Response)=>{ router.get('/:projectId/collections',reader,async(req:AuthRequest,res:Response)=>{ const id=Number(req.params.projectId);if(!await canWriteProject(req,id)){res.status(403).json({error:'无权查看该项目'});return} - const rows=await database.all(`SELECT c.*,COALESCE(s.work_count,0) AS work_count,COALESCE(s.approved_count,0) AS approved_count FROM collections c LEFT JOIN (SELECT collection_id,COUNT(*) AS work_count,SUM(CASE WHEN review_status='approved' THEN 1 ELSE 0 END) AS approved_count FROM notes GROUP BY collection_id) s ON s.collection_id=c.id WHERE c.project_id=? AND c.status!='archived' ORDER BY c.id DESC`,[id]);res.json(rows.map(collectionJson)); + const rows=await database.all(`SELECT c.*,COALESCE(s.work_count,0) AS work_count,COALESCE(s.approved_count,0) AS approved_count FROM collections c LEFT JOIN (SELECT collection_id,COUNT(*) AS work_count,SUM(CASE WHEN review_status='approved' THEN 1 ELSE 0 END) AS approved_count FROM notes WHERE review_status!='draft' GROUP BY collection_id) s ON s.collection_id=c.id WHERE c.project_id=? AND c.status!='archived' ORDER BY c.id DESC`,[id]);res.json(rows.map(collectionJson)); }); router.patch('/:projectId/customer-access',requireWriter,async(req:AuthRequest,res:Response)=>{ @@ -83,12 +83,12 @@ router.patch('/:projectId/customer-access',requireWriter,async(req:AuthRequest,r router.post('/:projectId/collections',requireWriter,async(req:AuthRequest,res:Response)=>{ const projectId=Number(req.params.projectId);if(!await canWriteProject(req,projectId)){res.status(403).json({error:'无权操作该项目'});return}const name=String(req.body?.name??'').trim();const description=String(req.body?.client_description??'').trim();if(!name){res.status(400).json({error:'作品交付集名称不能为空'});return} - try{const id=await database.insertId('INSERT INTO collections (project_id, name, client_description) VALUES (?, ?, ?)',[projectId,name,description]);await audit(req,'collection.create','collection',id,{projectId,name});res.status(201).json(collectionJson((await database.one('SELECT c.*, 0 AS work_count, 0 AS approved_count FROM collections c WHERE c.id = ?',[id]))!))}catch{res.status(409).json({error:'同一项目内作品交付集名称不能重复'})} + try{const id=await database.insertId("INSERT INTO collections (project_id, name, client_description, status) VALUES (?, ?, ?, 'draft')",[projectId,name,description]);await audit(req,'collection.create','collection',id,{projectId,name});res.status(201).json(collectionJson((await database.one('SELECT c.*, 0 AS work_count, 0 AS approved_count FROM collections c WHERE c.id = ?',[id]))!))}catch{res.status(409).json({error:'同一项目内作品交付集名称不能重复'})} }); router.patch('/:projectId/collections/:collectionId',requireWriter,async(req:AuthRequest,res:Response)=>{ const projectId=Number(req.params.projectId);if(!await canWriteProject(req,projectId)){res.status(403).json({error:'无权操作该项目'});return}const id=Number(req.params.collectionId);const name=String(req.body?.name??'').trim();const description=String(req.body?.client_description??'').trim();if(!name){res.status(400).json({error:'作品交付集名称不能为空'});return} - try{if(!(await database.execute('UPDATE collections SET name = ?, client_description = ? WHERE id = ? AND project_id = ?',[name,description,id,projectId])).changes){res.status(404).json({error:'作品交付集不存在'});return}await audit(req,'collection.update','collection',id,{projectId,name});const row=await database.one(`SELECT c.*,COALESCE(s.work_count,0) AS work_count,COALESCE(s.approved_count,0) AS approved_count FROM collections c LEFT JOIN (SELECT collection_id,COUNT(*) AS work_count,SUM(CASE WHEN review_status='approved' THEN 1 ELSE 0 END) AS approved_count FROM notes GROUP BY collection_id) s ON s.collection_id=c.id WHERE c.id=?`,[id]);res.json(collectionJson(row!))}catch{res.status(409).json({error:'同一项目内作品交付集名称不能重复'})} + try{if(!(await database.execute('UPDATE collections SET name = ?, client_description = ? WHERE id = ? AND project_id = ?',[name,description,id,projectId])).changes){res.status(404).json({error:'作品交付集不存在'});return}await audit(req,'collection.update','collection',id,{projectId,name});const row=await database.one(`SELECT c.*,COALESCE(s.work_count,0) AS work_count,COALESCE(s.approved_count,0) AS approved_count FROM collections c LEFT JOIN (SELECT collection_id,COUNT(*) AS work_count,SUM(CASE WHEN review_status='approved' THEN 1 ELSE 0 END) AS approved_count FROM notes WHERE review_status!='draft' GROUP BY collection_id) s ON s.collection_id=c.id WHERE c.id=?`,[id]);res.json(collectionJson(row!))}catch{res.status(409).json({error:'同一项目内作品交付集名称不能重复'})} }); export default router; diff --git a/api/routes/review.ts b/api/routes/review.ts index 075349d..f0ab220 100644 --- a/api/routes/review.ts +++ b/api/routes/review.ts @@ -4,6 +4,7 @@ import { createCustomerSession, customerSessionCookie, optionalCustomer, require import { verifyPassword } from '../auth.js'; import { notesService } from '../services/notesService.js'; import { annotationsRepository } from '../repositories/annotationsRepository.js'; +import { recalculateCollectionStatus } from '../services/collectionsService.js'; import type { TextAnnotation, WorkComment } from '../../shared/types.js'; const router=Router();router.use(optionalCustomer); @@ -15,18 +16,18 @@ router.get('/:slug/access',async(req:CustomerRequest,res:Response)=>{const proje router.post('/:slug/login',async(req:CustomerRequest,res:Response)=>{const project=await projectBySlug(req.params.slug);const reviewerName=String(req.body?.reviewer_name??'').trim();const password=String(req.body?.password??'');if(!project||project.status==='archived'){res.status(404).json({error:'项目不存在'});return}if(!project.customer_access_enabled){res.status(403).json({error:'该项目暂未开放客户访问'});return}if(expired(project.access_expires_at)){res.status(403).json({error:'项目访问链接已到期'});return}if(reviewerName.length<2||reviewerName.length>30){res.status(400).json({error:'请填写 2–30 个字符的姓名'});return}if(!project.access_password_hash||!verifyPassword(password,project.access_password_hash)){res.status(401).json({error:'访问密码错误'});return}const token=await createCustomerSession(Number(project.id),reviewerName);res.setHeader('Set-Cookie',customerSessionCookie(token));res.json({success:true,reviewer_name:reviewerName})}); -router.get('/:slug/project',requireCustomerProject,async(req:CustomerRequest,res:Response)=>{const project=(await projectBySlug(req.params.slug))!;const collections=await database.all>(`SELECT c.id,c.project_id,c.name,c.client_description,c.status,c.created_at,COUNT(n.id) AS work_count,COUNT(CASE WHEN n.review_status='approved' THEN 1 END) AS approved_count FROM collections c LEFT JOIN notes n ON n.collection_id=c.id AND n.review_status!='draft' WHERE c.project_id=? AND c.status IN ('reviewing','completed') GROUP BY c.id,c.project_id,c.name,c.client_description,c.status,c.created_at ORDER BY c.id DESC`,[project.id]);res.json({project:{id:Number(project.id),name:project.name,slug:project.slug,client_description:project.client_description,status:project.status},collections:collections.map((item)=>({...item,id:Number(item.id),project_id:Number(item.project_id),work_count:Number(item.work_count),approved_count:Number(item.approved_count)})),reviewer_name:req.customer!.reviewer_name})}); +router.get('/:slug/project',requireCustomerProject,async(req:CustomerRequest,res:Response)=>{const project=(await projectBySlug(req.params.slug))!;const collections=await database.all>(`SELECT c.id,c.project_id,c.name,c.client_description,c.status,c.completed_at,c.created_at,COUNT(n.id) AS work_count,COUNT(CASE WHEN n.review_status='approved' THEN 1 END) AS approved_count FROM collections c LEFT JOIN notes n ON n.collection_id=c.id AND n.review_status!='draft' WHERE c.project_id=? AND c.status IN ('reviewing','completed') GROUP BY c.id,c.project_id,c.name,c.client_description,c.status,c.completed_at,c.created_at ORDER BY c.id DESC`,[project.id]);res.json({project:{id:Number(project.id),name:project.name,slug:project.slug,client_description:project.client_description,status:project.status},collections:collections.map((item)=>({...item,id:Number(item.id),project_id:Number(item.project_id),work_count:Number(item.work_count),approved_count:Number(item.approved_count)})),reviewer_name:req.customer!.reviewer_name})}); -router.get('/:slug/collections/:collectionId/works',requireCustomerProject,async(req:CustomerRequest,res:Response)=>{const project=(await projectBySlug(req.params.slug))!;const collectionId=Number(req.params.collectionId);const collection=await database.one>("SELECT * FROM collections WHERE id=? AND project_id=? AND status IN ('reviewing','completed')",[collectionId,project.id]);if(!collection){res.status(404).json({error:'作品交付集不存在或尚未发布'});return}const works=(await notesService.list({collectionId})).filter((work)=>work.review_status!=='draft');res.json({collection,works})}); +router.get('/:slug/collections/:collectionId/works',requireCustomerProject,async(req:CustomerRequest,res:Response)=>{const project=(await projectBySlug(req.params.slug))!;const collectionId=Number(req.params.collectionId);const collection=await database.one>(`SELECT c.*,COUNT(n.id) AS work_count,COUNT(CASE WHEN n.review_status='approved' THEN 1 END) AS approved_count FROM collections c LEFT JOIN notes n ON n.collection_id=c.id AND n.review_status!='draft' WHERE c.id=? AND c.project_id=? AND c.status IN ('reviewing','completed') GROUP BY c.id,c.project_id,c.name,c.client_description,c.status,c.completed_at,c.created_at`,[collectionId,project.id]);if(!collection){res.status(404).json({error:'作品交付集不存在或尚未发布'});return}const works=(await notesService.list({collectionId})).filter((work)=>work.review_status!=='draft');res.json({collection:{...collection,id:Number(collection.id),project_id:Number(collection.project_id),work_count:Number(collection.work_count),approved_count:Number(collection.approved_count)},works})}); 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('SELECT n.id 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(!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}>('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/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('SELECT v.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=? AND c.project_id=? AND n.review_status!=?',[noteId,versionNumber,project.id,'draft']);if(!belongs){res.status(404).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}>('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/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(`SELECT i.id 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(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}>(`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/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}>(`SELECT n.version_number,n.review_status FROM notes n WHERE n.id=? AND n.review_status!='draft' AND n.collection_id IN (SELECT id FROM collections WHERE project_id=?)`,[noteId,project.id]);if(!current){res.status(404).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])});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 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})}); export default router; diff --git a/api/services/collectionsService.ts b/api/services/collectionsService.ts new file mode 100644 index 0000000..84badcc --- /dev/null +++ b/api/services/collectionsService.ts @@ -0,0 +1,46 @@ +import type { CollectionStatus } from '../../shared/types.js'; +import { database, databaseDialect, type QueryContext } from '../database.js'; + +export interface CollectionStatusResult { + status: CollectionStatus; + workCount: number; + approvedCount: number; + completedAt: string | null; +} + +export function deriveCollectionStatus(workCount: number, approvedCount: number): Exclude { + if (workCount === 0) return 'draft'; + if (approvedCount === workCount) return 'completed'; + return 'reviewing'; +} + +export async function recalculateCollectionStatus( + collectionId: number, + tx: QueryContext = database, +): Promise { + const collection = await tx.one<{ status: CollectionStatus; completed_at: string | null }>( + `SELECT status, completed_at FROM collections WHERE id = ?${databaseDialect === 'postgres' ? ' FOR NO KEY UPDATE' : ''}`, + [collectionId], + ); + if (!collection) return null; + + const counts = await tx.one<{ work_count: number | string; approved_count: number | string }>( + `SELECT COUNT(*) AS work_count, + SUM(CASE WHEN review_status = 'approved' THEN 1 ELSE 0 END) AS approved_count + FROM notes WHERE collection_id = ? AND review_status != 'draft'`, + [collectionId], + ); + const workCount = Number(counts?.work_count ?? 0); + const approvedCount = Number(counts?.approved_count ?? 0); + + if (collection.status === 'archived') { + return { status: 'archived', workCount, approvedCount, completedAt: collection.completed_at }; + } + + const status = deriveCollectionStatus(workCount, approvedCount); + const completedAt = status === 'completed' + ? collection.completed_at ?? new Date().toISOString() + : null; + await tx.execute('UPDATE collections SET status = ?, completed_at = ? WHERE id = ?', [status, completedAt, collectionId]); + return { status, workCount, approvedCount, completedAt }; +} diff --git a/api/services/notesService.ts b/api/services/notesService.ts index c5556ee..b27eb41 100644 --- a/api/services/notesService.ts +++ b/api/services/notesService.ts @@ -5,6 +5,7 @@ import { imagesRepository } from '../repositories/imagesRepository.js'; import { annotationsRepository } from '../repositories/annotationsRepository.js'; import { database, withTransaction } from '../database.js'; import { storeUploadedFile } from '../storage.js'; +import { recalculateCollectionStatus } from './collectionsService.js'; export interface UploadedFile { filename: string; originalname?: string; mimetype?: string; path: string } @@ -31,7 +32,7 @@ export const notesService = { if (requestedVersion && requestedVersion !== current.version_number && !selectedVersion) return null; const note: Note = selectedVersion ? { ...current, ...selectedVersion, version_number: Number(selectedVersion.version_number), tags: JSON.parse(selectedVersion.tags || '[]') as string[] } : current; 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 }>(`SELECT p.id AS project_id, p.name AS project_name, p.slug, c.id AS collection_id, c.name AS collection_name 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]); + 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 result: NoteDetail = { @@ -42,7 +43,7 @@ export const notesService = { versions: versionRows.map((item) => ({ ...item, version_number: Number(item.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 }, + collection: { id: Number(workContext.collection_id), name: workContext.collection_name, status: workContext.collection_status }, }; for (const image of images) result.images.push({ ...image, annotations: await annotationsRepository.listByImage(image.id) }); return result; @@ -54,6 +55,7 @@ export const notesService = { 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); return id; }); return (await notesRepository.findById(noteId))!; @@ -64,6 +66,7 @@ export const notesService = { 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); return id; }); return (await notesRepository.findById(noteId))!; @@ -83,6 +86,7 @@ export const notesService = { 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); }); return (await notesRepository.findById(id))!; }, @@ -96,10 +100,28 @@ export const notesService = { 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); }); return (await notesRepository.findById(id))!; }, - async remove(id: number) { return notesRepository.remove(id); }, - async setStatus(id: number, status: ReviewStatus) { return notesRepository.setStatus(id, status); }, + async remove(id: number) { + return withTransaction(async (tx) => { + const note = await tx.one<{ collection_id: number }>('SELECT collection_id FROM notes WHERE id = ?', [id]); + if (!note) return false; + const removed = (await tx.execute('DELETE FROM notes WHERE id = ?', [id])).changes > 0; + if (removed) await recalculateCollectionStatus(Number(note.collection_id), tx); + 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]); + 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]); + await recalculateCollectionStatus(Number(note.collection_id), tx); + return true; + }); + }, }; diff --git a/db/postgres/schema.sql b/db/postgres/schema.sql index f79c9df..0bf5648 100644 --- a/db/postgres/schema.sql +++ b/db/postgres/schema.sql @@ -40,11 +40,14 @@ CREATE TABLE IF NOT EXISTS collections ( project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, name TEXT NOT NULL, client_description TEXT NOT NULL DEFAULT '', - status TEXT NOT NULL DEFAULT 'reviewing' CHECK (status IN ('draft', 'reviewing', 'completed', 'archived')), + status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'reviewing', 'completed', 'archived')), + completed_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE (project_id, name) ); +ALTER TABLE collections ADD COLUMN IF NOT EXISTS completed_at TIMESTAMPTZ; + CREATE TABLE IF NOT EXISTS notes ( id BIGSERIAL PRIMARY KEY, collection_id BIGINT NOT NULL REFERENCES collections(id) ON DELETE CASCADE, @@ -204,4 +207,24 @@ CREATE INDEX IF NOT EXISTS customer_sessions_project_id_idx ON customer_sessions CREATE INDEX IF NOT EXISTS review_events_note_version_idx ON review_events(note_id, version_number); CREATE INDEX IF NOT EXISTS audit_logs_group_id_idx ON audit_logs(group_id); +-- COLLECTION_STATUS_REPAIR_START +UPDATE collections +SET status = 'draft', completed_at = NULL +WHERE status != 'archived' + AND id NOT IN (SELECT collection_id FROM notes WHERE review_status != 'draft'); + +UPDATE collections AS c +SET status = CASE WHEN s.approved_count = s.work_count THEN 'completed' ELSE 'reviewing' END, + completed_at = CASE WHEN s.approved_count = s.work_count THEN COALESCE(c.completed_at, NOW()) ELSE NULL END +FROM ( + SELECT collection_id, + COUNT(*) AS work_count, + SUM(CASE WHEN review_status = 'approved' THEN 1 ELSE 0 END) AS approved_count + FROM notes + WHERE review_status != 'draft' + GROUP BY collection_id +) AS s +WHERE c.id = s.collection_id AND c.status != 'archived'; +-- COLLECTION_STATUS_REPAIR_END + COMMIT; diff --git a/docs/architecture.md b/docs/architecture.md index 07c4b06..a2fa694 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -79,3 +79,4 @@ flowchart LR 作品状态为 `draft`、`pending`、`changes_requested`、`approved`。客户只能看到非草稿作品;客户可通过或要求修改,要求修改必须填写原因。已通过作品只能由平台管理员或所属组管理员填写原因后重新打开,历史事件保留。 +作品交付集状态由其中非草稿作品自动计算:没有已提交作品时为 `draft`,存在未通过作品时为 `reviewing`,全部已提交作品通过时为 `completed`。`archived` 是人工状态,自动计算不会覆盖。完成后客户页面只读;新增作品、新版本或重新打开作品会自动恢复为验收中。 diff --git a/docs/integration-guide.md b/docs/integration-guide.md index 2d090bf..5d27f36 100644 --- a/docs/integration-guide.md +++ b/docs/integration-guide.md @@ -72,6 +72,8 @@ 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` 分别表示已提交作品数、已通过作品数和本次完成时间。调用方不应直接维护作品交付集状态;创建作品、新版本、修改验收状态和删除作品都会触发服务端重算。 + ## 上传作品 外部客户端使用 JSON 创建作品,`images` 直接传入 1–30 个公开可读的 HTTP/HTTPS 图片 URL。服务只保存 URL,不会下载图片或再次上传到 COS。数组顺序就是展示顺序,第一张为封面。 diff --git a/package.json b/package.json index ebb2fb8..18d54f2 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "db:postgres:migrate": "tsx scripts/migrate-sqlite-to-postgres.ts", "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", "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 9dd0504..3f6aad8 100644 --- a/scripts/migrate-sqlite-to-postgres.ts +++ b/scripts/migrate-sqlite-to-postgres.ts @@ -26,7 +26,8 @@ const client = new pg.Client({ connectionString: databaseUrl, ssl: process.env.P try { await client.connect(); - await client.query(fs.readFileSync(schemaPath, 'utf8')); + const schema = fs.readFileSync(schemaPath, 'utf8'); + await client.query(schema); const existing = Number((await client.query('SELECT COUNT(*)::int AS count FROM operation_groups')).rows[0].count); if (existing && !replace) throw new Error('PostgreSQL 已有数据。确认覆盖时请显式添加 --replace'); await client.query('BEGIN'); @@ -45,6 +46,9 @@ 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]; + if (!collectionStatusRepair) throw new Error('PostgreSQL schema 缺少作品交付集状态修复脚本'); + await client.query(collectionStatusRepair); await client.query('COMMIT'); process.stdout.write(`迁移完成:${tables.length} 张表已从 ${sqlitePath} 导入 PostgreSQL\n`); } catch (error) { diff --git a/scripts/test-postgres-runtime.ts b/scripts/test-postgres-runtime.ts index 6010320..74b7757 100644 --- a/scripts/test-postgres-runtime.ts +++ b/scripts/test-postgres-runtime.ts @@ -86,6 +86,7 @@ try { const collection = await request(`/api/projects/${projectId}/collections`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: '第一阶段', client_description: '验收阶段' }) }, adminCookie); expectStatus(collection.response.status, 201, '创建作品交付集'); const collectionId=Number((collection.body as {id:number}).id); + if((collection.body as {status:string}).status!=='draft')throw new Error('空作品交付集未初始化为待提交'); const projectKey=await request('/api/management/api-keys',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:'项目接入测试 Key',project_id:projectId})},newAdminCookie); expectStatus(projectKey.response.status,201,'创建项目级 API Key',projectKey.body); @@ -104,6 +105,9 @@ try { const createdWork=await request('/api/notes',{method:'POST',headers:{...bearerHeaders,'Content-Type':'application/json'},body:JSON.stringify(createWorkBody)}); expectStatus(createdWork.response.status,201,'JSON URL 创建作品',createdWork.body); const workId=Number((createdWork.body as {id:number}).id); + const reviewingCollections=await request(`/api/projects/${projectId}/collections`,{headers:bearerHeaders}); + const reviewingCollection=(reviewingCollections.body as Array<{id:number;status:string;work_count:number}>).find((item)=>Number(item.id)===collectionId); + if(reviewingCollection?.status!=='reviewing'||Number(reviewingCollection.work_count)!==1)throw new Error('新增待验收作品后,作品交付集未进入验收中'); const repeatedWork=await request('/api/notes',{method:'POST',headers:{...bearerHeaders,'Content-Type':'application/json'},body:JSON.stringify(createWorkBody)}); expectStatus(repeatedWork.response.status,200,'externalId 幂等创建',repeatedWork.body); if(Number((repeatedWork.body as {id:number}).id)!==workId||!(repeatedWork.body as {idempotent?:boolean}).idempotent)throw new Error('externalId 重复请求创建了不同作品'); @@ -117,22 +121,80 @@ try { expectStatus(workDetail.response.status,200,'读取新版本作品',workDetail.body); 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 revokeProjectKey=await request(`/api/management/api-keys/${projectKeyId}`,{method:'DELETE'},newAdminCookie); - expectStatus(revokeProjectKey.response.status,204,'吊销项目级 API Key'); - const reviewLogin = await request('/api/review/postgres-runtime-test/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ reviewer_name: '客户测试', password: 'Review123!' }) }); expectStatus(reviewLogin.response.status, 200, '客户登录'); const reviewCookie = reviewLogin.response.headers.get('set-cookie')?.split(';')[0]; 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); + 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); + if(completedCollection?.status!=='completed'||!completedCollection.completed_at||Number(completedCollection.approved_count)!==1)throw new Error('全部作品通过后,作品交付集未自动完成'); + const readonlyComment=await request(`/api/review/postgres-runtime-test/works/${workId}/comments`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({content:'完成后不应写入'})},reviewCookie); + expectStatus(readonlyComment.response.status,409,'验收完毕后客户只读',readonlyComment.body); + const completedWorkDetail=await request(`/api/review/postgres-runtime-test/works/${workId}`,{},reviewCookie); + expectStatus(completedWorkDetail.response.status,200,'完成后读取作品',completedWorkDetail.body); + if((completedWorkDetail.body as {collection:{status:string}}).collection.status!=='completed')throw new Error('作品详情未返回作品交付集完成状态'); + + const reopenApproved=await request(`/api/notes/${workId}/reopen`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({reason:'补充复核'})},newAdminCookie); + 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); + 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']})}); + expectStatus(versionThree.response.status,201,'完成后创建新版本',versionThree.body); + 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); + 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); + expectStatus(approveV3.response.status,200,'客户再次通过',approveV3.body); + 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); + 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); + const reopenForDraft=await request(`/api/notes/${workId}/reopen`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({reason:'验证退回草稿后的集合状态'})},newAdminCookie); + expectStatus(reopenForDraft.response.status,200,'组管理员重新打开第一件作品',reopenForDraft.body); + const reopenSecondForDraft=await request(`/api/notes/${secondWorkId}/reopen`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({reason:'验证空集合状态'})},newAdminCookie); + expectStatus(reopenSecondForDraft.response.status,200,'组管理员重新打开第二件作品',reopenSecondForDraft.body); + const draftWork=await request(`/api/notes/${workId}/status`,{method:'PATCH',headers:{...bearerHeaders,'Content-Type':'application/json'},body:JSON.stringify({status:'draft'})}); + expectStatus(draftWork.response.status,200,'作品退回草稿',draftWork.body); + const draftSecondWork=await request(`/api/notes/${secondWorkId}/status`,{method:'PATCH',headers:{...bearerHeaders,'Content-Type':'application/json'},body:JSON.stringify({status:'draft'})}); + expectStatus(draftSecondWork.response.status,200,'新增作品退回草稿',draftSecondWork.body); + const draftCollections=await request(`/api/projects/${projectId}/collections`,{headers:bearerHeaders}); + const draftCollection=(draftCollections.body as Array<{id:number;status:string;work_count:number;completed_at:string|null}>).find((item)=>Number(item.id)===collectionId); + if(draftCollection?.status!=='draft'||Number(draftCollection.work_count)!==0||draftCollection.completed_at!==null)throw new Error('无已提交作品时未回到待提交'); + const resubmitWork=await request(`/api/notes/${workId}/status`,{method:'PATCH',headers:{...bearerHeaders,'Content-Type':'application/json'},body:JSON.stringify({status:'pending'})}); + expectStatus(resubmitWork.response.status,200,'重新提交作品',resubmitWork.body); + const deleteWork=await request(`/api/notes/${workId}`,{method:'DELETE',headers:bearerHeaders}); + expectStatus(deleteWork.response.status,204,'删除最后一件作品',deleteWork.body); + const deleteSecondWork=await request(`/api/notes/${secondWorkId}`,{method:'DELETE',headers:bearerHeaders}); + expectStatus(deleteSecondWork.response.status,204,'删除第二件作品',deleteSecondWork.body); + const emptyCollections=await request(`/api/projects/${projectId}/collections`,{headers:bearerHeaders}); + const emptyCollection=(emptyCollections.body as Array<{id:number;status:string;work_count:number}>).find((item)=>Number(item.id)===collectionId); + if(emptyCollection?.status!=='draft'||Number(emptyCollection.work_count)!==0)throw new Error('删除最后一件作品后未回到待提交'); + + const revokeProjectKey=await request(`/api/management/api-keys/${projectKeyId}`,{method:'DELETE'},newAdminCookie); + expectStatus(revokeProjectKey.response.status,204,'吊销项目级 API Key'); + const apiKey = await request('/api/management/api-keys', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: '运行时测试 Key' }) }, adminCookie); expectStatus(apiKey.response.status, 201, '创建平台 API Key'); const keyId = Number((apiKey.body as { item: { id: number } }).item.id); const revoke = await request(`/api/management/api-keys/${keyId}`, { method: 'DELETE' }, adminCookie); expectStatus(revoke.response.status, 204, '吊销平台 API Key'); - process.stdout.write('PostgreSQL 运行时验证通过:账号角色、项目隔离、客户门禁、作品交付集、API Key 发现、externalId 幂等、JSON URL 作品与新版本\n'); + process.stdout.write('PostgreSQL 运行时验证通过:账号角色、项目隔离、客户门禁、作品交付集自动状态、完成只读、API Key 发现、externalId 幂等、JSON URL 作品与新版本\n'); } finally { await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); await closeDatabase(); diff --git a/scripts/test-sqlite-collection-status.ts b/scripts/test-sqlite-collection-status.ts new file mode 100644 index 0000000..b286a03 --- /dev/null +++ b/scripts/test-sqlite-collection-status.ts @@ -0,0 +1,63 @@ +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 { deriveCollectionStatus, recalculateCollectionStatus } = await import('../api/services/collectionsService.js'); + +function assert(condition: unknown, message: string): asserts condition { + if (!condition) throw new Error(message); +} + +async function current(collectionId: number) { + return database.one<{ status: string; completed_at: string | null }>('SELECT status, completed_at FROM collections WHERE id = ?', [collectionId]); +} + +assert(deriveCollectionStatus(0, 0) === 'draft', '空作品交付集应为待提交'); +assert(deriveCollectionStatus(2, 1) === 'reviewing', '存在未通过作品时应为验收中'); +assert(deriveCollectionStatus(2, 2) === 'completed', '全部作品通过后应为验收完毕'); + +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 (?, ?, 'draft')", [project.id, `状态测试-${Date.now()}`]); + + await recalculateCollectionStatus(collectionId); + assert((await current(collectionId))?.status === 'draft', '新建空作品交付集状态错误'); + + const noteId = await database.insertId("INSERT INTO notes (collection_id, title, review_status) VALUES (?, ?, 'pending')", [collectionId, 'SQLite 状态测试作品']); + await recalculateCollectionStatus(collectionId); + assert((await current(collectionId))?.status === 'reviewing', '新增待验收作品后未进入验收中'); + + await database.execute("UPDATE notes SET review_status = 'approved' WHERE id = ?", [noteId]); + await recalculateCollectionStatus(collectionId); + const completed = await current(collectionId); + assert(completed?.status === 'completed' && completed.completed_at, '全部通过后未完成或缺少完成时间'); + + const secondNoteId = await database.insertId("INSERT INTO notes (collection_id, title, review_status) VALUES (?, ?, 'pending')", [collectionId, 'SQLite 状态测试作品二']); + const partial = await recalculateCollectionStatus(collectionId); + assert(partial?.status === 'reviewing' && partial.workCount === 2 && partial.approvedCount === 1, '部分作品通过时应保持验收中'); + await database.execute("UPDATE notes SET review_status = 'approved' WHERE id = ?", [secondNoteId]); + await recalculateCollectionStatus(collectionId); + assert((await current(collectionId))?.status === 'completed', '多件作品全部通过后应验收完毕'); + + await database.execute("UPDATE collections SET status = 'archived' WHERE id = ?", [collectionId]); + await database.execute("UPDATE notes SET review_status = 'pending' WHERE id = ?", [noteId]); + await recalculateCollectionStatus(collectionId); + assert((await current(collectionId))?.status === 'archived', '自动重算不应覆盖手动归档状态'); + + await database.execute("UPDATE collections SET status = 'reviewing' WHERE id = ?", [collectionId]); + await recalculateCollectionStatus(collectionId); + assert((await current(collectionId))?.status === 'reviewing', '恢复归档后应按作品状态重算'); + + await database.execute("UPDATE notes SET review_status = 'draft' WHERE id IN (?, ?)", [noteId, secondNoteId]); + await recalculateCollectionStatus(collectionId); + const draft = await current(collectionId); + assert(draft?.status === 'draft' && draft.completed_at === null, '全部作品退回草稿后应回到待提交并清除完成时间'); + + 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 8511178..c85f399 100644 --- a/scripts/validate-postgres-migration.ts +++ b/scripts/validate-postgres-migration.ts @@ -21,7 +21,8 @@ try { await client.connect(); 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(/CREATE UNIQUE INDEX IF NOT EXISTS one_active_storage_config[^;]+;/, '') + .replace(/-- COLLECTION_STATUS_REPAIR_START[\s\S]+?-- COLLECTION_STATUS_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); diff --git a/shared/types.ts b/shared/types.ts index 3d922e7..c3801cb 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -119,6 +119,7 @@ export interface WorkCollection { status: CollectionStatus; work_count: number; approved_count: number; + completed_at: string | null; created_at: string; } @@ -190,7 +191,7 @@ export interface NoteDetail extends Note { text_annotations: TextAnnotation[]; comments: WorkComment[]; project: Pick; - collection: Pick; + collection: Pick; versions: WorkVersion[]; review_events: ReviewEvent[]; } diff --git a/src/components/AnnotatableImage.tsx b/src/components/AnnotatableImage.tsx index 4004269..ce122fa 100644 --- a/src/components/AnnotatableImage.tsx +++ b/src/components/AnnotatableImage.tsx @@ -2,12 +2,12 @@ import { useRef, useState } from 'react'; import { Check, MessageCircle, X } from 'lucide-react'; import type { Annotation, NoteImage } from '@shared/types'; -export default function AnnotatableImage({image,annotations,onAdd}:{image:NoteImage;annotations:Annotation[];onAdd:(x:number,y:number,text:string)=>Promise}){ +export default function AnnotatableImage({image,annotations,onAdd,readOnly=false}:{image:NoteImage;annotations:Annotation[];onAdd:(x:number,y:number,text:string)=>Promise;readOnly?:boolean}){ const ref=useRef(null);const [point,setPoint]=useState<{x:number;y:number}|null>(null);const [selectedId,setSelectedId]=useState(null);const [text,setText]=useState(''); const selected=annotations.find(annotation=>annotation.id===selectedId); - const click=(e:React.MouseEvent)=>{if((e.target as HTMLElement).closest('button,textarea'))return;if(selectedId!==null){setSelectedId(null);return}const r=ref.current!.getBoundingClientRect();setPoint({x:(e.clientX-r.left)/r.width,y:(e.clientY-r.top)/r.height});setText('')}; + const click=(e:React.MouseEvent)=>{if((e.target as HTMLElement).closest('button,textarea'))return;if(selectedId!==null){setSelectedId(null);return}if(readOnly)return;const r=ref.current!.getBoundingClientRect();setPoint({x:(e.clientX-r.left)/r.width,y:(e.clientY-r.top)/r.height});setText('')}; const submit=async()=>{if(!point||!text.trim())return;await onAdd(point.x,point.y,text.trim());setPoint(null);setText('')}; - return
{annotations.map((annotation,index)=>)} + return
{annotations.map((annotation,index)=>)} {selected&&
.62?'translate(-50%, calc(-100% - 18px))':'translate(-50%, 18px)'}} onClick={event=>event.stopPropagation()}>

批注 {annotations.findIndex(annotation=>annotation.id===selected.id)+1}

{selected.content}

批注用户 · {selected.author_name||'未知用户'}
} {point&&
event.stopPropagation()}>
添加图片批注