perf: optimize order resolution history writes
This commit is contained in:
@@ -126,7 +126,9 @@ export function registerExtractorHandlers(): void {
|
|||||||
sendLog(sender, 'info', '正在解析订单号...')
|
sendLog(sender, 'info', '正在解析订单号...')
|
||||||
|
|
||||||
const resolver = new OrderNumberResolver(dbService)
|
const resolver = new OrderNumberResolver(dbService)
|
||||||
|
const resolutionStart = Date.now()
|
||||||
const mappings = await resolver.resolve(input.orderNumbers)
|
const mappings = await resolver.resolve(input.orderNumbers)
|
||||||
|
const resolutionDurationMs = Date.now() - resolutionStart
|
||||||
|
|
||||||
// Get valid order numbers and warnings
|
// Get valid order numbers and warnings
|
||||||
const validOrderNumbers = resolver.getValidOrderNumbers(mappings)
|
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
|
// Initialize operation history recording
|
||||||
const currentUser = SessionManager.getInstance().getUserInfo()
|
const currentUser = SessionManager.getInstance().getUserInfo()
|
||||||
@@ -155,6 +166,7 @@ export function registerExtractorHandlers(): void {
|
|||||||
|
|
||||||
// Save order records to history (preserve productionId -> orderNumber mapping)
|
// Save order records to history (preserve productionId -> orderNumber mapping)
|
||||||
if (currentUser) {
|
if (currentUser) {
|
||||||
|
const historyInsertStart = Date.now()
|
||||||
const orderRecords = mappings.map((m) => ({
|
const orderRecords = mappings.map((m) => ({
|
||||||
productionId: m.productionId || null,
|
productionId: m.productionId || null,
|
||||||
orderNumber: m.orderNumber || m.input
|
orderNumber: m.orderNumber || m.input
|
||||||
@@ -165,9 +177,11 @@ export function registerExtractorHandlers(): void {
|
|||||||
currentUser.username,
|
currentUser.username,
|
||||||
orderRecords
|
orderRecords
|
||||||
)
|
)
|
||||||
|
const historyInsertDurationMs = Date.now() - historyInsertStart
|
||||||
log.info('Operation history batch created', {
|
log.info('Operation history batch created', {
|
||||||
batchId,
|
batchId,
|
||||||
recordCount: orderRecords.length
|
recordCount: orderRecords.length,
|
||||||
|
durationMs: historyInsertDurationMs
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -126,35 +126,47 @@ export class ExtractorOperationHistoryDAO {
|
|||||||
recordCount: records.length
|
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 {
|
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 = `
|
const sqlString = `
|
||||||
INSERT INTO ${tableName}
|
INSERT INTO ${tableName}
|
||||||
(BatchId, UserId, Username, ProductionId, OrderNumber, OperationTime, Status)
|
(BatchId, UserId, Username, ProductionId, OrderNumber, OperationTime, Status)
|
||||||
VALUES
|
VALUES
|
||||||
(${dialect.param(0)}, ${dialect.param(1)}, ${dialect.param(2)}, ${dialect.param(3)}, ${dialect.param(4)}, ${dialect.currentTimestamp()}, 'pending')
|
${valuesSql.join(',\n ')}
|
||||||
`
|
`
|
||||||
await trackDuration(
|
await trackDuration(async () => await dbService.query(sqlString, params), {
|
||||||
async () =>
|
operationName: 'ExtractorOperationHistoryDAO.insertBatchRecords',
|
||||||
await dbService.query(sqlString, [
|
context: {
|
||||||
batchId,
|
tableName,
|
||||||
userId,
|
operationType: 'INSERT',
|
||||||
username,
|
batchId,
|
||||||
record.productionId || null,
|
batchOffset: offset,
|
||||||
record.orderNumber
|
batchCount: batch.length
|
||||||
]),
|
|
||||||
{
|
|
||||||
operationName: 'ExtractorOperationHistoryDAO.insertBatchRecords',
|
|
||||||
context: { tableName, operationType: 'INSERT', batchId }
|
|
||||||
}
|
}
|
||||||
)
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error('Error inserting individual record', {
|
log.error('Error inserting record batch', {
|
||||||
tableName,
|
tableName,
|
||||||
operationType: 'INSERT',
|
operationType: 'INSERT',
|
||||||
requestId,
|
requestId,
|
||||||
batchId,
|
batchId,
|
||||||
orderNumber: record.orderNumber,
|
batchOffset: offset,
|
||||||
|
batchCount: batch.length,
|
||||||
error: error instanceof Error ? error.message : String(error)
|
error: error instanceof Error ? error.message : String(error)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 ORDER_NUMBER_PATTERN = /^SC\d{14}$/i
|
||||||
|
|
||||||
|
const RESOLUTION_QUERY_BATCH_SIZE = 1000
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Database table and field names
|
* Database table and field names
|
||||||
* Loaded from config.yaml via ConfigManager
|
* Loaded from config.yaml via ConfigManager
|
||||||
@@ -56,6 +58,14 @@ export class OrderNumberResolver {
|
|||||||
this.dbService = dbService
|
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
|
* Get table name based on database type
|
||||||
* Converts schema.tablename format to database-specific quoting:
|
* Converts schema.tablename format to database-specific quoting:
|
||||||
@@ -156,36 +166,36 @@ export class OrderNumberResolver {
|
|||||||
// P1: Deduplicate input productionIds to avoid redundant queries
|
// P1: Deduplicate input productionIds to avoid redundant queries
|
||||||
const uniqueProductionIds = [...new Set(productionIds)]
|
const uniqueProductionIds = [...new Set(productionIds)]
|
||||||
|
|
||||||
// Use parameterized query to prevent SQL injection
|
|
||||||
const placeholders = uniqueProductionIds.map((_, i) => `@p${i}`).join(', ')
|
|
||||||
const params = uniqueProductionIds
|
|
||||||
|
|
||||||
let sql: string
|
|
||||||
if (this.dbService.type === 'sqlserver') {
|
|
||||||
// P0: Use DISTINCT to prevent duplicates from one-to-many relationships
|
|
||||||
// 使用 COLLATE 指定不区分大小写的排序规则
|
|
||||||
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(', ')
|
|
||||||
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 确保不区分大小写
|
|
||||||
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>()
|
const mappings = new Map<string, string>()
|
||||||
for (const row of result.rows) {
|
const batches = this.chunk(uniqueProductionIds, RESOLUTION_QUERY_BATCH_SIZE)
|
||||||
const keys = Object.keys(row)
|
|
||||||
const prodId = row[keys[0]] as string
|
for (const batch of batches) {
|
||||||
const orderNum = row[keys[1]] as string
|
let sql: string
|
||||||
if (prodId && orderNum) {
|
const params = batch
|
||||||
mappings.set(prodId, orderNum)
|
|
||||||
|
if (this.dbService.type === 'sqlserver') {
|
||||||
|
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 实现不区分大小写。
|
||||||
|
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 = 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)
|
||||||
|
|
||||||
|
for (const row of result.rows) {
|
||||||
|
const keys = Object.keys(row)
|
||||||
|
const prodId = row[keys[0]] as string
|
||||||
|
const orderNum = row[keys[1]] as string
|
||||||
|
if (prodId && orderNum) {
|
||||||
|
mappings.set(prodId, orderNum)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,13 +245,14 @@ export class OrderNumberResolver {
|
|||||||
// Build results while preserving original input order
|
// Build results while preserving original input order
|
||||||
// Note: Multiple productionIDs mapping to the same order number is VALID (not an error)
|
// Note: Multiple productionIDs mapping to the same order number is VALID (not an error)
|
||||||
const results: OrderMapping[] = []
|
const results: OrderMapping[] = []
|
||||||
|
const processedInputs = new Set<string>()
|
||||||
|
|
||||||
for (const input of inputs) {
|
for (const input of inputs) {
|
||||||
// Skip if this exact input was already processed
|
// Skip if this exact input was already processed
|
||||||
const alreadyProcessed = results.some((r) => r.input === input)
|
if (processedInputs.has(input)) {
|
||||||
if (alreadyProcessed) {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
processedInputs.add(input)
|
||||||
|
|
||||||
const mapping: OrderMapping = { input, resolved: false }
|
const mapping: OrderMapping = { input, resolved: false }
|
||||||
|
|
||||||
|
|||||||
@@ -173,6 +173,25 @@ describe('OrderNumberResolver', () => {
|
|||||||
// Should be optimized to query unique values only
|
// Should be optimized to query unique values only
|
||||||
expect(mockDbService.query).toHaveBeenCalledTimes(1)
|
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', () => {
|
describe('error handling', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user