P2AF 收口自动化后续和默认冻结口径

这个提交包含在:
2026-05-16 22:40:45 +08:00
父节点 edabce8561
当前提交 ed608034f6
共修改 9 个文件,包含 369 行新增和 28 行删除
+19 -3
查看文件
@@ -26,6 +26,7 @@ function parseArgs(argv) {
json: false,
strict: false,
manifest: process.env.OCR_PROVIDER_SAMPLE_MANIFEST || "",
imageEvaluation: process.env.OCR_IMAGE_OCR_EVALUATION === "1",
};
for (const arg of argv) {
@@ -33,6 +34,8 @@ function parseArgs(argv) {
options.json = true;
} else if (arg === "--strict") {
options.strict = true;
} else if (arg === "--image-evaluation") {
options.imageEvaluation = true;
} else if (arg.startsWith("--provider=")) {
options.provider = arg.slice("--provider=".length);
} else if (arg.startsWith("--manifest=")) {
@@ -164,6 +167,16 @@ function loadEnvSamples() {
return samples;
}
function hasExplicitImageEnv() {
return [
"PLAYWRIGHT_OCR_IMAGE_FILE",
"PLAYWRIGHT_OCR_IMAGE_FILES",
"PLAYWRIGHT_OCR_IMAGE_DIR",
"LIVE_OCR_IMAGE_FILE",
"LIVE_OCR_IMAGE_DIR",
].some((key) => Boolean(process.env[key]?.trim()));
}
function dedupeSamples(samples) {
const seen = new Set();
const result = [];
@@ -274,7 +287,8 @@ async function checkSample(provider, config, sample) {
}
}
function buildDecision(results, manifestError) {
function buildDecision(results, manifestError, imageEvaluation) {
if (!imageEvaluation) return "FREEZE_BY_DEFAULT";
if (manifestError) return "HOLD_FOR_SAMPLE";
if (!results.length) return "HOLD_FOR_SAMPLE";
const imageResults = results.filter((item) => item.kind === "image");
@@ -285,7 +299,7 @@ function buildDecision(results, manifestError) {
function printHuman(summary) {
console.log(`[ocr-provider-samples] status=${summary.status} decision=${summary.decision}`);
console.log(`[ocr-provider-samples] provider=${summary.provider} samples=${summary.counts.total} pdf=${summary.counts.pdf} image=${summary.counts.image}`);
console.log(`[ocr-provider-samples] provider=${summary.provider} imageEvaluation=${summary.imageEvaluation} samples=${summary.counts.total} pdf=${summary.counts.pdf} image=${summary.counts.image}`);
if (summary.manifestError) {
console.log(`[ocr-provider-samples] manifest=${summary.manifestError}`);
}
@@ -307,12 +321,13 @@ async function main() {
const samples = dedupeSamples([...manifest.samples, ...loadEnvSamples()]);
const config = getTencentConfig();
const results = [];
const imageEvaluation = options.imageEvaluation || Boolean(options.manifest) || hasExplicitImageEnv();
for (const sample of samples) {
results.push(await checkSample(options.provider, config, sample));
}
const decision = buildDecision(results, manifest.error);
const decision = buildDecision(results, manifest.error, imageEvaluation);
const failed = results.some((item) => item.status === "HOLD");
const skipped = !results.length || results.some((item) => item.status === "SKIP");
const summary = {
@@ -320,6 +335,7 @@ async function main() {
provider: options.provider,
status: failed ? "HOLD" : skipped ? "SKIP" : "PASS",
decision,
imageEvaluation,
manifestProvided: Boolean(options.manifest),
manifestError: manifest.error,
credentialReady: hasTencentCredentials(config),
+212
查看文件
@@ -0,0 +1,212 @@
#!/usr/bin/env node
const DEFAULT_BASE_URL = "https://oa.hyinda.com";
function parseArgs(argv) {
const options = {
baseUrl: process.env.LIVE_SMOKE_BASE_URL || DEFAULT_BASE_URL,
json: false,
strict: false,
};
for (const arg of argv) {
if (arg === "--json") {
options.json = true;
} else if (arg === "--strict") {
options.strict = true;
} else if (arg.startsWith("--base-url=")) {
options.baseUrl = arg.slice("--base-url=".length);
}
}
options.baseUrl = options.baseUrl.trim().replace(/\/$/, "");
return options;
}
function getCredential(role) {
const prefixByRole = {
finance: "LIVE_SMOKE_FINANCE",
employee: "LIVE_SMOKE_EMPLOYEE",
admin: "LIVE_SMOKE_ADMIN",
};
const prefix = prefixByRole[role];
const identifier = process.env[`${prefix}_IDENTIFIER`]?.trim();
const password = process.env[`${prefix}_PASSWORD`]?.trim();
if (!identifier || !password) {
return null;
}
return { identifier, password };
}
async function fetchJson(url, init = {}) {
const response = await fetch(url, {
...init,
signal: AbortSignal.timeout(15_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,
};
}
function summarizeError(json) {
const error = json?.error ?? {};
const details = error.details ?? {};
return {
code: typeof error.code === "string" ? error.code : null,
featureFlag: typeof details.featureFlag === "string" ? details.featureFlag : null,
reason: typeof details.reason === "string" ? details.reason : null,
};
}
async function login(baseUrl, role, credential) {
const response = await fetchJson(`${baseUrl}/api/auth/login`, {
method: "POST",
headers: {
"content-type": "application/json",
"x-device-fingerprint": `p2af-flag-off-${role}`,
},
body: JSON.stringify({
identifier: credential.identifier,
password: credential.password,
}),
});
const accessToken = response.json?.data?.accessToken;
if (!response.ok || typeof accessToken !== "string" || !accessToken) {
return {
ok: false,
role,
status: response.status,
error: summarizeError(response.json),
};
}
return {
ok: true,
role,
accessToken,
};
}
async function probeEndpoint(baseUrl, role, accessToken, endpoint) {
const response = await fetchJson(`${baseUrl}${endpoint}`, {
method: "GET",
headers: {
authorization: `Bearer ${accessToken}`,
accept: "application/json",
},
});
const error = summarizeError(response.json);
const featureFlagDisabled = response.status === 409 && error.featureFlag === "payment_batch_v1_disabled";
const permissionDenied = response.status === 403 && error.code === "PERM_001";
const unexpectedOpen = response.status === 200;
const acceptable = featureFlagDisabled || (role !== "finance" && permissionDenied);
return {
role,
endpoint,
httpStatus: response.status,
status: acceptable ? "PASS" : "FAIL",
featureFlagDisabled,
permissionDenied,
unexpectedOpen,
error,
};
}
function buildRolePlan() {
return [
{ role: "finance", endpoints: ["/api/payment-batches", "/api/ap-operations"] },
{ role: "employee", endpoints: ["/api/payment-batches"] },
{ role: "admin", endpoints: ["/api/payment-batches"] },
];
}
function printHuman(summary) {
console.log(`[payment-batch-flag-off] status=${summary.status} base=${summary.baseUrl}`);
for (const missing of summary.missingCredentials) {
console.log(`[payment-batch-flag-off] ${missing.role} status=SKIP reason=missing_credentials`);
}
for (const loginFailure of summary.loginFailures) {
console.log(`[payment-batch-flag-off] ${loginFailure.role} status=FAIL loginStatus=${loginFailure.status}`);
}
for (const result of summary.results) {
const detail = result.featureFlagDisabled
? "featureFlag=payment_batch_v1_disabled"
: result.permissionDenied
? "permission=denied"
: `error=${result.error.code || "-"}`;
console.log(
`[payment-batch-flag-off] ${result.role} ${result.endpoint} status=${result.status} http=${result.httpStatus} ${detail}`,
);
}
}
async function main() {
const options = parseArgs(process.argv.slice(2));
const missingCredentials = [];
const loginFailures = [];
const results = [];
for (const item of buildRolePlan()) {
const credential = getCredential(item.role);
if (!credential) {
missingCredentials.push({ role: item.role });
continue;
}
const session = await login(options.baseUrl, item.role, credential);
if (!session.ok) {
loginFailures.push(session);
continue;
}
for (const endpoint of item.endpoints) {
results.push(await probeEndpoint(options.baseUrl, item.role, session.accessToken, endpoint));
}
}
const hasFailures = loginFailures.length > 0 || results.some((result) => result.status !== "PASS");
const status = hasFailures ? "FAIL" : missingCredentials.length ? "SKIP" : results.length ? "PASS" : "SKIP";
const summary = {
checkedAt: new Date().toISOString(),
baseUrl: options.baseUrl,
status,
expectedFeatureFlag: "payment_batch_v1",
expectedState: "off",
readOnly: true,
missingCredentials,
loginFailures: loginFailures.map((item) => ({
role: item.role,
status: item.status,
error: item.error,
})),
results,
};
if (options.json) {
console.log(JSON.stringify(summary, null, 2));
} else {
printHuman(summary);
}
if (status === "FAIL" || (options.strict && status !== "PASS")) {
process.exit(1);
}
}
main().catch((error) => {
console.error(`[payment-batch-flag-off][ERROR] ${error instanceof Error ? error.message : String(error)}`);
process.exit(1);
});
+99
查看文件
@@ -0,0 +1,99 @@
#!/usr/bin/env bash
set -u -o pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
CLIENT_DIR="$ROOT_DIR/client"
SERVER_DIR="$ROOT_DIR/server"
PUBLIC_BASE_URL="${LIVE_SMOKE_BASE_URL:-https://oa.hyinda.com}"
REPORT_DIR="${P2_SAFE_REPORT_DIR:-$ROOT_DIR/artifacts/qa-flow/p2-safe-regression-$(date +%Y%m%d-%H%M%S)}"
SUMMARY_TXT="$REPORT_DIR/summary.txt"
STATUS=0
mkdir -p "$REPORT_DIR"
: >"$SUMMARY_TXT"
load_default_env() {
local env_file="${P2_SAFE_ENV_FILE:-$HOME/.hyb-oa/live-smoke.env}"
if [[ "${P2_SAFE_SKIP_ENV_FILE:-0}" == "1" || ! -f "$env_file" ]]; then
return 0
fi
set -a
# shellcheck disable=SC1090
source "$env_file"
set +a
PUBLIC_BASE_URL="${LIVE_SMOKE_BASE_URL:-$PUBLIC_BASE_URL}"
echo "[p2-safe] loaded masked env file" | tee -a "$SUMMARY_TXT"
}
run_step() {
local name="$1"
shift
local log_file="$REPORT_DIR/${name}.log"
echo ">>> [p2-safe] $name" | tee -a "$SUMMARY_TXT"
if "$@" > >(tee "$log_file") 2>&1; then
echo "[p2-safe][PASS] $name" | tee -a "$SUMMARY_TXT"
else
local code="$?"
echo "[p2-safe][FAIL] $name exit=$code" | tee -a "$SUMMARY_TXT"
STATUS=1
fi
}
run_shell_step() {
local name="$1"
local command="$2"
run_step "$name" bash -lc "$command"
}
write_summary_json() {
local qa_status="PASS"
if [[ "$STATUS" -ne 0 ]]; then
qa_status="FAIL"
fi
QA_REPORT_DIR="$REPORT_DIR" \
QA_ISSUE_ID="${QA_ISSUE_ID:-P2AF}" \
QA_BASE_URL="$PUBLIC_BASE_URL" \
QA_MODE="p2-safe-regression" \
QA_STATUS="$qa_status" \
QA_SUMMARY_NOTES="P2AF 安全自动回归:不部署、不启用公网 payment_batch_v1、不开放图片 OCR;图片 OCR 默认 FREEZE_BY_DEFAULT。" \
node "$ROOT_DIR/scripts/qa/write-qa-summary.mjs" >/dev/null 2>&1 || true
}
finish() {
write_summary_json
echo "[p2-safe] report=$REPORT_DIR" | tee -a "$SUMMARY_TXT"
exit "$STATUS"
}
trap finish EXIT
load_default_env
run_shell_step "backend-prisma" "cd '$SERVER_DIR' && npm run prisma:validate"
run_shell_step "backend-node-check" "cd '$SERVER_DIR' && node --check src/routes/invoicePool.js src/jobs/ocrJob.js src/services/ocr/tencent.js src/routes/paymentBatches.js src/routes/exports.js src/routes/workflow.js src/routes/wecomAuth.js"
run_shell_step "backend-focused-tests" "cd '$SERVER_DIR' && npm test -- tests/unit/services/ocr.tencent.test.js tests/unit/jobs/ocrJob.test.js tests/unit/routes/invoicePool.upload.test.js tests/unit/routes/paymentBatches.reimbursement.test.js tests/unit/routes/exports.apOperations.test.js tests/unit/routes/wecomAuth.test.js tests/unit/routes/notifications.test.js"
if [[ "${P2_SAFE_SKIP_BUILD:-0}" == "1" ]]; then
echo "[p2-safe][SKIP] frontend-build P2_SAFE_SKIP_BUILD=1" | tee -a "$SUMMARY_TXT"
else
run_shell_step "frontend-typecheck" "cd '$CLIENT_DIR' && npm run typecheck"
run_shell_step "frontend-build" "cd '$CLIENT_DIR' && npm run build"
fi
run_shell_step "business-flow-local" "BUSINESS_FLOW_GROUPS=auth,reimbursement,approval,leave,procurement,governance BUSINESS_FLOW_EXCLUDE_SPECS=tests/e2e/reimbursement-payment-batch-flow.spec.ts bash '$ROOT_DIR/scripts/qa/run-business-flow-gates.sh' local"
if [[ "${P2_SAFE_SKIP_LIVE:-0}" == "1" ]]; then
echo "[p2-safe][SKIP] live-readiness P2_SAFE_SKIP_LIVE=1" | tee -a "$SUMMARY_TXT"
echo "[p2-safe][SKIP] payment-batch-flag-off P2_SAFE_SKIP_LIVE=1" | tee -a "$SUMMARY_TXT"
echo "[p2-safe][SKIP] public-mobile-matrix P2_SAFE_SKIP_LIVE=1" | tee -a "$SUMMARY_TXT"
else
run_shell_step "live-readiness" "QA_ISSUE_ID=P2AF LIVE_SMOKE_BASE_URL='$PUBLIC_BASE_URL' bash '$ROOT_DIR/scripts/qa/run-live-smoke-readiness.sh'"
run_shell_step "payment-batch-flag-off" "LIVE_SMOKE_BASE_URL='$PUBLIC_BASE_URL' node '$ROOT_DIR/scripts/qa/check-payment-batch-flag-off.mjs' --json"
run_shell_step "public-mobile-matrix" "cd '$CLIENT_DIR' && E2E_BASE_URL='$PUBLIC_BASE_URL' npx playwright test tests/e2e/dg7-mobile-information-dedupe.spec.ts tests/e2e/r124-mobile-entry-matrix.spec.ts --workers=1 --reporter=list"
fi
run_shell_step "wecom-rendering-matrix" "cd '$CLIENT_DIR' && E2E_BASE_URL='$PUBLIC_BASE_URL' npx playwright test tests/e2e/r6-wecom-mobile-rendering-hardening.spec.ts --workers=1 --reporter=list"
run_shell_step "ocr-freeze-default" "node '$ROOT_DIR/scripts/qa/check-ocr-provider-samples.mjs' --provider=tencent --json"