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' ;
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 ( ) ;
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 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 ) = > {
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 } > ( 'SELECT c.project_id FROM work_versions v JOIN notes n ON n.id=v.note_id JOIN collections c ON c.id=n.collection_id WHERE v.note_id=? AND v.version_number=?' , [ noteId , versionNumber ] ) ;
if ( ! context ) { res . status ( 404 ) . json ( { error : '作品版本不存在' } ) ; return ; }
if ( ! await canWriteProject ( req , context . project_id ) ) { res . status ( 403 ) . 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 ] ) ) ;
} ) ;
// 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 ) ? ? [ ] ;
const collection = await database . one < { project_id : number } > ( 'SELECT c.project_id FROM collections c 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 ;
}
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 ) ;
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 ] ) ;
if ( ! context ) { res . status ( 404 ) . json ( { error : '作品不存在' } ) ; return ; }
if ( ! await canWriteProject ( req , context . project_id ) ) { res . status ( 403 ) . 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 ) ; }
} ) ;
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 ;
}
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 ; }
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 ; }
const note = await database . one < { review_status : string ; version_number : number ; project_id : number } > ( 'SELECT n.review_status, n.version_number, c.project_id FROM notes n JOIN collections c ON c.id = n.collection_id WHERE n.id = ?' , [ id ] ) ;
if ( ! note ) { res . status ( 404 ) . json ( { error : '作品不存在' } ) ; return ; }
if ( ! await canWriteProject ( req , note . project_id ) ) { res . status ( 403 ) . json ( { error : '无权操作该作品' } ) ; return ; }
if ( note . review_status !== 'approved' ) { res . status ( 409 ) . json ( { error : '只有已通过作品可以重新打开' } ) ; return ; }
const actor = req . authUser ! ;
await withTransaction ( async ( tx ) = > {
await tx . execute ( "UPDATE notes SET review_status = 'pending' WHERE id = ?" , [ id ] ) ;
await tx . execute ( "UPDATE work_versions SET review_status = 'pending' WHERE note_id = ? AND version_number = ?" , [ id , note . version_number ] ) ;
await tx . execute ( "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 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 ;
}
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 ok = await notesService . remove ( id ) ;
if ( ! ok ) {
res . status ( 404 ) . json ( { error : '笔记不存在' } ) ;
return ;
}
res . status ( 204 ) . end ( ) ;
} ) ;
export default router ;