From e7e268d4ebcb93af2df1354fe27ef1e441642161 Mon Sep 17 00:00:00 2001 From: yuzhe Date: Wed, 22 Jul 2026 16:41:03 +0800 Subject: [PATCH] =?UTF-8?q?feat(review):=20=E9=87=8D=E6=9E=84=E9=A1=B9?= =?UTF-8?q?=E7=9B=AE=E7=BA=A7=E5=8D=95=E6=96=B9=E6=A1=88=E9=AA=8C=E6=94=B6?= =?UTF-8?q?=E5=8D=8F=E4=BD=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 7 +- README.md | 77 +++----- api/app.ts | 4 + api/database.ts | 5 +- api/db.ts | 105 +++++++++++ api/repositories/annotationsRepository.ts | 6 +- api/repositories/notesRepository.ts | 14 +- api/routes/annotations.ts | 8 +- api/routes/comments.ts | 8 +- api/routes/images.ts | 8 +- api/routes/notes.ts | 46 +++-- api/routes/projectWorks.ts | 72 ++++++++ api/routes/projects.ts | 17 +- api/routes/review.ts | 173 ++++++++++++++++-- api/routes/works.ts | 182 ++++++++++++++++++ api/services/feedbackService.ts | 30 +++ api/services/notesService.ts | 213 ++++++++++++++-------- api/services/projectsService.ts | 55 ++++++ api/services/reviewService.ts | 58 +++--- db/postgres/schema.sql | 148 ++++++++++++++- docs/architecture.md | 99 +++++----- docs/handoff.md | 31 ++-- docs/integration-guide.md | 178 +++++++----------- scripts/migrate-sqlite-to-postgres.ts | 7 +- scripts/test-postgres-runtime.ts | 97 ++++++---- scripts/test-sqlite-review-rounds.ts | 81 +++++--- scripts/validate-postgres-migration.ts | 14 +- shared/types.ts | 66 ++++++- src/App.tsx | 1 + src/api/client.ts | 48 +++-- src/components/AnnotatableImage.tsx | 13 +- src/components/AnnotatableText.tsx | 60 +++--- src/components/CandidateStatusBadge.tsx | 13 -- src/components/CollaborationDrawer.tsx | 77 ++++++++ src/components/ImageReviewModal.tsx | 62 +++++++ src/pages/Collection.tsx | 24 +-- src/pages/CustomerReview.tsx | 98 ++++------ src/pages/Dashboard.tsx | 79 ++++++-- src/pages/Management.tsx | 4 +- src/pages/NewVersion.tsx | 65 +++---- src/pages/NoteDetail.tsx | 76 +++----- src/pages/Project.tsx | 65 +++---- src/pages/Upload.tsx | 6 +- tests/api_create_work.py | 41 ++--- tests/api_get_work_annotations.py | 101 ++++++++++ 45 files changed, 1830 insertions(+), 812 deletions(-) create mode 100644 api/routes/projectWorks.ts create mode 100644 api/routes/works.ts create mode 100644 api/services/feedbackService.ts create mode 100644 api/services/projectsService.ts delete mode 100644 src/components/CandidateStatusBadge.tsx create mode 100644 src/components/CollaborationDrawer.tsx create mode 100644 src/components/ImageReviewModal.tsx create mode 100644 tests/api_get_work_annotations.py diff --git a/AGENTS.md b/AGENTS.md index de6a6df..d1d7fdc 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,8 +1,9 @@ # Delivery Desk 开发约定 - 包管理器使用 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` 和相关迁移验证。 +- 业务术语统一为“运营组 → 项目 → 作品 → 验收轮次”;每轮只有一个方案。`collections` 和 `work_versions` 是迁移期内部兼容结构,不得出现在新产品界面或新 API 命名中。 +- 新接口使用 `/api/projects/:projectId/works` 和 `/api/works/:workId/rounds`;`notes`、`collections`、`versions` 路由只做一个兼容周期,不再扩展。 +- 数据库结构变更必须同时更新 `api/db.ts`、`db/postgres/schema.sql` 和迁移验证。 - `data/`、`uploads/`、`.env*`、COS 凭证、数据库文件及用户上传内容不得提交。 -- 本地开发可使用 SQLite;正式部署使用 PostgreSQL。COS 配置只通过平台管理界面或部署密钥注入,不写入源码。 +- 本地开发可使用 SQLite;正式部署使用 PostgreSQL。COS 配置只通过平台管理界面或部署密钥注入。 - 不把 `.trae/` 中的早期原型文档作为现行依据;以 README、`docs/` 和当前代码为准。 diff --git a/README.md b/README.md index dfae45f..131c48d 100644 --- a/README.md +++ b/README.md @@ -1,27 +1,19 @@ # 交付工作台(Delivery Desk) -面向图文作品交付与客户验收的响应式 Web 工作台。业务层级为“运营组 → 项目 → 作品交付集 → 作品 → 验收轮次 → 候选稿”。运营人员负责上传和处理反馈,客户通过项目链接完成查看、批注与验收。 +面向图文作品交付与客户验收的响应式 Web 工作台。当前产品层级为“运营组 → 项目 → 作品 → 验收轮次”;每轮只有一个方案。 ## 当前能力 - 平台管理员、组管理员、光影叙事三类账号及运营组数据隔离 -- 项目、作品交付集、作品和多候选稿验收轮次管理 -- 多图上传、封面预览、图片排序和腾讯云 COS 存储 -- 图片坐标批注、标题/正文批注、总体反馈和验收记录 +- 项目、作品、单方案验收轮次和项目级自动验收状态 +- 手动多图上传、公开图片 URL API、拖拽排序和腾讯云 COS +- 作品缩略图浏览;悬浮图片窗格中的原图查看、缩放和坐标批注 +- 标题、正文和 Tag 选区批注、作品总体反馈和验收记录 - 客户项目密码、访问期限和独立验收入口 - 平台级/项目级 API Key、审计日志和账号管理 -- SQLite 本地开发、PostgreSQL 正式运行及迁移脚本 -- Docker 单机部署 +- SQLite 本地开发、PostgreSQL 正式运行及 Docker 部署 -尚未落地的范围见 [初版交接说明](docs/handoff.md)。 - -## 技术结构 - -- React 18、TypeScript、Vite、Tailwind CSS -- Express API -- 本地开发:SQLite 与本地 `uploads` -- 正式环境:PostgreSQL、腾讯云 COS -- COS SecretId/SecretKey 由平台管理员在前端配置,服务端使用 AES-256-GCM 加密,接口不返回明文 +未落地范围见 [初版交接说明](docs/handoff.md)。 ## 本地开发 @@ -32,57 +24,25 @@ pnpm install pnpm dev ``` -- 前端:`http://localhost:5180` -- API:`http://localhost:3010` -- 健康检查:`http://localhost:3010/api/health` +- 前端:http://localhost:5180 +- API:http://localhost:3010 +- 健康检查:http://localhost:3010/api/health -未配置 `DATABASE_URL` 时使用 `data/app.db`;本地图片保存在 `uploads/`。这两个目录包含运行数据、账号信息或用户文件,已排除在 Git 之外。 +未配置 `DATABASE_URL` 时使用 `data/app.db`;本地上传文件保存在 `uploads/`。两者均包含运行数据或用户文件,已排除在 Git 之外。 -SQLite 首次启动会创建开发账号并要求首次登录改密。不要把这些开发账号用于公网环境。 +复制 `.env.example` 为 `.env` 后配置环境变量。生产环境至少需要 `DATABASE_URL`、`COS_CONFIG_ENCRYPTION_KEY` 和安全的初始管理员密码。真实 COS 凭证只能通过平台管理界面或部署密钥注入,不能提交到 Git。 -## 环境配置 - -复制 `.env.example` 为 `.env`,再按环境填写。关键变量: - -| 变量 | 用途 | -|---|---| -| `DATABASE_URL` | PostgreSQL 连接串;留空时使用 SQLite | -| `PGSSL` / `PG_POOL_MAX` | PostgreSQL SSL 与连接池配置 | -| `CORS_ORIGIN` | 允许携带凭据访问 API 的前端来源,多个值以逗号分隔 | -| `COS_CONFIG_ENCRYPTION_KEY` | 加密前端保存的 COS 凭证,至少 32 个随机字符 | -| `INITIAL_ADMIN_PASSWORD` | 空 PostgreSQL 首次初始化的平台管理员临时密码 | - -`COS_CONFIG_ENCRYPTION_KEY` 一经用于保存 COS 配置后必须稳定保管,更换会导致旧密文无法解密。真实 COS 凭证不得写入 `.env.example`、源码、镜像或日志。 - -## PostgreSQL 迁移 +## PostgreSQL 与 Docker ```bash pnpm db:postgres:validate pnpm db:postgres:migrate -``` - -目标数据库已有数据时迁移会拒绝覆盖。确认替换时才可执行: - -```bash -pnpm db:postgres:migrate -- --replace -``` - -## Docker 部署 - -在 `.env` 中至少设置 `POSTGRES_PASSWORD`、`COS_CONFIG_ENCRYPTION_KEY` 和 `INITIAL_ADMIN_PASSWORD`,然后运行: - -```bash docker compose up -d --build ``` -应用通过 `http://服务器地址:3010` 同时提供前端与 API。公网环境应在前面配置 HTTPS 反向代理;生产 Cookie 会自动添加 `Secure`。 +目标 PostgreSQL 已有数据时迁移默认拒绝覆盖。仅确认替换时使用 `pnpm db:postgres:migrate -- --replace`。 -## 文档与检查 - -- [架构与数据模型](docs/architecture.md) -- [API 接入指南](docs/integration-guide.md) -- [部署与运维手册](docs/operator-runbook.md) -- [初版交接说明](docs/handoff.md) +## 检查 ```bash pnpm check @@ -93,3 +53,10 @@ pnpm test:collection-status pnpm test:postgres-runtime pnpm db:postgres:validate ``` + +更多资料: + +- [架构与数据模型](docs/architecture.md) +- [API 接入指南](docs/integration-guide.md) +- [部署与运维手册](docs/operator-runbook.md) +- [初版交接说明](docs/handoff.md) diff --git a/api/app.ts b/api/app.ts index 0fc5816..ce569a4 100644 --- a/api/app.ts +++ b/api/app.ts @@ -22,6 +22,8 @@ import groupsRoutes from './routes/groups.js'; import managementRoutes from './routes/management.js'; import storageRoutes from './routes/storage.js'; import reviewRoutes from './routes/review.js'; +import worksRoutes from './routes/works.js'; +import projectWorksRoutes from './routes/projectWorks.js'; import { UPLOADS_DIR } from './upload.js'; import { database, databaseDialect } from './database.js'; @@ -50,6 +52,8 @@ app.use( * API 路由 */ app.use('/api/notes', notesRoutes); +app.use('/api/works', worksRoutes); +app.use('/api/projects/:projectId/works', projectWorksRoutes); app.use('/api/images', imagesRoutes); app.use('/api/annotations', annotationsRoutes); app.use('/api/projects', projectsRoutes); diff --git a/api/database.ts b/api/database.ts index f17bfa8..e2fb7ff 100644 --- a/api/database.ts +++ b/api/database.ts @@ -29,7 +29,10 @@ if (databaseUrl) { 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(/-- REVIEW_ROUND_REPAIR_START[\s\S]+?-- REVIEW_ROUND_REPAIR_END/, ''); + .replace(/-- REVIEW_ROUND_REPAIR_START[\s\S]+?-- REVIEW_ROUND_REPAIR_END/, '') + .replace(/-- PROJECT_REVIEW_STATUS_REPAIR_START[\s\S]+?-- PROJECT_REVIEW_STATUS_REPAIR_END/, '') + .replace(/-- TEXT_ANNOTATION_TARGET_REPAIR_START[\s\S]+?-- TEXT_ANNOTATION_TARGET_REPAIR_END/, '') + .replace(/-- SINGLE_SCHEME_REPAIR_START[\s\S]+?-- SINGLE_SCHEME_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 0435acb..b384624 100644 --- a/api/db.ts +++ b/api/db.ts @@ -102,6 +102,8 @@ db.exec(` slug TEXT NOT NULL UNIQUE, client_description TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT 'active', + review_status TEXT NOT NULL DEFAULT 'draft', + review_completed_at TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); CREATE TABLE IF NOT EXISTS collections ( @@ -116,6 +118,7 @@ db.exec(` ); CREATE TABLE IF NOT EXISTS notes ( id INTEGER PRIMARY KEY AUTOINCREMENT, + project_id INTEGER, external_id TEXT, title TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', @@ -136,16 +139,23 @@ db.exec(` x REAL NOT NULL, y REAL NOT NULL, content TEXT NOT NULL, + author_role TEXT NOT NULL DEFAULT 'client', + status TEXT NOT NULL DEFAULT 'open', + closure_reason TEXT NOT NULL DEFAULT '', + withdrawn_at TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), FOREIGN KEY (image_id) REFERENCES images(id) ON DELETE CASCADE ); CREATE TABLE IF NOT EXISTS work_comments ( id INTEGER PRIMARY KEY AUTOINCREMENT, note_id INTEGER NOT NULL, + version_number INTEGER NOT NULL DEFAULT 1, content TEXT NOT NULL, author_name TEXT NOT NULL DEFAULT '客户', author_role TEXT NOT NULL DEFAULT 'client', status TEXT NOT NULL DEFAULT 'open', + closure_reason TEXT NOT NULL DEFAULT '', + withdrawn_at TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE ); @@ -154,9 +164,30 @@ db.exec(` note_id INTEGER NOT NULL, version_number INTEGER NOT NULL, target TEXT NOT NULL, + start_offset INTEGER NOT NULL DEFAULT 0, + end_offset INTEGER NOT NULL DEFAULT 0, + selected_text TEXT NOT NULL DEFAULT '', + prefix_text TEXT NOT NULL DEFAULT '', + suffix_text TEXT NOT NULL DEFAULT '', content TEXT NOT NULL, author_name TEXT NOT NULL DEFAULT '客户', + author_role TEXT NOT NULL DEFAULT 'client', status TEXT NOT NULL DEFAULT 'open', + closure_reason TEXT NOT NULL DEFAULT '', + withdrawn_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE + ); + CREATE TABLE IF NOT EXISTS feedback_replies ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + note_id INTEGER NOT NULL, + version_number INTEGER NOT NULL, + feedback_type TEXT NOT NULL, + feedback_id INTEGER NOT NULL, + content TEXT NOT NULL, + author_name TEXT NOT NULL, + author_role TEXT NOT NULL, + withdrawn_at TEXT, created_at TEXT NOT NULL DEFAULT (datetime('now')), FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE ); @@ -182,6 +213,7 @@ db.exec(` selected_version_number INTEGER, created_by INTEGER, completed_at TEXT, + completion_reason TEXT NOT NULL DEFAULT '', created_at TEXT NOT NULL DEFAULT (datetime('now')), UNIQUE(note_id, round_number), FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE, @@ -211,6 +243,7 @@ function addColumn(table: string, definition: string) { } addColumn('notes', "collection_id INTEGER"); +addColumn('notes', 'project_id INTEGER'); addColumn('notes', 'external_id TEXT'); addColumn('notes', "tags TEXT NOT NULL DEFAULT '[]'"); addColumn('notes', "review_status TEXT NOT NULL DEFAULT 'pending'"); @@ -219,7 +252,12 @@ 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('annotations', "author_role TEXT NOT NULL DEFAULT 'client'"); +addColumn('annotations', 'withdrawn_at TEXT'); +addColumn('annotations', "closure_reason TEXT NOT NULL DEFAULT ''"); addColumn('projects', 'group_id INTEGER'); +addColumn('projects', "review_status TEXT NOT NULL DEFAULT 'draft'"); +addColumn('projects', 'review_completed_at TEXT'); addColumn('projects', "access_password_hash TEXT NOT NULL DEFAULT ''"); addColumn('projects', 'customer_access_enabled INTEGER NOT NULL DEFAULT 0'); addColumn('projects', 'access_expires_at TEXT'); @@ -228,9 +266,21 @@ 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_comments', 'version_number INTEGER NOT NULL DEFAULT 1'); +addColumn('work_comments', 'withdrawn_at TEXT'); +addColumn('work_comments', "closure_reason TEXT NOT NULL DEFAULT ''"); +addColumn('text_annotations', 'start_offset INTEGER NOT NULL DEFAULT 0'); +addColumn('text_annotations', 'end_offset INTEGER NOT NULL DEFAULT 0'); +addColumn('text_annotations', "selected_text TEXT NOT NULL DEFAULT ''"); +addColumn('text_annotations', "prefix_text TEXT NOT NULL DEFAULT ''"); +addColumn('text_annotations', "suffix_text TEXT NOT NULL DEFAULT ''"); +addColumn('text_annotations', "author_role TEXT NOT NULL DEFAULT 'client'"); +addColumn('text_annotations', 'withdrawn_at TEXT'); +addColumn('text_annotations', "closure_reason TEXT NOT NULL DEFAULT ''"); 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'"); +addColumn('review_rounds', "completion_reason TEXT NOT NULL DEFAULT ''"); 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 != ''"); @@ -270,6 +320,8 @@ if (!collectionId) { } db.prepare('UPDATE notes SET collection_id = ? WHERE collection_id IS NULL').run(collectionId); db.prepare('UPDATE projects SET group_id = ? WHERE group_id IS NULL').run(groupId); +db.prepare('UPDATE notes SET project_id = (SELECT c.project_id FROM collections c WHERE c.id = notes.collection_id) WHERE project_id IS NULL').run(); +db.prepare('UPDATE work_comments SET version_number = (SELECT n.version_number FROM notes n WHERE n.id = work_comments.note_id) WHERE version_number IS NULL OR version_number < 1').run(); db.prepare(`UPDATE collections SET name = '2026 年 7 月任务', client_description = '本月内容作品交付集' 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) @@ -300,6 +352,22 @@ db.exec(` 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 projects + SET review_status = CASE + WHEN status = 'archived' THEN 'archived' + WHEN NOT EXISTS (SELECT 1 FROM notes n WHERE n.project_id = projects.id AND n.review_status != 'draft') THEN 'draft' + WHEN NOT EXISTS (SELECT 1 FROM notes n WHERE n.project_id = projects.id AND n.review_status != 'draft' AND n.review_status != 'approved') THEN 'completed' + ELSE 'reviewing' + END, + review_completed_at = CASE + WHEN status != 'archived' + AND EXISTS (SELECT 1 FROM notes n WHERE n.project_id = projects.id AND n.review_status != 'draft') + AND NOT EXISTS (SELECT 1 FROM notes n WHERE n.project_id = projects.id AND n.review_status != 'draft' AND n.review_status != 'approved') + THEN COALESCE(review_completed_at, datetime('now')) + ELSE NULL + END; +`); db.exec(` UPDATE collections SET status = CASE @@ -319,10 +387,12 @@ db.exec(` db.exec(` CREATE INDEX IF NOT EXISTS idx_collections_project_id ON collections(project_id); CREATE INDEX IF NOT EXISTS idx_notes_collection_id ON notes(collection_id); + CREATE INDEX IF NOT EXISTS idx_notes_project_id ON notes(project_id); CREATE INDEX IF NOT EXISTS idx_images_note_id ON images(note_id); CREATE INDEX IF NOT EXISTS idx_annotations_image_id ON annotations(image_id); CREATE INDEX IF NOT EXISTS idx_comments_note_id ON work_comments(note_id); CREATE INDEX IF NOT EXISTS idx_text_annotations_note_id ON text_annotations(note_id, version_number); + CREATE INDEX IF NOT EXISTS idx_feedback_replies_target ON feedback_replies(note_id, version_number, feedback_type, feedback_id); CREATE INDEX IF NOT EXISTS idx_sessions_token_hash ON sessions(token_hash); CREATE INDEX IF NOT EXISTS idx_customer_sessions_token_hash ON customer_sessions(token_hash); CREATE INDEX IF NOT EXISTS idx_customer_sessions_project_id ON customer_sessions(project_id); @@ -336,4 +406,39 @@ db.exec(` CREATE INDEX IF NOT EXISTS idx_review_events_note_id ON review_events(note_id, version_number); `); +export function repairMultiSchemeRounds() { + db.transaction(() => { + const duplicates = db.prepare(`SELECT review_round_id, note_id FROM work_versions WHERE review_round_id IS NOT NULL GROUP BY review_round_id, note_id HAVING COUNT(*) > 1`).all() as Array<{ review_round_id: number; note_id: number }>; + for (const duplicate of duplicates) { + const round = db.prepare('SELECT * FROM review_rounds WHERE id=?').get(duplicate.review_round_id) as Record | undefined; + const note = db.prepare('SELECT active_round_id,version_number FROM notes WHERE id=?').get(duplicate.note_id) as { active_round_id: number | null; version_number: number } | undefined; + const versions = db.prepare('SELECT version_number FROM work_versions WHERE review_round_id=? ORDER BY version_number').all(duplicate.review_round_id) as Array<{ version_number: number }>; + if (!round || !note || versions.length < 2) continue; + const keeper = versions.some((item) => Number(item.version_number) === Number(note.version_number)) ? Number(note.version_number) : Number(versions[0].version_number); + for (const version of versions.filter((item) => Number(item.version_number) !== keeper)) { + const nextRound = Number((db.prepare('SELECT COALESCE(MAX(round_number),0)+1 AS value FROM review_rounds WHERE note_id=?').get(duplicate.note_id) as { value: number }).value); + const remainsActive = Number(note.active_round_id) === Number(duplicate.review_round_id) && Number(note.version_number) === Number(version.version_number); + const result = db.prepare(`INSERT INTO review_rounds (note_id,round_number,status,selected_version_number,completed_at,created_by,created_at,completion_reason) + VALUES (?,?,?,?,?,?,?,?)`).run( + duplicate.note_id, + nextRound, + remainsActive ? round.status : 'completed', + Number(round.selected_version_number) === Number(version.version_number) ? version.version_number : null, + remainsActive ? round.completed_at : (round.completed_at || new Date().toISOString()), + round.created_by ?? null, + round.created_at, + remainsActive ? round.completion_reason : (round.completion_reason || 'migrated_single_scheme'), + ); + const newRoundId = Number(result.lastInsertRowid); + db.prepare('UPDATE work_versions SET review_round_id=? WHERE note_id=? AND version_number=?').run(newRoundId, duplicate.note_id, version.version_number); + if (remainsActive) db.prepare('UPDATE notes SET active_round_id=? WHERE id=?').run(newRoundId, duplicate.note_id); + } + if (Number(round.selected_version_number) !== keeper) db.prepare('UPDATE review_rounds SET selected_version_number=NULL WHERE id=?').run(duplicate.review_round_id); + } + })(); +} +repairMultiSchemeRounds(); +db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_notes_project_external_id ON notes(project_id, external_id) WHERE external_id IS NOT NULL AND external_id != ''"); +db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_work_versions_one_per_round ON work_versions(review_round_id) WHERE review_round_id IS NOT NULL'); + export default db; diff --git a/api/repositories/annotationsRepository.ts b/api/repositories/annotationsRepository.ts index cb57877..d36a783 100644 --- a/api/repositories/annotationsRepository.ts +++ b/api/repositories/annotationsRepository.ts @@ -6,11 +6,11 @@ function toAnnotation(row: AnnotationRow): Annotation { return { ...row, id: Num export const annotationsRepository = { async listByImage(imageId: number): Promise { - return (await database.all('SELECT id, image_id, x, y, content, author_name, status, created_at FROM annotations WHERE image_id = ? ORDER BY id ASC', [imageId])).map(toAnnotation); + return (await database.all('SELECT id, image_id, x, y, content, author_name, author_role, status, closure_reason, withdrawn_at, created_at FROM annotations WHERE image_id = ? ORDER BY id ASC', [imageId])).map(toAnnotation); }, async create(imageId: number, data: CreateAnnotationRequest): Promise { - const id = await database.insertId('INSERT INTO annotations (image_id, x, y, content, author_name) VALUES (?, ?, ?, ?, ?)', [imageId, data.x, data.y, data.content, data.author_name || '客户']); - return toAnnotation((await database.one('SELECT id, image_id, x, y, content, author_name, status, created_at FROM annotations WHERE id = ?', [id]))!); + const id = await database.insertId('INSERT INTO annotations (image_id, x, y, content, author_name, author_role) VALUES (?, ?, ?, ?, ?, ?)', [imageId, data.x, data.y, data.content, data.author_name || '客户', data.author_role || 'client']); + return toAnnotation((await database.one('SELECT id, image_id, x, y, content, author_name, author_role, status, closure_reason, withdrawn_at, created_at FROM annotations WHERE id = ?', [id]))!); }, async remove(id: number): Promise { return (await database.execute('DELETE FROM annotations WHERE id = ?', [id])).changes > 0; }, }; diff --git a/api/repositories/notesRepository.ts b/api/repositories/notesRepository.ts index 8c12f43..6c87cbe 100644 --- a/api/repositories/notesRepository.ts +++ b/api/repositories/notesRepository.ts @@ -2,7 +2,7 @@ import { database } from '../database.js'; 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; + id: number; project_id: number; collection_id: number; external_id: string | null; title: string; description: string; tags: 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; } @@ -10,7 +10,7 @@ interface NoteRow { function toNote(row: NoteRow): Note { return { ...row, - id: Number(row.id), collection_id: Number(row.collection_id), version_number: Number(row.version_number), + id: Number(row.id), project_id: Number(row.project_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[], @@ -19,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.active_round_id, n.approved_version_number, n.created_at, + SELECT n.id, n.project_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, @@ -38,8 +38,8 @@ export const notesRepository = { if (query.status) { conditions.push('n.review_status = ?'); params.push(query.status); } if (query.q?.trim()) { conditions.push('(n.title LIKE ? OR n.description LIKE ?)'); params.push(`%${query.q.trim()}%`, `%${query.q.trim()}%`); } if (query.tag?.trim()) { conditions.push('n.tags LIKE ?'); params.push(`%"${query.tag.trim()}"%`); } - if (query.projectId) { conditions.push('n.collection_id IN (SELECT id FROM collections WHERE project_id = ?)'); params.push(query.projectId); } - if (query.groupId) { conditions.push('n.collection_id IN (SELECT c.id FROM collections c JOIN projects p ON p.id = c.project_id WHERE p.group_id = ?)'); params.push(query.groupId); } + if (query.projectId) { conditions.push('n.project_id = ?'); params.push(query.projectId); } + if (query.groupId) { conditions.push('n.project_id IN (SELECT id FROM projects WHERE group_id = ?)'); params.push(query.groupId); } const where = conditions.length ? ` WHERE ${conditions.join(' AND ')}` : ''; const order = query.order === 'asc' ? 'ASC' : 'DESC'; const sort = query.sort === 'annotations' ? 'annotation_count' : 'n.created_at'; @@ -53,6 +53,10 @@ export const notesRepository = { const row = await database.one(`${select} WHERE n.collection_id = ? AND n.external_id = ?`, [collectionId, externalId]); return row ? toNote(row) : null; }, + async findByProjectExternalId(projectId: number, externalId: string): Promise { + const row = await database.one(`${select} WHERE n.project_id = ? AND n.external_id = ?`, [projectId, externalId]); + return row ? toNote(row) : null; + }, async create(title: string, description: string, collectionId: number, tags: string[]): Promise { return database.insertId('INSERT INTO notes (title, description, collection_id, tags, review_status) VALUES (?, ?, ?, ?, ?)', [title, description, collectionId, JSON.stringify(tags), 'pending']); }, diff --git a/api/routes/annotations.ts b/api/routes/annotations.ts index f131379..c10bceb 100644 --- a/api/routes/annotations.ts +++ b/api/routes/annotations.ts @@ -1,5 +1,4 @@ import { Router, type Response } from 'express'; -import { annotationsRepository } from '../repositories/annotationsRepository.js'; import { canWriteProject, requireWriter, type AuthRequest } from '../auth.js'; import { database } from '../database.js'; @@ -8,10 +7,13 @@ const router = Router(); router.delete('/:annotationId', requireWriter, async (req: AuthRequest, res: Response) => { const id = Number(req.params.annotationId); if (!Number.isFinite(id)) { res.status(400).json({ error: '无效的批注 ID' }); return; } - const context = await database.one<{ project_id: number }>('SELECT c.project_id FROM annotations a JOIN images i ON i.id = a.image_id JOIN notes n ON n.id = i.note_id JOIN collections c ON c.id = n.collection_id WHERE a.id = ?', [id]); + const context = await database.one<{ project_id: number; version_number: number; current_version: number; author_name: string; author_role: string; round_status: string; project_status: string; project_review_status: string }>(`SELECT n.project_id,i.version_number,n.version_number AS current_version,a.author_name,a.author_role,r.status AS round_status,p.status AS project_status,p.review_status AS project_review_status + FROM annotations a JOIN images i ON i.id=a.image_id JOIN notes n ON n.id=i.note_id JOIN projects p ON p.id=n.project_id JOIN review_rounds r ON r.id=n.active_round_id WHERE a.id=?`, [id]); if (!context) { res.status(404).json({ error: '批注不存在' }); return; } if (!await canWriteProject(req, context.project_id)) { res.status(403).json({ error: '无权操作该批注' }); return; } - await annotationsRepository.remove(id); + if (context.project_status !== 'active' || context.project_review_status === 'completed' || context.round_status !== 'reviewing' || Number(context.version_number) !== Number(context.current_version)) { res.status(409).json({ error: '历史轮次、已关闭或已完成项目为只读状态' }); return; } + if (context.author_role !== 'operator' || context.author_name !== (req.authUser?.display_name || 'API')) { res.status(403).json({ error: '只能撤回自己提交的批注' }); return; } + await database.execute('UPDATE annotations SET withdrawn_at=CURRENT_TIMESTAMP WHERE id=? AND withdrawn_at IS NULL', [id]); res.status(204).end(); }); diff --git a/api/routes/comments.ts b/api/routes/comments.ts index 1639ee5..02ee937 100644 --- a/api/routes/comments.ts +++ b/api/routes/comments.ts @@ -7,20 +7,22 @@ const router = Router(); router.post('/notes/:noteId/comments', requireWriter, async (req: AuthRequest, res: Response) => { const noteId = Number(req.params.noteId); const content = String(req.body?.content ?? '').trim(); - 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 = ?', [noteId]); + const context = await database.one<{ project_id: number; version_number: number; round_status: string; project_status: string; project_review_status: string }>('SELECT n.project_id,n.version_number,r.status AS round_status,p.status AS project_status,p.review_status AS project_review_status FROM notes n JOIN projects p ON p.id=n.project_id JOIN review_rounds r ON r.id=n.active_round_id WHERE n.id = ?', [noteId]); if (!context) { res.status(404).json({ error: '作品不存在' }); return; } if (!await canWriteProject(req, context.project_id)) { res.status(403).json({ error: '无权操作该作品' }); return; } + if (context.project_status !== 'active' || context.project_review_status === 'completed' || context.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 (?, ?, ?, 'operator')", [noteId, content, req.authUser?.display_name || 'API']); + const id = await database.insertId("INSERT INTO work_comments (note_id, version_number, content, author_name, author_role) VALUES (?, ?, ?, ?, 'operator')", [noteId, context.version_number, content, req.authUser?.display_name || 'API']); res.status(201).json(await database.one('SELECT * FROM work_comments WHERE id = ?', [id])); }); router.patch('/comments/:commentId', requireWriter, async (req: AuthRequest, res: Response) => { const id = Number(req.params.commentId); const status = req.body?.status; if (!['open', 'resolved', 'confirmed'].includes(status)) { res.status(400).json({ error: '无效状态' }); return; } - const context = await database.one<{ project_id: number }>('SELECT c.project_id FROM work_comments wc JOIN notes n ON n.id = wc.note_id JOIN collections c ON c.id = n.collection_id WHERE wc.id = ?', [id]); + const context = await database.one<{ project_id: number; version_number: number; current_version: number; round_status: string; project_status: string; project_review_status: string }>('SELECT n.project_id,wc.version_number,n.version_number AS current_version,r.status AS round_status,p.status AS project_status,p.review_status AS project_review_status FROM work_comments wc JOIN notes n ON n.id=wc.note_id JOIN projects p ON p.id=n.project_id JOIN review_rounds r ON r.id=n.active_round_id WHERE wc.id = ?', [id]); if (!context) { res.status(404).json({ error: '反馈不存在' }); return; } if (!await canWriteProject(req, context.project_id)) { res.status(403).json({ error: '无权操作该反馈' }); return; } + if (context.project_status !== 'active' || context.project_review_status === 'completed' || context.round_status !== 'reviewing' || Number(context.version_number) !== Number(context.current_version)) { res.status(409).json({ error: '历史轮次、已关闭或已完成项目为只读状态' }); return; } await database.execute('UPDATE work_comments SET status = ? WHERE id = ?', [status, id]); res.json(await database.one('SELECT * FROM work_comments WHERE id = ?', [id])); }); diff --git a/api/routes/images.ts b/api/routes/images.ts index 9f42155..57ab85f 100644 --- a/api/routes/images.ts +++ b/api/routes/images.ts @@ -7,7 +7,7 @@ import { database } from '../database.js'; const router = Router(); async function imageProjectId(imageId: number): Promise { - return (await database.one<{ project_id: number }>('SELECT c.project_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 = ?', [imageId]))?.project_id; + return (await database.one<{ project_id: number }>('SELECT n.project_id FROM images i JOIN notes n ON n.id = i.note_id WHERE i.id = ?', [imageId]))?.project_id; } router.get('/:imageId/annotations', requireWriter, async (req: AuthRequest, res: Response, next: NextFunction) => { @@ -26,14 +26,14 @@ 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 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]); + const context = await database.one<{ project_id: number; review_round_id: number; active_round_id: number | null; round_status: string; project_review_status: string; project_status: string }>('SELECT n.project_id,v.review_round_id,n.active_round_id,r.status AS round_status,p.review_status AS project_review_status,p.status AS project_status FROM images i JOIN notes n ON n.id=i.note_id JOIN projects p ON p.id=n.project_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; } + if (context.project_status !== 'active' || context.project_review_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; } if (!content) { res.status(400).json({ error: '批注内容不能为空' }); return; } - res.status(201).json(await annotationsRepository.create(imageId, { x, y, content, author_name: req.authUser?.display_name || 'API' })); + res.status(201).json(await annotationsRepository.create(imageId, { x, y, content, author_name: req.authUser?.display_name || 'API', author_role: 'operator' })); } catch (error) { next(error); } }); diff --git a/api/routes/notes.ts b/api/routes/notes.ts index b12c3fb..4c9f5c5 100644 --- a/api/routes/notes.ts +++ b/api/routes/notes.ts @@ -5,10 +5,10 @@ import { Router, type Response, type NextFunction } from 'express'; import { upload } from '../upload.js'; import { notesService } from '../services/notesService.js'; import { recalculateCollectionStatus } from '../services/collectionsService.js'; +import { recalculateProjectReviewStatus } from '../services/projectsService.js'; import { audit, canWriteProject, requireRole, requireWriter, type AuthRequest } from '../auth.js'; import { database, withTransaction } from '../database.js'; import fs from 'fs'; -import type { TextAnnotation } from '../../shared/types.js'; const router = Router(); @@ -79,19 +79,8 @@ router.get('/:noteId', requireWriter, async (req: AuthRequest, res: Response, ne }); router.post('/:noteId/text-annotations', requireWriter, async (req: AuthRequest, res: Response) => { - 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(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; 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])); + res.setHeader('Deprecation', 'true'); + res.status(410).json({ error: '该接口已停用,请使用 /api/works/:workId/text-annotations 并提交明确的文字选区' }); }); // POST /api/notes - 上传新笔记 (multipart/form-data) @@ -124,12 +113,13 @@ router.post( return; } const files = (req.files as Express.Multer.File[] | undefined) ?? []; - const collection = await database.one<{ project_id: number }>('SELECT c.project_id FROM collections c WHERE c.id = ?', [collectionId]); + const collection = await database.one<{ project_id: number; project_status: string }>('SELECT c.project_id,p.status AS project_status FROM collections c JOIN projects p ON p.id=c.project_id WHERE c.id = ?', [collectionId]); if (!collection || !await canWriteProject(req, collection.project_id)) { files.forEach((file) => { try { fs.unlinkSync(file.path); } catch { /* uploaded file may already be gone */ } }); res.status(collection ? 403 : 404).json({ error: collection ? '无权向该作品交付集上传作品' : '作品交付集不存在' }); return; } + if (collection.project_status !== 'active') { files.forEach((file) => { try { fs.unlinkSync(file.path); } catch { /* noop */ } }); res.status(409).json({ error: '已关闭或归档项目为只读状态' }); return; } if (externalId) { const existing = await notesService.findByExternalId(collectionId, externalId); if (existing) { res.status(200).json({ ...existing, idempotent: true }); return; } @@ -167,9 +157,10 @@ router.post('/:noteId/versions', requireWriter, upload.array('images', 30), asyn const files = (req.files as Express.Multer.File[] | undefined) ?? []; try { const id = Number(req.params.noteId); - const context = await database.one<{ title: string; description: string; tags: string; project_id: number }>('SELECT n.title, n.description, n.tags, c.project_id FROM notes n JOIN collections c ON c.id = n.collection_id WHERE n.id = ?', [id]); + const context = await database.one<{ title: string; description: string; tags: string; project_id: number; project_status: string }>('SELECT n.title,n.description,n.tags,n.project_id,p.status AS project_status FROM notes n JOIN projects p ON p.id=n.project_id WHERE n.id = ?', [id]); if (!context) { res.status(404).json({ error: '作品不存在' }); return; } if (!await canWriteProject(req, context.project_id)) { res.status(403).json({ error: '无权操作该作品' }); return; } + if (context.project_status !== 'active') { res.status(409).json({ error: '已关闭或归档项目为只读状态' }); return; } const { valid: validImageUrls, urls: imageUrls } = parseImageUrls(req.body.images); if (!validImageUrls) { res.status(400).json({ error: 'images 需要包含 1–30 个有效的 HTTP/HTTPS 图片 URL' }); return; } if (!files.length && !imageUrls.length) { res.status(400).json({ error: '新版本至少需要一张图片' }); return; } @@ -190,11 +181,12 @@ router.post('/:noteId/review-rounds', requireWriter, upload.array('images', 30), 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]); + const context = await database.one<{ project_id: number; project_status: string }>('SELECT n.project_id,p.status AS project_status FROM notes n JOIN projects p ON p.id=n.project_id WHERE n.id=?', [id]); if (!context) { cleanupFiles(); res.status(404).json({ error: '作品不存在' }); return; } if (!await canWriteProject(req, context.project_id)) { cleanupFiles(); res.status(403).json({ error: '无权操作该作品' }); return; } + if (context.project_status !== 'active') { cleanupFiles(); res.status(409).json({ error: '已关闭或归档项目为只读状态' }); return; } const rawCandidates = parseCandidates(req.body?.candidates); - if (!rawCandidates || rawCandidates.length < 1 || rawCandidates.length > 5) { cleanupFiles(); res.status(400).json({ error: '每轮需要提交 1–5 个候选稿' }); return; } + if (!rawCandidates || rawCandidates.length !== 1) { cleanupFiles(); res.status(400).json({ error: '每个验收轮次只能提交一个方案' }); return; } const normalized = rawCandidates.map((candidate, index) => ({ candidate_name: String(candidate.candidate_name ?? `方案 ${String.fromCharCode(65 + index)}`).trim(), @@ -215,13 +207,15 @@ router.post('/:noteId/review-rounds', requireWriter, upload.array('images', 30), 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); + const only = uploadCandidates[0]; + note = await notesService.createRound(id, { title: only.title, description: only.description, tags: only.tags, files: only.files }, req.authUser?.id); } else { const totalImages = normalized.reduce((sum, candidate) => sum + candidate.imageUrls.urls.length, 0); if (totalImages > 30 || normalized.some((candidate) => !candidate.imageUrls.valid || candidate.imageUrls.urls.length < 1)) { cleanupFiles(); res.status(400).json({ error: '每个候选稿至少需要 1 个有效公开图片 URL,本轮总计不超过 30 张' }); return; } - note = await notesService.createReviewRoundFromUrls(id, normalized.map((candidate) => ({ ...candidate, images: candidate.imageUrls.urls })), req.authUser?.id); + const only = normalized[0]; + note = await notesService.createRoundFromUrls(id, { title: only.title, description: only.description, tags: only.tags, images: only.imageUrls.urls }, req.authUser?.id); } - await audit(req, 'work.review_round_create', 'work', id, { candidateCount: normalized.length, versionNumber: note.version_number }); + await audit(req, 'work.review_round_create', 'work', id, { candidateCount: 1, versionNumber: note.version_number, deprecatedRoute: true }); res.status(201).json(note); } catch (error) { cleanupFiles(); @@ -236,8 +230,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; 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]); + const context = await database.one<{ project_id: number; review_status: string; project_status: string }>('SELECT n.project_id,n.review_status,p.status AS project_status FROM notes n JOIN projects p ON p.id=n.project_id WHERE n.id = ?', [id]); if (context && !await canWriteProject(req, context.project_id)) { res.status(403).json({ error: '无权操作该作品' }); return; } + if (context && context.project_status !== 'active') { res.status(409).json({ error: '已关闭或归档项目为只读状态' }); return; } if (context?.review_status === 'approved') { res.status(409).json({ error: '已通过作品只能由组管理员填写原因后重新打开' }); return; } if (!await notesService.setStatus(id, status)) { res.status(404).json({ error: '作品不存在' }); @@ -250,9 +245,10 @@ 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; 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]); + const note = await database.one<{ review_status: string; version_number: number; project_id: number; collection_id: number; active_round_id: number | null; project_status: string }>('SELECT n.review_status,n.version_number,n.project_id,n.collection_id,n.active_round_id,p.status AS project_status FROM notes n JOIN projects p ON p.id=n.project_id WHERE n.id = ?', [id]); if (!note) { res.status(404).json({ error: '作品不存在' }); return; } if (!await canWriteProject(req, note.project_id)) { res.status(403).json({ error: '无权操作该作品' }); return; } + if (note.project_status !== 'active') { res.status(409).json({ error: '已关闭或归档项目为只读状态' }); return; } if (note.review_status !== 'approved') { res.status(409).json({ error: '只有已通过作品可以重新打开' }); return; } const actor = req.authUser!; await withTransaction(async (tx) => { @@ -262,6 +258,7 @@ router.post('/:noteId/reopen', requireRole('platform_admin', 'group_admin'), asy if (note.active_round_id) await tx.execute("UPDATE review_rounds SET status = 'reviewing', selected_version_number = NULL, completed_at = NULL WHERE id = ?", [note.active_round_id]); await tx.execute("INSERT INTO review_events (note_id, version_number, event_type, from_status, to_status, reason, actor_name, actor_role) VALUES (?, ?, 'reopened', 'approved', 'pending', ?, ?, ?)", [id, note.version_number, reason, actor.display_name, actor.role]); await recalculateCollectionStatus(Number(note.collection_id), tx); + await recalculateProjectReviewStatus(Number(note.project_id), tx); }); await audit(req, 'work.reopen', 'work', id, { reason, versionNumber: note.version_number }); res.json({ success: true, status: 'pending' }); @@ -274,8 +271,9 @@ router.delete('/:noteId', requireWriter, async (req: AuthRequest, res: Response) res.status(400).json({ error: '无效的笔记 ID' }); return; } - const context = await database.one<{ project_id: number }>('SELECT c.project_id FROM notes n JOIN collections c ON c.id = n.collection_id WHERE n.id = ?', [id]); + const context = await database.one<{ project_id: number; project_status: string }>('SELECT n.project_id,p.status AS project_status FROM notes n JOIN projects p ON p.id=n.project_id WHERE n.id = ?', [id]); if (context && !await canWriteProject(req, context.project_id)) { res.status(403).json({ error: '无权操作该作品' }); return; } + if (context && context.project_status !== 'active') { res.status(409).json({ error: '已关闭或归档项目为只读状态' }); return; } const ok = await notesService.remove(id); if (!ok) { res.status(404).json({ error: '笔记不存在' }); diff --git a/api/routes/projectWorks.ts b/api/routes/projectWorks.ts new file mode 100644 index 0000000..abd3f10 --- /dev/null +++ b/api/routes/projectWorks.ts @@ -0,0 +1,72 @@ +import fs from 'node:fs'; +import { Router, type NextFunction, type Response } from 'express'; +import { canWriteProject, requireWriter, audit, type AuthRequest } from '../auth.js'; +import { database } from '../database.js'; +import { notesService } from '../services/notesService.js'; +import { upload } from '../upload.js'; + +const router = Router({ mergeParams: true }); + +function parseTags(value: unknown): string[] { + if (Array.isArray(value)) return value.map((tag) => String(tag).trim()).filter(Boolean); + const raw = String(value ?? '').trim(); + if (!raw) return []; + try { const parsed = JSON.parse(raw); if (Array.isArray(parsed)) return parsed.map((tag) => String(tag).trim()).filter(Boolean); } + catch { /* comma-separated form input */ } + return raw.split(',').map((tag) => tag.trim()).filter(Boolean); +} + +function parseImageUrls(value: unknown): string[] | null { + if (value === undefined) return []; + if (!Array.isArray(value) || value.length < 1 || value.length > 30) return null; + const urls = value.map((item) => String(item).trim()); + return urls.every((url) => { + try { return url.length <= 2048 && ['http:', 'https:'].includes(new URL(url).protocol); } + catch { return false; } + }) ? urls : null; +} + +router.get('/', requireWriter, async (req: AuthRequest, res: Response) => { + const projectId = Number(req.params.projectId); + if (!Number.isInteger(projectId) || !await canWriteProject(req, projectId)) { res.status(403).json({ error: '无权查看该项目' }); return; } + const { sort, order, q, status, tag, externalId } = req.query as Record; + res.json(await notesService.list({ projectId, sort, order, q, status: status as Parameters[0]['status'], tag, externalId })); +}); + +router.post('/', requireWriter, upload.array('images', 30), async (req: AuthRequest, res: Response, next: NextFunction) => { + const files = (req.files as Express.Multer.File[] | undefined) ?? []; + const cleanup = () => files.forEach((file) => { try { if (fs.existsSync(file.path)) fs.unlinkSync(file.path); } catch { /* noop */ } }); + try { + const projectId = Number(req.params.projectId); + if (!Number.isInteger(projectId) || !await canWriteProject(req, projectId)) { cleanup(); res.status(403).json({ error: '无权向该项目上传作品' }); return; } + const project = await database.one<{ status: string }>('SELECT status FROM projects WHERE id = ?', [projectId]); + if (!project) { cleanup(); res.status(404).json({ error: '项目不存在' }); return; } + if (project.status !== 'active') { cleanup(); res.status(409).json({ error: '已关闭或归档项目为只读状态' }); return; } + const title = String(req.body?.title ?? '').trim(); + const description = String(req.body?.description ?? '').trim(); + const tags = parseTags(req.body?.tags); + const externalId = String(req.body?.externalId ?? req.body?.external_id ?? '').trim() || null; + const imageUrls = parseImageUrls(req.body?.images); + if (!title) { cleanup(); res.status(400).json({ error: '标题不能为空' }); return; } + if (externalId && (externalId.length > 128 || !/^[A-Za-z0-9._:-]+$/.test(externalId))) { cleanup(); res.status(400).json({ error: 'externalId 格式无效' }); return; } + if (imageUrls === null || (!files.length && !imageUrls.length)) { cleanup(); res.status(400).json({ error: '请提供 1–30 张上传图片或公开图片 URL' }); return; } + if (externalId) { + const existing = await notesService.findByProjectExternalId(projectId, externalId); + if (existing) { cleanup(); res.json({ ...existing, idempotent: true }); return; } + } + let work; + try { + work = files.length + ? await notesService.createInProject(projectId, title, description, files.map((file) => ({ filename: file.filename, originalname: file.originalname, mimetype: file.mimetype, path: file.path })), tags, externalId) + : await notesService.createInProjectFromUrls(projectId, title, description, imageUrls, tags, externalId); + } catch (error) { + const existing = externalId ? await notesService.findByProjectExternalId(projectId, externalId) : null; + if (existing) { cleanup(); res.json({ ...existing, idempotent: true }); return; } + throw error; + } + await audit(req, 'work.create', 'work', work.id, { projectId, imageCount: files.length || imageUrls.length, imageSource: files.length ? 'upload' : 'external_url' }); + res.status(201).json(work); + } catch (error) { cleanup(); next(error); } +}); + +export default router; diff --git a/api/routes/projects.ts b/api/routes/projects.ts index d1ed1ba..d4c9e5b 100644 --- a/api/routes/projects.ts +++ b/api/routes/projects.ts @@ -5,22 +5,29 @@ import type { Project, WorkCollection } from '../../shared/types.js'; const router = Router(); const reader = requireWriter; -type ProjectRow = Project & { customer_access_enabled: boolean | number; has_access_password: boolean | number; collection_count: number | string; work_count: number | string }; +type ProjectRow = Project & { customer_access_enabled: boolean | number; has_access_password: boolean | number; collection_count: number | string; work_count: number | string; pending_count: number | string; changes_requested_count: number | string; approved_count: number | string }; type CollectionRow = WorkCollection & { work_count: number | string; approved_count: number | string }; function projectSelect(where: string) { - return `SELECT p.id, p.group_id, g.name AS group_name, p.name, p.slug, p.client_description, p.status, p.created_at, + return `SELECT p.id, p.group_id, g.name AS group_name, p.name, p.slug, p.client_description, p.status, p.review_status, p.review_completed_at, p.created_at, p.customer_access_enabled, p.access_expires_at, CASE WHEN p.access_password_hash != '' THEN 1 ELSE 0 END AS has_access_password, COALESCE(cc.collection_count, 0) AS collection_count, - COALESCE(wc.work_count, 0) AS work_count + COALESCE(wc.work_count, 0) AS work_count, + COALESCE(wc.pending_count, 0) AS pending_count, + COALESCE(wc.changes_requested_count, 0) AS changes_requested_count, + COALESCE(wc.approved_count, 0) AS approved_count FROM projects p JOIN operation_groups g ON g.id = p.group_id LEFT JOIN (SELECT project_id, COUNT(*) AS collection_count FROM collections GROUP BY project_id) cc ON cc.project_id = p.id - LEFT JOIN (SELECT c.project_id, COUNT(n.id) AS work_count FROM collections c LEFT JOIN notes n ON n.collection_id = c.id GROUP BY c.project_id) wc ON wc.project_id = p.id + LEFT JOIN (SELECT project_id, COUNT(*) AS work_count, + SUM(CASE WHEN review_status = 'pending' THEN 1 ELSE 0 END) AS pending_count, + SUM(CASE WHEN review_status = 'changes_requested' THEN 1 ELSE 0 END) AS changes_requested_count, + SUM(CASE WHEN review_status = 'approved' THEN 1 ELSE 0 END) AS approved_count + FROM notes GROUP BY project_id) wc ON wc.project_id = p.id ${where}`; } -function projectJson(row: ProjectRow) { return { ...row, id: Number(row.id), group_id: Number(row.group_id), customer_access_enabled: Boolean(row.customer_access_enabled), has_access_password: Boolean(row.has_access_password), collection_count: Number(row.collection_count), work_count: Number(row.work_count) }; } +function projectJson(row: ProjectRow) { return { ...row, id: Number(row.id), group_id: Number(row.group_id), customer_access_enabled: Boolean(row.customer_access_enabled), has_access_password: Boolean(row.has_access_password), collection_count: Number(row.collection_count), work_count: Number(row.work_count), pending_count: Number(row.pending_count), changes_requested_count: Number(row.changes_requested_count), approved_count: Number(row.approved_count) }; } function collectionJson(row: CollectionRow) { return { ...row, id: Number(row.id), project_id: Number(row.project_id), work_count: Number(row.work_count), approved_count: Number(row.approved_count) }; } router.get('/', reader, async (req: AuthRequest, res: Response) => { diff --git a/api/routes/review.ts b/api/routes/review.ts index b1e1359..cf30c99 100644 --- a/api/routes/review.ts +++ b/api/routes/review.ts @@ -4,30 +4,173 @@ import { createCustomerSession, customerSessionCookie, optionalCustomer, require import { verifyPassword } from '../auth.js'; import { notesService } from '../services/notesService.js'; import { annotationsRepository } from '../repositories/annotationsRepository.js'; -import { decideCandidate, ReviewDecisionError } from '../services/reviewService.js'; -import type { TextAnnotation, WorkComment } from '../../shared/types.js'; +import { decideRound, ReviewDecisionError } from '../services/reviewService.js'; +import { addFeedbackReply, findFeedbackTarget, withdrawFeedback } from '../services/feedbackService.js'; +import type { FeedbackType, TextAnnotation, WorkComment } from '../../shared/types.js'; -const router=Router();router.use(optionalCustomer); -type ProjectAccess={id:number;name:string;slug:string;client_description:string;status:string;customer_access_enabled:boolean|number;access_password_hash:string;access_expires_at:string|Date|null}; -const projectBySlug=(slug:string)=>database.one(`SELECT id,name,slug,client_description,status,customer_access_enabled,access_password_hash,access_expires_at FROM projects WHERE slug=?`,[slug]); -const expired=(value:string|Date|null)=>Boolean(value&&new Date(value).getTime()<=Date.now()); +const router = Router(); +router.use(optionalCustomer); -router.get('/:slug/access',async(req:CustomerRequest,res:Response)=>{const project=await projectBySlug(req.params.slug);if(!project||project.status==='archived'){res.status(404).json({error:'项目不存在'});return}res.json({project_name:project.name,client_description:project.client_description,enabled:Boolean(project.customer_access_enabled),expired:expired(project.access_expires_at),authenticated:Boolean(req.customer?.project_id===Number(project.id)),reviewer_name:req.customer?.project_id===Number(project.id)?req.customer.reviewer_name:null})}); +type ProjectAccess = { + id: number; name: string; slug: string; client_description: string; status: string; review_status: string; + customer_access_enabled: boolean | number; access_password_hash: string; access_expires_at: string | Date | null; +}; -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})}); +const projectBySlug = (slug: string) => database.one( + 'SELECT id,name,slug,client_description,status,review_status,customer_access_enabled,access_password_hash,access_expires_at FROM projects WHERE slug=?', + [slug], +); +const expired = (value: string | Date | null) => Boolean(value && new Date(value).getTime() <= Date.now()); +const feedbackType = (value: string): FeedbackType | null => ['image_annotation', 'text_annotation', 'comment'].includes(value) ? value as FeedbackType : null; +const storedTagsText = (value: string) => { try { const parsed = JSON.parse(value || '[]'); return Array.isArray(parsed) ? parsed.map(String).join(' ') : ''; } catch { return ''; } }; -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/access', async (req: CustomerRequest, res: Response) => { + const project = await projectBySlug(req.params.slug); + if (!project || project.status === 'archived') { res.status(404).json({ error: '项目不存在' }); return; } + res.json({ project_name: project.name, client_description: project.client_description, enabled: Boolean(project.customer_access_enabled), expired: expired(project.access_expires_at), authenticated: Boolean(req.customer?.project_id === Number(project.id)), reviewer_name: req.customer?.project_id === Number(project.id) ? req.customer.reviewer_name : null }); +}); -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.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/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.get('/:slug/project', requireCustomerProject, async (req: CustomerRequest, res: Response) => { + const project = (await projectBySlug(req.params.slug))!; + const works = (await notesService.list({ projectId: Number(project.id) })).filter((work) => work.review_status !== 'draft'); + res.json({ + project: { id: Number(project.id), name: project.name, slug: project.slug, client_description: project.client_description, status: project.status, review_status: project.review_status }, + works, + reviewer_name: req.customer!.reviewer_name, + }); +}); -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.get('/:slug/collections/:collectionId/works', requireCustomerProject, async (req: CustomerRequest, res: Response) => { + const project = (await projectBySlug(req.params.slug))!; + const works = (await notesService.list({ projectId: Number(project.id) })).filter((work) => work.review_status !== 'draft'); + res.setHeader('Deprecation', 'true'); + res.json({ redirect_to: `/review/${project.slug}`, works }); +}); -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.get('/:slug/works/:workId', requireCustomerProject, async (req: CustomerRequest, res: Response) => { + const project = (await projectBySlug(req.params.slug))!; + const workId = Number(req.params.workId); + const belongs = await database.one<{ review_status: string }>('SELECT review_status FROM notes WHERE id=? AND project_id=?', [workId, project.id]); + if (!belongs || belongs.review_status === 'draft') { res.status(404).json({ error: '作品不存在或尚未提交' }); return; } + const round = req.query.round ? Number(req.query.round) : undefined; + const version = req.query.version ? Number(req.query.version) : undefined; + const detail = await notesService.getDetail(workId, version, round); + if (!detail) { res.status(404).json({ error: '验收轮次不存在' }); return; } + res.json(detail); +}); -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.get('/:slug/works/:workId/annotations', requireCustomerProject, async (req: CustomerRequest, res: Response) => { + const project = (await projectBySlug(req.params.slug))!; + const workId = Number(req.params.workId); + if (!await database.one('SELECT id FROM notes WHERE id=? AND project_id=? AND review_status!=?', [workId, project.id, 'draft'])) { res.status(404).json({ error: '作品不存在' }); return; } + res.json(await notesService.getFeedback(workId)); +}); -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}}); +router.post('/:slug/works/:workId/comments', requireCustomerProject, async (req: CustomerRequest, res: Response) => { + const project = (await projectBySlug(req.params.slug))!; + const workId = Number(req.params.workId); + const content = String(req.body?.content ?? '').trim(); + const work = await database.one<{ version_number: number; round_status: string }>('SELECT n.version_number,r.status AS round_status FROM notes n JOIN review_rounds r ON r.id=n.active_round_id WHERE n.id=? AND n.project_id=? AND n.review_status!=?', [workId, project.id, 'draft']); + if (!work) { res.status(404).json({ error: '作品不存在' }); return; } + if (project.status !== 'active' || project.review_status === 'completed' || work.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,version_number,content,author_name,author_role) VALUES (?,?,?,?,'client')", [workId, work.version_number, content, req.customer!.reviewer_name]); + res.status(201).json(await database.one('SELECT * FROM work_comments WHERE id=?', [id])); +}); + +router.post('/:slug/works/:workId/text-annotations', requireCustomerProject, async (req: CustomerRequest, res: Response) => { + const project = (await projectBySlug(req.params.slug))!; + const workId = Number(req.params.workId); + const roundNumber = Number(req.body?.round_number); + const target = req.body?.target as 'title' | 'description' | 'tags'; + const startOffset = Number(req.body?.start_offset); + const endOffset = Number(req.body?.end_offset); + const selectedText = String(req.body?.selected_text ?? ''); + const content = String(req.body?.content ?? '').trim(); + const version = await database.one<{ version_number: number; title: string; description: string; tags: string; review_round_id: number; active_round_id: number | null; round_status: string }>(`SELECT v.version_number,v.title,v.description,v.tags,v.review_round_id,n.active_round_id,r.status AS round_status + FROM work_versions v JOIN review_rounds r ON r.id=v.review_round_id JOIN notes n ON n.id=v.note_id + WHERE v.note_id=? AND r.round_number=? AND n.project_id=?`, [workId, roundNumber, project.id]); + if (!version) { res.status(404).json({ error: '验收轮次不存在' }); return; } + if (project.status !== 'active' || project.review_status === 'completed' || version.round_status !== 'reviewing' || Number(version.review_round_id) !== Number(version.active_round_id)) { res.status(409).json({ error: '历史轮次、已关闭或已完成项目为只读状态' }); return; } + const source = target === 'title' ? version.title : target === 'description' ? version.description : target === 'tags' ? storedTagsText(version.tags) : ''; + if (!source || !Number.isInteger(startOffset) || !Number.isInteger(endOffset) || startOffset < 0 || endOffset <= startOffset || endOffset > source.length || source.slice(startOffset, endOffset) !== selectedText) { res.status(400).json({ error: '请选择标题或正文中的有效文字区域' }); return; } + if (!content || content.length > 1000) { res.status(400).json({ error: '批注内容须为 1–1000 个字符' }); return; } + const id = await database.insertId(`INSERT INTO text_annotations (note_id,version_number,target,start_offset,end_offset,selected_text,prefix_text,suffix_text,content,author_name,author_role) + VALUES (?,?,?,?,?,?,?,?,?,?,'client')`, [workId, version.version_number, target, startOffset, endOffset, selectedText, source.slice(Math.max(0, startOffset - 24), startOffset), source.slice(endOffset, endOffset + 24), 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 context = await database.one<{ review_round_id: number; active_round_id: number | null; round_status: string }>(`SELECT v.review_round_id,n.active_round_id,r.status AS round_status FROM images i JOIN notes n ON n.id=i.note_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 n.project_id=? AND n.review_status!='draft'`, [imageId, project.id]); + if (!context) { res.status(404).json({ error: '图片不存在' }); return; } + if (project.status !== 'active' || project.review_status === 'completed' || context.round_status !== 'reviewing' || Number(context.review_round_id) !== Number(context.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, author_role: 'client' })); +}); + +router.post('/:slug/works/:workId/feedback/:type/:feedbackId/replies', requireCustomerProject, async (req: CustomerRequest, res: Response) => { + const project = (await projectBySlug(req.params.slug))!; const workId = Number(req.params.workId); + const type = feedbackType(req.params.type); const feedbackId = Number(req.params.feedbackId); const content = String(req.body?.content ?? '').trim(); + if (!type || !Number.isInteger(feedbackId)) { res.status(400).json({ error: '反馈类型或编号无效' }); return; } + if (!await database.one('SELECT id FROM notes WHERE id=? AND project_id=?', [workId, project.id])) { res.status(404).json({ error: '作品不存在' }); return; } + const target = await findFeedbackTarget(workId, type, feedbackId); + const state = await database.one<{ version_number: number; round_status: string }>('SELECT n.version_number,r.status AS round_status FROM notes n JOIN review_rounds r ON r.id=n.active_round_id WHERE n.id=?', [workId]); + if (!target) { res.status(404).json({ error: '反馈不存在' }); return; } + if (project.status !== 'active' || project.review_status === 'completed' || state?.round_status !== 'reviewing' || Number(target.version_number) !== Number(state?.version_number)) { res.status(409).json({ error: '历史轮次、已关闭或已完成项目为只读状态' }); return; } + if (!content || content.length > 1000) { res.status(400).json({ error: '回复内容须为 1–1000 个字符' }); return; } + res.status(201).json(await addFeedbackReply(target, type, feedbackId, content, req.customer!.reviewer_name, 'client')); +}); + +router.post('/:slug/works/:workId/feedback/:type/:feedbackId/withdraw', requireCustomerProject, async (req: CustomerRequest, res: Response) => { + const project = (await projectBySlug(req.params.slug))!; const workId = Number(req.params.workId); + const type = feedbackType(req.params.type); const feedbackId = Number(req.params.feedbackId); + if (!type || !Number.isInteger(feedbackId)) { res.status(400).json({ error: '反馈类型或编号无效' }); return; } + if (!await database.one('SELECT id FROM notes WHERE id=? AND project_id=?', [workId, project.id])) { res.status(404).json({ error: '作品不存在' }); return; } + const target = await findFeedbackTarget(workId, type, feedbackId); + if (!target) { res.status(404).json({ error: '反馈不存在' }); return; } + const state = await database.one<{ version_number: number; round_status: string }>('SELECT n.version_number,r.status AS round_status FROM notes n JOIN review_rounds r ON r.id=n.active_round_id WHERE n.id=?', [workId]); + if (project.status !== 'active' || project.review_status === 'completed' || state?.round_status !== 'reviewing' || Number(target.version_number) !== Number(state?.version_number)) { res.status(409).json({ error: '历史轮次、已关闭或已完成项目为只读状态' }); return; } + if (target.withdrawn_at) { res.json({ success: true }); return; } + if (target.author_role !== 'client' || target.author_name !== req.customer!.reviewer_name) { res.status(403).json({ error: '只能撤回自己提交的反馈' }); return; } + await withdrawFeedback(type, feedbackId); res.json({ success: true }); +}); + +router.post('/:slug/works/:workId/decision', requireCustomerProject, async (req: CustomerRequest, res: Response) => { + const project = (await projectBySlug(req.params.slug))!; + const workId = Number(req.params.workId); + const roundNumber = Number(req.body?.round_number); + const legacyVersion = Number(req.body?.version_number); + const round = Number.isInteger(roundNumber) ? await database.one<{ version_number: number }>('SELECT v.version_number FROM review_rounds r JOIN work_versions v ON v.review_round_id=r.id WHERE r.note_id=? AND r.round_number=?', [workId, roundNumber]) : undefined; + const versionNumber = round ? Number(round.version_number) : legacyVersion; + 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 decideRound({ noteId: workId, 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/routes/works.ts b/api/routes/works.ts new file mode 100644 index 0000000..36152e0 --- /dev/null +++ b/api/routes/works.ts @@ -0,0 +1,182 @@ +import fs from 'node:fs'; +import { Router, type NextFunction, type Response } from 'express'; +import { audit, canWriteProject, requireWriter, type AuthRequest } from '../auth.js'; +import { database } from '../database.js'; +import { notesService } from '../services/notesService.js'; +import { addFeedbackReply, findFeedbackTarget, withdrawFeedback } from '../services/feedbackService.js'; +import { upload } from '../upload.js'; +import type { FeedbackType, TextAnnotation, WorkComment } from '../../shared/types.js'; + +const router = Router(); + +function parseTags(value: unknown): string[] { + if (Array.isArray(value)) return value.map((tag) => String(tag).trim()).filter(Boolean); + const raw = String(value ?? '').trim(); + if (!raw) return []; + try { const parsed = JSON.parse(raw); if (Array.isArray(parsed)) return parsed.map((tag) => String(tag).trim()).filter(Boolean); } + catch { /* comma-separated form input */ } + return raw.split(',').map((tag) => tag.trim()).filter(Boolean); +} + +function parseImageUrls(value: unknown): string[] | null { + if (value === undefined) return []; + if (!Array.isArray(value) || value.length < 1 || value.length > 30) return null; + const urls = value.map((item) => String(item).trim()); + return urls.every((url) => { try { return url.length <= 2048 && ['http:', 'https:'].includes(new URL(url).protocol); } catch { return false; } }) ? urls : null; +} + +async function workProjectId(workId: number): Promise { + return (await database.one<{ project_id: number }>('SELECT project_id FROM notes WHERE id = ?', [workId]))?.project_id; +} + +const feedbackType = (value: string): FeedbackType | null => ['image_annotation', 'text_annotation', 'comment'].includes(value) ? value as FeedbackType : null; + +router.get('/:workId', requireWriter, async (req: AuthRequest, res: Response) => { + const workId = Number(req.params.workId); + const projectId = await workProjectId(workId); + if (!projectId) { res.status(404).json({ error: '作品不存在' }); return; } + if (!await canWriteProject(req, projectId)) { res.status(403).json({ error: '无权查看该作品' }); return; } + const round = req.query.round ? Number(req.query.round) : undefined; + const detail = await notesService.getDetail(workId, undefined, round); + if (!detail) { res.status(404).json({ error: '验收轮次不存在' }); return; } + res.json(detail); +}); + +router.get('/:workId/annotations', requireWriter, async (req: AuthRequest, res: Response) => { + const workId = Number(req.params.workId); + const projectId = await workProjectId(workId); + if (!projectId) { res.status(404).json({ error: '作品不存在' }); return; } + if (!await canWriteProject(req, projectId)) { res.status(403).json({ error: '无权查看该作品反馈' }); return; } + res.json(await notesService.getFeedback(workId)); +}); + +router.get('/:workId/optimization-context', requireWriter, async (req: AuthRequest, res: Response) => { + const workId = Number(req.params.workId); + const roundNumber = Number(req.query.round); + const includeHistory = req.query.include_history === 'true'; + if (!Number.isInteger(roundNumber) || roundNumber < 1) { res.status(400).json({ error: '请指定有效的验收轮次' }); return; } + const projectId = await workProjectId(workId); + if (!projectId) { res.status(404).json({ error: '作品不存在' }); return; } + if (!await canWriteProject(req, projectId)) { res.status(403).json({ error: '无权查看该作品反馈' }); return; } + + const [detail, feedback] = await Promise.all([ + notesService.getDetail(workId, undefined, roundNumber), + notesService.getFeedback(workId), + ]); + const round = feedback?.rounds.find((item) => item.round_number === roundNumber); + if (!detail || !round) { res.status(404).json({ error: '验收轮次不存在' }); return; } + + const visible = (item: { status: string; withdrawn_at: string | null }) => includeHistory || (item.status === 'open' && !item.withdrawn_at); + const repliesFor = (type: FeedbackType, id: number) => round.feedback_replies.filter((reply) => reply.feedback_type === type && reply.feedback_id === id && (includeHistory || !reply.withdrawn_at)); + const withReplies = (type: FeedbackType, item: T) => ({ ...item, replies: repliesFor(type, item.id) }); + + res.json({ + project: detail.project, + work_id: workId, + work_label: `Work ${String(workId).padStart(3, '0')}`, + round_number: roundNumber, + version_number: round.version_number, + content: { + title: detail.title, + description: detail.description, + tags: detail.tags, + images: detail.images.map(({ id, url, width, height, order_index }) => ({ image_id: id, url, width, height, order_index })), + }, + feedback: { + image_annotations: round.image_annotations.filter(visible).map((item) => withReplies('image_annotation', item)), + text_annotations: round.text_annotations.filter(visible).map((item) => withReplies('text_annotation', item)), + general_comments: round.comments.filter(visible).map((item) => withReplies('comment', item)), + }, + }); +}); + +router.post('/:workId/rounds', requireWriter, upload.array('images', 30), async (req: AuthRequest, res: Response, next: NextFunction) => { + const files = (req.files as Express.Multer.File[] | undefined) ?? []; + const cleanup = () => files.forEach((file) => { try { if (fs.existsSync(file.path)) fs.unlinkSync(file.path); } catch { /* noop */ } }); + try { + const workId = Number(req.params.workId); + const projectId = await workProjectId(workId); + if (!projectId) { cleanup(); res.status(404).json({ error: '作品不存在' }); return; } + if (!await canWriteProject(req, projectId)) { cleanup(); res.status(403).json({ error: '无权操作该作品' }); return; } + const projectState = await database.one<{ status: string }>('SELECT status FROM projects WHERE id=?', [projectId]); + if (!projectState || projectState.status !== 'active') { cleanup(); res.status(409).json({ error: '已关闭或归档项目为只读状态' }); return; } + const current = await notesService.getDetail(workId); + if (!current) { cleanup(); res.status(404).json({ error: '作品不存在' }); return; } + const title = String(req.body?.title ?? current.title).trim(); + const description = String(req.body?.description ?? current.description).trim(); + const tags = parseTags(req.body?.tags ?? current.tags); + const imageUrls = parseImageUrls(req.body?.images); + if (!title) { cleanup(); res.status(400).json({ error: '标题不能为空' }); return; } + if (imageUrls === null || (!files.length && !imageUrls.length)) { cleanup(); res.status(400).json({ error: '每轮必须提供 1–30 张图片' }); return; } + const work = files.length + ? await notesService.createRound(workId, { title, description, tags, files: files.map((file) => ({ filename: file.filename, originalname: file.originalname, mimetype: file.mimetype, path: file.path })) }, req.authUser?.id) + : await notesService.createRoundFromUrls(workId, { title, description, tags, images: imageUrls }, req.authUser?.id); + await audit(req, 'work.round_create', 'work', workId, { roundNumber: work.version_number, imageCount: files.length || imageUrls.length }); + res.status(201).json(work); + } catch (error) { cleanup(); next(error); } +}); + +router.post('/:workId/text-annotations', requireWriter, async (req: AuthRequest, res: Response) => { + const workId = Number(req.params.workId); + const roundNumber = Number(req.body?.round_number); + const target = req.body?.target as 'title' | 'description' | 'tags'; + const startOffset = Number(req.body?.start_offset); + const endOffset = Number(req.body?.end_offset); + const selectedText = String(req.body?.selected_text ?? ''); + const content = String(req.body?.content ?? '').trim(); + const version = await database.one<{ version_number: number; title: string; description: string; tags: string; review_round_id: number; active_round_id: number | null; round_status: string; project_id: number; project_review_status: string; project_status: string }>(`SELECT v.version_number,v.title,v.description,v.tags,v.review_round_id,n.active_round_id,n.project_id,r.status AS round_status,p.review_status AS project_review_status,p.status AS project_status + FROM work_versions v JOIN review_rounds r ON r.id=v.review_round_id JOIN notes n ON n.id=v.note_id JOIN projects p ON p.id=n.project_id + WHERE v.note_id=? AND r.round_number=?`, [workId, roundNumber]); + if (!version) { res.status(404).json({ error: '验收轮次不存在' }); return; } + if (!await canWriteProject(req, Number(version.project_id))) { res.status(403).json({ error: '无权批注该作品' }); return; } + if (version.project_status !== 'active' || version.project_review_status === 'completed' || version.round_status !== 'reviewing' || Number(version.review_round_id) !== Number(version.active_round_id)) { res.status(409).json({ error: '历史轮次、已关闭或已完成项目为只读状态' }); return; } + const source = target === 'title' ? version.title : target === 'description' ? version.description : target === 'tags' ? parseTags(version.tags).join(' ') : ''; + if (!source || !Number.isInteger(startOffset) || !Number.isInteger(endOffset) || startOffset < 0 || endOffset <= startOffset || endOffset > source.length || source.slice(startOffset, endOffset) !== selectedText) { res.status(400).json({ error: '请选择标题或正文中的有效文字区域' }); return; } + if (!content || content.length > 1000) { res.status(400).json({ error: '批注内容须为 1–1000 个字符' }); return; } + const prefix = source.slice(Math.max(0, startOffset - 24), startOffset); + const suffix = source.slice(endOffset, endOffset + 24); + const id = await database.insertId(`INSERT INTO text_annotations (note_id,version_number,target,start_offset,end_offset,selected_text,prefix_text,suffix_text,content,author_name,author_role) + VALUES (?,?,?,?,?,?,?,?,?,?, 'operator')`, [workId, version.version_number, target, startOffset, endOffset, selectedText, prefix, suffix, content, req.authUser?.display_name || 'API']); + await audit(req, 'text_annotation.create', 'text_annotation', id, { workId, roundNumber, target, startOffset, endOffset }); + res.status(201).json(await database.one('SELECT * FROM text_annotations WHERE id = ?', [id])); +}); + +router.post('/:workId/comments', requireWriter, async (req: AuthRequest, res: Response) => { + const workId = Number(req.params.workId); + const content = String(req.body?.content ?? '').trim(); + const context = await database.one<{ project_id: number; version_number: number; review_status: string; project_status: string; round_status: string }>('SELECT n.project_id,n.version_number,p.review_status,p.status AS project_status,r.status AS round_status FROM notes n JOIN projects p ON p.id=n.project_id JOIN review_rounds r ON r.id=n.active_round_id WHERE n.id=?', [workId]); + if (!context) { res.status(404).json({ error: '作品不存在' }); return; } + if (!await canWriteProject(req, Number(context.project_id))) { res.status(403).json({ error: '无权操作该作品' }); return; } + if (context.project_status !== 'active' || context.review_status === 'completed' || context.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,version_number,content,author_name,author_role) VALUES (?,?,?,?, 'operator')", [workId, context.version_number, content, req.authUser?.display_name || 'API']); + res.status(201).json(await database.one('SELECT * FROM work_comments WHERE id = ?', [id])); +}); + +router.post('/:workId/feedback/:type/:feedbackId/replies', requireWriter, async (req: AuthRequest, res: Response) => { + const workId = Number(req.params.workId); const type = feedbackType(req.params.type); const feedbackId = Number(req.params.feedbackId); + const content = String(req.body?.content ?? '').trim(); const projectId = await workProjectId(workId); + if (!type || !Number.isInteger(feedbackId)) { res.status(400).json({ error: '反馈类型或编号无效' }); return; } + if (!projectId || !await canWriteProject(req, projectId)) { res.status(projectId ? 403 : 404).json({ error: projectId ? '无权操作该作品' : '作品不存在' }); return; } + const target = await findFeedbackTarget(workId, type, feedbackId); + const state = await database.one<{ version_number: number; review_status: string; project_status: string; round_status: string }>('SELECT n.version_number,p.review_status,p.status AS project_status,r.status AS round_status FROM notes n JOIN projects p ON p.id=n.project_id JOIN review_rounds r ON r.id=n.active_round_id WHERE n.id=?', [workId]); + if (!target) { res.status(404).json({ error: '反馈不存在' }); return; } + if (!state || state.project_status !== 'active' || state.review_status === 'completed' || state.round_status !== 'reviewing' || Number(target.version_number) !== Number(state.version_number)) { res.status(409).json({ error: '历史轮次、已关闭或已完成项目为只读状态' }); return; } + if (!content || content.length > 1000) { res.status(400).json({ error: '回复内容须为 1–1000 个字符' }); return; } + res.status(201).json(await addFeedbackReply(target, type, feedbackId, content, req.authUser?.display_name || 'API', 'operator')); +}); + +router.post('/:workId/feedback/:type/:feedbackId/withdraw', requireWriter, async (req: AuthRequest, res: Response) => { + const workId = Number(req.params.workId); const type = feedbackType(req.params.type); const feedbackId = Number(req.params.feedbackId); const projectId = await workProjectId(workId); + if (!type || !Number.isInteger(feedbackId)) { res.status(400).json({ error: '反馈类型或编号无效' }); return; } + if (!projectId || !await canWriteProject(req, projectId)) { res.status(projectId ? 403 : 404).json({ error: projectId ? '无权操作该作品' : '作品不存在' }); return; } + const target = await findFeedbackTarget(workId, type, feedbackId); + if (!target) { res.status(404).json({ error: '反馈不存在' }); return; } + const state = await database.one<{ version_number: number; review_status: string; project_status: string; round_status: string }>('SELECT n.version_number,p.review_status,p.status AS project_status,r.status AS round_status FROM notes n JOIN projects p ON p.id=n.project_id JOIN review_rounds r ON r.id=n.active_round_id WHERE n.id=?', [workId]); + if (!state || state.project_status !== 'active' || state.review_status === 'completed' || state.round_status !== 'reviewing' || Number(target.version_number) !== Number(state.version_number)) { res.status(409).json({ error: '历史轮次、已关闭或已完成项目为只读状态' }); return; } + if (target.withdrawn_at) { res.json({ success: true }); return; } + if (target.author_role !== 'operator' || target.author_name !== (req.authUser?.display_name || 'API')) { res.status(403).json({ error: '只能撤回自己提交的反馈' }); return; } + await withdrawFeedback(type, feedbackId); res.json({ success: true }); +}); + +export default router; diff --git a/api/services/feedbackService.ts b/api/services/feedbackService.ts new file mode 100644 index 0000000..3c05950 --- /dev/null +++ b/api/services/feedbackService.ts @@ -0,0 +1,30 @@ +import { database } from '../database.js'; +import type { FeedbackReply, FeedbackType } from '../../shared/types.js'; + +export type FeedbackTarget = { + note_id: number; + version_number: number; + author_name: string; + author_role: 'client' | 'operator'; + withdrawn_at: string | null; +}; + +export async function findFeedbackTarget(workId: number, type: FeedbackType, feedbackId: number): Promise { + if (type === 'image_annotation') { + return database.one(`SELECT i.note_id,i.version_number,a.author_name,a.author_role,a.withdrawn_at + FROM annotations a JOIN images i ON i.id=a.image_id WHERE a.id=? AND i.note_id=?`, [feedbackId, workId]); + } + const table = type === 'text_annotation' ? 'text_annotations' : 'work_comments'; + return database.one(`SELECT note_id,version_number,author_name,author_role,withdrawn_at FROM ${table} WHERE id=? AND note_id=?`, [feedbackId, workId]); +} + +export async function addFeedbackReply(target: FeedbackTarget, type: FeedbackType, feedbackId: number, content: string, authorName: string, authorRole: 'client' | 'operator'): Promise { + const id = await database.insertId(`INSERT INTO feedback_replies (note_id,version_number,feedback_type,feedback_id,content,author_name,author_role) + VALUES (?,?,?,?,?,?,?)`, [target.note_id, target.version_number, type, feedbackId, content, authorName, authorRole]); + return (await database.one('SELECT * FROM feedback_replies WHERE id=?', [id]))!; +} + +export async function withdrawFeedback(type: FeedbackType, feedbackId: number): Promise { + const table = type === 'image_annotation' ? 'annotations' : type === 'text_annotation' ? 'text_annotations' : 'work_comments'; + await database.execute(`UPDATE ${table} SET withdrawn_at=CURRENT_TIMESTAMP WHERE id=? AND withdrawn_at IS NULL`, [feedbackId]); +} diff --git a/api/services/notesService.ts b/api/services/notesService.ts index fd56ec3..93eb6ad 100644 --- a/api/services/notesService.ts +++ b/api/services/notesService.ts @@ -1,18 +1,19 @@ import sharp from 'sharp'; -import type { ImageWithAnnotations, Note, NoteDetail, ReviewStatus } from '../../shared/types.js'; +import type { ImageWithAnnotations, Note, NoteDetail, ReviewStatus, WorkFeedbackBundle, WorkRound } from '../../shared/types.js'; import { notesRepository } from '../repositories/notesRepository.js'; import { imagesRepository } from '../repositories/imagesRepository.js'; import { annotationsRepository } from '../repositories/annotationsRepository.js'; import { database, databaseDialect, withTransaction, type QueryContext } from '../database.js'; import { storeUploadedFile } from '../storage.js'; import { recalculateCollectionStatus } from './collectionsService.js'; +import { ensureProjectCompatibilityCollection, recalculateProjectReviewStatus } from './projectsService.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[] } +export interface UploadRound { title: string; description: string; tags: string[]; files: UploadedFile[] } +export interface UrlRound { 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[] }; +type PreparedRound = { 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 }; } @@ -30,59 +31,62 @@ function externalImages(images: string[]): StoredImage[] { async function createRoundInTransaction( tx: QueryContext, noteId: number, + projectId: number, collectionId: number, - candidates: PreparedCandidate[], + round: PreparedRound, 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]); +): Promise<{ roundId: number; roundNumber: number; versionNumber: 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]); + await tx.execute("UPDATE work_versions SET candidate_status = 'not_selected', review_status = 'draft' WHERE review_round_id = ? AND review_status != 'approved'", [note.active_round_id]); + await tx.execute("UPDATE review_rounds SET status = 'completed', completion_reason = 'superseded', completed_at = COALESCE(completed_at, ?) WHERE id = ? AND status IN ('draft', '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 versionNumber = 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( + "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, round.title, round.description, JSON.stringify(round.tags), roundId, createdBy ?? null], + ); + await imagesRepository.createMany(noteId, round.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, '工作台'], + ); 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], + [round.title, round.description, JSON.stringify(round.tags), versionNumber, roundId, noteId], ); await recalculateCollectionStatus(collectionId, tx); - return { roundId, roundNumber, firstVersion }; + await recalculateProjectReviewStatus(projectId, tx); + return { roundId, roundNumber, versionNumber }; } -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), - }))); +async function prepareUploadRound(round: UploadRound): Promise { + return { title: round.title, description: round.description, tags: round.tags, images: await prepareFiles(round.files) }; +} + +function mapRound(row: Omit & { tags: string }): WorkRound { + return { + ...row, + version_number: Number(row.version_number), + review_round_id: Number(row.review_round_id), + round_number: Number(row.round_number), + tags: JSON.parse(row.tags || '[]') as string[], + }; } export const notesService = { @@ -90,103 +94,154 @@ export const notesService = { 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 }); }, - async getDetail(id: number, requestedVersion?: number): Promise { + async getDetail(id: number, requestedVersion?: number, requestedRound?: number): Promise { const current = await notesRepository.findById(id); if (!current) return null; - const selectedVersion = requestedVersion && requestedVersion !== current.version_number - ? await database.one<{ version_number: number; title: string; description: string; tags: string; review_status: ReviewStatus }>('SELECT version_number, title, description, tags, review_status FROM work_versions WHERE note_id = ? AND version_number = ?', [id, requestedVersion]) + const requested = requestedRound + ? await database.one<{ version_number: number }>('SELECT v.version_number FROM work_versions v JOIN review_rounds r ON r.id = v.review_round_id WHERE v.note_id = ? AND r.round_number = ?', [id, requestedRound]) : undefined; - if (requestedVersion && requestedVersion !== current.version_number && !selectedVersion) return null; + const targetVersion = requested ? Number(requested.version_number) : requestedVersion; + const selectedVersion = targetVersion && targetVersion !== current.version_number + ? await database.one<{ version_number: number; title: string; description: string; tags: string; review_status: ReviewStatus }>('SELECT version_number, title, description, tags, review_status FROM work_versions WHERE note_id = ? AND version_number = ?', [id, targetVersion]) + : undefined; + if (targetVersion && targetVersion !== 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; 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 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 + const project = await database.one<{ id: number; name: string; slug: string; status: NoteDetail['project']['status']; review_status: NoteDetail['project']['review_status'] }>('SELECT p.id, p.name, p.slug, p.status, p.review_status FROM notes n JOIN projects p ON p.id = n.project_id WHERE n.id = ?', [id]); + if (!project) return null; + const roundRows = await database.all & { tags: string }>[number]>(`SELECT v.version_number, v.title, v.description, v.tags, v.review_status, v.review_round_id, + v.created_at, r.round_number, r.status AS round_status, r.completion_reason 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]); + WHERE v.note_id = ? ORDER BY r.round_number DESC`, [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), 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 }, + text_annotations: await database.all('SELECT * 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 = ? AND version_number = ? ORDER BY id ASC', [id, note.version_number]), + feedback_replies: await database.all('SELECT * FROM feedback_replies WHERE note_id = ? AND version_number = ? ORDER BY id ASC', [id, note.version_number]), + rounds: roundRows.map(mapRound), + review_events: await database.all('SELECT * FROM review_events WHERE note_id = ? AND version_number = ? ORDER BY id DESC', [id, note.version_number]), + project: { id: Number(project.id), name: project.name, slug: project.slug, status: project.status, review_status: project.review_status }, }; for (const image of images) result.images.push({ ...image, annotations: await annotationsRepository.listByImage(image.id) }); return result; }, - async create(title: string, description: string, files: UploadedFile[], collectionId: number, tags: string[], externalId: string | null = null): Promise { + async createInProject(projectId: number, title: string, description: string, files: UploadedFile[], 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 (?, ?, ?, ?, ?, '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 withTransaction(async (tx) => { + const collectionId = await ensureProjectCompatibilityCollection(projectId, tx); + const id = await tx.insertId("INSERT INTO notes (project_id, collection_id, external_id, title, description, tags, review_status) VALUES (?, ?, ?, ?, ?, ?, 'pending')", [projectId, collectionId, externalId, title, description, JSON.stringify(tags)]); + await createRoundInTransaction(tx, id, projectId, collectionId, { title, description, tags, images: prepared }, undefined, 'draft'); + return (await notesRepository.findById(id))!; + }); + }, + + async createInProjectFromUrls(projectId: number, title: string, description: string, imageUrls: string[], tags: string[], externalId: string | null = null): Promise { + return withTransaction(async (tx) => { + const collectionId = await ensureProjectCompatibilityCollection(projectId, tx); + const id = await tx.insertId("INSERT INTO notes (project_id, collection_id, external_id, title, description, tags, review_status) VALUES (?, ?, ?, ?, ?, ?, 'pending')", [projectId, collectionId, externalId, title, description, JSON.stringify(tags)]); + await createRoundInTransaction(tx, id, projectId, collectionId, { title, description, tags, images: externalImages(imageUrls) }, undefined, 'draft'); + return (await notesRepository.findById(id))!; + }); + }, + + async create(title: string, description: string, files: UploadedFile[], collectionId: number, tags: string[], externalId: string | null = null): Promise { + const collection = await database.one<{ project_id: number }>('SELECT project_id FROM collections WHERE id = ?', [collectionId]); + if (!collection) throw new Error('作品交付集不存在'); + const projectId = Number(collection.project_id); + const prepared = await prepareFiles(files); + return withTransaction(async (tx) => { + const id = await tx.insertId("INSERT INTO notes (project_id, collection_id, external_id, title, description, tags, review_status) VALUES (?, ?, ?, ?, ?, ?, 'pending')", [projectId, collectionId, externalId, title, description, JSON.stringify(tags)]); + await createRoundInTransaction(tx, id, projectId, collectionId, { title, description, tags, images: prepared }, undefined, 'draft'); + return (await notesRepository.findById(id))!; }); - return (await notesRepository.findById(noteId))!; }, 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 (?, ?, ?, ?, ?, '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; + const collection = await database.one<{ project_id: number }>('SELECT project_id FROM collections WHERE id = ?', [collectionId]); + if (!collection) throw new Error('作品交付集不存在'); + const projectId = Number(collection.project_id); + return withTransaction(async (tx) => { + const id = await tx.insertId("INSERT INTO notes (project_id, collection_id, external_id, title, description, tags, review_status) VALUES (?, ?, ?, ?, ?, ?, 'pending')", [projectId, collectionId, externalId, title, description, JSON.stringify(tags)]); + await createRoundInTransaction(tx, id, projectId, collectionId, { title, description, tags, images: externalImages(imageUrls) }, undefined, 'draft'); + return (await notesRepository.findById(id))!; }); - return (await notesRepository.findById(noteId))!; }, - async findByExternalId(collectionId: number, externalId: string): Promise { - return notesRepository.findByExternalId(collectionId, externalId); - }, + async findByExternalId(collectionId: number, externalId: string): Promise { return notesRepository.findByExternalId(collectionId, externalId); }, + async findByProjectExternalId(projectId: number, externalId: string): Promise { return notesRepository.findByProjectExternalId(projectId, externalId); }, - async createReviewRound(id: number, candidates: UploadCandidate[], createdBy?: number): Promise { + async createRound(id: number, round: UploadRound, createdBy?: number): Promise { const current = await notesRepository.findById(id); if (!current) throw new Error('作品不存在'); - const prepared = await prepareUploadCandidates(candidates); - await withTransaction((tx) => createRoundInTransaction(tx, id, current.collection_id, prepared, createdBy, current.review_status)); + const prepared = await prepareUploadRound(round); + await withTransaction((tx) => createRoundInTransaction(tx, id, current.project_id, current.collection_id, prepared, createdBy, current.review_status)); return (await notesRepository.findById(id))!; }, - async createReviewRoundFromUrls(id: number, candidates: UrlCandidate[], createdBy?: number): Promise { + async createRoundFromUrls(id: number, round: UrlRound, createdBy?: number): Promise { const current = await notesRepository.findById(id); if (!current) throw new Error('作品不存在'); - const prepared = candidates.map((candidate) => ({ ...candidate, images: externalImages(candidate.images) })); - await withTransaction((tx) => createRoundInTransaction(tx, id, current.collection_id, prepared, createdBy, current.review_status)); + const prepared = { ...round, images: externalImages(round.images) }; + await withTransaction((tx) => createRoundInTransaction(tx, id, current.project_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); + return this.createRound(id, { 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); + return this.createRoundFromUrls(id, { title, description, tags, images: imageUrls }, createdBy); + }, + + async getFeedback(id: number): Promise { + const rounds = await database.all<{ round_number: number; version_number: number }>('SELECT r.round_number, v.version_number FROM review_rounds r JOIN work_versions v ON v.review_round_id = r.id WHERE r.note_id = ? ORDER BY r.round_number DESC', [id]); + if (!rounds.length && !await notesRepository.findById(id)) return null; + const imageAnnotations = await database.all[number]>(`SELECT a.*, i.id AS image_id, i.url AS image_url, i.version_number FROM annotations a JOIN images i ON i.id = a.image_id WHERE i.note_id = ? ORDER BY a.id`, [id]); + const textAnnotations = await database.all[number]>('SELECT * FROM text_annotations WHERE note_id = ? ORDER BY id', [id]); + const comments = await database.all('SELECT * FROM work_comments WHERE note_id = ? ORDER BY id', [id]); + const replies = await database.all('SELECT * FROM feedback_replies WHERE note_id = ? ORDER BY id', [id]); + const events = await database.all('SELECT * FROM review_events WHERE note_id = ? ORDER BY id', [id]); + return { + work_id: id, + rounds: rounds.map((round) => ({ + round_number: Number(round.round_number), + version_number: Number(round.version_number), + image_annotations: imageAnnotations.filter((item) => Number(item.version_number) === Number(round.version_number)), + text_annotations: textAnnotations.filter((item) => Number(item.version_number) === Number(round.version_number)), + comments: comments.filter((item) => Number(item.version_number) === Number(round.version_number)), + feedback_replies: replies.filter((item) => Number(item.version_number) === Number(round.version_number)), + review_events: events.filter((item) => Number(item.version_number) === Number(round.version_number)), + })), + }; }, async remove(id: number) { return withTransaction(async (tx) => { - const note = await tx.one<{ collection_id: number }>('SELECT collection_id FROM notes WHERE id = ?', [id]); + const note = await tx.one<{ collection_id: number; project_id: number }>('SELECT collection_id, project_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); + if (removed) { + await recalculateCollectionStatus(Number(note.collection_id), tx); + await recalculateProjectReviewStatus(Number(note.project_id), tx); + } return removed; }); }, async setStatus(id: number, status: ReviewStatus) { return withTransaction(async (tx) => { - const note = await tx.one<{ collection_id: number; active_round_id: number | null }>('SELECT collection_id, active_round_id FROM notes WHERE id = ?', [id]); + const note = await tx.one<{ collection_id: number; project_id: number; active_round_id: number | null }>('SELECT collection_id, project_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]); 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 tx.execute("UPDATE work_versions SET review_status = ?, candidate_status = ? WHERE review_round_id = ?", [status, status === 'draft' ? 'draft' : 'pending', note.active_round_id]); + await tx.execute("UPDATE review_rounds SET status = ?, completion_reason = '', selected_version_number = NULL, completed_at = NULL WHERE id = ?", [status === 'draft' ? 'draft' : 'reviewing', note.active_round_id]); } await recalculateCollectionStatus(Number(note.collection_id), tx); + await recalculateProjectReviewStatus(Number(note.project_id), tx); return true; }); }, diff --git a/api/services/projectsService.ts b/api/services/projectsService.ts new file mode 100644 index 0000000..3d5c641 --- /dev/null +++ b/api/services/projectsService.ts @@ -0,0 +1,55 @@ +import type { ProjectReviewStatus } from '../../shared/types.js'; +import { database, databaseDialect, type QueryContext } from '../database.js'; + +export interface ProjectReviewStatusResult { + reviewStatus: ProjectReviewStatus; + workCount: number; + approvedCount: number; + completedAt: string | null; +} + +export function deriveProjectReviewStatus(workCount: number, approvedCount: number): Exclude { + if (workCount === 0) return 'draft'; + if (approvedCount === workCount) return 'completed'; + return 'reviewing'; +} + +export async function recalculateProjectReviewStatus( + projectId: number, + tx: QueryContext = database, +): Promise { + const project = await tx.one<{ status: string; review_status: ProjectReviewStatus; review_completed_at: string | null }>( + `SELECT status, review_status, review_completed_at FROM projects WHERE id = ?${databaseDialect === 'postgres' ? ' FOR NO KEY UPDATE' : ''}`, + [projectId], + ); + if (!project) 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 project_id = ? AND review_status != 'draft'`, + [projectId], + ); + const workCount = Number(counts?.work_count ?? 0); + const approvedCount = Number(counts?.approved_count ?? 0); + + if (project.status === 'archived') { + return { reviewStatus: 'archived', workCount, approvedCount, completedAt: project.review_completed_at }; + } + + const reviewStatus = deriveProjectReviewStatus(workCount, approvedCount); + const completedAt = reviewStatus === 'completed' + ? project.review_completed_at ?? new Date().toISOString() + : null; + await tx.execute('UPDATE projects SET review_status = ?, review_completed_at = ? WHERE id = ?', [reviewStatus, completedAt, projectId]); + return { reviewStatus, workCount, approvedCount, completedAt }; +} + +export async function ensureProjectCompatibilityCollection(projectId: number, tx: QueryContext = database): Promise { + const existing = await tx.one<{ id: number }>('SELECT id FROM collections WHERE project_id = ? ORDER BY id LIMIT 1', [projectId]); + if (existing) return Number(existing.id); + return tx.insertId( + "INSERT INTO collections (project_id, name, client_description, status) VALUES (?, ?, '', 'draft')", + [projectId, '__project_default__'], + ); +} diff --git a/api/services/reviewService.ts b/api/services/reviewService.ts index 223c61a..3f952fd 100644 --- a/api/services/reviewService.ts +++ b/api/services/reviewService.ts @@ -1,12 +1,13 @@ import type { ReviewStatus } from '../../shared/types.js'; import { databaseDialect, withTransaction, type QueryContext } from '../database.js'; import { recalculateCollectionStatus } from './collectionsService.js'; +import { recalculateProjectReviewStatus } from './projectsService.js'; export class ReviewDecisionError extends Error { constructor(public statusCode: number, message: string) { super(message); } } -export interface CandidateDecisionInput { +export interface RoundDecisionInput { noteId: number; versionNumber: number; projectId: number; @@ -16,43 +17,48 @@ export interface CandidateDecisionInput { 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' : ''}`, +export async function decideRoundInTransaction(tx: QueryContext, input: RoundDecisionInput) { + const round = await tx.one<{ + review_round_id: number; review_status: ReviewStatus; collection_id: number; project_id: number; + active_round_id: number | null; round_status: string; project_status: string; title: string; description: string; tags: string; + }>(`SELECT v.review_round_id,v.review_status,v.title,v.description,v.tags, + n.collection_id,n.project_id,n.active_round_id,r.status AS round_status,p.status AS project_status + FROM work_versions v JOIN notes n ON n.id=v.note_id JOIN review_rounds r ON r.id=v.review_round_id JOIN projects p ON p.id=n.project_id + WHERE v.note_id=? AND v.version_number=? AND n.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') { + if (!round) throw new ReviewDecisionError(404, '验收轮次不存在'); + if (round.project_status !== 'active') throw new ReviewDecisionError(409, '已关闭或归档项目为只读状态'); + if (Number(round.active_round_id) !== Number(round.review_round_id) || round.round_status !== 'reviewing') { throw new ReviewDecisionError(409, '历史验收轮次为只读状态'); } - if (!['pending', 'changes_requested'].includes(candidate.candidate_status)) { - throw new ReviewDecisionError(409, '该候选稿当前不能重复验收'); + if (!['pending', 'changes_requested'].includes(round.review_status)) { + throw new ReviewDecisionError(409, '该轮次当前不能重复验收'); } + const now = new Date().toISOString(); 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]); + await tx.execute("UPDATE review_rounds SET status='completed', completion_reason='approved', selected_version_number=?, completed_at=? WHERE id=?", [input.versionNumber, now, round.review_round_id]); + await tx.execute("UPDATE notes SET title=?,description=?,tags=?,version_number=?,approved_version_number=?,review_status='approved' WHERE id=?", [round.title, round.description, round.tags, input.versionNumber, input.versionNumber, input.noteId]); + await tx.execute("UPDATE annotations SET status='confirmed', closure_reason='approved_with_round' WHERE image_id IN (SELECT id FROM images WHERE note_id=? AND version_number=?) AND status='open' AND withdrawn_at IS NULL", [input.noteId, input.versionNumber]); + await tx.execute("UPDATE text_annotations SET status='confirmed', closure_reason='approved_with_round' WHERE note_id=? AND version_number=? AND status='open' AND withdrawn_at IS NULL", [input.noteId, input.versionNumber]); + await tx.execute("UPDATE work_comments SET status='confirmed', closure_reason='approved_with_round' WHERE note_id=? AND version_number=? AND status='open' AND withdrawn_at IS NULL", [input.noteId, input.versionNumber]); } 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("UPDATE review_rounds SET status='completed', completion_reason='changes_requested', completed_at=? WHERE id=?", [now, round.review_round_id]); + await tx.execute("UPDATE notes SET title=?,description=?,tags=?,version_number=?,review_status='changes_requested' WHERE id=?", [round.title, round.description, round.tags, input.versionNumber, 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); + 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, round.review_status, input.decision, input.reason, input.actorName, input.actorRole]); + if (input.reason) await tx.execute("INSERT INTO work_comments (note_id,version_number,content,author_name,author_role) VALUES (?,?,?,?,'client')", [input.noteId, input.versionNumber, input.reason, input.actorName]); + await recalculateCollectionStatus(Number(round.collection_id), tx); + await recalculateProjectReviewStatus(Number(round.project_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)); +export async function decideRound(input: RoundDecisionInput) { + return withTransaction((tx) => decideRoundInTransaction(tx, input)); } + +export const decideCandidate = decideRound; +export const decideCandidateInTransaction = decideRoundInTransaction; diff --git a/db/postgres/schema.sql b/db/postgres/schema.sql index 05811aa..71a8806 100644 --- a/db/postgres/schema.sql +++ b/db/postgres/schema.sql @@ -29,6 +29,8 @@ CREATE TABLE IF NOT EXISTS projects ( slug TEXT NOT NULL UNIQUE, client_description TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'closed', 'archived')), + review_status TEXT NOT NULL DEFAULT 'draft' CHECK (review_status IN ('draft', 'reviewing', 'completed', 'archived')), + review_completed_at TIMESTAMPTZ, access_password_hash TEXT NOT NULL DEFAULT '', customer_access_enabled BOOLEAN NOT NULL DEFAULT FALSE, access_expires_at TIMESTAMPTZ, @@ -51,6 +53,7 @@ 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, + project_id BIGINT REFERENCES projects(id) ON DELETE CASCADE, external_id TEXT, title TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', @@ -63,6 +66,9 @@ CREATE TABLE IF NOT EXISTS notes ( ); ALTER TABLE notes ADD COLUMN IF NOT EXISTS external_id TEXT; +ALTER TABLE notes ADD COLUMN IF NOT EXISTS project_id BIGINT REFERENCES projects(id) ON DELETE CASCADE; +ALTER TABLE projects ADD COLUMN IF NOT EXISTS review_status TEXT NOT NULL DEFAULT 'draft'; +ALTER TABLE projects ADD COLUMN IF NOT EXISTS review_completed_at TIMESTAMPTZ; 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 != ''; CREATE TABLE IF NOT EXISTS images ( @@ -84,17 +90,23 @@ CREATE TABLE IF NOT EXISTS annotations ( y DOUBLE PRECISION NOT NULL CHECK (y BETWEEN 0 AND 1), content TEXT NOT NULL, author_name TEXT NOT NULL DEFAULT '客户', + author_role TEXT NOT NULL DEFAULT 'client' CHECK (author_role IN ('client', 'operator')), status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'resolved', 'confirmed')), + closure_reason TEXT NOT NULL DEFAULT '', + withdrawn_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE TABLE IF NOT EXISTS work_comments ( id BIGSERIAL PRIMARY KEY, note_id BIGINT NOT NULL REFERENCES notes(id) ON DELETE CASCADE, + version_number INTEGER NOT NULL DEFAULT 1, content TEXT NOT NULL, author_name TEXT NOT NULL DEFAULT '客户', author_role TEXT NOT NULL DEFAULT 'client' CHECK (author_role IN ('client', 'operator')), status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'resolved', 'confirmed')), + closure_reason TEXT NOT NULL DEFAULT '', + withdrawn_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); @@ -102,15 +114,38 @@ CREATE TABLE IF NOT EXISTS text_annotations ( id BIGSERIAL PRIMARY KEY, note_id BIGINT NOT NULL REFERENCES notes(id) ON DELETE CASCADE, version_number INTEGER NOT NULL CHECK (version_number > 0), - target TEXT NOT NULL CHECK (target IN ('title', 'description')), + target TEXT NOT NULL CHECK (target IN ('title', 'description', 'tags')), + start_offset INTEGER NOT NULL DEFAULT 0, + end_offset INTEGER NOT NULL DEFAULT 0, + selected_text TEXT NOT NULL DEFAULT '', + prefix_text TEXT NOT NULL DEFAULT '', + suffix_text TEXT NOT NULL DEFAULT '', content TEXT NOT NULL, author_name TEXT NOT NULL DEFAULT '客户', + author_role TEXT NOT NULL DEFAULT 'client' CHECK (author_role IN ('client', 'operator')), status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'resolved', 'confirmed')), + closure_reason TEXT NOT NULL DEFAULT '', + withdrawn_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS idx_text_annotations_note_id ON text_annotations(note_id, version_number); +CREATE TABLE IF NOT EXISTS feedback_replies ( + id BIGSERIAL PRIMARY KEY, + note_id BIGINT NOT NULL REFERENCES notes(id) ON DELETE CASCADE, + version_number INTEGER NOT NULL CHECK (version_number > 0), + feedback_type TEXT NOT NULL CHECK (feedback_type IN ('image_annotation', 'text_annotation', 'comment')), + feedback_id BIGINT NOT NULL, + content TEXT NOT NULL, + author_name TEXT NOT NULL, + author_role TEXT NOT NULL CHECK (author_role IN ('client', 'operator')), + withdrawn_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_feedback_replies_target ON feedback_replies(note_id, version_number, feedback_type, feedback_id); + CREATE TABLE IF NOT EXISTS work_versions ( id BIGSERIAL PRIMARY KEY, note_id BIGINT NOT NULL REFERENCES notes(id) ON DELETE CASCADE, @@ -135,6 +170,7 @@ CREATE TABLE IF NOT EXISTS review_rounds ( selected_version_number INTEGER, created_by BIGINT REFERENCES users(id) ON DELETE SET NULL, completed_at TIMESTAMPTZ, + completion_reason TEXT NOT NULL DEFAULT '', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE(note_id, round_number) ); @@ -144,6 +180,27 @@ 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'; +ALTER TABLE review_rounds ADD COLUMN IF NOT EXISTS completion_reason TEXT NOT NULL DEFAULT ''; +ALTER TABLE annotations ADD COLUMN IF NOT EXISTS author_role TEXT NOT NULL DEFAULT 'client'; +ALTER TABLE annotations ADD COLUMN IF NOT EXISTS withdrawn_at TIMESTAMPTZ; +ALTER TABLE annotations ADD COLUMN IF NOT EXISTS closure_reason TEXT NOT NULL DEFAULT ''; +ALTER TABLE work_comments ADD COLUMN IF NOT EXISTS version_number INTEGER NOT NULL DEFAULT 1; +ALTER TABLE work_comments ADD COLUMN IF NOT EXISTS withdrawn_at TIMESTAMPTZ; +ALTER TABLE work_comments ADD COLUMN IF NOT EXISTS closure_reason TEXT NOT NULL DEFAULT ''; +ALTER TABLE text_annotations ADD COLUMN IF NOT EXISTS start_offset INTEGER NOT NULL DEFAULT 0; +ALTER TABLE text_annotations ADD COLUMN IF NOT EXISTS end_offset INTEGER NOT NULL DEFAULT 0; +ALTER TABLE text_annotations ADD COLUMN IF NOT EXISTS selected_text TEXT NOT NULL DEFAULT ''; +ALTER TABLE text_annotations ADD COLUMN IF NOT EXISTS prefix_text TEXT NOT NULL DEFAULT ''; +ALTER TABLE text_annotations ADD COLUMN IF NOT EXISTS suffix_text TEXT NOT NULL DEFAULT ''; +ALTER TABLE text_annotations ADD COLUMN IF NOT EXISTS author_role TEXT NOT NULL DEFAULT 'client'; +ALTER TABLE text_annotations ADD COLUMN IF NOT EXISTS withdrawn_at TIMESTAMPTZ; +ALTER TABLE text_annotations ADD COLUMN IF NOT EXISTS closure_reason TEXT NOT NULL DEFAULT ''; + +-- TEXT_ANNOTATION_TARGET_REPAIR_START +ALTER TABLE text_annotations DROP CONSTRAINT IF EXISTS text_annotations_target_check; +ALTER TABLE text_annotations ADD CONSTRAINT text_annotations_target_check + CHECK (target IN ('title', 'description', 'tags')); +-- TEXT_ANNOTATION_TARGET_REPAIR_END CREATE TABLE IF NOT EXISTS review_events ( id BIGSERIAL PRIMARY KEY, @@ -223,6 +280,8 @@ CREATE TABLE IF NOT EXISTS storage_configs ( CREATE UNIQUE INDEX IF NOT EXISTS one_active_storage_config ON storage_configs ((status)) WHERE status = 'active'; CREATE INDEX IF NOT EXISTS collections_project_id_idx ON collections(project_id); CREATE INDEX IF NOT EXISTS notes_collection_id_idx ON notes(collection_id); +CREATE INDEX IF NOT EXISTS notes_project_id_idx ON notes(project_id); +CREATE UNIQUE INDEX IF NOT EXISTS idx_notes_project_external_id ON notes(project_id, external_id) WHERE external_id IS NOT NULL AND external_id != ''; CREATE INDEX IF NOT EXISTS images_note_version_idx ON images(note_id, version_number, order_index); 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); @@ -231,6 +290,56 @@ CREATE INDEX IF NOT EXISTS review_events_note_version_idx ON review_events(note_ 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); +-- SINGLE_SCHEME_REPAIR_START +DO $$ +DECLARE + duplicate RECORD; + version_row RECORD; + source_round RECORD; + note_row RECORD; + keeper_version INTEGER; + next_round INTEGER; + new_round_id BIGINT; + remains_active BOOLEAN; +BEGIN + FOR duplicate IN + SELECT review_round_id, note_id FROM work_versions + WHERE review_round_id IS NOT NULL + GROUP BY review_round_id, note_id HAVING COUNT(*) > 1 + LOOP + SELECT * INTO source_round FROM review_rounds WHERE id = duplicate.review_round_id; + SELECT active_round_id, version_number INTO note_row FROM notes WHERE id = duplicate.note_id; + SELECT COALESCE( + (SELECT v.version_number FROM work_versions v WHERE v.review_round_id=duplicate.review_round_id AND v.version_number=note_row.version_number LIMIT 1), + (SELECT MIN(v.version_number) FROM work_versions v WHERE v.review_round_id=duplicate.review_round_id) + ) INTO keeper_version; + FOR version_row IN SELECT version_number FROM work_versions WHERE review_round_id=duplicate.review_round_id AND version_number<>keeper_version ORDER BY version_number + LOOP + SELECT COALESCE(MAX(round_number),0)+1 INTO next_round FROM review_rounds WHERE note_id=duplicate.note_id; + remains_active := note_row.active_round_id=duplicate.review_round_id AND note_row.version_number=version_row.version_number; + INSERT INTO review_rounds (note_id,round_number,status,selected_version_number,completed_at,created_by,created_at,completion_reason) + VALUES ( + duplicate.note_id, + next_round, + CASE WHEN remains_active THEN source_round.status ELSE 'completed' END, + CASE WHEN source_round.selected_version_number=version_row.version_number THEN version_row.version_number ELSE NULL END, + CASE WHEN remains_active THEN source_round.completed_at ELSE COALESCE(source_round.completed_at,NOW()) END, + source_round.created_by, + source_round.created_at, + CASE WHEN remains_active THEN source_round.completion_reason ELSE COALESCE(NULLIF(source_round.completion_reason,''),'migrated_single_scheme') END + ) RETURNING id INTO new_round_id; + UPDATE work_versions SET review_round_id=new_round_id WHERE note_id=duplicate.note_id AND version_number=version_row.version_number; + IF remains_active THEN UPDATE notes SET active_round_id=new_round_id WHERE id=duplicate.note_id; END IF; + END LOOP; + IF source_round.selected_version_number IS DISTINCT FROM keeper_version THEN + UPDATE review_rounds SET selected_version_number=NULL WHERE id=duplicate.review_round_id; + END IF; + END LOOP; +END $$; +-- SINGLE_SCHEME_REPAIR_END + +CREATE UNIQUE INDEX IF NOT EXISTS idx_work_versions_one_per_round ON work_versions(review_round_id) WHERE review_round_id IS NOT NULL; + -- COLLECTION_STATUS_REPAIR_START UPDATE collections SET status = 'draft', completed_at = NULL @@ -281,4 +390,41 @@ 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 +-- PROJECT_REVIEW_STATUS_REPAIR_START +UPDATE notes n +SET project_id = c.project_id +FROM collections c +WHERE c.id = n.collection_id AND n.project_id IS NULL; + +UPDATE work_comments wc +SET version_number = n.version_number +FROM notes n +WHERE n.id = wc.note_id AND wc.version_number < 1; + +UPDATE projects p +SET review_status = CASE + WHEN p.status = 'archived' THEN 'archived' + WHEN s.work_count IS NULL OR s.work_count = 0 THEN 'draft' + WHEN s.approved_count = s.work_count THEN 'completed' + ELSE 'reviewing' + END, + review_completed_at = CASE + WHEN p.status != 'archived' AND s.work_count > 0 AND s.approved_count = s.work_count + THEN COALESCE(p.review_completed_at, NOW()) + ELSE NULL + END +FROM ( + SELECT project_id, + COUNT(*) FILTER (WHERE review_status != 'draft') AS work_count, + COUNT(*) FILTER (WHERE review_status = 'approved') AS approved_count + FROM notes GROUP BY project_id +) s +WHERE p.id = s.project_id; + +UPDATE projects p +SET review_status = CASE WHEN p.status = 'archived' THEN 'archived' ELSE 'draft' END, + review_completed_at = NULL +WHERE NOT EXISTS (SELECT 1 FROM notes n WHERE n.project_id = p.id AND n.review_status != 'draft'); +-- PROJECT_REVIEW_STATUS_REPAIR_END + COMMIT; diff --git a/docs/architecture.md b/docs/architecture.md index fe45918..78ab387 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -2,83 +2,72 @@ ## 系统边界 -Delivery Desk 是单体 Web 应用:React 前端调用 Express API,前后端共享 `shared/types.ts` 类型。开发环境使用 SQLite 和本地上传目录;正式环境使用 PostgreSQL 和腾讯云 COS。 +Delivery Desk 是 React + Express 单体应用,前后端共享 `shared/types.ts`。开发环境使用 SQLite 和本地上传目录,正式环境使用 PostgreSQL 和腾讯云 COS。 ```mermaid flowchart LR - browser["浏览器"] --> app["Express / React 应用"] - app --> database["SQLite 或 PostgreSQL"] - app --> local["本地 uploads(开发)"] - app --> cos["腾讯云 COS(正式)"] - customer["客户验收链接"] --> app - client["外部 API 客户端"] --> app + browser["工作台 / 客户浏览器"] --> app["Express + React"] + api["外部 API 客户端"] --> app + app --> db["SQLite / PostgreSQL"] + app --> storage["本地 uploads / 腾讯云 COS"] ``` -## 业务层级 +## 产品层级 ```text 运营组 └── 项目 - └── 作品交付集 - └── 作品 - └── 验收轮次 - └── 候选稿(1–5 个) + └── 作品 + └── 验收轮次(每轮一个方案) ``` -- 一个运营组只能有一位组管理员,可以有多位光影叙事。 -- 平台管理员可以有多位,不属于固定运营组。 -- 普通工作台账号只能读写所属运营组的数据;平台管理员可跨组管理。 -- 客户会话只绑定一个项目,不能跨项目浏览。 -- 平台级 API Key 可创建项目;项目级 API Key 只能操作指定项目。 - -## 运行结构 - -- `src/`:React 页面、组件、状态和 API 客户端。 -- `api/routes/`:HTTP 路由与输入校验。 -- `api/services/`:作品、存储等业务编排。 -- `api/repositories/`:查询封装。 -- `api/database.ts`:SQLite/PostgreSQL 统一查询接口和事务。 -- `api/db.ts`:SQLite 初始化及增量迁移。 -- `db/postgres/schema.sql`:PostgreSQL 当前完整 schema。 -- `shared/types.ts`:前后端共享领域类型。 - -`DATABASE_URL` 存在时使用 PostgreSQL,否则使用 SQLite。两套数据库必须保持相同业务约束;涉及表或字段的修改必须同时更新 `api/db.ts`、`db/postgres/schema.sql` 及迁移验证脚本。 +- 客户会话绑定项目,不能跨项目访问。 +- 项目级 API Key 只能访问绑定项目;平台级 Key 可跨组管理项目。 +- 历史作品交付集不再是产品层级。`collections` 表仅作为旧数据和旧 URL 的迁移兼容容器。 +- `work_versions` 继续保存每轮内容快照,但与 `review_rounds` 强制一对一。 +- 升级时如检测到旧的一轮多方案数据,会把额外方案拆成只读的独立历史轮次,保留图片、批注、验收事件和当前活动方案,再建立一轮一方案唯一约束。 ## 主要数据表 | 表 | 用途 | |---|---| -| `operation_groups` | 运营组及启停状态 | -| `users` / `sessions` | 工作台账号、角色和登录会话 | -| `customer_sessions` | 客户项目级验收会话 | -| `projects` | 项目、客户访问密码和访问期限 | -| `collections` | 项目下的作品交付集 | -| `notes` | 作品当前状态、活动轮次和选中稿 | -| `review_rounds` | 验收轮次、完成状态和选中候选稿 | -| `work_versions` | 各候选稿的标题、正文、标签和验收状态快照 | -| `images` | 候选稿图片、顺序、存储提供方和对象 Key | +| `operation_groups` | 运营组及状态 | +| `users` / `sessions` | 工作台账号、角色和会话 | +| `customer_sessions` | 项目级客户会话 | +| `projects` | 项目、客户访问配置和自动验收状态 | +| `collections` | 迁移期内部兼容容器,不属于产品层级 | +| `notes` | 作品当前状态、活动轮次和项目归属 | +| `review_rounds` | 验收轮次与完成原因 | +| `work_versions` | 单轮内容快照;每轮恰好一条 | +| `images` | 轮次图片、顺序和存储信息 | | `annotations` | 图片坐标批注 | -| `text_annotations` | 标题或正文的版本级批注 | -| `work_comments` | 作品总体反馈与回复 | -| `review_events` | 提交、修改、通过、重新打开等验收记录 | -| `api_keys` | 平台级或项目级 API Key 的哈希与状态 | -| `storage_configs` | 加密后的 COS 配置及启用状态 | -| `audit_logs` | 管理和业务操作审计 | +| `text_annotations` | 标题、正文和 Tag 选区批注与文本上下文 | +| `work_comments` | 作品总体反馈 | +| `review_events` | 提交、退修、通过和重新打开记录 | +| `api_keys` | 平台级/项目级 API Key 哈希 | +| `storage_configs` | 加密后的 COS 配置 | +| `audit_logs` | 管理与业务审计 | -## 存储流程 +## 状态计算 -平台管理员在管理页新增 COS 配置。SecretId 和 SecretKey 使用 `COS_CONFIG_ENCRYPTION_KEY` 派生的 AES-256-GCM 密钥加密后写入数据库,读取配置的接口不会返回明文。 +作品状态为 `draft`、`pending`、`changes_requested`、`approved`。只有活动轮次可新增批注和作出验收决定;新轮次会锁定旧轮次。客户通过活动轮次后作品为已通过,退修后运营通过新轮次提交修改。 -启用配置前会在目标桶的 `.delivery-desk-check/` 路径依次上传、读取并删除一个临时对象。启用后,新上传文件写入: +项目验收状态自动计算: -```text -/originals/YYYY/MM/. -``` +- 没有非草稿作品:`draft` +- 存在未通过作品:`reviewing` +- 所有非草稿作品通过:`completed` +- 人工归档:`archived` -未启用 COS 时,上传文件保存在本地 `uploads/`。图片 URL 按产品约定为公开随机地址,不提供对象级访问鉴权。 +完成项目为只读。新增作品、创建新轮次或由管理员重新打开作品时,项目恢复为验收中;已关闭或归档项目始终只读。开放反馈不会阻止通过;通过时仍为开放的反馈会标记为随该轮验收关闭,历史内容保留。 -## 验收状态 +## 批注模型 -作品状态为 `draft`、`pending`、`changes_requested`、`approved`。一个验收轮次可包含 1–5 个候选稿;单稿退修时,其他待验收稿仍可继续验收。客户选中并通过任意一稿后,作品即通过,同轮其他稿标记为未选用,历史轮次只读。已通过作品只能由平台管理员或所属组管理员填写原因后重新打开,历史事件保留。 +- 作品缩略图只展示现有坐标标记,不能新增坐标批注;点击标记会联动打开验收协作面板中的对应反馈。 +- 点击图片打开悬浮图片窗格;只有该窗格可以新增坐标批注,并支持原图查看、缩放和前后切换。点击窗格外会同时关闭图片窗格和验收协作面板。 +- 标题、正文和 Tag 批注保存 `start_offset`、`end_offset`、`selected_text` 及前后文,提交时校验选区仍与轮次快照一致。 +- `GET /api/works/:workId/annotations` 按轮次返回图片批注、文字批注、总体反馈和验收事件。 -作品交付集状态由其中非草稿作品自动计算:没有已提交作品时为 `draft`,存在未通过作品时为 `reviewing`,全部已提交作品通过时为 `completed`。`archived` 是人工状态,自动计算不会覆盖。完成后客户页面只读;新增作品、新验收轮次或重新打开作品会自动恢复为验收中。 +## 存储 + +平台管理员可在管理页保存和测试 COS 配置。SecretId/SecretKey 使用 `COS_CONFIG_ENCRYPTION_KEY` 派生的 AES-256-GCM 密钥加密,读取接口不返回明文。连接测试会上传、读取并删除临时对象。外部 API 提供的公开图片 URL 只保存地址,不下载、不转存 COS。 diff --git a/docs/handoff.md b/docs/handoff.md index 243d122..be907de 100644 --- a/docs/handoff.md +++ b/docs/handoff.md @@ -3,34 +3,33 @@ ## 已完成 - 三类工作台角色、运营组隔离、账号管理和 7 天会话 -- 项目、作品交付集、作品、多候选稿验收轮次和验收状态 -- 手动多图上传、封面、上传前拖拽排序及新验收轮次 -- 图片坐标批注、标题/正文批注、总体反馈和验收记录 -- 客户项目链接、密码、姓名、期限和验收决定 +- 项目 → 作品 → 单方案验收轮次,以及项目级自动验收状态 +- 手动多图上传、公开 URL API、封面与上传前拖拽排序 +- 缩略图只读标记、悬浮图片窗格、原图缩放与坐标批注 +- 标题、正文和 Tag 选区批注、总体反馈、按作品聚合反馈和验收记录 +- 批注回复线程、只能撤回本人反馈并保留撤回记录 +- 客户项目链接、密码、姓名、访问期限和验收决定 - API Key、审计日志、COS 前端配置及连接测试 - SQLite/PostgreSQL 双运行时、迁移验证和 Docker 部署 - 桌面端与移动端响应式页面 ## 初版上线前仍需完成 -以下需求尚未在代码中完整落地,不应在交付时宣称可用: +以下范围尚未完整落地,不应在交付时宣称可用: - ZIP + CSV 批量导入和最多 100 个作品的异步批量 API -- `externalId` 幂等创建作品(项目和作品交付集暂未支持) - webhook 与站内未读通知 - PDF 验收报告和最终原图 ZIP 导出 -- 批注/回复的参考图片附件 -- 项目、作品交付集、作品的回收站、归档恢复和永久删除规则 +- 批注/回复中的参考图片附件 +- 项目与作品的回收站、归档恢复和永久删除流程 - 已上传作品在所有阶段的图片重新排序 -- 在线人员状态、实时变更通知和并发版本冲突保护 -- HEIC/HEIF 转换、缩略图流水线和 EXIF 定位信息清理 -- 自动化端到端浏览器测试及真实腾讯云、PostgreSQL 部署演练 +- 在线人员状态、实时变更通知和并发冲突保护 +- HEIC/HEIF 转换、多尺寸缩略图和 EXIF 定位信息清理 +- 真实腾讯云、生产 PostgreSQL、HTTPS 和备份恢复演练 ## 上线门槛 -初版正式发布至少应满足: - -1. 使用 PostgreSQL 和独立生产 COS 桶,完成一次备份恢复演练。 -2. 轮换所有在聊天、截图或开发数据库中出现过的云密钥和临时密码。 +1. 使用 PostgreSQL 和独立生产 COS 桶,完成备份恢复演练。 +2. 轮换所有在聊天、截图或开发数据中出现过的云密钥和临时密码。 3. 在 HTTPS 域名下验证平台管理员、组管理员、光影叙事和客户四条核心流程。 -4. 根据真实交付承诺,从上方未完成清单中选定必须进入初版的项目。 +4. 根据真实交付承诺,从未完成清单中选定必须进入初版的项目。 diff --git a/docs/integration-guide.md b/docs/integration-guide.md index b005c52..53fff29 100644 --- a/docs/integration-guide.md +++ b/docs/integration-guide.md @@ -1,60 +1,32 @@ # API 接入指南 -## 认证方式 +## 认证 -工作台网页使用 HttpOnly Cookie 会话。外部客户端使用: +工作台使用 HttpOnly Cookie;外部客户端使用: ```http Authorization: Bearer dd_live_xxx ``` -API Key 明文只在创建时返回一次,数据库仅保存 SHA-256 哈希。平台管理员创建平台级 Key;组管理员创建本组项目级 Key。失效或越权请求会返回 `401` 或 `403`。 +平台级 Key 可跨组创建和查询项目。项目级 Key 只能操作绑定项目,包括在该项目中新建作品和验收轮次。密钥明文只在创建时返回一次。 -## 主要路由 - -| 路由组 | 用途 | -|---|---| -| `/api/auth/*` | 登录、退出、当前账号、修改密码 | -| `/api/management/groups` | 运营组创建、改名、启停和管理员更换 | -| `/api/management/users` | 账号创建、改名、启停和重置密码 | -| `/api/management/api-keys` | API Key 创建、查询和吊销 | -| `/api/management/audit-logs` | 审计日志查询 | -| `/api/management/storage-configs` | COS 配置、连接测试和启用 | -| `/api/projects` | 项目创建、查询和编辑 | -| `/api/projects/:projectId/collections` | 作品交付集创建、查询和编辑 | -| `/api/notes` | 作品查询与创建 | -| `/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/works/:noteId/decision` | 客户对指定候选稿作出验收决定 | -| `/api/health` | 数据库就绪检查 | - -## 查询运营组、项目、作品交付集和作品 - -调用方不需要预先知道数据库 ID。使用 API Key 按顺序查询: +## 发现资源 ```bash -# 返回 Key 有权访问的项目,响应包含 group_id、group_name 和项目 id +# 查询 Key 可访问的项目,响应包含 group_id、group_name 和项目 id curl http://localhost:3010/api/projects \ -H "Authorization: Bearer $DELIVERY_DESK_API_KEY" -# 查询项目中的作品交付集 -curl http://localhost:3010/api/projects/1/collections \ - -H "Authorization: Bearer $DELIVERY_DESK_API_KEY" - -# 查询交付集中的作品,响应包含作品 id、external_id 和 version_number -curl "http://localhost:3010/api/notes?collectionId=1" \ +# 查询项目作品 +curl http://localhost:3010/api/projects/1/works \ -H "Authorization: Bearer $DELIVERY_DESK_API_KEY" ``` -项目级 Key 的项目列表只会返回绑定项目;平台级 Key 可以查询全部运营组的项目。创建作品时只传 `collectionId`,服务会据此确定项目和运营组并校验权限,不需要重复传递 `projectId` 或 `groupId`。 +调用方不再需要作品交付集 ID。`externalId` 在项目内唯一,可用于安全重试和找回作品。 ## 创建项目 -平台级 API Key 可以指定目标运营组。项目级 Key 不能创建项目。 +只有平台级 Key 可以创建项目。 ```bash curl -X POST http://localhost:3010/api/projects \ @@ -63,120 +35,94 @@ curl -X POST http://localhost:3010/api/projects \ -d '{"name":"7 月内容计划","slug":"july-content","groupId":1,"client_description":"客户可见说明"}' ``` -`slug` 仅支持小写字母、数字和连字符,并作为客户验收链接的一部分。 +## 创建作品 -## 创建作品交付集 +JSON 请求中的 `images` 为 1–30 个公开 HTTP/HTTPS URL。服务只保存 URL,不下载也不转存 COS;数组顺序就是展示顺序,第一张为封面。 ```bash -curl -X POST http://localhost:3010/api/projects/1/collections \ - -H "Authorization: Bearer $DELIVERY_DESK_API_KEY" \ - -H "Content-Type: application/json" \ - -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。数组顺序就是展示顺序,第一张为封面。 - -```bash -curl -X POST http://localhost:3010/api/notes \ +curl -X POST http://localhost:3010/api/projects/1/works \ -H "Authorization: Bearer $DELIVERY_DESK_API_KEY" \ -H "Content-Type: application/json" \ -d '{ - "collectionId": 1, - "externalId": "client-work-20260721-001", - "title": "作品标题", - "description": "正文内容", - "tags": ["用户填写的标签原文"], - "images": [ - "https://cdn.example.com/works/01.jpg", - "https://cdn.example.com/works/02.jpg" - ] + "externalId":"client-work-20260722-001", + "title":"作品标题", + "description":"正文内容", + "tags":["#夏日","用户原文"], + "images":["https://cdn.example.com/01.jpg","https://cdn.example.com/02.jpg"] }' ``` -`externalId` 是调用方在当前作品交付集内的作品唯一标识,支持字母、数字、点、下划线、冒号和横线,最长 128 位。相同 `collectionId + externalId` 的重复请求不会重复创建作品,而会以 `200` 返回原作品并包含 `"idempotent": true`。创建成功响应中的 `id` 是后续上传版本所需的 `workId`;如果调用方丢失了该 ID,可以通过 `GET /api/notes?collectionId=1&externalId=client-work-20260721-001` 找回。 +相同 `projectId + externalId` 的重试不会重复创建,响应包含 `idempotent: true`。调用方负责保证外部图片 URL 长期公开可用。 -URL 图片不会进入当前配置的 COS,也不会由服务检查其内容或长期可用性,因此调用方需要保证链接公开、稳定且确实指向图片。工作台手动上传仍接受 JPEG、PNG、GIF、WebP 和 AVIF。标签按原文保存和展示,不会自动添加 `#` 或拆分为标签库。 +## 创建新验收轮次 -## 创建单候选稿验收轮次(兼容接口) +每轮只能提交一个方案。标题、正文、标签和图片会形成不可修改的轮次快照;新轮次自动锁定上一轮。 ```bash -curl -X POST http://localhost:3010/api/notes/12/versions \ +curl -X POST http://localhost:3010/api/works/12/rounds \ -H "Authorization: Bearer $DELIVERY_DESK_API_KEY" \ -H "Content-Type: application/json" \ -d '{ - "title": "修改后的标题", - "description": "修改后的正文", - "tags": ["修改后的标签原文"], - "images": [ - "https://cdn.example.com/works/v2-01.jpg", - "https://cdn.example.com/works/v2-02.jpg" - ] + "title":"修改后的标题", + "description":"修改后的正文", + "tags":["#第二轮"], + "images":["https://cdn.example.com/round-2.jpg"] }' ``` -该接口保留给只提交一个方案的现有调用方。批注绑定候选稿或具体图片,不会被新验收轮次覆盖。 +## 查询作品与全部反馈 -## 提交多候选稿验收轮次 +```bash +# 当前轮或指定轮 +curl "http://localhost:3010/api/works/12?round=2" \ + -H "Authorization: Bearer $DELIVERY_DESK_API_KEY" -`POST /api/notes/:noteId/review-rounds` 可在同一轮中提交 1–5 个候选稿。JSON 请求中每个候选稿使用公开图片 URL: +# 按轮返回该作品全部反馈和验收事件 +curl http://localhost:3010/api/works/12/annotations \ + -H "Authorization: Bearer $DELIVERY_DESK_API_KEY" +``` + +标题、正文和 Tag 选区批注使用: ```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"] - } - ] + "round_number": 2, + "target": "description", + "start_offset": 4, + "end_offset": 8, + "selected_text": "选中文字", + "content": "这里需要调整" } ``` -客户验收决定必须带上候选稿的 `version_number`。选中并通过某稿后,同轮其他稿自动标记为 `not_selected`,历史轮次变为只读。提交新轮次时,尚未结束的上一轮会自动关闭,其中仍在等待验收的候选稿会标记为 `not_selected`。旧的 `/versions` 接口继续可用,等价于创建只有一个候选稿的新轮次。决定接口使用客户登录后获得的 Cookie,不能使用工作台 API Key 代替。 +服务会校验偏移量和所选文字是否匹配当前轮次快照。历史轮次或已完成项目返回 `409`。 + +批注、文字批注和总体反馈都可回复,类型分别为 `image_annotation`、`text_annotation`、`comment`: + +```http +POST /api/works/:workId/feedback/:type/:feedbackId/replies +POST /api/works/:workId/feedback/:type/:feedbackId/withdraw +``` + +撤回只允许原作者执行,不会删除数据库记录。客户入口在路径前增加 `/api/review/:slug`,并执行相同的项目归属与身份校验。 + +## 客户验收 + +客户输入项目密码和姓名后使用 Cookie 调用: ```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" - }' + -d '{"round_number":2,"decision":"approved"}' ``` -## Python 冒烟脚本 +`decision` 为 `approved` 或 `changes_requested`;退修必须填写 `reason`。只允许决定活动轮次。 -项目自带 `tests/api_create_work.py`,只使用 Python 标准库。推荐通过环境变量提供项目级 API Key: +客户侧全部反馈接口为 `/api/review/:slug/works/:workId/annotations`,仍会校验客户会话绑定的项目。 -```powershell -$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 -``` +旧的 `/api/notes`、`/api/notes/:id/versions`、`/api/notes/:id/review-rounds` 与 `/api/projects/:id/collections` 暂保留一个兼容周期。旧交付集 URL 会跳转到项目页;旧多候选稿请求会返回 `400`,不会再创建多方案轮次。新接入必须使用项目、作品和轮次接口。 -如果项目级 Key 只能访问一个项目,并且项目下只有一个作品交付集,可以省略两个 ID。脚本也支持不传 Key、改用 `--username` 后交互输入密码。 - -## 错误响应 - -错误统一以 JSON 返回: - -```json -{ "error": "错误说明" } -``` - -常见状态码:`400` 输入无效、`401` 未认证、`403` 越权、`404` 资源不存在、`409` 唯一性或状态冲突、`500` 服务端错误。 +错误统一为 `{ "error": "错误说明" }`。常见状态码:`400` 输入无效、`401` 未认证、`403` 越权、`404` 不存在、`409` 状态冲突。 diff --git a/scripts/migrate-sqlite-to-postgres.ts b/scripts/migrate-sqlite-to-postgres.ts index ff4fbc2..7cf76dd 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_rounds', 'review_events', 'sessions', 'customer_sessions', + 'work_comments', 'feedback_replies', 'work_versions', 'review_rounds', 'review_events', 'sessions', 'customer_sessions', 'audit_logs', 'api_keys', 'storage_configs', ] as const; const booleanColumns: Record> = { @@ -31,6 +31,7 @@ try { 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'); + await client.query('DROP INDEX IF EXISTS idx_work_versions_one_per_round'); if (replace) await client.query(`TRUNCATE ${[...tables].reverse().map((table) => `"${table}"`).join(', ')} RESTART IDENTITY CASCADE`); for (const table of tables) { @@ -48,9 +49,13 @@ try { } 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]; + const singleSchemeRepair = schema.match(/-- SINGLE_SCHEME_REPAIR_START([\s\S]+?)-- SINGLE_SCHEME_REPAIR_END/)?.[1]; if (!collectionStatusRepair) throw new Error('PostgreSQL schema 缺少作品交付集状态修复脚本'); if (!reviewRoundRepair) throw new Error('PostgreSQL schema 缺少验收轮次修复脚本'); + if (!singleSchemeRepair) throw new Error('PostgreSQL schema 缺少单方案轮次修复脚本'); await client.query(reviewRoundRepair); + await client.query(singleSchemeRepair); + await client.query('CREATE UNIQUE INDEX idx_work_versions_one_per_round ON work_versions(review_round_id) WHERE review_round_id IS NOT NULL'); 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 919c693..e14d1ea 100644 --- a/scripts/test-postgres-runtime.ts +++ b/scripts/test-postgres-runtime.ts @@ -4,7 +4,7 @@ process.env.INITIAL_ADMIN_PASSWORD = 'AdminTest123!'; process.env.COS_CONFIG_ENCRYPTION_KEY = 'test-only-encryption-key-at-least-32-characters'; const { default: app } = await import('../api/app.js'); -const { closeDatabase } = await import('../api/database.js'); +const { database, closeDatabase } = await import('../api/database.js'); const server = app.listen(0, '127.0.0.1'); await new Promise((resolve) => server.once('listening', resolve)); const address = server.address(); @@ -79,6 +79,9 @@ try { const otherProject=await request('/api/projects',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({name:'同组隔离项目',slug:'isolated-project',client_description:'不应被项目 Key 看见',groupId})},adminCookie); expectStatus(otherProject.response.status,201,'创建同组隔离项目',otherProject.body); const otherProjectId=Number((otherProject.body as {id:number}).id); + const otherWork=await request(`/api/projects/${otherProjectId}/works`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({title:'其他项目作品',images:['https://cdn.example.com/isolated.jpg']})},adminCookie); + expectStatus(otherWork.response.status,201,'创建其他项目作品',otherWork.body); + const otherWorkId=Number((otherWork.body as {id:number}).id); const access = await request(`/api/projects/${projectId}/customer-access`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled: true, password: 'Review123!' }) }, adminCookie); expectStatus(access.response.status, 200, '配置客户访问'); @@ -98,6 +101,8 @@ try { if((visibleProjects.body as Array<{id:number}>).length!==1||Number((visibleProjects.body as Array<{id:number}>)[0].id)!==projectId)throw new Error('项目级 Key 未严格隔离到绑定项目'); const forbiddenProject=await request(`/api/projects/${otherProjectId}`,{headers:bearerHeaders}); expectStatus(forbiddenProject.response.status,403,'项目级 Key 拒绝访问其他项目',forbiddenProject.body); + const forbiddenFeedback=await request(`/api/works/${otherWorkId}/annotations`,{headers:bearerHeaders}); + expectStatus(forbiddenFeedback.response.status,403,'项目级 Key 拒绝读取其他项目作品反馈',forbiddenFeedback.body); const visibleCollections=await request(`/api/projects/${projectId}/collections`,{headers:bearerHeaders}); expectStatus(visibleCollections.response.status,200,'项目级 Key 查询作品交付集',visibleCollections.body); if(!(visibleCollections.body as Array<{id:number}>).some((item)=>Number(item.id)===collectionId))throw new Error('项目级 Key 未返回目标作品交付集'); @@ -108,6 +113,8 @@ try { 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 reviewingProject=await request(`/api/projects/${projectId}`,{headers:bearerHeaders}); + if((reviewingProject.body as {review_status:string}).review_status!=='reviewing')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 重复请求创建了不同作品'); @@ -119,8 +126,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 firstRound=(workDetail.body as {rounds:Array<{version_number:number;round_status:string;completion_reason:string}>}).rounds.find((item)=>item.version_number===1); + if(firstRound?.round_status!=='completed'||firstRound.completion_reason!=='superseded')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!' }) }); @@ -138,7 +145,9 @@ try { 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('作品详情未返回作品交付集完成状态'); + if((completedWorkDetail.body as {project:{review_status:string}}).project.review_status!=='completed')throw new Error('作品详情未返回项目验收完成状态'); + const completedProject=await request(`/api/projects/${projectId}`,{headers:bearerHeaders}); + if((completedProject.body as {review_status:string}).review_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); @@ -152,43 +161,61 @@ 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({version_number:3,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({round_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({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); + const closedRoundComment=await request(`/api/works/${workId}/comments`,{method:'POST',headers:{...bearerHeaders,'Content-Type':'application/json'},body:JSON.stringify({content:'退修轮次不应继续写入'})}); + expectStatus(closedRoundComment.response.status,409,'退修轮次禁止新增总体反馈',closedRoundComment.body); + const closedRoundDecision=await request(`/api/review/postgres-runtime-test/works/${workId}/decision`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({round_number:3,decision:'approved'})},reviewCookie); + expectStatus(closedRoundDecision.response.status,409,'退修轮次不可再次通过',closedRoundDecision.body); + const roundFour=await request(`/api/works/${workId}/rounds`,{method:'POST',headers:{...bearerHeaders,'Content-Type':'application/json'},body:JSON.stringify({title:'接口作品 V4',description:'第四轮',tags:['API 测试'],images:['https://cdn.example.com/runtime-v4.jpg']})}); + expectStatus(roundFour.response.status,201,'创建单方案第 4 轮',roundFour.body); + const multiRound=await request(`/api/notes/${workId}/review-rounds`,{method:'POST',headers:{...bearerHeaders,'Content-Type':'application/json'},body:JSON.stringify({candidates:[{title:'方案 A',images:['https://cdn.example.com/a.jpg']},{title:'方案 B',images:['https://cdn.example.com/b.jpg']}]})}); + expectStatus(multiRound.response.status,400,'拒绝一轮多个方案',multiRound.body); + const historicalClientAnnotation=await request(`/api/review/postgres-runtime-test/works/${workId}/text-annotations`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({round_number:3,target:'title',start_offset:0,end_offset:4,selected_text:'接口作品',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']})}); + const mismatchedSelection=await request(`/api/works/${workId}/text-annotations`,{method:'POST',headers:{...bearerHeaders,'Content-Type':'application/json'},body:JSON.stringify({round_number:4,target:'title',start_offset:0,end_offset:4,selected_text:'错误文字',content:'不应写入'})}); + expectStatus(mismatchedSelection.response.status,400,'拒绝与内容快照不匹配的文字选区',mismatchedSelection.body); + const textAnnotation=await request(`/api/works/${workId}/text-annotations`,{method:'POST',headers:{...bearerHeaders,'Content-Type':'application/json'},body:JSON.stringify({round_number:4,target:'title',start_offset:0,end_offset:4,selected_text:'接口作品',content:'标题选区批注'})}); + expectStatus(textAnnotation.response.status,201,'创建标题选区批注',textAnnotation.body); + const textAnnotationId=Number((textAnnotation.body as {id:number}).id); + const tagAnnotation=await request(`/api/review/postgres-runtime-test/works/${workId}/text-annotations`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({round_number:4,target:'tags',start_offset:0,end_offset:6,selected_text:'API 测试',content:'Tag 选区批注'})},reviewCookie); + expectStatus(tagAnnotation.response.status,201,'客户创建 Tag 选区批注',tagAnnotation.body); + const feedbackReply=await request(`/api/works/${workId}/feedback/text_annotation/${textAnnotationId}/replies`,{method:'POST',headers:{...bearerHeaders,'Content-Type':'application/json'},body:JSON.stringify({content:'已记录这条意见'})}); + expectStatus(feedbackReply.response.status,201,'回复文字批注',feedbackReply.body); + const feedback=await request(`/api/works/${workId}/annotations`,{headers:bearerHeaders}); + expectStatus(feedback.response.status,200,'按作品 ID 获取全部反馈',feedback.body); + const roundFeedback=(feedback.body as {rounds:Array<{round_number:number;text_annotations:Array;feedback_replies:Array}>}).rounds.find((item)=>item.round_number===4); + if(roundFeedback?.text_annotations.length!==2||roundFeedback.feedback_replies.length!==1)throw new Error('作品反馈聚合未返回标题、Tag 批注及其回复'); + const optimizationContext=await request(`/api/works/${workId}/optimization-context?round=4`,{headers:bearerHeaders}); + expectStatus(optimizationContext.response.status,200,'获取内容优化上下文',optimizationContext.body); + const optimizationBody=optimizationContext.body as {content:{title:string;tags:string[];images:Array<{url:string}>};feedback:{text_annotations:Array<{target:string;replies:Array}>}}; + if(optimizationBody.content.title!=='接口作品 V4'||optimizationBody.content.tags[0]!=='API 测试'||optimizationBody.content.images[0]?.url!=='https://cdn.example.com/runtime-v4.jpg')throw new Error('内容优化上下文缺少当前轮次图文快照'); + if(optimizationBody.feedback.text_annotations.length!==2||optimizationBody.feedback.text_annotations.find((item)=>item.target==='title')?.replies.length!==1)throw new Error('内容优化上下文未组合有效批注与回复'); + const withdrawText=await request(`/api/works/${workId}/feedback/text_annotation/${textAnnotationId}/withdraw`,{method:'POST',headers:bearerHeaders}); + expectStatus(withdrawText.response.status,200,'本人留痕撤回文字批注',withdrawText.body); + const afterWithdraw=await request(`/api/works/${workId}/annotations`,{headers:bearerHeaders}); + const withdrawnItem=(afterWithdraw.body as {rounds:Array<{round_number:number;text_annotations:Array<{id:number;withdrawn_at:string|null}>}>}).rounds.find((item)=>item.round_number===4)?.text_annotations.find((item)=>item.id===textAnnotationId); + if(!withdrawnItem?.withdrawn_at)throw new Error('撤回批注未在作品聚合接口中保留记录'); + const actionableContext=await request(`/api/works/${workId}/optimization-context?round=4`,{headers:bearerHeaders}); + if((actionableContext.body as {feedback:{text_annotations:Array<{id:number}>}}).feedback.text_annotations.some((item)=>item.id===textAnnotationId))throw new Error('内容优化上下文默认返回了已撤回批注'); + const historyContext=await request(`/api/works/${workId}/optimization-context?round=4&include_history=true`,{headers:bearerHeaders}); + if(!(historyContext.body as {feedback:{text_annotations:Array<{id:number}>}}).feedback.text_annotations.some((item)=>item.id===textAnnotationId))throw new Error('内容优化上下文无法按需返回历史批注'); + await database.execute("UPDATE projects SET status='closed' WHERE id=?",[projectId]); + const closedProjectRound=await request(`/api/works/${workId}/rounds`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({title:'不应创建',images:['https://cdn.example.com/closed.jpg']})},adminCookie); + expectStatus(closedProjectRound.response.status,409,'已关闭项目禁止创建新轮次',closedProjectRound.body); + const closedProjectDecision=await request(`/api/review/postgres-runtime-test/works/${workId}/decision`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({round_number:4,decision:'approved'})},reviewCookie); + expectStatus(closedProjectDecision.response.status,409,'已关闭项目禁止客户验收写入',closedProjectDecision.body); + await database.execute("UPDATE projects SET status='active' WHERE id=?",[projectId]); + const approveRoundFour=await request(`/api/review/postgres-runtime-test/works/${workId}/decision`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({round_number:4,decision:'approved'})},reviewCookie); + expectStatus(approveRoundFour.response.status,200,'客户通过第 4 轮',approveRoundFour.body); + const secondWork=await request(`/api/projects/${projectId}/works`,{method:'POST',headers:{...bearerHeaders,'Content-Type':'application/json'},body:JSON.stringify({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 reopenedProject=await request(`/api/projects/${projectId}`,{headers:bearerHeaders}); + if((reopenedProject.body as {review_status:string}).review_status!=='reviewing')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({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'})}); @@ -223,7 +250,7 @@ try { 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-review-rounds.ts b/scripts/test-sqlite-review-rounds.ts index 8d38442..0792cd3 100644 --- a/scripts/test-sqlite-review-rounds.ts +++ b/scripts/test-sqlite-review-rounds.ts @@ -1,44 +1,67 @@ process.env.NODE_ENV = 'test'; delete process.env.DATABASE_URL; -const { db } = await import('../api/db.js'); +const { db, repairMultiSchemeRounds } = await import('../api/db.js'); const { database, closeDatabase } = await import('../api/database.js'); -const { decideCandidateInTransaction, ReviewDecisionError } = await import('../api/services/reviewService.js'); +const { decideRoundInTransaction, ReviewDecisionError } = await import('../api/services/reviewService.js'); +const { addFeedbackReply, findFeedbackTarget, withdrawFeedback } = await import('../api/services/feedbackService.js'); -function assert(condition: unknown, message: string): asserts condition { - if (!condition) throw new Error(message); -} +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']); + const group = await database.one<{ id: number }>('SELECT id FROM operation_groups ORDER BY id LIMIT 1'); + assert(group, 'SQLite 测试需要至少一个运营组'); + const project = { id: await database.insertId("INSERT INTO projects (group_id,name,slug) VALUES (?,?,?)", [group.id, '单轮验收测试', `round-test-${Date.now()}`]) }; + 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 (project_id,collection_id,title,review_status) VALUES (?,?,?,'pending')", [project.id, collectionId, '第 1 轮']); + const round1 = 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=?', [round1, 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, '第 1 轮', round1]); - 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', '仍有待验收候选稿时作品应保持待验收'); + db.exec('DROP INDEX idx_work_versions_one_per_round'); + await database.execute("INSERT INTO work_versions (note_id,version_number,title,review_status,review_round_id) VALUES (?,99,?,'pending',?)", [noteId, '旧多方案数据', round1]); + repairMultiSchemeRounds(); + const migrated = await database.one<{ review_round_id: number; round_status: string }>('SELECT v.review_round_id,r.status AS round_status FROM work_versions v JOIN review_rounds r ON r.id=v.review_round_id WHERE v.note_id=? AND v.version_number=99', [noteId]); + assert(migrated && Number(migrated.review_round_id) !== Number(round1) && migrated.round_status === 'completed', '旧多方案轮次未拆分为单方案历史轮次'); + await database.execute('DELETE FROM work_versions WHERE note_id=? AND version_number=99', [noteId]); + await database.execute('DELETE FROM review_rounds WHERE id=?', [migrated.review_round_id]); + db.exec('CREATE UNIQUE INDEX idx_work_versions_one_per_round ON work_versions(review_round_id) WHERE review_round_id IS NOT NULL'); - 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 duplicateRejected = false; + try { await database.execute("INSERT INTO work_versions (note_id,version_number,title,review_status,review_round_id) VALUES (?,2,?,'pending',?)", [noteId, '非法第二方案', round1]); } + catch { duplicateRejected = true; } + assert(duplicateRejected, '同一验收轮次必须拒绝第二个方案'); + + await decideRoundInTransaction(database, { noteId, versionNumber: 1, projectId: project.id, decision: 'changes_requested', reason: '需要调整', actorName: '测试客户', actorRole: 'client' }); + assert((await database.one<{ review_status: string }>('SELECT review_status FROM notes WHERE id=?', [noteId]))?.review_status === 'changes_requested', '要求修改后作品状态错误'); + assert((await database.one<{ completion_reason: string }>('SELECT completion_reason FROM review_rounds WHERE id=?', [round1]))?.completion_reason === 'changes_requested', '退修轮次未完成'); + + const round2 = await database.insertId("INSERT INTO review_rounds (note_id,round_number,status) VALUES (?,2,'reviewing')", [noteId]); + 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, '第 2 轮', round2]); + await database.execute("UPDATE notes SET active_round_id=?,version_number=2,title=?,review_status='pending' WHERE id=?", [round2, '第 2 轮', noteId]); + const imageId = await database.insertId("INSERT INTO images (note_id,url,version_number) VALUES (?, '/test.jpg', 2)", [noteId]); + const annotationId = await database.insertId("INSERT INTO annotations (image_id,x,y,content,author_name,author_role) VALUES (?,.5,.5,'待处理','测试运营','operator')", [imageId]); + const commentId = await database.insertId("INSERT INTO work_comments (note_id,version_number,content) VALUES (?,2,'总体意见')", [noteId]); + + const feedbackTarget = await findFeedbackTarget(noteId, 'image_annotation', annotationId); + assert(feedbackTarget, '无法按作品找到图片批注'); + await addFeedbackReply(feedbackTarget, 'image_annotation', annotationId, '已收到,正在处理', '测试客户', 'client'); + assert((await database.one<{ count: number }>('SELECT COUNT(*) AS count FROM feedback_replies WHERE note_id=? AND feedback_id=?', [noteId, annotationId]))?.count === 1, '批注回复未保存'); + await withdrawFeedback('image_annotation', annotationId); + assert(Boolean((await database.one<{ withdrawn_at: string | null }>('SELECT withdrawn_at FROM annotations WHERE id=?', [annotationId]))?.withdrawn_at), '撤回没有保留时间记录'); + + await decideRoundInTransaction(database, { noteId, versionNumber: 2, projectId: project.id, decision: 'approved', reason: '', actorName: '测试客户', actorRole: 'client' }); + const note = await database.one<{ version_number: number; review_status: string }>('SELECT version_number,review_status FROM notes WHERE id=?', [noteId]); + assert(note?.version_number === 2 && note.review_status === 'approved', '第 2 轮通过后作品状态错误'); + assert((await database.one<{ review_status: string }>('SELECT review_status FROM projects WHERE id=?', [project.id]))?.review_status === 'completed', '全部作品通过后项目未完成'); + assert((await database.one<{ closure_reason: string }>('SELECT closure_reason FROM work_comments WHERE id=?', [commentId]))?.closure_reason === 'approved_with_round', '未处理反馈没有随轮关闭'); 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'); + try { await decideRoundInTransaction(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 034dede..4603791 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_rounds', 'review_events', 'sessions', 'customer_sessions', + 'work_comments', 'feedback_replies', 'work_versions', 'review_rounds', 'review_events', 'sessions', 'customer_sessions', 'audit_logs', 'api_keys', 'storage_configs', ] as const; const booleanColumns: Record> = { @@ -19,11 +19,16 @@ const sqlite = new Database(path.resolve('data/app.db'), { readonly: true }); try { await client.connect(); - const schema = fs.readFileSync(path.resolve('db/postgres/schema.sql'), 'utf8') + const schemaSource = fs.readFileSync(path.resolve('db/postgres/schema.sql'), 'utf8'); + if (!/-- SINGLE_SCHEME_REPAIR_START[\s\S]+?-- SINGLE_SCHEME_REPAIR_END/.test(schemaSource)) throw new Error('PostgreSQL schema 缺少旧多方案数据修复脚本'); + const schema = schemaSource .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(/-- REVIEW_ROUND_REPAIR_START[\s\S]+?-- REVIEW_ROUND_REPAIR_END/, ''); + .replace(/-- REVIEW_ROUND_REPAIR_START[\s\S]+?-- REVIEW_ROUND_REPAIR_END/, '') + .replace(/-- PROJECT_REVIEW_STATUS_REPAIR_START[\s\S]+?-- PROJECT_REVIEW_STATUS_REPAIR_END/, '') + .replace(/-- TEXT_ANNOTATION_TARGET_REPAIR_START[\s\S]+?-- TEXT_ANNOTATION_TARGET_REPAIR_END/, '') + .replace(/-- SINGLE_SCHEME_REPAIR_START[\s\S]+?-- SINGLE_SCHEME_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); @@ -41,7 +46,8 @@ try { 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 invalidProjects = Number((await client.query('SELECT COUNT(*)::int AS count FROM notes n LEFT JOIN projects p ON p.id=n.project_id WHERE p.id IS NULL')).rows[0].count); + if (invalidRoundLinks || invalidActiveRounds || invalidProjects) throw new Error(`迁移关联无效:rounds=${invalidRoundLinks}, notes=${invalidActiveRounds}, projects=${invalidProjects}`); 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 1951154..b4f1dcf 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -1,7 +1,7 @@ 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 ProjectReviewStatus = 'draft' | 'reviewing' | 'completed' | 'archived'; export type UserRole = 'platform_admin' | 'group_admin' | 'operator'; export interface CurrentUser { @@ -96,11 +96,16 @@ export interface Project { slug: string; client_description: string; status: 'active' | 'closed' | 'archived'; + review_status: ProjectReviewStatus; + review_completed_at: string | null; customer_access_enabled: boolean; access_expires_at: string | null; has_access_password: boolean; collection_count: number; work_count: number; + pending_count: number; + changes_requested_count: number; + approved_count: number; created_at: string; } @@ -127,6 +132,7 @@ export interface WorkCollection { export interface Note { id: number; + project_id: number; collection_id: number; external_id: string | null; title: string; @@ -161,17 +167,23 @@ export interface Annotation { y: number; content: string; author_name: string; + author_role: 'client' | 'operator'; status: 'open' | 'resolved' | 'confirmed'; + closure_reason: string; + withdrawn_at: string | null; created_at: string; } export interface WorkComment { id: number; note_id: number; + version_number: number; content: string; author_name: string; author_role: 'client' | 'operator'; status: 'open' | 'resolved' | 'confirmed'; + closure_reason: string; + withdrawn_at: string | null; created_at: string; } @@ -179,10 +191,33 @@ export interface TextAnnotation { id: number; note_id: number; version_number: number; - target: 'title' | 'description'; + target: 'title' | 'description' | 'tags'; + start_offset: number; + end_offset: number; + selected_text: string; + prefix_text: string; + suffix_text: string; content: string; author_name: string; + author_role: 'client' | 'operator'; status: 'open' | 'resolved' | 'confirmed'; + closure_reason: string; + withdrawn_at: string | null; + created_at: string; +} + +export type FeedbackType = 'image_annotation' | 'text_annotation' | 'comment'; + +export interface FeedbackReply { + id: number; + note_id: number; + version_number: number; + feedback_type: FeedbackType; + feedback_id: number; + content: string; + author_name: string; + author_role: 'client' | 'operator'; + withdrawn_at: string | null; created_at: string; } @@ -194,20 +229,18 @@ export interface NoteDetail extends Note { images: ImageWithAnnotations[]; text_annotations: TextAnnotation[]; comments: WorkComment[]; - project: Pick; - collection: Pick; - versions: WorkVersion[]; + feedback_replies: FeedbackReply[]; + project: Pick; + rounds: WorkRound[]; review_events: ReviewEvent[]; } -export interface WorkVersion { +export interface WorkRound { version_number: number; review_round_id: number; round_number: number; - candidate_name: string; - candidate_status: CandidateStatus; round_status: ReviewRoundStatus; - selected_version_number: number | null; + completion_reason: string; title: string; description: string; tags: string[]; @@ -222,6 +255,7 @@ export interface ReviewRound { status: ReviewRoundStatus; selected_version_number: number | null; completed_at: string | null; + completion_reason: string; created_at: string; } @@ -242,6 +276,20 @@ export interface CreateAnnotationRequest { y: number; content: string; author_name?: string; + author_role?: 'client' | 'operator'; +} + +export interface WorkFeedbackBundle { + work_id: number; + rounds: Array<{ + round_number: number; + version_number: number; + image_annotations: Array; + text_annotations: TextAnnotation[]; + comments: WorkComment[]; + feedback_replies: FeedbackReply[]; + review_events: ReviewEvent[]; + }>; } export interface NoteListQuery { diff --git a/src/App.tsx b/src/App.tsx index f81e5f0..2ff1277 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -39,6 +39,7 @@ function AppContent() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/api/client.ts b/src/api/client.ts index 20a00ee..474143a 100644 --- a/src/api/client.ts +++ b/src/api/client.ts @@ -1,4 +1,4 @@ -import type { Annotation, AuditLogEntry, CurrentUser, CustomerAccessState, ManagedApiKey, ManagedUser, Note, NoteDetail, NoteListQuery, OperationGroup, Project, ReviewStatus, StorageConfig, TextAnnotation, UserRole, WorkCollection, WorkComment } from '@shared/types'; +import type { Annotation, AuditLogEntry, CurrentUser, CustomerAccessState, FeedbackReply, FeedbackType, ManagedApiKey, ManagedUser, Note, NoteDetail, NoteListQuery, OperationGroup, Project, ReviewStatus, StorageConfig, TextAnnotation, UserRole, WorkComment, WorkFeedbackBundle } from '@shared/types'; export class ApiError extends Error { constructor(public status: number, message: string) { super(message); this.name = 'ApiError'; } @@ -44,49 +44,47 @@ export const api = { createProject: (data: { name: string; slug: string; client_description: string; groupId?: number }) => request('/api/projects', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }), updateProject: (id: number, data: { name: string; client_description: string }) => request(`/api/projects/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }), updateCustomerAccess: (id: number, data: { enabled: boolean; password?: string; expires_at?: string | null }) => request(`/api/projects/${id}/customer-access`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }), - listCollections: (projectId: number) => request(`/api/projects/${projectId}/collections`), - createCollection: (projectId: number, data: { name: string; client_description: string }) => request(`/api/projects/${projectId}/collections`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }), - updateCollection: (projectId: number, id: number, data: { name: string; client_description: string }) => request(`/api/projects/${projectId}/collections/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }), listNotes: (query: NoteListQuery = {}) => { const params = new URLSearchParams(); Object.entries(query).forEach(([key, value]) => value != null && params.set(key, String(value))); return request(`/api/notes?${params}`); }, - getNote: (id: number, version?: number) => request(`/api/notes/${id}${version ? `?version=${version}` : ''}`), - createNote: (payload: { collectionId: number; title: string; description: string; tags: string[]; images: File[] }) => { - const form = new FormData(); - form.append('collectionId', String(payload.collectionId)); - form.append('title', payload.title); - form.append('description', payload.description); - form.append('tags', payload.tags.join(',')); - payload.images.forEach((file) => form.append('images', file)); - return request('/api/notes', { method: 'POST', body: form }); + listProjectWorks: (projectId: number, query: NoteListQuery = {}) => { + const params = new URLSearchParams(); + Object.entries(query).forEach(([key, value]) => value != null && params.set(key, String(value))); + return request(`/api/projects/${projectId}/works?${params}`); }, - createWorkVersion: (noteId: number, payload: { title: string; description: string; tags: string[]; images: File[] }) => { + getWork: (id: number, round?: number) => request(`/api/works/${id}${round ? `?round=${round}` : ''}`), + getWorkFeedback: (id: number) => request(`/api/works/${id}/annotations`), + createWork: (projectId: number, payload: { title: string; description: string; tags: string[]; images: File[] }) => { const form = new FormData(); form.append('title', payload.title); form.append('description', payload.description); form.append('tags', payload.tags.join(',')); payload.images.forEach((file) => form.append('images', file)); - return request(`/api/notes/${noteId}/versions`, { method: 'POST', body: form }); + return request(`/api/projects/${projectId}/works`, { method: 'POST', body: form }); }, - createReviewRound: (noteId: number, candidates: Array<{ candidate_name: string; title: string; description: string; tags: string[]; images: File[] }>) => { + createRound: (workId: number, payload: { 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 }); + form.append('title', payload.title); form.append('description', payload.description); form.append('tags', payload.tags.join(',')); + payload.images.forEach((file) => form.append('images', file)); + return request(`/api/works/${workId}/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) }), - addTextAnnotation: (noteId: number, data: { version_number: number; target: 'title' | 'description'; content: string }) => request(`/api/notes/${noteId}/text-annotations`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }), + addTextSelectionAnnotation: (workId: number, data: { round_number: number; target: 'title' | 'description' | 'tags'; start_offset: number; end_offset: number; selected_text: string; content: string }) => request(`/api/works/${workId}/text-annotations`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }), + replyToFeedback: (workId: number, type: FeedbackType, feedbackId: number, content: string) => request(`/api/works/${workId}/feedback/${type}/${feedbackId}/replies`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content }) }), + withdrawFeedback: (workId: number, type: FeedbackType, feedbackId: number) => request<{ success: true }>(`/api/works/${workId}/feedback/${type}/${feedbackId}/withdraw`, { method: 'POST' }), deleteAnnotation: (id: number) => request(`/api/annotations/${id}`, { method: 'DELETE' }), addComment: (noteId: number, data: { content: string; author_name: string; author_role?: 'client' | 'operator' }) => request(`/api/notes/${noteId}/comments`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }), getCustomerAccess: (slug: string) => request(`/api/review/${slug}/access`), customerLogin: (slug: string, data: { reviewer_name: string; password: string }) => request<{ success: true; reviewer_name: string }>(`/api/review/${slug}/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }), - getCustomerProject: (slug: string) => request<{ project: Pick; collections: WorkCollection[]; reviewer_name: string }>(`/api/review/${slug}/project`), - getCustomerCollection: (slug: string, collectionId: number) => request<{ collection: WorkCollection; works: Note[] }>(`/api/review/${slug}/collections/${collectionId}/works`), - getCustomerWork: (slug: string, noteId: number, version?: number) => request(`/api/review/${slug}/works/${noteId}${version ? `?version=${version}` : ''}`), + getCustomerProject: (slug: string) => request<{ project: Pick; works: Note[]; reviewer_name: string }>(`/api/review/${slug}/project`), + getCustomerWorkRound: (slug: string, workId: number, round?: number) => request(`/api/review/${slug}/works/${workId}${round ? `?round=${round}` : ''}`), + getCustomerWorkFeedback: (slug: string, workId: number) => request(`/api/review/${slug}/works/${workId}/annotations`), 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, 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 }) }), + addCustomerTextSelectionAnnotation: (slug: string, workId: number, data: { round_number: number; target: 'title' | 'description' | 'tags'; start_offset: number; end_offset: number; selected_text: string; content: string }) => request(`/api/review/${slug}/works/${workId}/text-annotations`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data) }), + replyToCustomerFeedback: (slug: string, workId: number, type: FeedbackType, feedbackId: number, content: string) => request(`/api/review/${slug}/works/${workId}/feedback/${type}/${feedbackId}/replies`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ content }) }), + withdrawCustomerFeedback: (slug: string, workId: number, type: FeedbackType, feedbackId: number) => request<{ success: true }>(`/api/review/${slug}/works/${workId}/feedback/${type}/${feedbackId}/withdraw`, { method: 'POST' }), + submitCustomerRoundDecision: (slug: string, workId: number, roundNumber: number, decision: 'approved' | 'changes_requested', reason?: string) => request<{ success: true; status: ReviewStatus; version_number: number }>(`/api/review/${slug}/works/${workId}/decision`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ round_number: roundNumber, decision, reason }) }), }; diff --git a/src/components/AnnotatableImage.tsx b/src/components/AnnotatableImage.tsx index ce122fa..df2a575 100644 --- a/src/components/AnnotatableImage.tsx +++ b/src/components/AnnotatableImage.tsx @@ -1,13 +1,14 @@ -import { useRef, useState } from 'react'; +import { useEffect, 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,readOnly=false}:{image:NoteImage;annotations:Annotation[];onAdd:(x:number,y:number,text:string)=>Promise;readOnly?:boolean}){ +export default function AnnotatableImage({image,annotations,onAdd,readOnly=false,onOpen,onAnnotationOpen,initialAnnotationId}:{image:NoteImage;annotations:Annotation[];onAdd:(x:number,y:number,text:string)=>Promise;readOnly?:boolean;onOpen?:(annotationId?:number)=>void;onAnnotationOpen?:(annotationId:number)=>void;initialAnnotationId?:number}){ const ref=useRef(null);const [point,setPoint]=useState<{x:number;y:number}|null>(null);const [selectedId,setSelectedId]=useState(null);const [text,setText]=useState(''); + useEffect(()=>setSelectedId(initialAnnotationId??null),[initialAnnotationId,image.id]); 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}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 click=(e:React.MouseEvent)=>{if((e.target as HTMLElement).closest('button,textarea'))return;if(onOpen){onOpen();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)=>)} - {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()}>
添加图片批注