ASSET1D 完成三条 EA-DOC 受控导入

这个提交包含在:
2026-05-23 16:31:06 +08:00
父节点 c76001c8d2
当前提交 a4096806cb
共修改 2 个文件,包含 364 行新增和 2 行删除
+362
查看文件
@@ -0,0 +1,362 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
const DEFAULT_BASE_URL = "https://oa.hyinda.com";
const DEFAULT_INDEX =
"/home/shunli/Projects/faculty-daily-assistant/公司/00_保密索引/内部资料保密索引_含真实路径_不得外发.csv";
const DEFAULT_ENV_FILE = path.join(process.env.HOME || "", ".hyb-oa/live-smoke.env");
function argValue(name, fallback = null) {
const prefix = `${name}=`;
const found = process.argv.slice(2).find((item) => item === name || item.startsWith(prefix));
if (!found) return fallback;
if (found === name) return "1";
return found.slice(prefix.length);
}
function hasArg(name) {
return process.argv.slice(2).includes(name);
}
function parseEnvLine(line) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) return null;
const index = trimmed.indexOf("=");
if (index < 1) return null;
const key = trimmed.slice(0, index).trim();
let value = trimmed.slice(index + 1).trim();
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
return { key, value };
}
function loadEnvFile(filePath) {
if (!filePath || !fs.existsSync(filePath)) return;
for (const line of fs.readFileSync(filePath, "utf8").split(/\r?\n/)) {
const parsed = parseEnvLine(line);
if (!parsed) continue;
if (process.env[parsed.key] == null) {
process.env[parsed.key] = parsed.value;
}
}
}
function parseCsvLine(line) {
const cells = [];
let current = "";
let quoted = false;
for (let index = 0; index < line.length; index += 1) {
const char = line[index];
const next = line[index + 1];
if (quoted && char === '"' && next === '"') {
current += '"';
index += 1;
continue;
}
if (char === '"') {
quoted = !quoted;
continue;
}
if (!quoted && char === ",") {
cells.push(current);
current = "";
continue;
}
current += char;
}
cells.push(current);
return cells;
}
function parseCsv(content) {
const lines = content.replace(/^\uFEFF/, "").split(/\r?\n/).filter(Boolean);
if (!lines.length) return [];
const headers = parseCsvLine(lines[0]).map((item) => item.trim());
return lines.slice(1).map((line) => {
const values = parseCsvLine(line);
return Object.fromEntries(headers.map((header, index) => [header, values[index] ?? ""]));
});
}
function mapSensitivity(level) {
if (level === "S4") return "strictly_confidential";
if (level === "S3") return "confidential";
if (level === "S1") return "public_candidate";
return "internal";
}
function buildItems(input, limit) {
const rows = parseCsv(fs.readFileSync(input, "utf8"));
const selected = rows.slice(0, Number.isFinite(limit) && limit > 0 ? limit : 3);
return {
totalRows: rows.length,
items: selected.map((row, index) => ({
code: row.code || null,
name: row.doc_type ? `${row.doc_type}-${row.code || index + 1}` : `电子资料-${row.code || index + 1}`,
extension: row.extension || null,
docType: row.doc_type || null,
sourceHash: row.sha256_12 || null,
sensitivityLevel: mapSensitivity(row.level),
detectedCategories: row.detected_categories || null,
requiredRedaction: row.required_redaction || null,
verificationNo: null,
})),
};
}
async function fetchJson(url, init = {}) {
const response = await fetch(url, {
...init,
signal: AbortSignal.timeout(30_000),
});
const text = await response.text();
let json = null;
try {
json = text ? JSON.parse(text) : null;
} catch {
json = null;
}
return { ok: response.ok, status: response.status, json, text: text.slice(0, 200) };
}
async function login(baseUrl) {
const identifier = process.env.LIVE_SMOKE_ADMIN_IDENTIFIER?.trim();
const password = process.env.LIVE_SMOKE_ADMIN_PASSWORD?.trim();
if (!identifier || !password) {
throw new Error("missing LIVE_SMOKE_ADMIN_IDENTIFIER/PASSWORD");
}
const response = await fetchJson(`${baseUrl}/api/auth/login`, {
method: "POST",
headers: {
"content-type": "application/json",
"x-device-fingerprint": "asset1d-ea-doc-import",
},
body: JSON.stringify({ identifier, password }),
});
const accessToken = response.json?.data?.accessToken;
if (!response.ok || typeof accessToken !== "string" || !accessToken) {
throw new Error(`admin login failed: HTTP ${response.status} ${response.json?.error?.code ?? ""}`.trim());
}
return accessToken;
}
async function apiGet(baseUrl, token, urlPath) {
const response = await fetchJson(`${baseUrl}${urlPath}`, {
headers: { accept: "application/json", authorization: `Bearer ${token}` },
});
if (!response.ok) {
throw new Error(`GET ${urlPath} failed: HTTP ${response.status} ${response.json?.error?.code ?? ""}`.trim());
}
return response.json?.data;
}
async function apiPost(baseUrl, token, urlPath, data) {
const response = await fetchJson(`${baseUrl}${urlPath}`, {
method: "POST",
headers: {
accept: "application/json",
authorization: `Bearer ${token}`,
"content-type": "application/json",
},
body: JSON.stringify(data),
});
if (!response.ok) {
const code = response.json?.error?.code ?? "";
const details = JSON.stringify(response.json?.error?.details ?? {}).slice(0, 240);
throw new Error(`POST ${urlPath} failed: HTTP ${response.status} ${code} ${details}`.trim());
}
return response.json?.data;
}
function chooseAssetCategory(items, requestedCode) {
const active = items.filter((item) => item.enabled && !item.archivedAt);
if (requestedCode) {
const matched = active.find((item) => item.code === requestedCode);
if (!matched) throw new Error(`asset category not found or inactive: ${requestedCode}`);
return matched;
}
const asset1d = active
.filter((item) => /^ASSET1D_/i.test(item.code))
.sort((a, b) => String(b.createdAt || b.code).localeCompare(String(a.createdAt || a.code)));
if (asset1d[0]) return asset1d[0];
if (active[0]) return active[0];
throw new Error("no active asset category available");
}
function chooseLegalEntity(items, requestedCode) {
const active = items.filter((item) => item.enabled && !item.archivedAt);
if (requestedCode) {
const matched = active.find((item) => item.code === requestedCode);
if (!matched) throw new Error(`legal entity not found or inactive: ${requestedCode}`);
return matched;
}
if (active[0]) return active[0];
throw new Error("no active legal entity available");
}
function chooseCostCenter(items, legalEntityId, requestedCode) {
const active = items.filter((item) => item.enabled && !item.archivedAt && item.legalEntityId === legalEntityId);
if (requestedCode) {
const matched = active.find((item) => item.code === requestedCode);
if (!matched) throw new Error(`cost center not found or inactive: ${requestedCode}`);
return matched;
}
if (active[0]) return active[0];
throw new Error("no active cost center available for selected legal entity");
}
function redactImportItems(items) {
return items.map((item) => ({
code: item.code,
status: item.status,
assetNo: item.assetNo ?? null,
sourceHash: item.sourceHash ?? null,
}));
}
async function verifyAssets(baseUrl, token, importedItems) {
const verified = [];
for (const item of importedItems) {
if (!item.assetNo) continue;
const data = await apiGet(
baseUrl,
token,
`/api/assets?assetType=electronic_document&q=${encodeURIComponent(item.assetNo)}`,
);
const found = (data?.items ?? []).find((candidate) => candidate.assetNo === item.assetNo);
verified.push({
assetNo: item.assetNo,
status: found ? "PASS" : "FAIL",
assetType: found?.assetType ?? null,
sensitivityLevel: found?.sensitivityLevel ?? null,
});
}
return verified;
}
async function verifyAudit(baseUrl, token, sinceIso) {
const query = new URLSearchParams({
page: "1",
pageSize: "50",
targetType: "asset_card",
dateFrom: sinceIso,
});
const data = await apiGet(baseUrl, token, `/api/audit-logs?${query.toString()}`);
const actions = (data?.items ?? []).map((item) => item.action);
return {
importedItems: actions.filter((action) => action === "ASSET_REDACTED_DOC_INDEX_IMPORTED").length,
batchRecords: actions.filter((action) => action === "ASSET_REDACTED_DOC_INDEX_IMPORT_BATCH").length,
};
}
function privacySmoke(summaryText) {
const patterns = [
/LIVE_SMOKE_[A-Z0-9_]*PASSWORD/i,
/WECOM_[A-Z0-9_]*(SECRET|TOKEN)/i,
/ASSET_INDEX_IMPORT_WRITE=1/i,
/\/home\/shunli\/Projects\/faculty-daily-assistant\/公司\//,
/1[3-9]\d{9}/,
/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/,
];
return patterns.some((pattern) => pattern.test(summaryText)) ? "FAIL" : "PASS";
}
async function main() {
if (process.env.ASSET1D_SKIP_ENV_FILE !== "1") {
loadEnvFile(argValue("--env-file", DEFAULT_ENV_FILE));
}
const apply = hasArg("--apply");
if (apply && process.env.ASSET_INDEX_IMPORT_WRITE !== "1") {
throw new Error("--apply requires local ASSET_INDEX_IMPORT_WRITE=1");
}
const baseUrl = (argValue("--base-url", process.env.LIVE_SMOKE_BASE_URL || DEFAULT_BASE_URL) || DEFAULT_BASE_URL)
.trim()
.replace(/\/$/, "");
const input = argValue("--input", DEFAULT_INDEX);
const limit = Number(argValue("--limit", "3"));
const reportDir = path.resolve(
argValue(
"--report-dir",
`artifacts/qa-flow/asset-redacted-index-import-${new Date().toISOString().replace(/[-:.TZ]/g, "").slice(0, 14)}`,
),
);
fs.mkdirSync(reportDir, { recursive: true });
const checkedAt = new Date().toISOString();
const { totalRows, items } = buildItems(input, limit);
const token = await login(baseUrl);
const [categoryData, legalData] = await Promise.all([
apiGet(baseUrl, token, "/api/asset-categories?includeArchived=1"),
apiGet(baseUrl, token, "/api/master-data/legal-entities?enabled=true"),
]);
const category = chooseAssetCategory(categoryData?.items ?? [], argValue("--category-code"));
const legalEntity = chooseLegalEntity(legalData?.items ?? [], argValue("--legal-entity-code"));
const costCenterData = await apiGet(
baseUrl,
token,
`/api/master-data/cost-centers?enabled=true&legalEntityId=${encodeURIComponent(legalEntity.id)}`,
);
const costCenter = chooseCostCenter(costCenterData?.items ?? [], legalEntity.id, argValue("--cost-center-code"));
const importStartedAt = new Date().toISOString();
const importResult = await apiPost(baseUrl, token, "/api/assets/import/redacted-doc-index", {
dryRun: !apply,
write: apply,
categoryId: category.id,
legalEntityId: legalEntity.id,
costCenterId: costCenter.id,
items,
});
const resultItems = importResult?.items ?? [];
const verifiedAssets = apply ? await verifyAssets(baseUrl, token, resultItems) : [];
const audit = apply ? await verifyAudit(baseUrl, token, importStartedAt) : null;
const summary = {
issueId: "ASSET1D-6",
status: apply ? "PASS_EA_DOC_IMPORT_READY" : "PASS_EA_DOC_IMPORT_DRY_RUN_READY",
mode: apply ? "asset-redacted-index-import-write" : "asset-redacted-index-import-dry-run",
baseUrl,
checkedAt,
totalRows,
selectedRows: items.length,
imported: importResult?.imported ?? 0,
selectedMasterData: {
categoryCode: category.code,
legalEntityCode: legalEntity.code,
costCenterCode: costCenter.code,
},
items: redactImportItems(resultItems),
verifiedAssets,
audit,
boundaries: {
noFileUpload: true,
noRawContent: true,
noFullPathOutput: true,
noSecretOutput: true,
noPasswordOutput: true,
noSourceBundleUpload: true,
writeSwitchRequired: true,
publicOaAuditRetained: apply,
},
};
const summaryText = `${JSON.stringify(summary, null, 2)}\n`;
summary.privacySmoke = privacySmoke(summaryText);
fs.writeFileSync(path.join(reportDir, "summary.json"), `${JSON.stringify(summary, null, 2)}\n`, "utf8");
console.log(JSON.stringify(summary, null, 2));
if (summary.privacySmoke !== "PASS") process.exit(1);
if (apply && (verifiedAssets.some((item) => item.status !== "PASS") || (audit?.importedItems ?? 0) < 1)) {
process.exit(1);
}
}
main().catch((error) => {
console.error(`[asset-redacted-index-import][ERROR] ${error instanceof Error ? error.message : String(error)}`);
process.exit(1);
});