perf: optimize order resolution history writes

This commit is contained in:
Misaka_Company
2026-04-28 13:39:08 +08:00
parent 1ffa0650a6
commit dbb8e4904e
4 changed files with 106 additions and 50 deletions

View File

@@ -126,7 +126,9 @@ export function registerExtractorHandlers(): void {
sendLog(sender, 'info', '正在解析订单号...')
const resolver = new OrderNumberResolver(dbService)
const resolutionStart = Date.now()
const mappings = await resolver.resolve(input.orderNumbers)
const resolutionDurationMs = Date.now() - resolutionStart
// Get valid order numbers and warnings
const validOrderNumbers = resolver.getValidOrderNumbers(mappings)
@@ -146,7 +148,16 @@ export function registerExtractorHandlers(): void {
)
}
log.info('Resolved order numbers', { count: validOrderNumbers.length })
log.info('Resolved order numbers', {
inputCount: input.orderNumbers.length,
count: validOrderNumbers.length,
durationMs: resolutionDurationMs
})
sendLog(
sender,
'info',
`订单号解析完成:${validOrderNumbers.length}/${input.orderNumbers.length} 个有效,耗时 ${(resolutionDurationMs / 1000).toFixed(2)}`
)
// Initialize operation history recording
const currentUser = SessionManager.getInstance().getUserInfo()
@@ -155,6 +166,7 @@ export function registerExtractorHandlers(): void {
// Save order records to history (preserve productionId -> orderNumber mapping)
if (currentUser) {
const historyInsertStart = Date.now()
const orderRecords = mappings.map((m) => ({
productionId: m.productionId || null,
orderNumber: m.orderNumber || m.input
@@ -165,9 +177,11 @@ export function registerExtractorHandlers(): void {
currentUser.username,
orderRecords
)
const historyInsertDurationMs = Date.now() - historyInsertStart
log.info('Operation history batch created', {
batchId,
recordCount: orderRecords.length
recordCount: orderRecords.length,
durationMs: historyInsertDurationMs
})
}

View File

@@ -126,35 +126,47 @@ export class ExtractorOperationHistoryDAO {
recordCount: records.length
})
for (const record of records) {
const columnsPerRecord = 5
const batchSize = Math.max(1, dialect.maxBatchRows(columnsPerRecord))
for (let offset = 0; offset < records.length; offset += batchSize) {
const batch = records.slice(offset, offset + batchSize)
try {
const valuesSql: string[] = []
const params: (string | number | null)[] = []
batch.forEach((record, index) => {
const paramOffset = index * columnsPerRecord
valuesSql.push(
`(${dialect.param(paramOffset)}, ${dialect.param(paramOffset + 1)}, ${dialect.param(paramOffset + 2)}, ${dialect.param(paramOffset + 3)}, ${dialect.param(paramOffset + 4)}, ${dialect.currentTimestamp()}, 'pending')`
)
params.push(batchId, userId, username, record.productionId || null, record.orderNumber)
})
const sqlString = `
INSERT INTO ${tableName}
(BatchId, UserId, Username, ProductionId, OrderNumber, OperationTime, Status)
VALUES
(${dialect.param(0)}, ${dialect.param(1)}, ${dialect.param(2)}, ${dialect.param(3)}, ${dialect.param(4)}, ${dialect.currentTimestamp()}, 'pending')
${valuesSql.join(',\n ')}
`
await trackDuration(
async () =>
await dbService.query(sqlString, [
batchId,
userId,
username,
record.productionId || null,
record.orderNumber
]),
{
await trackDuration(async () => await dbService.query(sqlString, params), {
operationName: 'ExtractorOperationHistoryDAO.insertBatchRecords',
context: { tableName, operationType: 'INSERT', batchId }
context: {
tableName,
operationType: 'INSERT',
batchId,
batchOffset: offset,
batchCount: batch.length
}
)
})
} catch (error) {
log.error('Error inserting individual record', {
log.error('Error inserting record batch', {
tableName,
operationType: 'INSERT',
requestId,
batchId,
orderNumber: record.orderNumber,
batchOffset: offset,
batchCount: batch.length,
error: error instanceof Error ? error.message : String(error)
})
}

View File

@@ -32,6 +32,8 @@ const PRODUCTION_ID_PATTERN = /^\d{2}[A-Z]\d{1,6}$/i
*/
const ORDER_NUMBER_PATTERN = /^SC\d{14}$/i
const RESOLUTION_QUERY_BATCH_SIZE = 1000
/**
* Database table and field names
* Loaded from config.yaml via ConfigManager
@@ -56,6 +58,14 @@ export class OrderNumberResolver {
this.dbService = dbService
}
private chunk<T>(items: T[], size: number): T[][] {
const chunks: T[][] = []
for (let index = 0; index < items.length; index += size) {
chunks.push(items.slice(index, index + size))
}
return chunks
}
/**
* Get table name based on database type
* Converts schema.tablename format to database-specific quoting:
@@ -156,30 +166,29 @@ export class OrderNumberResolver {
// P1: Deduplicate input productionIds to avoid redundant queries
const uniqueProductionIds = [...new Set(productionIds)]
// Use parameterized query to prevent SQL injection
const placeholders = uniqueProductionIds.map((_, i) => `@p${i}`).join(', ')
const params = uniqueProductionIds
const mappings = new Map<string, string>()
const batches = this.chunk(uniqueProductionIds, RESOLUTION_QUERY_BATCH_SIZE)
for (const batch of batches) {
let sql: string
const params = batch
if (this.dbService.type === 'sqlserver') {
// P0: Use DISTINCT to prevent duplicates from one-to-many relationships
// 使用 COLLATE 指定不区分大小写的排序规则
const placeholders = batch.map((_, i) => `@p${i}`).join(', ')
// P0: Use DISTINCT to prevent duplicates from one-to-many relationships.
sql = `SELECT DISTINCT [${dbConfig.FIELD_PRODUCTION_ID}], [${dbConfig.FIELD_ORDER_NUMBER}] FROM ${tableName} WHERE [${dbConfig.FIELD_PRODUCTION_ID}] COLLATE SQL_Latin1_General_CP1_CI_AS IN (${placeholders})`
} else if (this.dbService.type === 'postgresql') {
// PostgreSQL: 使用双引号保护中文标识符UPPER 实现不区分大小写
// 注意getTableName() 已返回带双引号的表名,不应再加引号
const pgPlaceholders = uniqueProductionIds.map((_, i) => `UPPER($${i + 1})`).join(', ')
// PostgreSQL: 使用双引号保护中文标识符UPPER 实现不区分大小写
const pgPlaceholders = batch.map((_, i) => `UPPER($${i + 1})`).join(', ')
sql = `SELECT DISTINCT "${dbConfig.FIELD_PRODUCTION_ID}", "${dbConfig.FIELD_ORDER_NUMBER}" FROM ${tableName} WHERE UPPER("${dbConfig.FIELD_PRODUCTION_ID}") IN (${pgPlaceholders})`
} else {
const idPlaceholders = uniqueProductionIds.map(() => 'UPPER(?)').join(', ')
// P0: Use DISTINCT to prevent duplicates from one-to-many relationships
// MySQL: 使用 UPPER 确保不区分大小写
const idPlaceholders = batch.map(() => 'UPPER(?)').join(', ')
// MySQL: 使用 UPPER 确保不区分大小写。
sql = `SELECT DISTINCT \`${dbConfig.FIELD_PRODUCTION_ID}\`, \`${dbConfig.FIELD_ORDER_NUMBER}\` FROM \`${tableName}\` WHERE UPPER(\`${dbConfig.FIELD_PRODUCTION_ID}\`) IN (${idPlaceholders})`
}
const result = await this.dbService.query(sql, params)
const mappings = new Map<string, string>()
for (const row of result.rows) {
const keys = Object.keys(row)
const prodId = row[keys[0]] as string
@@ -188,6 +197,7 @@ export class OrderNumberResolver {
mappings.set(prodId, orderNum)
}
}
}
return mappings
} catch (error) {
@@ -235,13 +245,14 @@ export class OrderNumberResolver {
// Build results while preserving original input order
// Note: Multiple productionIDs mapping to the same order number is VALID (not an error)
const results: OrderMapping[] = []
const processedInputs = new Set<string>()
for (const input of inputs) {
// Skip if this exact input was already processed
const alreadyProcessed = results.some((r) => r.input === input)
if (alreadyProcessed) {
if (processedInputs.has(input)) {
continue
}
processedInputs.add(input)
const mapping: OrderMapping = { input, resolved: false }

View File

@@ -173,6 +173,25 @@ describe('OrderNumberResolver', () => {
// Should be optimized to query unique values only
expect(mockDbService.query).toHaveBeenCalledTimes(1)
})
it('splits large mapping queries into bounded batches', async () => {
const largeInput = Array.from({ length: 1001 }, (_, i) => `22A${i}`)
vi.mocked(mockDbService.query).mockImplementation(async (_sql, params = []) => ({
rows: params.map((prodId, i) => ({
总排号: prodId,
: `SC7020260212${String(i).padStart(5, '0')}`
})),
columns: ['总排号', '生产订单号'],
rowCount: params.length
}))
await resolver.mapProductionIdsToOrderNumbers(largeInput)
expect(mockDbService.query).toHaveBeenCalledTimes(2)
expect(vi.mocked(mockDbService.query).mock.calls[0][1]).toHaveLength(1000)
expect(vi.mocked(mockDbService.query).mock.calls[1][1]).toHaveLength(1)
})
})
describe('error handling', () => {