2026-03-12 11:23:10 +08:00
const express = require ( 'express' );
const db = require ( '../db' );
2026-07-01 16:13:47 +08:00
const config = require ( '../config' );
2026-03-26 10:39:58 +08:00
const logger = require ( '../utils/logger' );
2026-07-01 16:13:47 +08:00
const {
parseCSVLine ,
AppError ,
validateNumber ,
validateInList ,
validateIsoDate ,
optionalString
} = require ( '../utils/helpers' );
2026-03-12 11:23:10 +08:00
const router = express . Router ();
2026-07-01 16:13:47 +08:00
function emptyToNull ( value ) {
return value === undefined || value === null || value === '' ? null : value ;
}
function nullableDbValue ( value ) {
return value === undefined || value === null || value === '' ? null : value ;
}
2026-03-12 11:23:10 +08:00
// 导出数据
2026-03-26 10:39:58 +08:00
router . get ( '/export' , ( req , res , next ) => {
try {
const format = req . query . format ;
if ( ! format || ! [ 'json' , 'csv' ]. includes ( format )) {
throw new AppError ( '请指定格式: json 或 csv' , 400 );
}
const records = db . prepare (
'SELECT type, category, amount, quantity, unit_price, rebate, boss, partner, source, destination, note, created_at FROM records WHERE user_id = ? ORDER BY created_at DESC'
). all ( req . user . id );
const timestamp = new Date (). toISOString (). replace ( /[:.]/g , '-' ). slice ( 0 , 19 );
const filename = `gamer-export- ${ timestamp } ` ;
if ( format === 'json' ) {
const exportData = {
exportVersion : 1 ,
exportedAt : new Date (). toISOString (),
username : req . user . username ,
recordCount : records . length ,
records
};
logger . info ( `用户 ${ req . user . username } 导出 JSON 数据: ${ records . length } 条` );
res . setHeader ( 'Content-Type' , 'application/json; charset=utf-8' );
res . setHeader ( 'Content-Disposition' , `attachment; filename=" ${ filename } .json"` );
return res . send ( JSON . stringify ( exportData , null , 2 ));
}
// CSV with UTF-8 BOM
const BOM = '\uFEFF' ;
const cols = [ 'type' , 'category' , 'amount' , 'quantity' , 'unit_price' , 'rebate' , 'boss' , 'partner' , 'source' , 'destination' , 'note' , 'created_at' ];
const csvRows = records . map ( r => {
return cols . map ( col => {
const val = r [ col ];
if ( val === null || val === undefined ) return '' ;
const str = String ( val );
if ( str . includes ( ',' ) || str . includes ( '"' ) || str . includes ( '\n' )) {
return '"' + str . replace ( /"/g , '""' ) + '"' ;
}
return str ;
}). join ( ',' );
});
logger . info ( `用户 ${ req . user . username } 导出 CSV 数据: ${ records . length } 条` );
res . setHeader ( 'Content-Type' , 'text/csv; charset=utf-8' );
res . setHeader ( 'Content-Disposition' , `attachment; filename=" ${ filename } .csv"` );
res . send ( BOM + cols . join ( ',' ) + '\n' + csvRows . join ( '\n' ));
} catch ( err ) {
next ( err );
2026-03-12 11:23:10 +08:00
}
});
// 导入数据
2026-03-26 10:39:58 +08:00
router . post ( '/import' , express . text ({ type : '*/*' , limit : '10mb' }), ( req , res , next ) => {
2026-03-12 11:23:10 +08:00
try {
const raw = req . body ;
if ( ! raw || typeof raw !== 'string' ) {
2026-03-26 10:39:58 +08:00
throw new AppError ( '未收到文件内容' , 400 );
2026-03-12 11:23:10 +08:00
}
let records = [];
const requiredFields = [ 'type' , 'category' , 'amount' , 'created_at' ];
const allFields = [ 'type' , 'category' , 'amount' , 'quantity' , 'unit_price' , 'rebate' , 'boss' , 'partner' , 'source' , 'destination' , 'note' , 'created_at' ];
const trimmed = raw . replace ( /^\uFEFF/ , '' ). trim ();
if ( trimmed . startsWith ( '{' )) {
const data = JSON . parse ( trimmed );
if ( ! data . exportVersion || ! Array . isArray ( data . records )) {
2026-03-26 10:39:58 +08:00
throw new AppError ( '无效的JSON导出文件格式' , 400 );
2026-03-12 11:23:10 +08:00
}
records = data . records ;
} else {
const lines = trimmed . split ( '\n' ). map ( l => l . replace ( /\r$/ , '' ));
if ( lines . length < 2 ) {
2026-03-26 10:39:58 +08:00
throw new AppError ( 'CSV文件为空或格式不正确' , 400 );
2026-03-12 11:23:10 +08:00
}
if ( lines [ 0 ] !== allFields . join ( ',' )) {
2026-03-26 10:39:58 +08:00
throw new AppError ( 'CSV列头不匹配,请使用本系统导出的文件' , 400 );
2026-03-12 11:23:10 +08:00
}
const csvHeaders = lines [ 0 ]. split ( ',' );
for ( let i = 1 ; i < lines . length ; i ++ ) {
if ( ! lines [ i ]. trim ()) continue ;
const values = parseCSVLine ( lines [ i ]);
const record = {};
2026-07-01 16:13:47 +08:00
csvHeaders . forEach (( h , idx ) => { record [ h ] = emptyToNull ( values [ idx ]); });
if ( record . amount !== null ) record . amount = parseFloat ( record . amount );
if ( record . quantity !== null ) record . quantity = parseInt ( record . quantity , 10 );
if ( record . unit_price !== null ) record . unit_price = parseFloat ( record . unit_price );
if ( record . rebate !== null ) record . rebate = parseFloat ( record . rebate );
2026-03-12 11:23:10 +08:00
records . push ( record );
}
}
2026-07-01 16:13:47 +08:00
if ( records . length > config . import . maxRecords ) {
throw new AppError ( `单次最多导入 ${ config . import . maxRecords } 条记录` , 400 );
2026-03-12 11:23:10 +08:00
}
2026-07-01 16:13:47 +08:00
const allCategories = [... config . categories . income , ... config . categories . expense ];
const normalizedRecords = records . map (( r , index ) => {
for ( const f of requiredFields ) {
if ( ! r [ f ] && r [ f ] !== 0 ) {
throw new AppError ( `第 ${ index + 1 } 条记录缺少必填字段: ${ f } ` , 400 );
}
}
validateInList ( r . type , 'type' , [ 'income' , 'expense' ]);
validateInList ( r . category , 'category' , allCategories );
const amount = r . category === '派单'
? validateNumber ( r . amount , '金额' )
: validateNumber ( r . amount , '金额' , 0 );
return {
type : r . type ,
category : r . category ,
amount ,
quantity : r . quantity === undefined || r . quantity === null || r . quantity === '' ? null : validateNumber ( r . quantity , '数量' , 0 ),
unit_price : r . unit_price === undefined || r . unit_price === null || r . unit_price === '' ? null : validateNumber ( r . unit_price , '单价' , 0 ),
rebate : r . rebate === undefined || r . rebate === null || r . rebate === '' ? null : validateNumber ( r . rebate , '返点' ),
boss : optionalString ( r . boss , '老板' , 100 ),
partner : optionalString ( r . partner , '陪玩' , 100 ),
source : optionalString ( r . source , '来源' , 100 ),
destination : optionalString ( r . destination , '去向' , 100 ),
note : optionalString ( r . note , '备注' , 500 ),
created_at : validateIsoDate ( r . created_at , '创建时间' )
};
});
const existingCheck = db . prepare ( `
SELECT id FROM records
WHERE user_id = ?
AND type = ?
AND category = ?
AND amount = ?
AND COALESCE(quantity, '') = COALESCE(?, '')
AND COALESCE(unit_price, '') = COALESCE(?, '')
AND COALESCE(rebate, '') = COALESCE(?, '')
AND COALESCE(boss, '') = COALESCE(?, '')
AND COALESCE(partner, '') = COALESCE(?, '')
AND COALESCE(source, '') = COALESCE(?, '')
AND COALESCE(destination, '') = COALESCE(?, '')
AND COALESCE(note, '') = COALESCE(?, '')
AND created_at = ?
` );
2026-03-12 11:23:10 +08:00
const insertStmt = db . prepare ( `
INSERT INTO records (user_id, type, category, amount, quantity, unit_price, rebate, boss, partner, source, destination, note, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
` );
let imported = 0 , skipped = 0 ;
const insertMany = db . transaction (( recs ) => {
for ( const r of recs ) {
2026-07-01 16:13:47 +08:00
const values = [
req . user . id ,
r . type ,
r . category ,
r . amount ,
r . quantity ,
r . unit_price ,
r . rebate ,
r . boss ,
r . partner ,
r . source ,
r . destination ,
r . note ,
r . created_at
];
if ( existingCheck . get (... values )) {
2026-03-12 11:23:10 +08:00
skipped ++ ; continue ;
}
insertStmt . run ( req . user . id , r . type , r . category , r . amount ,
2026-07-01 16:13:47 +08:00
nullableDbValue ( r . quantity ), nullableDbValue ( r . unit_price ), nullableDbValue ( r . rebate ),
nullableDbValue ( r . boss ), nullableDbValue ( r . partner ),
nullableDbValue ( r . source ), nullableDbValue ( r . destination ),
nullableDbValue ( r . note ), r . created_at );
2026-03-12 11:23:10 +08:00
imported ++ ;
}
});
2026-07-01 16:13:47 +08:00
insertMany ( normalizedRecords );
2026-03-12 11:23:10 +08:00
2026-03-26 10:39:58 +08:00
logger . info ( `用户 ${ req . user . username } 导入数据: ${ imported } 条, 跳过 ${ skipped } 条` );
2026-07-01 16:13:47 +08:00
res . json ({ imported , skipped , total : normalizedRecords . length });
2026-03-26 10:39:58 +08:00
} catch ( err ) {
if ( err instanceof SyntaxError ) {
next ( new AppError ( '文件解析失败: ' + err . message , 400 ));
} else {
next ( err );
}
2026-03-12 11:23:10 +08:00
}
});
module . exports = router ;