293 lines
11 KiB
JavaScript
293 lines
11 KiB
JavaScript
'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 };
|