ASSET1D 完成三条 EA-DOC 受控导入
这个提交包含在:
@@ -5,6 +5,6 @@ ASSET1D-2,SYNC_ENV=0 公网部署,"使用既有 trialrun 部署流程发布 ASSE
|
|||||||
ASSET1D-3,公网资产台账基础核验,"部署后验证公网资产页、编号族、筛选和固定资产扫码入口未回归。","/ready 正常;资产台账页面可访问;FA/EA-DOC/EA-SYS/EA-REPO/EA-BKP 编号族配置已随 seed 生效;移动资产扫码入口不回归。","受影响流程:管理员进入资产台账 -> 查询筛选 -> 移动扫码入口;验证步骤:公网 /ready;必要时使用浏览器或现有 smoke 检查 /assets 和 /mobile/assets/scan;核对部署输出和页面加载。","curl|ssh|node|manual",DONE,DONE,DONE,"client/src/pages/AssetRegistryPage.tsx|client/src/pages/MobileAssetScanPage.tsx|server/src/scripts/seed-config.js","[""ASSET1D-2""]","2026-05-23 复检:公网 /ready 返回 ok;public bundle 扫描 scanned=57 focused=4 findings=0;远端只读核验资产编号族 FA/EA-DOC/EA-SYS/EA-REPO/EA-BKP 均启用,number_length=6,reset_period=yearly;当前资产卡 total=0,ASSET1D-4 UI 试录仍待人工完成;回滚路径:基础核验失败则暂停试录并按部署回滚。"
|
ASSET1D-3,公网资产台账基础核验,"部署后验证公网资产页、编号族、筛选和固定资产扫码入口未回归。","/ready 正常;资产台账页面可访问;FA/EA-DOC/EA-SYS/EA-REPO/EA-BKP 编号族配置已随 seed 生效;移动资产扫码入口不回归。","受影响流程:管理员进入资产台账 -> 查询筛选 -> 移动扫码入口;验证步骤:公网 /ready;必要时使用浏览器或现有 smoke 检查 /assets 和 /mobile/assets/scan;核对部署输出和页面加载。","curl|ssh|node|manual",DONE,DONE,DONE,"client/src/pages/AssetRegistryPage.tsx|client/src/pages/MobileAssetScanPage.tsx|server/src/scripts/seed-config.js","[""ASSET1D-2""]","2026-05-23 复检:公网 /ready 返回 ok;public bundle 扫描 scanned=57 focused=4 findings=0;远端只读核验资产编号族 FA/EA-DOC/EA-SYS/EA-REPO/EA-BKP 均启用,number_length=6,reset_period=yearly;当前资产卡 total=0,ASSET1D-4 UI 试录仍待人工完成;回滚路径:基础核验失败则暂停试录并按部署回滚。"
|
||||||
ASSET1D-4,最小资产 UI 试录,"管理员在公网 OA 通过 UI 手工创建 1 条 FA、1 条 EA-SYS、1 条 EA-REPO、1 条 EA-BKP,并检查查看、编辑、筛选。","四类资产均可创建和查看;EA-SYS/EA-REPO/EA-BKP 不保存 Secret、源码、完整配置或完整路径;FA 可进入既有领用、归还、盘点链路。","受影响流程:管理员新建资产 -> 填元数据 -> 保存 -> 列表筛选 -> 详情查看;验证步骤:ASSET1D_REPORT_DIR=artifacts/qa-flow/asset1d-public-trial-20260523-155346 LIVE_SMOKE_BASE_URL=https://oa.hyinda.com bash scripts/qa/run-asset1d-public-trial.sh。","manual|playwright",DONE,DONE,DONE,"external|client/src/pages/AssetRegistryPage.tsx|client/tests/e2e/manual/asset1d-public-trial.spec.ts|scripts/qa/run-asset1d-public-trial.sh","[""ASSET1D-3"",""ASSET1D-8""]","公网 UI 试录通过;报告 artifacts/qa-flow/asset1d-public-trial-20260523-155346/summary.json;创建并保留审计:FA-2026-000001、EA-SYS-2026-000001、EA-REPO-2026-000001、EA-BKP-2026-000001;未写 EA-DOC,未上传文件、源码、Secret、完整配置或完整路径;回滚路径:如需清理,按测试标记归档或删除未被业务引用的试录资产。"
|
ASSET1D-4,最小资产 UI 试录,"管理员在公网 OA 通过 UI 手工创建 1 条 FA、1 条 EA-SYS、1 条 EA-REPO、1 条 EA-BKP,并检查查看、编辑、筛选。","四类资产均可创建和查看;EA-SYS/EA-REPO/EA-BKP 不保存 Secret、源码、完整配置或完整路径;FA 可进入既有领用、归还、盘点链路。","受影响流程:管理员新建资产 -> 填元数据 -> 保存 -> 列表筛选 -> 详情查看;验证步骤:ASSET1D_REPORT_DIR=artifacts/qa-flow/asset1d-public-trial-20260523-155346 LIVE_SMOKE_BASE_URL=https://oa.hyinda.com bash scripts/qa/run-asset1d-public-trial.sh。","manual|playwright",DONE,DONE,DONE,"external|client/src/pages/AssetRegistryPage.tsx|client/tests/e2e/manual/asset1d-public-trial.spec.ts|scripts/qa/run-asset1d-public-trial.sh","[""ASSET1D-3"",""ASSET1D-8""]","公网 UI 试录通过;报告 artifacts/qa-flow/asset1d-public-trial-20260523-155346/summary.json;创建并保留审计:FA-2026-000001、EA-SYS-2026-000001、EA-REPO-2026-000001、EA-BKP-2026-000001;未写 EA-DOC,未上传文件、源码、Secret、完整配置或完整路径;回滚路径:如需清理,按测试标记归档或删除未被业务引用的试录资产。"
|
||||||
ASSET1D-5,EA-DOC 历史索引脱敏 dry-run,"从本地公司保密索引读取最多 3 条脱敏元数据,生成 dry-run 报告,不写公网 OA。","dry-run 报告状态为 PASS_REDACTED_INDEX_DRY_RUN_READY;报告仅含 code、docType、hash、敏感级别和脱敏要求,不含原文、完整路径、Secret 或账号凭据。","受影响流程:历史资料索引 -> 脱敏预览 -> 人工判断是否允许写入;验证步骤:node scripts/qa/collect-asset-redacted-index.mjs --limit=3。","node",DONE,DONE,DONE,"scripts/qa/collect-asset-redacted-index.mjs|artifacts/qa-flow/asset-redacted-index-*","[""ASSET1D-1""]","最新 dry-run 报告 artifacts/qa-flow/asset-redacted-index-20260523040650/summary.json;627 行中预览 3 行,均为 confidential;不含原文、完整路径、Secret 或账号凭据;未写 OA。"
|
ASSET1D-5,EA-DOC 历史索引脱敏 dry-run,"从本地公司保密索引读取最多 3 条脱敏元数据,生成 dry-run 报告,不写公网 OA。","dry-run 报告状态为 PASS_REDACTED_INDEX_DRY_RUN_READY;报告仅含 code、docType、hash、敏感级别和脱敏要求,不含原文、完整路径、Secret 或账号凭据。","受影响流程:历史资料索引 -> 脱敏预览 -> 人工判断是否允许写入;验证步骤:node scripts/qa/collect-asset-redacted-index.mjs --limit=3。","node",DONE,DONE,DONE,"scripts/qa/collect-asset-redacted-index.mjs|artifacts/qa-flow/asset-redacted-index-*","[""ASSET1D-1""]","最新 dry-run 报告 artifacts/qa-flow/asset-redacted-index-20260523040650/summary.json;627 行中预览 3 行,均为 confidential;不含原文、完整路径、Secret 或账号凭据;未写 OA。"
|
||||||
ASSET1D-6,EA-DOC 三条受控导入二次确认,"UI 试录和 dry-run 通过后,再向用户确认是否开启 ASSET_INDEX_IMPORT_WRITE=1 写入 3 条脱敏 EA-DOC。","未获得二次确认前不得写入;若用户批准,必须使用进程级 ASSET_INDEX_IMPORT_WRITE=1、管理员权限、必填主数据,并验证审计日志。","受影响流程:脱敏预览 -> 人工批准 -> 受控导入 -> 审计核验;验证步骤:未批准时记录 PAUSED_MANUAL_CONFIRMATION;批准后调用 POST /api/assets/import/redacted-doc-index 并核对 imported/skipped 和审计。","manual|node|curl",TODO,TODO,TODO,"server/src/routes/assets.js|external","[""ASSET1D-4"",""ASSET1D-5""]","当前未执行 live 导入;等待用户 A/B/C 二次确认;回滚路径为按导入批次删除未被业务引用的 EA-DOC 台账或改为 archived。"
|
ASSET1D-6,EA-DOC 三条受控导入二次确认,"UI 试录和 dry-run 通过后,再向用户确认是否开启 ASSET_INDEX_IMPORT_WRITE=1 写入 3 条脱敏 EA-DOC。","未获得二次确认前不得写入;若用户批准,必须使用进程级 ASSET_INDEX_IMPORT_WRITE=1、管理员权限、必填主数据,并验证审计日志。","受影响流程:脱敏预览 -> 人工批准 -> 受控导入 -> 审计核验;验证步骤:node scripts/qa/import-asset-redacted-index.mjs --limit=3;ASSET_INDEX_IMPORT_WRITE=1 node scripts/qa/import-asset-redacted-index.mjs --apply --limit=3;curl -fsS https://oa.hyinda.com/ready。","manual|node|curl",DONE,DONE,DONE,"server/src/routes/assets.js|scripts/qa/import-asset-redacted-index.mjs|external","[""ASSET1D-4"",""ASSET1D-5""]","用户选择 A 后已执行受控写入;dry-run 报告 artifacts/qa-flow/asset-redacted-index-import-dryrun-20260523-162244/summary.json;live 报告 artifacts/qa-flow/asset-redacted-index-import-live-20260523-162358/summary.json;创建 EA-DOC-2026-000001/000002/000003,imported=3,audit importedItems=3 batchRecords=1,privacySmoke=PASS;写入后已恢复远端 ASSET_INDEX_IMPORT_WRITE 关闭并复检 /ready;回滚路径为按导入批次删除未被业务引用的 EA-DOC 台账或改为 archived。"
|
||||||
ASSET1D-7,ASSET1D 收口与提交,"汇总本地门禁、部署结果、dry-run 报告、人工 UI 待办和二次确认状态,并用中文提交 Issue CSV 状态。","自动项完成并回填状态;人工项如未完成保持 TODO 或 PAUSED 说明;git commit 信息中文并引用 ASSET1D。","受影响流程:部署验收 -> 资产试录 -> 证据归档 -> 后续人工导入决策;验证步骤:git status;git diff --check;提交后 git status 干净。","git|manual",DONE,DONE,DONE,"issues/2026-05-23_10-42-03-oa-asset-deploy-and-trial-v1.csv","[""ASSET1D-2"",""ASSET1D-5""]","ASSET1D-4 公网 UI 试录已完成;ASSET1D-6 live 导入确认保持 TODO;本次提交记录合同与事实状态,不伪造 EA-DOC 写入;回滚路径:提交前发现阻断则保持 TODO 并不推进 EA-DOC 写入。"
|
ASSET1D-7,ASSET1D 收口与提交,"汇总本地门禁、部署结果、dry-run 报告、人工 UI 待办和二次确认状态,并用中文提交 Issue CSV 状态。","自动项完成并回填状态;人工项如未完成保持 TODO 或 PAUSED 说明;git commit 信息中文并引用 ASSET1D。","受影响流程:部署验收 -> 资产试录 -> 证据归档 -> 后续人工导入决策;验证步骤:git status;git diff --check;提交后 git status 干净。","git|manual",DONE,DONE,DONE,"issues/2026-05-23_10-42-03-oa-asset-deploy-and-trial-v1.csv","[""ASSET1D-2"",""ASSET1D-5""]","ASSET1D-4 公网 UI 试录和 ASSET1D-6 三条 EA-DOC 受控导入已完成;本次提交记录合同与事实状态;回滚路径:提交前发现阻断则保持 TODO,已写入 EA-DOC 如需清理按导入批次删除未被业务引用的台账或改 archived。"
|
||||||
ASSET1D-8,资产创建 metadata 校验 500 修复,"公网 ASSET1D-4 UI 试录保存资产时,POST /api/assets 因 Zod v4 record schema 写法不兼容返回 500,需要修复为参数校验可解析 metadata。","资产创建接口带 metadata 对象时不再 500;公网 UI 可继续试录 FA/EA-SYS/EA-REPO/EA-BKP;不改变权限模型或数据迁移。","受影响流程:管理员新建资产 -> 填写 metadata/保密说明 -> 保存 -> 列表回看;验证步骤:cd server && node --check src/routes/assets.js;cd server && npm run test -- tests/unit/routes/assets.validation.test.js;公网重跑 scripts/qa/run-asset1d-public-trial.sh。","node|vitest|playwright",DONE,DONE,DONE,"server/src/routes/assets.js;server/tests/unit/routes/assets.validation.test.js","[""ASSET1D-3""]","已将 metadata schema 修正为 Zod v4 兼容写法并部署;目标单测通过,公网 UI 试录复跑通过;初始失败 requestId 56d66b2c-02b5-4a99-8d8f-709c1d76b190;回滚路径:恢复 metadata schema 写法并将 ASSET1D-4 保持 blocked。"
|
ASSET1D-8,资产创建 metadata 校验 500 修复,"公网 ASSET1D-4 UI 试录保存资产时,POST /api/assets 因 Zod v4 record schema 写法不兼容返回 500,需要修复为参数校验可解析 metadata。","资产创建接口带 metadata 对象时不再 500;公网 UI 可继续试录 FA/EA-SYS/EA-REPO/EA-BKP;不改变权限模型或数据迁移。","受影响流程:管理员新建资产 -> 填写 metadata/保密说明 -> 保存 -> 列表回看;验证步骤:cd server && node --check src/routes/assets.js;cd server && npm run test -- tests/unit/routes/assets.validation.test.js;公网重跑 scripts/qa/run-asset1d-public-trial.sh。","node|vitest|playwright",DONE,DONE,DONE,"server/src/routes/assets.js;server/tests/unit/routes/assets.validation.test.js","[""ASSET1D-3""]","已将 metadata schema 修正为 Zod v4 兼容写法并部署;目标单测通过,公网 UI 试录复跑通过;初始失败 requestId 56d66b2c-02b5-4a99-8d8f-709c1d76b190;回滚路径:恢复 metadata schema 写法并将 ASSET1D-4 保持 blocked。"
|
||||||
|
|||||||
|
+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);
|
||||||
|
});
|
||||||
在新工单中引用
屏蔽一个用户