feat(review): 重构项目级单方案验收协作
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
105
api/db.ts
105
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<string, unknown> | 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;
|
||||
|
||||
@@ -6,11 +6,11 @@ function toAnnotation(row: AnnotationRow): Annotation { return { ...row, id: Num
|
||||
|
||||
export const annotationsRepository = {
|
||||
async listByImage(imageId: number): Promise<Annotation[]> {
|
||||
return (await database.all<AnnotationRow>('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<AnnotationRow>('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<Annotation> {
|
||||
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<AnnotationRow>('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<AnnotationRow>('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<boolean> { return (await database.execute('DELETE FROM annotations WHERE id = ?', [id])).changes > 0; },
|
||||
};
|
||||
|
||||
@@ -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<NoteRow>(`${select} WHERE n.collection_id = ? AND n.external_id = ?`, [collectionId, externalId]);
|
||||
return row ? toNote(row) : null;
|
||||
},
|
||||
async findByProjectExternalId(projectId: number, externalId: string): Promise<Note | null> {
|
||||
const row = await database.one<NoteRow>(`${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<number> {
|
||||
return database.insertId('INSERT INTO notes (title, description, collection_id, tags, review_status) VALUES (?, ?, ?, ?, ?)', [title, description, collectionId, JSON.stringify(tags), 'pending']);
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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<WorkComment>('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<WorkComment>('SELECT * FROM work_comments WHERE id = ?', [id]));
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@ import { database } from '../database.js';
|
||||
const router = Router();
|
||||
|
||||
async function imageProjectId(imageId: number): Promise<number | undefined> {
|
||||
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); }
|
||||
});
|
||||
|
||||
|
||||
@@ -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<TextAnnotation>('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: '笔记不存在' });
|
||||
|
||||
72
api/routes/projectWorks.ts
Normal file
72
api/routes/projectWorks.ts
Normal file
@@ -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<string, string | undefined>;
|
||||
res.json(await notesService.list({ projectId, sort, order, q, status: status as Parameters<typeof notesService.list>[0]['status'], tag, externalId }));
|
||||
});
|
||||
|
||||
router.post('/', requireWriter, upload.array('images', 30), async (req: AuthRequest, res: Response, next: NextFunction) => {
|
||||
const files = (req.files as Express.Multer.File[] | undefined) ?? [];
|
||||
const cleanup = () => files.forEach((file) => { try { if (fs.existsSync(file.path)) fs.unlinkSync(file.path); } catch { /* noop */ } });
|
||||
try {
|
||||
const projectId = Number(req.params.projectId);
|
||||
if (!Number.isInteger(projectId) || !await canWriteProject(req, projectId)) { cleanup(); res.status(403).json({ error: '无权向该项目上传作品' }); return; }
|
||||
const project = await database.one<{ status: string }>('SELECT status FROM projects WHERE id = ?', [projectId]);
|
||||
if (!project) { cleanup(); res.status(404).json({ error: '项目不存在' }); return; }
|
||||
if (project.status !== 'active') { cleanup(); res.status(409).json({ error: '已关闭或归档项目为只读状态' }); return; }
|
||||
const title = String(req.body?.title ?? '').trim();
|
||||
const description = String(req.body?.description ?? '').trim();
|
||||
const tags = parseTags(req.body?.tags);
|
||||
const externalId = String(req.body?.externalId ?? req.body?.external_id ?? '').trim() || null;
|
||||
const imageUrls = parseImageUrls(req.body?.images);
|
||||
if (!title) { cleanup(); res.status(400).json({ error: '标题不能为空' }); return; }
|
||||
if (externalId && (externalId.length > 128 || !/^[A-Za-z0-9._:-]+$/.test(externalId))) { cleanup(); res.status(400).json({ error: 'externalId 格式无效' }); return; }
|
||||
if (imageUrls === null || (!files.length && !imageUrls.length)) { cleanup(); res.status(400).json({ error: '请提供 1–30 张上传图片或公开图片 URL' }); return; }
|
||||
if (externalId) {
|
||||
const existing = await notesService.findByProjectExternalId(projectId, externalId);
|
||||
if (existing) { cleanup(); res.json({ ...existing, idempotent: true }); return; }
|
||||
}
|
||||
let work;
|
||||
try {
|
||||
work = files.length
|
||||
? await notesService.createInProject(projectId, title, description, files.map((file) => ({ filename: file.filename, originalname: file.originalname, mimetype: file.mimetype, path: file.path })), tags, externalId)
|
||||
: await notesService.createInProjectFromUrls(projectId, title, description, imageUrls, tags, externalId);
|
||||
} catch (error) {
|
||||
const existing = externalId ? await notesService.findByProjectExternalId(projectId, externalId) : null;
|
||||
if (existing) { cleanup(); res.json({ ...existing, idempotent: true }); return; }
|
||||
throw error;
|
||||
}
|
||||
await audit(req, 'work.create', 'work', work.id, { projectId, imageCount: files.length || imageUrls.length, imageSource: files.length ? 'upload' : 'external_url' });
|
||||
res.status(201).json(work);
|
||||
} catch (error) { cleanup(); next(error); }
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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<ProjectAccess>(`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<ProjectAccess>(
|
||||
'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<Record<string,unknown>>(`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<Record<string,unknown>>(`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<WorkComment>('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<TextAnnotation>('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<WorkComment>('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<TextAnnotation>('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;
|
||||
|
||||
182
api/routes/works.ts
Normal file
182
api/routes/works.ts
Normal file
@@ -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<number | undefined> {
|
||||
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 = <T extends { id: number }>(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<TextAnnotation>('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<WorkComment>('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;
|
||||
30
api/services/feedbackService.ts
Normal file
30
api/services/feedbackService.ts
Normal file
@@ -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<FeedbackTarget | undefined> {
|
||||
if (type === 'image_annotation') {
|
||||
return database.one<FeedbackTarget>(`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<FeedbackTarget>(`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<FeedbackReply> {
|
||||
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<FeedbackReply>('SELECT * FROM feedback_replies WHERE id=?', [id]))!;
|
||||
}
|
||||
|
||||
export async function withdrawFeedback(type: FeedbackType, feedbackId: number): Promise<void> {
|
||||
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]);
|
||||
}
|
||||
@@ -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<PreparedCandidate[]> {
|
||||
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<PreparedRound> {
|
||||
return { title: round.title, description: round.description, tags: round.tags, images: await prepareFiles(round.files) };
|
||||
}
|
||||
|
||||
function mapRound(row: Omit<WorkRound, 'tags'> & { 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<NoteDetail | null> {
|
||||
async getDetail(id: number, requestedVersion?: number, requestedRound?: number): Promise<NoteDetail | null> {
|
||||
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<Array<Omit<NoteDetail['versions'][number], 'tags'> & { 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<Array<Omit<WorkRound, 'tags'> & { 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<NoteDetail['text_annotations'][number]>('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<NoteDetail['comments'][number]>('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<NoteDetail['review_events'][number]>('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<NoteDetail['text_annotations'][number]>('SELECT * FROM text_annotations WHERE note_id = ? AND version_number = ? ORDER BY id ASC', [id, note.version_number]),
|
||||
comments: await database.all<NoteDetail['comments'][number]>('SELECT * FROM work_comments WHERE note_id = ? AND version_number = ? ORDER BY id ASC', [id, note.version_number]),
|
||||
feedback_replies: await database.all<NoteDetail['feedback_replies'][number]>('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<NoteDetail['review_events'][number]>('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<Note> {
|
||||
async createInProject(projectId: number, title: string, description: string, files: UploadedFile[], tags: string[], externalId: string | null = null): Promise<Note> {
|
||||
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<Note> {
|
||||
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<Note> {
|
||||
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<Note> {
|
||||
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<Note | null> {
|
||||
return notesRepository.findByExternalId(collectionId, externalId);
|
||||
},
|
||||
async findByExternalId(collectionId: number, externalId: string): Promise<Note | null> { return notesRepository.findByExternalId(collectionId, externalId); },
|
||||
async findByProjectExternalId(projectId: number, externalId: string): Promise<Note | null> { return notesRepository.findByProjectExternalId(projectId, externalId); },
|
||||
|
||||
async createReviewRound(id: number, candidates: UploadCandidate[], createdBy?: number): Promise<Note> {
|
||||
async createRound(id: number, round: UploadRound, createdBy?: number): Promise<Note> {
|
||||
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<Note> {
|
||||
async createRoundFromUrls(id: number, round: UrlRound, createdBy?: number): Promise<Note> {
|
||||
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<Note> {
|
||||
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<Note> {
|
||||
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<WorkFeedbackBundle | null> {
|
||||
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<Array<WorkFeedbackBundle['rounds'][number]['image_annotations'][number] & { version_number: number }>[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<Array<NoteDetail['text_annotations'][number] & { version_number: number }>[number]>('SELECT * FROM text_annotations WHERE note_id = ? ORDER BY id', [id]);
|
||||
const comments = await database.all<NoteDetail['comments'][number]>('SELECT * FROM work_comments WHERE note_id = ? ORDER BY id', [id]);
|
||||
const replies = await database.all<NoteDetail['feedback_replies'][number]>('SELECT * FROM feedback_replies WHERE note_id = ? ORDER BY id', [id]);
|
||||
const events = await database.all<NoteDetail['review_events'][number]>('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;
|
||||
});
|
||||
},
|
||||
|
||||
55
api/services/projectsService.ts
Normal file
55
api/services/projectsService.ts
Normal file
@@ -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<ProjectReviewStatus, 'archived'> {
|
||||
if (workCount === 0) return 'draft';
|
||||
if (approvedCount === workCount) return 'completed';
|
||||
return 'reviewing';
|
||||
}
|
||||
|
||||
export async function recalculateProjectReviewStatus(
|
||||
projectId: number,
|
||||
tx: QueryContext = database,
|
||||
): Promise<ProjectReviewStatusResult | null> {
|
||||
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<number> {
|
||||
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__'],
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user