2026-07-21 15:28:55 +08:00
import sharp from 'sharp' ;
2026-07-22 16:41:03 +08:00
import type { ImageWithAnnotations , Note , NoteDetail , ReviewStatus , WorkFeedbackBundle , WorkRound } from '../../shared/types.js' ;
2026-07-21 15:28:55 +08:00
import { notesRepository } from '../repositories/notesRepository.js' ;
import { imagesRepository } from '../repositories/imagesRepository.js' ;
import { annotationsRepository } from '../repositories/annotationsRepository.js' ;
2026-07-21 20:25:52 +08:00
import { database , databaseDialect , withTransaction , type QueryContext } from '../database.js' ;
2026-07-23 13:26:10 +08:00
import { StorageImportError , storeExternalImageUrl , storeUploadedFile } from '../storage.js' ;
2026-07-21 19:23:58 +08:00
import { recalculateCollectionStatus } from './collectionsService.js' ;
2026-07-22 16:41:03 +08:00
import { ensureProjectCompatibilityCollection , recalculateProjectReviewStatus } from './projectsService.js' ;
2026-07-21 15:28:55 +08:00
export interface UploadedFile { filename : string ; originalname? : string ; mimetype? : string ; path : string }
2026-07-22 16:41:03 +08:00
export interface UploadRound { title : string ; description : string ; tags : string [ ] ; files : UploadedFile [ ] }
export interface UrlRound { title : string ; description : string ; tags : string [ ] ; images : string [ ] }
2026-07-21 20:25:52 +08:00
type StoredImage = { url : string ; width : number ; height : number ; storageProvider : 'local' | 'tencent_cos' | 'external' ; storageKey : string } ;
2026-07-22 16:41:03 +08:00
type PreparedRound = { title : string ; description : string ; tags : string [ ] ; images : StoredImage [ ] } ;
2026-07-21 15:28:55 +08:00
2026-07-21 20:25:52 +08:00
async function prepareFiles ( files : UploadedFile [ ] ) : Promise < StoredImage [ ] > {
2026-07-23 13:26:10 +08:00
const supported = new Map ( [
[ 'jpeg' , { extension : '.jpg' , contentType : 'image/jpeg' } ] ,
[ 'png' , { extension : '.png' , contentType : 'image/png' } ] ,
[ 'gif' , { extension : '.gif' , contentType : 'image/gif' } ] ,
[ 'webp' , { extension : '.webp' , contentType : 'image/webp' } ] ,
[ 'avif' , { extension : '.avif' , contentType : 'image/avif' } ] ,
] ) ;
const inspected = await Promise . all ( files . map ( async ( file ) = > {
try {
const metadata = await sharp ( file . path ) . metadata ( ) ;
const detected = metadata . format ? supported . get ( metadata . format ) : undefined ;
if ( ! detected || ! metadata . width || ! metadata . height ) throw new Error ( 'invalid image' ) ;
return { file , width : metadata.width , height : metadata.height , . . . detected } ;
} catch {
throw new StorageImportError ( 422 , ` 上传文件不是有效或受支持的图片: ${ file . originalname || file . filename } ` ) ;
}
} ) ) ;
const prepared : StoredImage [ ] = [ ] ;
for ( const item of inspected ) {
const stored = await storeUploadedFile ( {
. . . item . file ,
originalname : ` upload ${ item . extension } ` ,
mimetype : item.contentType ,
} ) ;
prepared . push ( { width : item.width , height : item.height , . . . stored } ) ;
}
return prepared ;
2026-07-21 15:28:55 +08:00
}
2026-07-22 18:12:23 +08:00
async function prepareExternalImages ( images : string [ ] ) : Promise < StoredImage [ ] > {
const prepared : StoredImage [ ] = [ ] ;
for ( const image of images ) prepared . push ( await storeExternalImageUrl ( image ) ) ;
return prepared ;
2026-07-21 20:25:52 +08:00
}
async function createRoundInTransaction (
tx : QueryContext ,
noteId : number ,
2026-07-22 16:41:03 +08:00
projectId : number ,
2026-07-21 20:25:52 +08:00
collectionId : number ,
2026-07-22 16:41:03 +08:00
round : PreparedRound ,
2026-07-21 20:25:52 +08:00
createdBy : number | undefined ,
fromStatus : ReviewStatus ,
2026-07-22 16:41:03 +08:00
) : 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 ] ,
) ;
2026-07-21 20:25:52 +08:00
if ( note ? . active_round_id ) {
2026-07-22 16:41:03 +08:00
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 ] ) ;
2026-07-21 20:25:52 +08:00
}
2026-07-22 16:41:03 +08:00
2026-07-21 20:25:52 +08:00
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 ] ,
) ;
2026-07-22 16:41:03 +08:00
const versionNumber = Number ( maxima ? . max_version ? ? 0 ) + 1 ;
2026-07-21 20:25:52 +08:00
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 ] ,
) ;
2026-07-22 16:41:03 +08:00
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 , '工作台' ] ,
) ;
2026-07-21 20:25:52 +08:00
await tx . execute (
"UPDATE notes SET title = ?, description = ?, tags = ?, version_number = ?, active_round_id = ?, approved_version_number = NULL, review_status = 'pending' WHERE id = ?" ,
2026-07-22 16:41:03 +08:00
[ round . title , round . description , JSON . stringify ( round . tags ) , versionNumber , roundId , noteId ] ,
2026-07-21 20:25:52 +08:00
) ;
await recalculateCollectionStatus ( collectionId , tx ) ;
2026-07-22 16:41:03 +08:00
await recalculateProjectReviewStatus ( projectId , tx ) ;
return { roundId , roundNumber , versionNumber } ;
}
async function prepareUploadRound ( round : UploadRound ) : Promise < PreparedRound > {
return { title : round.title , description : round.description , tags : round.tags , images : await prepareFiles ( round . files ) } ;
2026-07-21 20:25:52 +08:00
}
2026-07-22 16:41:03 +08:00
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 [ ] ,
} ;
2026-07-21 20:25:52 +08:00
}
2026-07-21 15:28:55 +08:00
export const notesService = {
2026-07-21 18:28:21 +08:00
async list ( query : { sort? : string ; order? : string ; q? : string ; collectionId? : number ; status? : ReviewStatus ; tag? : string ; projectId? : number ; groupId? : number ; externalId? : string } ) {
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 } ) ;
2026-07-21 15:28:55 +08:00
} ,
2026-07-22 16:41:03 +08:00
async getDetail ( id : number , requestedVersion? : number , requestedRound? : number ) : Promise < NoteDetail | null > {
2026-07-21 15:28:55 +08:00
const current = await notesRepository . findById ( id ) ;
if ( ! current ) return null ;
2026-07-22 16:41:03 +08:00
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 ] )
2026-07-21 15:28:55 +08:00
: undefined ;
2026-07-22 16:41:03 +08:00
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 ;
2026-07-21 15:28:55 +08:00
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 ) ;
2026-07-22 16:41:03 +08:00
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
2026-07-21 20:25:52 +08:00
FROM work_versions v JOIN review_rounds r ON r . id = v . review_round_id
2026-07-22 16:41:03 +08:00
WHERE v . note_id = ? ORDER BY r . round_number DESC ` , [id]);
2026-07-21 15:28:55 +08:00
const result : NoteDetail = {
. . . note ,
images : [ ] as ImageWithAnnotations [ ] ,
2026-07-22 16:41:03 +08:00
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 } ,
2026-07-21 15:28:55 +08:00
} ;
for ( const image of images ) result . images . push ( { . . . image , annotations : await annotationsRepository . listByImage ( image . id ) } ) ;
return result ;
} ,
2026-07-22 16:41:03 +08:00
async createInProject ( projectId : number , title : string , description : string , files : UploadedFile [ ] , tags : string [ ] , externalId : string | null = null ) : Promise < Note > {
const prepared = await prepareFiles ( files ) ;
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 > {
2026-07-22 18:12:23 +08:00
const prepared = await prepareExternalImages ( imageUrls ) ;
2026-07-22 16:41:03 +08:00
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 ) ] ) ;
2026-07-22 18:12:23 +08:00
await createRoundInTransaction ( tx , id , projectId , collectionId , { title , description , tags , images : prepared } , undefined , 'draft' ) ;
2026-07-22 16:41:03 +08:00
return ( await notesRepository . findById ( id ) ) ! ;
} ) ;
} ,
2026-07-21 18:28:21 +08:00
async create ( title : string , description : string , files : UploadedFile [ ] , collectionId : number , tags : string [ ] , externalId : string | null = null ) : Promise < Note > {
2026-07-22 16:41:03 +08:00
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 ) ;
2026-07-21 15:28:55 +08:00
const prepared = await prepareFiles ( files ) ;
2026-07-22 16:41:03 +08:00
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 ) ) ! ;
2026-07-21 15:28:55 +08:00
} ) ;
} ,
2026-07-21 18:28:21 +08:00
async createFromUrls ( title : string , description : string , imageUrls : string [ ] , collectionId : number , tags : string [ ] , externalId : string | null = null ) : Promise < Note > {
2026-07-22 16:41:03 +08:00
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 ) ;
2026-07-22 18:12:23 +08:00
const prepared = await prepareExternalImages ( imageUrls ) ;
2026-07-22 16:41:03 +08:00
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 ) ] ) ;
2026-07-22 18:12:23 +08:00
await createRoundInTransaction ( tx , id , projectId , collectionId , { title , description , tags , images : prepared } , undefined , 'draft' ) ;
2026-07-22 16:41:03 +08:00
return ( await notesRepository . findById ( id ) ) ! ;
2026-07-21 18:28:21 +08:00
} ) ;
} ,
2026-07-22 16:41:03 +08:00
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 ) ; } ,
2026-07-21 18:28:21 +08:00
2026-07-22 16:41:03 +08:00
async createRound ( id : number , round : UploadRound , createdBy? : number ) : Promise < Note > {
2026-07-21 15:28:55 +08:00
const current = await notesRepository . findById ( id ) ;
if ( ! current ) throw new Error ( '作品不存在' ) ;
2026-07-22 16:41:03 +08:00
const prepared = await prepareUploadRound ( round ) ;
await withTransaction ( ( tx ) = > createRoundInTransaction ( tx , id , current . project_id , current . collection_id , prepared , createdBy , current . review_status ) ) ;
2026-07-21 15:28:55 +08:00
return ( await notesRepository . findById ( id ) ) ! ;
} ,
2026-07-22 16:41:03 +08:00
async createRoundFromUrls ( id : number , round : UrlRound , createdBy? : number ) : Promise < Note > {
2026-07-21 18:28:21 +08:00
const current = await notesRepository . findById ( id ) ;
if ( ! current ) throw new Error ( '作品不存在' ) ;
2026-07-22 18:12:23 +08:00
const prepared = { . . . round , images : await prepareExternalImages ( round . images ) } ;
2026-07-22 16:41:03 +08:00
await withTransaction ( ( tx ) = > createRoundInTransaction ( tx , id , current . project_id , current . collection_id , prepared , createdBy , current . review_status ) ) ;
2026-07-21 18:28:21 +08:00
return ( await notesRepository . findById ( id ) ) ! ;
} ,
2026-07-21 20:25:52 +08:00
async createVersion ( id : number , title : string , description : string , files : UploadedFile [ ] , tags : string [ ] , createdBy? : number ) : Promise < Note > {
2026-07-22 16:41:03 +08:00
return this . createRound ( id , { title , description , tags , files } , createdBy ) ;
2026-07-21 20:25:52 +08:00
} ,
async createVersionFromUrls ( id : number , title : string , description : string , imageUrls : string [ ] , tags : string [ ] , createdBy? : number ) : Promise < Note > {
2026-07-22 16:41:03 +08:00
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 ) ) ,
} ) ) ,
} ;
2026-07-21 20:25:52 +08:00
} ,
2026-07-21 19:23:58 +08:00
async remove ( id : number ) {
return withTransaction ( async ( tx ) = > {
2026-07-22 16:41:03 +08:00
const note = await tx . one < { collection_id : number ; project_id : number } > ( 'SELECT collection_id, project_id FROM notes WHERE id = ?' , [ id ] ) ;
2026-07-21 19:23:58 +08:00
if ( ! note ) return false ;
const removed = ( await tx . execute ( 'DELETE FROM notes WHERE id = ?' , [ id ] ) ) . changes > 0 ;
2026-07-22 16:41:03 +08:00
if ( removed ) {
await recalculateCollectionStatus ( Number ( note . collection_id ) , tx ) ;
await recalculateProjectReviewStatus ( Number ( note . project_id ) , tx ) ;
}
2026-07-21 19:23:58 +08:00
return removed ;
} ) ;
} ,
2026-07-21 20:25:52 +08:00
2026-07-21 19:23:58 +08:00
async setStatus ( id : number , status : ReviewStatus ) {
return withTransaction ( async ( tx ) = > {
2026-07-22 16:41:03 +08:00
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 ] ) ;
2026-07-21 19:23:58 +08:00
if ( ! note ) return false ;
await tx . execute ( 'UPDATE notes SET review_status = ? WHERE id = ?' , [ status , id ] ) ;
2026-07-21 20:25:52 +08:00
if ( note . active_round_id ) {
2026-07-22 16:41:03 +08:00
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 ] ) ;
2026-07-21 20:25:52 +08:00
}
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 19:23:58 +08:00
return true ;
} ) ;
} ,
2026-07-21 15:28:55 +08:00
} ;