Agent/server/src/upload/upload.js

293 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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 };