#!/usr/bin/env bash set -u -o pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" PUBLIC_BASE_URL="${LIVE_SMOKE_BASE_URL:-https://oa.hyinda.com}" REPORT_DIR="${OALAUNCH_REPORT_DIR:-$ROOT_DIR/artifacts/qa-flow/oa-final-launch-readiness-$(date +%Y%m%d-%H%M%S)}" SUMMARY_TXT="$REPORT_DIR/summary.txt" STATUS=0 mkdir -p "$REPORT_DIR" : >"$SUMMARY_TXT" log() { printf '%s\n' "$*" | tee -a "$SUMMARY_TXT" } run_step() { local name="$1" shift local log_file="$REPORT_DIR/${name}.log" log ">>> [oalaunch1] $name" if "$@" > >(tee "$log_file") 2>&1; then log "[oalaunch1][PASS] $name" else local code="$?" log "[oalaunch1][FAIL] $name exit=$code" STATUS=1 fi } write_manual_gates() { cat >"$REPORT_DIR/manual-gates.md" <<'EOF' # OALAUNCH1 最终启用人工门禁 ## Day 7 Go/No-Go - A:接受 `PASS_OA_LAUNCH_READY`,进入小团队正式启用。 - B:接受 `PASS_WITH_FINDINGS`,但必须指定 owner、处理日期和接受理由。 - C:暂停启用,先整改。 ## 阻断处理 - A:当天新建修复合同并修复。 - B:临时人工绕行,记录风险、补录责任人和补录方式。 - C:暂停对应流程。 ## 备份恢复演练 - A:接受恢复演练结果,进入 Go/No-Go。 - B:修复后重跑恢复演练。 - C:暂停正式启用。 ## 必须补齐的业务证据 - 至少 1 条真实报销 PDF OCR -> 审批 -> 付款批次 -> 银行回单 -> 已付款 -> AP 导出。 - 至少 1 条采购/费用审批并关联项目或合同。 - 至少 1 条合同台账含归档位置和权限检查。 - 资产台账 ASSET1D 已完成后,补真实资产责任人或保管人抽查。 EOF } run_optional_live_checks() { if [[ "${OALAUNCH_RUN_OAOPS1_DAILY:-0}" == "1" ]]; then run_step "oaops1-daily-readonly" env \ LIVE_SMOKE_BASE_URL="$PUBLIC_BASE_URL" \ OAOPS1_REPORT_DIR="$REPORT_DIR/oaops1-daily-readonly" \ bash "$ROOT_DIR/scripts/qa/run-oa-real-use-daily.sh" else log "[oalaunch1][SKIP] oaops1-daily-readonly:默认不跑公网巡检;需要时设置 OALAUNCH_RUN_OAOPS1_DAILY=1" fi } write_summary_json() { node - "$ROOT_DIR" "$REPORT_DIR" "$PUBLIC_BASE_URL" "$STATUS" <<'NODE' const fs = require("node:fs"); const path = require("node:path"); const { execSync } = require("node:child_process"); const [rootDir, reportDir, baseURL, statusInput] = process.argv.slice(2); const qaFlowDir = path.join(rootDir, "artifacts", "qa-flow"); const oalaunchCsv = path.join(rootDir, "issues", "2026-05-23_16-49-39-oa-final-launch-readiness-v1.csv"); const oaopsCsv = path.join(rootDir, "issues", "2026-05-22_20-09-22-oa-real-use-stabilization-v1.csv"); const assetCsv = path.join(rootDir, "issues", "2026-05-23_10-42-03-oa-asset-deploy-and-trial-v1.csv"); const opolicyCsv = path.join(rootDir, "issues", "2026-05-24_01-20-52-oa-policy-system-alignment-v1.csv"); function readJson(file) { if (!file || !fs.existsSync(file)) return null; try { return JSON.parse(fs.readFileSync(file, "utf8")); } catch { return null; } } function parseCsv(text) { const rows = []; let field = ""; let row = []; let inQuotes = false; for (let index = 0; index < text.length; index += 1) { const ch = text[index]; const next = text[index + 1]; if (ch === '"') { if (inQuotes && next === '"') { field += '"'; index += 1; } else { inQuotes = !inQuotes; } } else if (ch === "," && !inQuotes) { row.push(field); field = ""; } else if ((ch === "\n" || ch === "\r") && !inQuotes) { if (ch === "\r" && next === "\n") index += 1; row.push(field); if (row.some((item) => item !== "")) rows.push(row); row = []; field = ""; } else { field += ch; } } if (field || row.length) { row.push(field); if (row.some((item) => item !== "")) rows.push(row); } if (!rows.length) return []; const header = rows[0]; return rows.slice(1).map((items) => Object.fromEntries(header.map((key, index) => [key, items[index] ?? ""]))); } function readCsvRows(file) { if (!fs.existsSync(file)) return []; return parseCsv(fs.readFileSync(file, "utf8")); } function rowById(rows, id) { return rows.find((row) => row.ID === id) || null; } function isDone(row) { return row?.Dev_Status === "DONE" && row?.Review1_Status === "DONE" && row?.Regression_Status === "DONE"; } function statuses(rows, ids) { return Object.fromEntries(ids.map((id) => { const row = rowById(rows, id); return [id, { title: row?.Title || null, dev: row?.Dev_Status || "MISSING", review: row?.Review1_Status || "MISSING", regression: row?.Regression_Status || "MISSING", done: isDone(row), }]; })); } function allPrefixDone(rows, prefix) { const matching = rows.filter((row) => String(row.ID || "").startsWith(prefix)); return matching.length > 0 && matching.every(isDone); } function reportRef(file) { if (!file) return null; const absolute = path.resolve(file); const relative = path.relative(rootDir, absolute); if (relative && !relative.startsWith("..") && !path.isAbsolute(relative)) { return relative; } return path.basename(absolute); } function latestSummary(prefixes) { if (!fs.existsSync(qaFlowDir)) return null; const entries = fs.readdirSync(qaFlowDir, { withFileTypes: true }) .filter((entry) => entry.isDirectory()) .filter((entry) => prefixes.some((prefix) => entry.name.startsWith(prefix))) .map((entry) => { const dir = path.join(qaFlowDir, entry.name); const summaryPath = path.join(dir, "summary.json"); const stat = fs.existsSync(summaryPath) ? fs.statSync(summaryPath) : fs.statSync(dir); return { name: entry.name, dir, summaryPath, mtimeMs: stat.mtimeMs }; }) .filter((entry) => fs.existsSync(entry.summaryPath)) .sort((a, b) => b.mtimeMs - a.mtimeMs); if (!entries.length) return null; const latest = entries[0]; const json = readJson(latest.summaryPath) || {}; return { name: latest.name, reportRef: reportRef(latest.summaryPath), status: json.status || json.conclusion || "UNKNOWN", findingCount: Number(json.findingCount || 0), }; } function resolveEvidenceRef(value) { if (!value) return null; const file = path.isAbsolute(value) ? value : path.join(rootDir, value); return { provided: true, exists: fs.existsSync(file), ref: reportRef(file), }; } function parseDayFromName(name) { const match = String(name).match(/20\d{6}/); return match ? match[0] : null; } function normalizeDay(value) { const text = String(value || "").trim(); const match = text.match(/^(20\d{2})-?(\d{2})-?(\d{2})$/); return match ? `${match[1]}${match[2]}${match[3]}` : null; } function addDays(day, offset) { const year = Number(day.slice(0, 4)); const month = Number(day.slice(4, 6)) - 1; const date = Number(day.slice(6, 8)); const value = new Date(Date.UTC(year, month, date + offset)); return `${value.getUTCFullYear()}${String(value.getUTCMonth() + 1).padStart(2, "0")}${String(value.getUTCDate()).padStart(2, "0")}`; } function collectDailyReports(prefixes) { if (!fs.existsSync(qaFlowDir)) return {}; const byDay = {}; for (const entry of fs.readdirSync(qaFlowDir, { withFileTypes: true })) { if (!entry.isDirectory()) continue; if (!prefixes.some((prefix) => entry.name.startsWith(prefix))) continue; const day = parseDayFromName(entry.name); if (!day) continue; const summaryPath = path.join(qaFlowDir, entry.name, "summary.json"); if (!fs.existsSync(summaryPath)) continue; const json = readJson(summaryPath) || {}; const stat = fs.statSync(summaryPath); byDay[day] ||= []; byDay[day].push({ name: entry.name, reportRef: reportRef(summaryPath), status: json.status || json.conclusion || "UNKNOWN", findingCount: Number(json.findingCount || 0), mtimeMs: stat.mtimeMs, }); } for (const reports of Object.values(byDay)) { reports.sort((a, b) => b.mtimeMs - a.mtimeMs); } return byDay; } function summarizeStabilization(prefixes) { const startDay = normalizeDay(process.env.OALAUNCH_STABILIZATION_START_DATE || ""); const reportsByDay = collectDailyReports(prefixes); const observedDays = Object.keys(reportsByDay).sort(); if (!startDay) { return { status: "MISSING_START_DATE", startDay: null, requiredDays: [], observedDays, dayReports: {}, }; } const requiredDays = Array.from({ length: 7 }, (_, index) => addDays(startDay, index)); const dayReports = {}; const missingDays = []; const blockedDays = []; const findingDays = []; for (const day of requiredDays) { const latest = reportsByDay[day]?.[0] || null; dayReports[day] = latest ? { name: latest.name, reportRef: latest.reportRef, status: latest.status, findingCount: latest.findingCount, } : null; if (!latest) { missingDays.push(day); } else if (/BLOCKED|FAIL|ERROR/i.test(latest.status)) { blockedDays.push(day); } else if (latest.findingCount > 0 || /FINDING/i.test(latest.status)) { findingDays.push(day); } } return { status: missingDays.length ? "MISSING_DAYS" : blockedDays.length ? "HAS_BLOCKERS" : findingDays.length ? "HAS_FINDINGS" : "PASS", startDay, requiredDays, observedDays, missingDays, blockedDays, findingDays, dayReports, }; } let commit = "unknown"; try { commit = execSync("git rev-parse --short HEAD", { cwd: rootDir, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim(); } catch {} const oaopsRows = readCsvRows(oaopsCsv); const assetRows = readCsvRows(assetCsv); const oalaunchRows = readCsvRows(oalaunchCsv); const opolicyRows = readCsvRows(opolicyCsv); const oaopsRequired = ["OAOPS1-3", "OAOPS1-4", "OAOPS1-5", "OAOPS1-6"]; const oalaunchRequired = ["OALAUNCH1-0", "OALAUNCH1-1", "OALAUNCH1-2", "OALAUNCH1-3", "OALAUNCH1-4", "OALAUNCH1-5", "OALAUNCH1-6", "OALAUNCH1-7", "OALAUNCH1-8"]; const opolicyRequired = ["OPOLICY1-0", "OPOLICY1-1", "OPOLICY1-2", "OPOLICY1-3", "OPOLICY1-4", "OPOLICY1-5", "OPOLICY1-6", "OPOLICY1-7"]; const oaopsStatuses = statuses(oaopsRows, oaopsRequired); const oalaunchStatuses = statuses(oalaunchRows, oalaunchRequired); const opolicyStatuses = statuses(opolicyRows, opolicyRequired); const assetDone = allPrefixDone(assetRows, "ASSET1D-"); const latestOaops = latestSummary(["oa-real-use-daily-", "oaops1-daily-", "oaops1-daily-after-throttle-"]); const latestAssetTrial = latestSummary(["asset1d-public-trial-"]); const latestAssetImport = latestSummary(["asset-redacted-index-import-live-"]); const backupEvidence = resolveEvidenceRef(process.env.OALAUNCH_BACKUP_EVIDENCE || ""); const permissionEvidence = resolveEvidenceRef(process.env.OALAUNCH_PERMISSION_EVIDENCE || ""); const businessEvidence = resolveEvidenceRef(process.env.OALAUNCH_BUSINESS_EVIDENCE || ""); const policyEvidence = resolveEvidenceRef(process.env.OALAUNCH_POLICY_EVIDENCE || opolicyCsv); const manualAccept = String(process.env.OALAUNCH_MANUAL_ACCEPT || "").trim(); const stabilization = summarizeStabilization(["oa-real-use-daily-", "oaops1-daily-", "oaops1-daily-after-throttle-"]); const openGates = []; const findings = []; for (const [id, state] of Object.entries(oaopsStatuses)) { if (!state.done) openGates.push({ gate: id, reason: "OAOPS1 真实业务闭环仍未完成", state }); } for (const [id, state] of Object.entries(oalaunchStatuses)) { if (!state.done) openGates.push({ gate: id, reason: "OALAUNCH1 本轮合同/脚本/事实源/门禁尚未全部回填 DONE", state }); } for (const [id, state] of Object.entries(opolicyStatuses)) { if (!state.done) openGates.push({ gate: id, reason: "OPOLICY1 制度与 OA 对齐证据尚未全量收口", state }); } if (!policyEvidence?.exists) openGates.push({ gate: "OPOLICY1_POLICY_EVIDENCE", reason: "缺制度边界和资料留存证据;可用 OALAUNCH_POLICY_EVIDENCE 指定脱敏证据路径" }); if (!assetDone) openGates.push({ gate: "ASSET1D", reason: "资产台账部署与试录未全量完成" }); if (!latestOaops) openGates.push({ gate: "OAOPS1_DAILY_REPORT", reason: "未找到 OAOPS1 每日巡检 summary.json" }); if (latestOaops && /BLOCKED|FAIL|ERROR/i.test(latestOaops.status)) findings.push({ source: "latest_oaops1_daily", severity: "blocker", status: latestOaops.status, reportRef: latestOaops.reportRef }); if (latestOaops && latestOaops.findingCount > 0) findings.push({ source: "latest_oaops1_daily", severity: "finding", status: latestOaops.status, findingCount: latestOaops.findingCount, reportRef: latestOaops.reportRef }); if (stabilization.status !== "PASS") { const reason = stabilization.status === "MISSING_START_DATE" ? "缺 7 天稳定化起始日期;设置 OALAUNCH_STABILIZATION_START_DATE=YYYY-MM-DD 后按连续 7 天检查" : stabilization.status === "MISSING_DAYS" ? "7 天稳定化连续报告不完整" : stabilization.status === "HAS_BLOCKERS" ? "7 天稳定化窗口内存在阻断报告" : "7 天稳定化窗口内存在 finding,必须按 PASS_WITH_FINDINGS 人工接受"; openGates.push({ gate: "SEVEN_DAY_STABILIZATION", reason, stabilization }); } if (stabilization.status === "HAS_BLOCKERS") findings.push({ source: "seven_day_stabilization", severity: "blocker", blockedDays: stabilization.blockedDays }); if (stabilization.status === "HAS_FINDINGS") findings.push({ source: "seven_day_stabilization", severity: "finding", findingDays: stabilization.findingDays }); if (!backupEvidence?.exists) openGates.push({ gate: "BACKUP_RESTORE_DRILL", reason: "缺备份恢复演练或影子库恢复证据;可用 OALAUNCH_BACKUP_EVIDENCE 指定脱敏证据路径" }); if (!permissionEvidence?.exists) openGates.push({ gate: "PERMISSION_MATRIX", reason: "缺最终权限矩阵负例/角色入口证据;可用 OALAUNCH_PERMISSION_EVIDENCE 指定脱敏证据路径" }); if (!businessEvidence?.exists && Object.values(oaopsStatuses).some((state) => !state.done)) { openGates.push({ gate: "BUSINESS_ACCEPTANCE_EVIDENCE", reason: "缺真实业务闭环证据;可用 OALAUNCH_BUSINESS_EVIDENCE 指定脱敏证据路径" }); } if (!/^PASS_OA_LAUNCH_READY$|^PASS_WITH_FINDINGS$/i.test(manualAccept)) { openGates.push({ gate: "DAY7_GO_NO_GO", reason: "缺 Day 7 人工 Go/No-Go 接受结论;可用 OALAUNCH_MANUAL_ACCEPT=PASS_OA_LAUNCH_READY 或 PASS_WITH_FINDINGS 指定" }); } if (findings.length > 0 && manualAccept !== "PASS_WITH_FINDINGS") { openGates.push({ gate: "FINDING_ACCEPTANCE_REQUIRED", reason: "存在 finding 时必须由 Day 7 人工选择 PASS_WITH_FINDINGS,并指定 owner、日期和接受理由" }); } let status = "PAUSED_MANUAL_CONFIRMATION"; if (Number(statusInput || 0) !== 0 || findings.some((item) => item.severity === "blocker")) { status = "SKIP/BLOCKED_BY_EXTERNAL_CONFIG"; } else if (openGates.length > 0) { status = "PAUSED_MANUAL_CONFIRMATION"; } else if (findings.length > 0 && manualAccept === "PASS_WITH_FINDINGS") { status = "PASS_WITH_FINDINGS"; } else if (manualAccept === "PASS_OA_LAUNCH_READY") { status = "PASS_OA_LAUNCH_READY"; } else if (manualAccept === "PASS_WITH_FINDINGS") { status = "PASS_WITH_FINDINGS"; } const summary = { issueId: "OALAUNCH1", mode: "oa-final-launch-readiness", status, conclusion: status, baseURL, commit, checkedAt: new Date().toISOString(), reportRef: reportRef(reportDir), scope: { launchScope: "P0 + light P1", stabilizationDaysRequired: 7, smallTeamFormalUse: true, wecomHrAttendanceStatus: "frozen_at_wecom_owned_oa_archive_audit", }, evidence: { latestOaops, latestAssetTrial, latestAssetImport, backupEvidence, permissionEvidence, businessEvidence, policyEvidence, stabilization, }, issueStatus: { oaopsRequired: oaopsStatuses, oalaunchRequired, oalaunchStatuses, opolicyRequired, opolicyStatuses, assetDone, }, gates: { openCount: openGates.length, openGates, findings, }, manualGates: { day7GoNoGo: [ "A:接受 PASS_OA_LAUNCH_READY,进入小团队正式启用。", "B:接受 PASS_WITH_FINDINGS,但指定 owner 和完成日期后启用。", "C:暂停启用,先整改。", ], blockerHandling: [ "A:当天新建修复合同并修复。", "B:临时人工绕行,记录风险。", "C:暂停对应流程。", ], backupDrill: [ "A:接受恢复演练结果,进入 Go/No-Go。", "B:修复后重跑恢复演练。", "C:暂停正式启用。", ], }, boundaries: { defaultReadOnly: true, noBusinessWritesByThisScript: true, noWeComWrites: true, noPaymentExecution: true, noDbResetOrRestore: true, frozenItems: [ "image_ocr", "complex_bpm", "budget_control", "complex_procurement", "e_signature", "full_e_archive", "erp_write", "bank_statement_import", "ai_rule_writes", "real_payment_provider", "wecom_hr_deep_mirror", "emergency_takeover", "recovery_writeback", "ruleops", ], }, privacy: "Report keeps paths/status/counts only; no passwords, tokens, Secrets, full accounts, contract bodies, bank statements, raw invoice images, or raw payloads.", }; fs.writeFileSync(path.join(reportDir, "summary.json"), `${JSON.stringify(summary, null, 2)}\n`, "utf8"); console.log(`[oalaunch1] summary=${reportRef(path.join(reportDir, "summary.json"))}`); if (status === "SKIP/BLOCKED_BY_EXTERNAL_CONFIG") process.exitCode = 1; NODE } run_privacy_smoke() { local sensitive_pattern='(["'\''"]?(WECOM_[A-Z0-9_]*(SECRET|TOKEN)|[A-Z0-9_]*(TOKEN|SECRET|PASSWORD|ENCODING_AES_KEY|ENCODINGAESKEY|CORP_SECRET|ACCESS_TOKEN)[A-Z0-9_]*)["'\''"]?[[:space:]]*[:=][[:space:]]*["'\''"]?[A-Za-z0-9._:/+=-]{8,}["'\''"]?)|Authorization:[[:space:]]*Bearer[[:space:]]+[A-Za-z0-9._-]{8,}|access_token=[A-Za-z0-9._-]{8,}|-----BEGIN|BEGIN [A-Z ]*KEY' local matches matches="$(grep -RInE "$sensitive_pattern" "$REPORT_DIR" || true)" if [[ -n "$matches" ]]; then printf '%s\n' "$matches" >"$REPORT_DIR/privacy-findings.txt" log "[oalaunch1][FAIL] report-privacy-smoke matched sensitive pattern" STATUS=1 else log "[oalaunch1][PASS] report-privacy-smoke" fi } finish() { write_manual_gates run_privacy_smoke write_summary_json || STATUS=1 local before_privacy_status="$STATUS" run_privacy_smoke if [[ "$STATUS" != "$before_privacy_status" ]]; then write_summary_json || true fi local report_ref="${REPORT_DIR#$ROOT_DIR/}" log "[oalaunch1] report=$report_ref" exit "$STATUS" } trap finish EXIT log "[oalaunch1] mode=oa-final-launch-readiness" log "[oalaunch1] default=no live writes, no business writes, no DB restore" run_optional_live_checks