2026-07-21 15:28:55 +08:00
/ * *
* 笔 记 路 由
* /
import { Router , type Response , type NextFunction } from 'express' ;
import { upload } from '../upload.js' ;
import { notesService } from '../services/notesService.js' ;
2026-07-21 19:23:58 +08:00
import { recalculateCollectionStatus } from '../services/collectionsService.js' ;
2026-07-22 16:41:03 +08:00
import { recalculateProjectReviewStatus } from '../services/projectsService.js' ;
2026-07-21 15:28:55 +08:00
import { audit , canWriteProject , requireRole , requireWriter , type AuthRequest } from '../auth.js' ;
import { database , withTransaction } from '../database.js' ;
import fs from 'fs' ;
const router = Router ( ) ;
2026-07-21 18:28:21 +08:00
function parseTags ( value : unknown ) : string [ ] {
return Array . isArray ( value )
? value . map ( ( tag ) = > String ( tag ) . trim ( ) ) . filter ( Boolean )
: String ( value || '' ) . trim ( ) ? [ String ( value ) . trim ( ) ] : [ ] ;
}
function parseImageUrls ( value : unknown ) : { valid : boolean ; urls : string [ ] } {
if ( value === undefined ) return { valid : true , urls : [ ] } ;
if ( ! Array . isArray ( value ) ) return { valid : false , urls : [ ] } ;
const urls = value . map ( ( item ) = > String ( item ) . trim ( ) ) ;
const valid = urls . length <= 30 && urls . every ( ( url ) = > {
if ( ! url || url . length > 2048 ) return false ;
try { return [ 'http:' , 'https:' ] . includes ( new URL ( url ) . protocol ) ; }
catch { return false ; }
} ) ;
return { valid , urls } ;
}
2026-07-21 20:25:52 +08:00
type CandidateBody = { candidate_name? : unknown ; title? : unknown ; description? : unknown ; tags? : unknown ; images? : unknown ; image_count? : unknown } ;
function parseCandidates ( value : unknown ) : CandidateBody [ ] | null {
try {
const parsed = typeof value === 'string' ? JSON . parse ( value ) : value ;
return Array . isArray ( parsed ) ? parsed as CandidateBody [ ] : null ;
} catch { return null ; }
}
2026-07-21 15:28:55 +08:00
// GET /api/notes - 笔记列表
router . get ( '/' , requireWriter , async ( req : AuthRequest , res : Response ) = > {
2026-07-21 18:28:21 +08:00
const { sort , order , q , collectionId , status , tag , externalId } = req . query as {
2026-07-21 15:28:55 +08:00
sort? : string ;
order? : string ;
q? : string ;
collectionId? : string ;
status ? : 'draft' | 'pending' | 'changes_requested' | 'approved' ;
tag? : string ;
2026-07-21 18:28:21 +08:00
externalId? : string ;
2026-07-21 15:28:55 +08:00
} ;
const groupId = req . authUser ? . role === 'platform_admin' || req . apiKey ? . scope === 'platform' ? undefined : req . authUser ? . group_id ? ? undefined ;
const projectId = req . apiKey ? . scope === 'project' ? req . apiKey . project_id ? ? undefined : undefined ;
2026-07-21 18:28:21 +08:00
const list = await notesService . list ( { sort , order , q , collectionId : collectionId ? Number ( collectionId ) : undefined , status , tag , groupId , projectId , externalId } ) ;
2026-07-21 15:28:55 +08:00
res . json ( list ) ;
} ) ;
// GET /api/notes/:noteId - 笔记详情
router . get ( '/:noteId' , requireWriter , async ( req : AuthRequest , res : Response , next : NextFunction ) = > {
try {
const id = Number ( req . params . noteId ) ;
if ( ! Number . isFinite ( id ) ) {
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 ] ) ;
if ( context && ! await canWriteProject ( req , context . project_id ) ) { res . status ( 403 ) . json ( { error : '无权查看该作品' } ) ; return ; }
const version = req . query . version ? Number ( req . query . version ) : undefined ;
const detail = await notesService . getDetail ( id , version ) ;
if ( ! detail ) {
res . status ( 404 ) . json ( { error : '笔记不存在' } ) ;
return ;
}
res . json ( detail ) ;
} catch ( err ) {
next ( err ) ;
}
} ) ;
router . post ( '/:noteId/text-annotations' , requireWriter , async ( req : AuthRequest , res : Response ) = > {
2026-07-22 16:41:03 +08:00
res . setHeader ( 'Deprecation' , 'true' ) ;
res . status ( 410 ) . json ( { error : '该接口已停用,请使用 /api/works/:workId/text-annotations 并提交明确的文字选区' } ) ;
2026-07-21 15:28:55 +08:00
} ) ;
// POST /api/notes - 上传新笔记 (multipart/form-data)
router . post (
'/' ,
requireWriter ,
upload . array ( 'images' , 30 ) ,
async ( req : AuthRequest , res : Response , next : NextFunction ) = > {
try {
const title = ( req . body . title || '' ) . toString ( ) . trim ( ) ;
const description = ( req . body . description || '' ) . toString ( ) . trim ( ) ;
const collectionId = Number ( req . body . collectionId ) ;
2026-07-21 18:28:21 +08:00
const tags = parseTags ( req . body . tags ) ;
const { valid : validImageUrls , urls : imageUrls } = parseImageUrls ( req . body . images ) ;
const externalId = String ( req . body . externalId ? ? req . body . external_id ? ? '' ) . trim ( ) || null ;
if ( ! validImageUrls ) {
res . status ( 400 ) . json ( { error : 'images 需要包含 1– 30 个有效的 HTTP/HTTPS 图片 URL' } ) ;
return ;
}
if ( externalId && ( externalId . length > 128 || ! /^[A-Za-z0-9._:-]+$/ . test ( externalId ) ) ) {
res . status ( 400 ) . json ( { error : 'externalId 仅支持 1– 128 位字母、数字、点、下划线、冒号和横线' } ) ;
return ;
}
2026-07-21 15:28:55 +08:00
if ( ! title ) {
res . status ( 400 ) . json ( { error : '标题不能为空' } ) ;
return ;
}
if ( ! Number . isFinite ( collectionId ) ) {
res . status ( 400 ) . json ( { error : '请选择作品交付集' } ) ;
return ;
}
const files = ( req . files as Express . Multer . File [ ] | undefined ) ? ? [ ] ;
2026-07-22 16:41:03 +08:00
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 ] ) ;
2026-07-21 15:28:55 +08:00
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 ;
}
2026-07-22 16:41:03 +08:00
if ( collection . project_status !== 'active' ) { files . forEach ( ( file ) = > { try { fs . unlinkSync ( file . path ) ; } catch { /* noop */ } } ) ; res . status ( 409 ) . json ( { error : '已关闭或归档项目为只读状态' } ) ; return ; }
2026-07-21 18:28:21 +08:00
if ( externalId ) {
const existing = await notesService . findByExternalId ( collectionId , externalId ) ;
if ( existing ) { res . status ( 200 ) . json ( { . . . existing , idempotent : true } ) ; return ; }
}
if ( files . length === 0 && imageUrls . length === 0 ) {
2026-07-21 15:28:55 +08:00
res . status ( 400 ) . json ( { error : '请至少上传一张图片' } ) ;
return ;
}
2026-07-21 18:28:21 +08:00
let note ;
try {
note = files . length
? await notesService . create (
title ,
description ,
files . map ( ( f ) = > ( { filename : f.filename , originalname : f.originalname , mimetype : f.mimetype , path : f.path } ) ) ,
collectionId ,
tags ,
externalId ,
)
: await notesService . createFromUrls ( title , description , imageUrls , collectionId , tags , externalId ) ;
} catch ( error ) {
const existing = externalId ? await notesService . findByExternalId ( collectionId , externalId ) : null ;
if ( existing ) { res . status ( 200 ) . json ( { . . . existing , idempotent : true } ) ; return ; }
throw error ;
}
await audit ( req , 'work.create' , 'work' , note . id , { collectionId , imageCount : files.length || imageUrls . length , imageSource : files.length ? 'upload' : 'external_url' } ) ;
2026-07-21 15:28:55 +08:00
res . status ( 201 ) . json ( note ) ;
} catch ( err ) {
next ( err ) ;
}
} ,
) ;
router . post ( '/:noteId/versions' , requireWriter , upload . array ( 'images' , 30 ) , async ( req : AuthRequest , res : Response , next : NextFunction ) = > {
const files = ( req . files as Express . Multer . File [ ] | undefined ) ? ? [ ] ;
try {
const id = Number ( req . params . noteId ) ;
2026-07-22 16:41:03 +08:00
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 ] ) ;
2026-07-21 15:28:55 +08:00
if ( ! context ) { res . status ( 404 ) . json ( { error : '作品不存在' } ) ; return ; }
if ( ! await canWriteProject ( req , context . project_id ) ) { res . status ( 403 ) . json ( { error : '无权操作该作品' } ) ; return ; }
2026-07-22 16:41:03 +08:00
if ( context . project_status !== 'active' ) { res . status ( 409 ) . json ( { error : '已关闭或归档项目为只读状态' } ) ; return ; }
2026-07-21 18:28:21 +08:00
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 ; }
2026-07-21 15:28:55 +08:00
const title = String ( req . body ? . title ? ? context . title ) . trim ( ) ;
const description = String ( req . body ? . description ? ? context . description ) . trim ( ) ;
2026-07-21 18:28:21 +08:00
const tags = parseTags ( req . body ? . tags ) ;
2026-07-21 15:28:55 +08:00
if ( ! title ) { res . status ( 400 ) . json ( { error : '标题不能为空' } ) ; return ; }
2026-07-21 18:28:21 +08:00
const note = files . length
? await notesService . createVersion ( id , title , description , files . map ( ( file ) = > ( { filename : file.filename , originalname : file.originalname , mimetype : file.mimetype , path : file.path } ) ) , tags , req . authUser ? . id )
: await notesService . createVersionFromUrls ( id , title , description , imageUrls , tags , req . authUser ? . id ) ;
await audit ( req , 'work.version_create' , 'work' , id , { versionNumber : note.version_number , imageCount : files.length || imageUrls . length , imageSource : files.length ? 'upload' : 'external_url' } ) ;
2026-07-21 15:28:55 +08:00
res . status ( 201 ) . json ( note ) ;
} catch ( error ) { files . forEach ( ( file ) = > { try { if ( fs . existsSync ( file . path ) ) fs . unlinkSync ( file . path ) ; } catch { /* noop */ } } ) ; next ( error ) ; }
} ) ;
2026-07-21 20:25:52 +08:00
router . post ( '/:noteId/review-rounds' , requireWriter , upload . array ( 'images' , 30 ) , async ( req : AuthRequest , res : Response , next : NextFunction ) = > {
const files = ( req . files as Express . Multer . File [ ] | undefined ) ? ? [ ] ;
const cleanupFiles = ( ) = > files . forEach ( ( file ) = > { try { if ( fs . existsSync ( file . path ) ) fs . unlinkSync ( file . path ) ; } catch { /* noop */ } } ) ;
try {
const id = Number ( req . params . noteId ) ;
2026-07-22 16:41:03 +08:00
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 ] ) ;
2026-07-21 20:25:52 +08:00
if ( ! context ) { cleanupFiles ( ) ; res . status ( 404 ) . json ( { error : '作品不存在' } ) ; return ; }
if ( ! await canWriteProject ( req , context . project_id ) ) { cleanupFiles ( ) ; res . status ( 403 ) . json ( { error : '无权操作该作品' } ) ; return ; }
2026-07-22 16:41:03 +08:00
if ( context . project_status !== 'active' ) { cleanupFiles ( ) ; res . status ( 409 ) . json ( { error : '已关闭或归档项目为只读状态' } ) ; return ; }
2026-07-21 20:25:52 +08:00
const rawCandidates = parseCandidates ( req . body ? . candidates ) ;
2026-07-22 16:41:03 +08:00
if ( ! rawCandidates || rawCandidates . length !== 1 ) { cleanupFiles ( ) ; res . status ( 400 ) . json ( { error : '每个验收轮次只能提交一个方案' } ) ; return ; }
2026-07-21 20:25:52 +08:00
const normalized = rawCandidates . map ( ( candidate , index ) = > ( {
candidate_name : String ( candidate . candidate_name ? ? ` 方案 ${ String . fromCharCode ( 65 + index ) } ` ) . trim ( ) ,
title : String ( candidate . title ? ? '' ) . trim ( ) ,
description : String ( candidate . description ? ? '' ) . trim ( ) ,
tags : parseTags ( candidate . tags ) ,
image_count : Number ( candidate . image_count ? ? 0 ) ,
imageUrls : parseImageUrls ( candidate . images ) ,
} ) ) ;
if ( normalized . some ( ( candidate ) = > ! candidate . candidate_name || candidate . candidate_name . length > 30 || ! candidate . title ) ) { cleanupFiles ( ) ; res . status ( 400 ) . json ( { error : '候选稿名称须为 1– 30 个字符,标题不能为空' } ) ; return ; }
let note ;
if ( files . length ) {
const expected = normalized . reduce ( ( sum , candidate ) = > sum + candidate . image_count , 0 ) ;
if ( expected !== files . length || normalized . some ( ( candidate ) = > candidate . image_count < 1 || candidate . image_count > 30 ) ) { cleanupFiles ( ) ; res . status ( 400 ) . json ( { error : '候选稿图片数量与上传文件不一致' } ) ; return ; }
let offset = 0 ;
const uploadCandidates = normalized . map ( ( candidate ) = > {
const candidateFiles = files . slice ( offset , offset + candidate . image_count ) ; offset += candidate . image_count ;
return { . . . candidate , files : candidateFiles.map ( ( file ) = > ( { filename : file.filename , originalname : file.originalname , mimetype : file.mimetype , path : file.path } ) ) } ;
} ) ;
2026-07-22 16:41:03 +08:00
const only = uploadCandidates [ 0 ] ;
note = await notesService . createRound ( id , { title : only.title , description : only.description , tags : only.tags , files : only.files } , req . authUser ? . id ) ;
2026-07-21 20:25:52 +08:00
} 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 ; }
2026-07-22 16:41:03 +08:00
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 ) ;
2026-07-21 20:25:52 +08:00
}
2026-07-22 16:41:03 +08:00
await audit ( req , 'work.review_round_create' , 'work' , id , { candidateCount : 1 , versionNumber : note.version_number , deprecatedRoute : true } ) ;
2026-07-21 20:25:52 +08:00
res . status ( 201 ) . json ( note ) ;
} catch ( error ) {
cleanupFiles ( ) ;
next ( error ) ;
}
} ) ;
2026-07-21 15:28:55 +08:00
router . patch ( '/:noteId/status' , requireWriter , async ( req : AuthRequest , res : Response ) = > {
const id = Number ( req . params . noteId ) ;
const status = req . body ? . status ;
if ( ! [ 'draft' , 'pending' ] . includes ( status ) ) {
res . status ( 400 ) . json ( { error : '无效的验收状态' } ) ;
return ;
}
2026-07-22 16:41:03 +08:00
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 ] ) ;
2026-07-21 15:28:55 +08:00
if ( context && ! await canWriteProject ( req , context . project_id ) ) { res . status ( 403 ) . json ( { error : '无权操作该作品' } ) ; return ; }
2026-07-22 16:41:03 +08:00
if ( context && context . project_status !== 'active' ) { res . status ( 409 ) . json ( { error : '已关闭或归档项目为只读状态' } ) ; return ; }
2026-07-21 19:23:58 +08:00
if ( context ? . review_status === 'approved' ) { res . status ( 409 ) . json ( { error : '已通过作品只能由组管理员填写原因后重新打开' } ) ; return ; }
2026-07-21 15:28:55 +08:00
if ( ! await notesService . setStatus ( id , status ) ) {
res . status ( 404 ) . json ( { error : '作品不存在' } ) ;
return ;
}
res . json ( { success : true , status } ) ;
} ) ;
router . post ( '/:noteId/reopen' , requireRole ( 'platform_admin' , 'group_admin' ) , async ( req : AuthRequest , res : Response ) = > {
const id = Number ( req . params . noteId ) ;
const reason = String ( req . body ? . reason ? ? '' ) . trim ( ) ;
if ( ! reason ) { res . status ( 400 ) . json ( { error : '重新打开验收时必须填写原因' } ) ; return ; }
2026-07-22 16:41:03 +08:00
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 ] ) ;
2026-07-21 15:28:55 +08:00
if ( ! note ) { res . status ( 404 ) . json ( { error : '作品不存在' } ) ; return ; }
if ( ! await canWriteProject ( req , note . project_id ) ) { res . status ( 403 ) . json ( { error : '无权操作该作品' } ) ; return ; }
2026-07-22 16:41:03 +08:00
if ( note . project_status !== 'active' ) { res . status ( 409 ) . json ( { error : '已关闭或归档项目为只读状态' } ) ; return ; }
2026-07-21 15:28:55 +08:00
if ( note . review_status !== 'approved' ) { res . status ( 409 ) . json ( { error : '只有已通过作品可以重新打开' } ) ; return ; }
const actor = req . authUser ! ;
await withTransaction ( async ( tx ) = > {
await tx . execute ( "UPDATE notes SET review_status = 'pending' WHERE id = ?" , [ id ] ) ;
2026-07-21 20:25:52 +08:00
await tx . execute ( "UPDATE notes SET approved_version_number = NULL WHERE id = ?" , [ id ] ) ;
await tx . execute ( "UPDATE work_versions SET review_status = 'pending', candidate_status = 'pending' WHERE note_id = ? AND version_number = ?" , [ id , note . version_number ] ) ;
if ( note . active_round_id ) await tx . execute ( "UPDATE review_rounds SET status = 'reviewing', selected_version_number = NULL, completed_at = NULL WHERE id = ?" , [ note . active_round_id ] ) ;
2026-07-21 15:28:55 +08:00
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 ] ) ;
2026-07-21 19:23:58 +08:00
await recalculateCollectionStatus ( Number ( note . collection_id ) , tx ) ;
2026-07-22 16:41:03 +08:00
await recalculateProjectReviewStatus ( Number ( note . project_id ) , tx ) ;
2026-07-21 15:28:55 +08:00
} ) ;
await audit ( req , 'work.reopen' , 'work' , id , { reason , versionNumber : note.version_number } ) ;
res . json ( { success : true , status : 'pending' } ) ;
} ) ;
// DELETE /api/notes/:noteId - 删除笔记
router . delete ( '/:noteId' , requireWriter , async ( req : AuthRequest , res : Response ) = > {
const id = Number ( req . params . noteId ) ;
if ( ! Number . isFinite ( id ) ) {
res . status ( 400 ) . json ( { error : '无效的笔记 ID' } ) ;
return ;
}
2026-07-22 16:41:03 +08:00
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 ] ) ;
2026-07-21 15:28:55 +08:00
if ( context && ! await canWriteProject ( req , context . project_id ) ) { res . status ( 403 ) . json ( { error : '无权操作该作品' } ) ; return ; }
2026-07-22 16:41:03 +08:00
if ( context && context . project_status !== 'active' ) { res . status ( 409 ) . json ( { error : '已关闭或归档项目为只读状态' } ) ; return ; }
2026-07-21 15:28:55 +08:00
const ok = await notesService . remove ( id ) ;
if ( ! ok ) {
res . status ( 404 ) . json ( { error : '笔记不存在' } ) ;
return ;
}
res . status ( 204 ) . end ( ) ;
} ) ;
export default router ;