文件
hyb-oa/scripts/qa/run-oa-final-launch-readiness.sh

498 行
18 KiB
Bash
可执行文件

#!/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