'use strict'; const { randomUUID } = require('crypto'); const { MODULE_REGISTRY } = require('@profile/shared'); const db = require('../infra/db'); const sync = require('../sync/sync'); const registry = require('../precompute/registry'); const precomputeService = require('../precompute/service'); const dashboardSummary = require('../precompute/dashboard-summary'); const audit = require('../audit/audit'); const crypto = require('../common/crypto'); const { getColumns, monthWhere, buildMultiInsert, chunk } = require('../common/table-utils'); const { ApiError, API_CODES } = require('../common/api-codes'); const INSERT_CHUNK = 500; // employees 明文 → 密文列映射(§10.2)。address/emergency 无等值查询需求,不建 hmac。 const EMPLOYEE_PII = [ { plain: 'id_card', enc: 'id_card_enc', hmac: 'id_card_hmac' }, { plain: 'mobile', enc: 'mobile_enc', hmac: 'mobile_hmac' }, { plain: 'address', enc: 'address_enc' }, { plain: 'emergency_contact', enc: 'emergency_contact_enc' }, ]; function findModule(moduleKey) { const desc = MODULE_REGISTRY.find((m) => m.key === moduleKey); if (!desc) throw new ApiError(API_CODES.NOT_FOUND, `未知模块:${moduleKey}`, 404); return desc; } /** employees 行:加密 PII 明文列,剥离明文,生成 *_enc / *_hmac */ function encryptEmployeeRow(row) { const out = { ...row }; for (const f of EMPLOYEE_PII) { if (out[f.plain] != null && out[f.plain] !== '') { out[f.enc] = crypto.encrypt(out[f.plain]); if (f.hmac) out[f.hmac] = crypto.hmac(out[f.plain]); } delete out[f.plain]; } return out; } /** * 通用上传(选 A:入参为已解析的 JSON 行数组;Excel 解析后续接入)。 * 阶段 1:短事务写原始表;阶段 2:导入即算(有 Precomputer 的模块)。 * * @param {{ module: string, year?: number, month?: number, rows: Object[], operator: { id:number, username:string }, meta: { ip?:string, userAgent?:string } }} args */ async function upload({ module, year, month, rows, operator, meta }) { const desc = findModule(module); if (!Array.isArray(rows) || rows.length === 0) { throw new ApiError(API_CODES.BAD_PARAMS, 'rows 不能为空(防止误清空整表)', 400); } if (desc.uploadMode === 'monthly' && (year == null || month == null)) { throw new ApiError(API_CODES.BAD_PARAMS, `模块 ${module} 为按月上传,需提供 year / month`, 400); } const table = desc.rawTable; const columns = await getColumns(table); if (columns.length === 0) throw new ApiError(API_CODES.NOT_FOUND, `原始表不存在:${table}`, 404); let preparedRows = module === 'employees' ? rows.map(encryptEmployeeRow) : rows; if (module === 'employees') { const existingRows = await db.query( `SELECT employee_id, id_card_enc, id_card_hmac, mobile_enc, mobile_hmac, address_enc, emergency_contact_enc FROM employees`, ); const existingByEmployee = new Map(existingRows.map((row) => [String(row.employee_id), row])); preparedRows = preparedRows.map((row) => { const existing = existingByEmployee.get(String(row.employee_id)); if (!existing) return row; const merged = { ...row }; for (const column of [ 'id_card_enc', 'id_card_hmac', 'mobile_enc', 'mobile_hmac', 'address_enc', 'emergency_contact_enc', ]) { if (merged[column] == null && existing[column] != null) merged[column] = existing[column]; } return merged; }); } const batchUuid = randomUUID(); // ---- 阶段 1:原始数据事务(短) ---- const batchId = await db.transaction(async (conn) => { const [ins] = await conn.execute( `INSERT INTO upload_batches (batch_uuid, module, year, month, record_count, status, operator_id, locked_at) VALUES (?, ?, ?, ?, ?, 'lock-writing', ?, NOW())`, [batchUuid, module, year ?? null, month ?? null, preparedRows.length, operator.id], ); // 同 (module, year, month) 并发串行化(§5.1) await conn.execute( `SELECT id FROM upload_batches WHERE module = ? AND (year <=> ?) AND (month <=> ?) AND status IN ('lock-writing','precomputing') FOR UPDATE`, [module, year ?? null, month ?? null], ); // 删旧:按月覆盖 / 全量镜像替换(§5.1 幂等:同月重复上传=覆盖) if (desc.uploadMode === 'monthly') { const mw = monthWhere(columns, year, month); if (mw.clause) await conn.execute(`DELETE FROM \`${table}\` WHERE ${mw.clause}`, mw.params); } else { await conn.execute(`DELETE FROM \`${table}\``); } // 写新(分批) for (const part of chunk(preparedRows, INSERT_CHUNK)) { const built = buildMultiInsert(table, columns, part); if (built) await conn.execute(built.sql, built.params); } await conn.execute( `UPDATE upload_batches SET status = 'precomputing' WHERE id = ?`, [ins.insertId], ); return ins.insertId; }); await audit.log({ userId: operator.id, username: operator.username, clientType: 'ui', action: 'upload', module, targetType: 'upload_batch', targetId: batchUuid, payload: { year, month, recordCount: preparedRows.length }, ip: meta.ip, userAgent: meta.userAgent, }); // ---- 阶段 2:仅同步业务表,预计算改为用户手动触发(设置页全量/单月计算)---- let report = null; try { await sync.bumpVersions([table]); await db.execute(`UPDATE upload_batches SET status = 'completed', completed_at = NOW() WHERE id = ?`, [batchId]); } catch (err) { await db.execute(`UPDATE upload_batches SET status = 'failed' WHERE id = ?`, [batchId]); await audit.log({ userId: operator.id, username: operator.username, clientType: 'system', action: 'upload_precompute_failed', module, targetId: batchUuid, payload: { error: err.message }, }); throw new ApiError(API_CODES.SERVER_ERROR, `原始数据已入库,但预计算失败:${err.message}(可重试 recompute)`, 500); } return { batchId, batchUuid, recordCount: preparedRows.length, status: 'completed', report }; } async function clearData({ module, year, month, operator, meta }) { const desc = findModule(module); if (desc.uploadMode === 'monthly' && (year == null || month == null)) { throw new ApiError(API_CODES.BAD_PARAMS, `模块 ${module} 为按月数据,清空时需提供 year / month`, 400); } const table = desc.rawTable; const columns = await getColumns(table); let result; if (desc.uploadMode === 'monthly') { const mw = monthWhere(columns, year, month); if (!mw.clause) throw new ApiError(API_CODES.BAD_PARAMS, `表 ${table} 无法按年月清空`, 400); result = await db.execute(`DELETE FROM \`${table}\` WHERE ${mw.clause}`, mw.params); } else { result = await db.execute(`DELETE FROM \`${table}\``); } await sync.bumpVersions([table]); await audit.log({ userId: operator.id, username: operator.username, clientType: 'ui', action: 'clear', module, targetType: desc.uploadMode === 'monthly' ? 'month' : 'table', targetId: desc.uploadMode === 'monthly' ? `${year}-${String(month).padStart(2, '0')}` : table, payload: { deletedCount: result.affectedRows || 0 }, ip: meta.ip, userAgent: meta.userAgent, }); return { module, year: year ?? null, month: month ?? null, deletedCount: result.affectedRows || 0 }; } async function uploadPracticalPaperSteps({ rows, operator, meta }) { if (!Array.isArray(rows) || rows.length === 0) { throw new ApiError(API_CODES.BAD_PARAMS, 'paperSteps 不能为空', 400); } const groups = new Map(); for (const row of rows) { const uuid = String(row.paperUUID || row.paperUuid || `legacy-${row.paperId || ''}`).trim(); if (!uuid || uuid === 'legacy-') { throw new ApiError(API_CODES.BAD_PARAMS, '标准步骤缺少 paperUUID / paperId', 400); } if (!groups.has(uuid)) groups.set(uuid, []); groups.get(uuid).push(row); } const report = await db.transaction(async (conn) => { let stepCount = 0; for (const [uuid, steps] of groups) { const first = steps[0]; const scenarioCount = Math.max(...steps.map((row) => Number(row.scenarioIndex) || 0)) + 1; const [paperResult] = await conn.execute( `INSERT INTO papers (uuid, name, class, scenario_count, status, details) VALUES (?, ?, ?, ?, 'active', ?) ON DUPLICATE KEY UPDATE id = LAST_INSERT_ID(id), name = VALUES(name), class = VALUES(class), scenario_count = VALUES(scenario_count), details = VALUES(details)`, [ uuid, first.paperName || uuid, first.paperClass || null, scenarioCount, JSON.stringify({ examType: first.examType || null, legacyPaperId: first.paperId ?? null }), ], ); const paperId = paperResult.insertId; await conn.execute('DELETE FROM paper_steps WHERE paper_id = ?', [paperId]); const normalized = steps.map((row) => ({ paper_id: paperId, scenario_index: Number(row.scenarioIndex) || 0, step_index: Number(row.stepIndex) || 0, operation: row.stepName || row.targetOperation || null, target: row.targetDevice || null, criteria: row.stepDescription || row.scenarioDescription || null, score_weight: Number(row.stepScore) >= 0 ? Number(row.stepScore) : null, details: { scenarioId: row.scenarioId ?? null, scenarioName: row.scenarioName || null, stepId: row.stepId ?? null, targetOperation: row.targetOperation || null, operationValue: row.operationValue || null, deviceType: row.deviceType || null, deviceTypeName: row.deviceTypeName || null, deviceId: row.deviceId || null, itemId: row.itemId || null, faultTolerance: row.faultTolerance ?? null, timeout: row.timeout ?? null, }, })); for (const part of chunk(normalized, INSERT_CHUNK)) { const built = buildMultiInsert('paper_steps', await getColumns('paper_steps'), part); if (built) await conn.execute(built.sql, built.params); } stepCount += normalized.length; } return { paperCount: groups.size, stepCount }; }); await sync.bumpVersions(['papers', 'paper_steps']); await audit.log({ userId: operator.id, username: operator.username, clientType: 'ui', action: 'upload', module: 'practical_exams', targetType: 'paper_steps', payload: report, ip: meta.ip, userAgent: meta.userAgent, }); return report; } async function getBatch(id) { const rows = await db.query('SELECT * FROM upload_batches WHERE id = ? LIMIT 1', [id]); if (!rows[0]) throw new ApiError(API_CODES.BATCH_NOT_FOUND, `上传批次不存在:${id}`, 404); return rows[0]; } async function listBatches(moduleKey, limit = 50) { const safeLimit = Math.min(Math.max(Number(limit) || 50, 1), 200); if (moduleKey) { return db.query( `SELECT * FROM upload_batches WHERE module = ? ORDER BY id DESC LIMIT ${safeLimit}`, [moduleKey], ); } return db.query(`SELECT * FROM upload_batches ORDER BY id DESC LIMIT ${safeLimit}`); } module.exports = { upload, clearData, uploadPracticalPaperSteps, getBatch, listBatches };