From a4096806cbaf8b6afd3e4326b3f7340a7c5e1c37 Mon Sep 17 00:00:00 2001 From: shunli Date: Sat, 23 May 2026 16:31:06 +0800 Subject: [PATCH] =?UTF-8?q?ASSET1D=20=E5=AE=8C=E6=88=90=E4=B8=89=E6=9D=A1?= =?UTF-8?q?=20EA-DOC=20=E5=8F=97=E6=8E=A7=E5=AF=BC=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ..._10-42-03-oa-asset-deploy-and-trial-v1.csv | 4 +- scripts/qa/import-asset-redacted-index.mjs | 362 ++++++++++++++++++ 2 files changed, 364 insertions(+), 2 deletions(-) create mode 100755 scripts/qa/import-asset-redacted-index.mjs diff --git a/issues/2026-05-23_10-42-03-oa-asset-deploy-and-trial-v1.csv b/issues/2026-05-23_10-42-03-oa-asset-deploy-and-trial-v1.csv index f24f1453..74db329c 100644 --- a/issues/2026-05-23_10-42-03-oa-asset-deploy-and-trial-v1.csv +++ b/issues/2026-05-23_10-42-03-oa-asset-deploy-and-trial-v1.csv @@ -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-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-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-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-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 三条 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。" diff --git a/scripts/qa/import-asset-redacted-index.mjs b/scripts/qa/import-asset-redacted-index.mjs new file mode 100755 index 00000000..82e07b60 --- /dev/null +++ b/scripts/qa/import-asset-redacted-index.mjs @@ -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); +});