P2AF 收口自动化后续和默认冻结口径
这个提交包含在:
@@ -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"
|
||||
在新工单中引用
屏蔽一个用户