WOPS1 调整巡检告警降噪
Issue: WOPS1, WROAD2C, WREAL1P
这个提交包含在:
@@ -9,7 +9,8 @@ const { getConfig } = require("../config");
|
||||
const { sendWecomMessage } = require("../services/integrations/im/wecom");
|
||||
|
||||
const DEFAULT_REPORT_ROOT = path.resolve(__dirname, "../../../artifacts/qa-flow");
|
||||
const DEFAULT_RECIPIENTS = Object.freeze(["wangshunli", "HYD000006"]);
|
||||
const DEFAULT_ALERT_STATE_FILE = path.join(DEFAULT_REPORT_ROOT, "warch3-ops-alert-state.json");
|
||||
const DEFAULT_RECIPIENTS = Object.freeze(["HYD000006"]);
|
||||
const SEND_CONFIRM_ENV = "WARCH3_OPS_ALERT_SEND";
|
||||
|
||||
function printHelp() {
|
||||
@@ -20,9 +21,10 @@ QA-only WARCH3 企微巡检脱敏告警。
|
||||
|
||||
Options:
|
||||
--summary-file=<path> WARCH3/WROAD2 summary.json。
|
||||
--recipient-employee-no=<no> 收件员工号,可重复;默认 wangshunli + HYD000006。
|
||||
--recipient-employee-no=<no> 收件员工号,可重复;默认 HYD000006。
|
||||
--send 发送企微应用消息。
|
||||
--force PASS 且无 finding 时也发送测试消息。
|
||||
--state-file=<path> 告警状态文件,默认 artifacts/qa-flow/warch3-ops-alert-state.json。
|
||||
--format=text|json 输出格式,默认 text。
|
||||
--report-dir=<path> 报告目录,默认 artifacts/qa-flow/wecom-ops-alert-<timestamp>。
|
||||
--help 显示帮助。
|
||||
@@ -66,6 +68,7 @@ function parseArgs(argv) {
|
||||
recipientEmployeeNos: [],
|
||||
send: false,
|
||||
force: false,
|
||||
stateFile: process.env.WARCH3_OPS_ALERT_STATE_FILE || DEFAULT_ALERT_STATE_FILE,
|
||||
format: "text",
|
||||
reportDir: path.join(DEFAULT_REPORT_ROOT, `wecom-ops-alert-${timestamp()}`),
|
||||
help: false,
|
||||
@@ -89,6 +92,11 @@ function parseArgs(argv) {
|
||||
options.send = true;
|
||||
} else if (arg === "--force") {
|
||||
options.force = true;
|
||||
} else if (arg === "--state-file") {
|
||||
options.stateFile = toSafeString(argv[index + 1], 1000);
|
||||
index += 1;
|
||||
} else if (arg.startsWith("--state-file=")) {
|
||||
options.stateFile = toSafeString(arg.slice("--state-file=".length), 1000);
|
||||
} else if (arg === "--format") {
|
||||
options.format = argv[index + 1] || options.format;
|
||||
index += 1;
|
||||
@@ -144,8 +152,7 @@ function readSummary(summaryFile) {
|
||||
|
||||
function shouldSendForSummary(summary, options) {
|
||||
if (options.force) return true;
|
||||
const status = toSafeString(summary?.status || summary?.conclusion, 80);
|
||||
return status !== "PASS_ARCHIVE_OPS_READY" || Number(summary?.findingCount || 0) > 0;
|
||||
return evaluateAlertDecision(summary, options).shouldSend;
|
||||
}
|
||||
|
||||
function nextStepForStatus(summary) {
|
||||
@@ -178,6 +185,132 @@ function buildAlertMessage(summary) {
|
||||
};
|
||||
}
|
||||
|
||||
function statusRank(statusValue) {
|
||||
const status = toSafeString(statusValue, 120).toUpperCase();
|
||||
if (!status || status === "UNKNOWN") return 2;
|
||||
if (status.includes("SKIP") || status.includes("BLOCKED") || status.includes("FAIL") || status.includes("ERROR")) {
|
||||
return 4;
|
||||
}
|
||||
if (status.includes("PARTIAL") || status.includes("NEEDS_CONFIG")) {
|
||||
return 3;
|
||||
}
|
||||
if (status.includes("WITH_FINDINGS")) {
|
||||
return 2;
|
||||
}
|
||||
if (status.includes("PASS")) {
|
||||
return 1;
|
||||
}
|
||||
return 2;
|
||||
}
|
||||
|
||||
function collectFindingKeys(summary) {
|
||||
const findings = Array.isArray(summary?.findings) ? summary.findings : [];
|
||||
const keys = findings
|
||||
.map((finding) => [
|
||||
toSafeString(finding?.source, 80),
|
||||
toSafeString(finding?.status, 80),
|
||||
toSafeString(finding?.severity, 40),
|
||||
toSafeString(finding?.evidenceDigest || finding?.detail, 160),
|
||||
].filter(Boolean).join(":"))
|
||||
.filter(Boolean);
|
||||
if (!keys.length && Number(summary?.findingCount || 0) > 0) {
|
||||
keys.push(`findingCount:${Number(summary.findingCount || 0)}`);
|
||||
}
|
||||
return Array.from(new Set(keys)).sort();
|
||||
}
|
||||
|
||||
function buildAlertState(summary, generatedAt = new Date().toISOString()) {
|
||||
const status = toSafeString(summary?.status || summary?.conclusion || "UNKNOWN", 120);
|
||||
return {
|
||||
status,
|
||||
statusRank: statusRank(status),
|
||||
findingKeys: collectFindingKeys(summary),
|
||||
findingCount: Number(summary?.findingCount || 0),
|
||||
reportDir: summary?.reportDir || null,
|
||||
updatedAt: generatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function readAlertState(stateFile) {
|
||||
if (!stateFile) return null;
|
||||
try {
|
||||
if (!fs.existsSync(stateFile)) return null;
|
||||
const parsed = JSON.parse(fs.readFileSync(stateFile, "utf8"));
|
||||
return parsed && typeof parsed === "object" ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeAlertState(stateFile, state) {
|
||||
if (!stateFile) return null;
|
||||
const resolved = path.resolve(stateFile);
|
||||
fs.mkdirSync(path.dirname(resolved), { recursive: true });
|
||||
fs.writeFileSync(resolved, `${JSON.stringify(state, null, 2)}\n`, "utf8");
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function evaluateAlertDecision(summary, options = {}, priorState = null) {
|
||||
const currentState = buildAlertState(summary);
|
||||
if (options.force) {
|
||||
return {
|
||||
shouldSend: true,
|
||||
reason: "force_requested",
|
||||
newFindingKeys: [],
|
||||
previousStatus: priorState?.status || null,
|
||||
currentStatus: currentState.status,
|
||||
currentState,
|
||||
};
|
||||
}
|
||||
|
||||
const currentRank = currentState.statusRank;
|
||||
const previousRank = Number(priorState?.statusRank || statusRank(priorState?.status));
|
||||
const previousKeys = new Set(Array.isArray(priorState?.findingKeys) ? priorState.findingKeys : []);
|
||||
const newFindingKeys = currentState.findingKeys.filter((key) => !previousKeys.has(key));
|
||||
|
||||
if (currentRank >= 4) {
|
||||
return {
|
||||
shouldSend: true,
|
||||
reason: "blocked_or_failed",
|
||||
newFindingKeys,
|
||||
previousStatus: priorState?.status || null,
|
||||
currentStatus: currentState.status,
|
||||
currentState,
|
||||
};
|
||||
}
|
||||
|
||||
if (priorState && currentRank > previousRank) {
|
||||
return {
|
||||
shouldSend: true,
|
||||
reason: "status_worsened",
|
||||
newFindingKeys,
|
||||
previousStatus: priorState.status || null,
|
||||
currentStatus: currentState.status,
|
||||
currentState,
|
||||
};
|
||||
}
|
||||
|
||||
if (priorState && newFindingKeys.length > 0) {
|
||||
return {
|
||||
shouldSend: true,
|
||||
reason: "new_findings",
|
||||
newFindingKeys,
|
||||
previousStatus: priorState.status || null,
|
||||
currentStatus: currentState.status,
|
||||
currentState,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
shouldSend: false,
|
||||
reason: priorState ? "no_new_findings_or_status_worsening" : "baseline_recorded",
|
||||
newFindingKeys,
|
||||
previousStatus: priorState?.status || null,
|
||||
currentStatus: currentState.status,
|
||||
currentState,
|
||||
};
|
||||
}
|
||||
|
||||
async function queryRecipientRows(employeeNos) {
|
||||
if (!process.env.DATABASE_URL) {
|
||||
throw new Error("Missing DATABASE_URL");
|
||||
@@ -280,7 +413,11 @@ async function executeOpsAlert(options, deps = {}) {
|
||||
const config = deps.config || getConfig();
|
||||
const summary = deps.summary || readSummary(options.summaryFile);
|
||||
const message = buildAlertMessage(summary);
|
||||
const shouldSend = shouldSendForSummary(summary, options);
|
||||
const priorState = Object.prototype.hasOwnProperty.call(deps, "priorState")
|
||||
? deps.priorState
|
||||
: readAlertState(options.stateFile);
|
||||
const alertDecision = evaluateAlertDecision(summary, options, priorState);
|
||||
const shouldSend = alertDecision.shouldSend;
|
||||
|
||||
let recipientRows = deps.recipientRows;
|
||||
let recipientReadError = null;
|
||||
@@ -344,6 +481,21 @@ async function executeOpsAlert(options, deps = {}) {
|
||||
conclusion = "PASS_ALERT_SKIPPED_NO_FINDINGS";
|
||||
}
|
||||
|
||||
let stateWrite = {
|
||||
status: "SKIPPED",
|
||||
stateFile: options.stateFile ? path.resolve(options.stateFile) : null,
|
||||
};
|
||||
if (options.send && (delivery.status === "OK" || delivery.status === "SKIPPED_NO_FINDINGS")) {
|
||||
const writeStateImpl = deps.writeAlertState || writeAlertState;
|
||||
stateWrite = {
|
||||
status: "OK",
|
||||
stateFile: writeStateImpl(options.stateFile, {
|
||||
...alertDecision.currentState,
|
||||
updatedAt: generatedAt,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
issueId: "WARCH3",
|
||||
mode: "wecom-ops-alert",
|
||||
@@ -353,6 +505,13 @@ async function executeOpsAlert(options, deps = {}) {
|
||||
sendRequested: Boolean(options.send),
|
||||
sendAllowed: Boolean(options.send && env[SEND_CONFIRM_ENV] === "1"),
|
||||
shouldSend,
|
||||
alertDecision: {
|
||||
reason: alertDecision.reason,
|
||||
previousStatus: alertDecision.previousStatus,
|
||||
currentStatus: alertDecision.currentStatus,
|
||||
newFindingCount: alertDecision.newFindingKeys.length,
|
||||
},
|
||||
stateWrite,
|
||||
summary: {
|
||||
issueId: summary?.issueId || null,
|
||||
status: summary?.status || summary?.conclusion || "UNKNOWN",
|
||||
@@ -381,6 +540,7 @@ function printText(report, reportPath) {
|
||||
if (report.recipientGaps.length) {
|
||||
process.stdout.write(`[wecom-ops-alert] recipientGaps=${JSON.stringify(report.recipientGaps)}\n`);
|
||||
}
|
||||
process.stdout.write(`[wecom-ops-alert] decision=${report.alertDecision.reason} newFindings=${report.alertDecision.newFindingCount}\n`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
@@ -407,9 +567,14 @@ if (require.main === module) {
|
||||
|
||||
module.exports = {
|
||||
assertAlertSendAllowed,
|
||||
buildAlertState,
|
||||
buildAlertMessage,
|
||||
evaluateAlertDecision,
|
||||
executeOpsAlert,
|
||||
parseArgs,
|
||||
readAlertState,
|
||||
sanitizeDeliveryResponse,
|
||||
shouldSendForSummary,
|
||||
statusRank,
|
||||
writeAlertState,
|
||||
};
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
const {
|
||||
assertAlertSendAllowed,
|
||||
buildAlertMessage,
|
||||
buildAlertState,
|
||||
evaluateAlertDecision,
|
||||
executeOpsAlert,
|
||||
parseArgs,
|
||||
shouldSendForSummary,
|
||||
@@ -12,9 +14,10 @@ const SENSITIVE_TOKEN = ["ops", "alert", "token", "should", "not", "leak"].join(
|
||||
function baseOptions(overrides = {}) {
|
||||
return {
|
||||
summaryFile: "",
|
||||
recipientEmployeeNos: ["wangshunli", "HYD000006"],
|
||||
recipientEmployeeNos: ["HYD000006"],
|
||||
send: false,
|
||||
force: false,
|
||||
stateFile: "/tmp/wecom-ops-alert-state-test.json",
|
||||
format: "json",
|
||||
reportDir: "/tmp/wecom-ops-alert-test",
|
||||
help: false,
|
||||
@@ -34,12 +37,6 @@ function baseSummary(overrides = {}) {
|
||||
|
||||
function recipientRows(overrides = {}) {
|
||||
return [
|
||||
{
|
||||
employeeNo: "wangshunli",
|
||||
status: "active",
|
||||
archived: false,
|
||||
wecomExternalUserId: SENSITIVE_USER_ID,
|
||||
},
|
||||
{
|
||||
employeeNo: "HYD000006",
|
||||
status: "active",
|
||||
@@ -53,15 +50,16 @@ describe("send-wecom-ops-alert", () => {
|
||||
it("parses recipients and enforces double confirmation", () => {
|
||||
const options = parseArgs([
|
||||
"--summary-file=/tmp/summary.json",
|
||||
"--recipient-employee-no=wangshunli",
|
||||
"--recipient-employee-no",
|
||||
"HYD000006",
|
||||
"--send",
|
||||
"--state-file=/tmp/warch3-state.json",
|
||||
"--format=json",
|
||||
]);
|
||||
|
||||
expect(options.summaryFile).toBe("/tmp/summary.json");
|
||||
expect(options.recipientEmployeeNos).toEqual(["wangshunli", "HYD000006"]);
|
||||
expect(options.recipientEmployeeNos).toEqual(["HYD000006"]);
|
||||
expect(options.stateFile).toBe("/tmp/warch3-state.json");
|
||||
expect(options.send).toBe(true);
|
||||
expect(() => assertAlertSendAllowed(options, {})).toThrow(/WARCH3_OPS_ALERT_SEND=1/);
|
||||
expect(() => assertAlertSendAllowed(options, { WARCH3_OPS_ALERT_SEND: "1" })).not.toThrow();
|
||||
@@ -83,6 +81,54 @@ describe("send-wecom-ops-alert", () => {
|
||||
expect(shouldSendForSummary(summary, baseOptions({ force: true }))).toBe(true);
|
||||
});
|
||||
|
||||
it("records the first PASS_WITH_FINDINGS run as baseline without sending", () => {
|
||||
const summary = baseSummary({
|
||||
findings: [{ source: "approvalArchive", status: "PASS_WITH_FINDINGS", severity: "info", evidenceDigest: "known" }],
|
||||
});
|
||||
const decision = evaluateAlertDecision(summary, baseOptions(), null);
|
||||
|
||||
expect(decision.shouldSend).toBe(false);
|
||||
expect(decision.reason).toBe("baseline_recorded");
|
||||
});
|
||||
|
||||
it("sends when a new finding appears after baseline", () => {
|
||||
const previousSummary = baseSummary({
|
||||
findings: [{ source: "approvalArchive", status: "PASS_WITH_FINDINGS", severity: "info", evidenceDigest: "known" }],
|
||||
});
|
||||
const currentSummary = baseSummary({
|
||||
findings: [
|
||||
{ source: "approvalArchive", status: "PASS_WITH_FINDINGS", severity: "info", evidenceDigest: "known" },
|
||||
{ source: "wecomRuntimeReady", status: "PASS_WITH_FINDINGS", severity: "warn", evidenceDigest: "new" },
|
||||
],
|
||||
});
|
||||
const decision = evaluateAlertDecision(currentSummary, baseOptions(), buildAlertState(previousSummary));
|
||||
|
||||
expect(decision.shouldSend).toBe(true);
|
||||
expect(decision.reason).toBe("new_findings");
|
||||
expect(decision.newFindingKeys).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("skips repeated known findings after baseline", () => {
|
||||
const summary = baseSummary({
|
||||
findings: [{ source: "approvalArchive", status: "PASS_WITH_FINDINGS", severity: "info", evidenceDigest: "known" }],
|
||||
});
|
||||
const decision = evaluateAlertDecision(summary, baseOptions(), buildAlertState(summary));
|
||||
|
||||
expect(decision.shouldSend).toBe(false);
|
||||
expect(decision.reason).toBe("no_new_findings_or_status_worsening");
|
||||
});
|
||||
|
||||
it("sends when status worsens or blocks", () => {
|
||||
const previous = buildAlertState(baseSummary({ status: "PASS_ARCHIVE_OPS_READY", findingCount: 0, findings: [] }));
|
||||
const worsened = evaluateAlertDecision(baseSummary({ status: "PASS_WITH_FINDINGS", findingCount: 0, findings: [] }), baseOptions(), previous);
|
||||
const blocked = evaluateAlertDecision(baseSummary({ status: "SKIP/BLOCKED_BY_EXTERNAL_CONFIG", findingCount: 0, findings: [] }), baseOptions(), null);
|
||||
|
||||
expect(worsened.shouldSend).toBe(true);
|
||||
expect(worsened.reason).toBe("status_worsened");
|
||||
expect(blocked.shouldSend).toBe(true);
|
||||
expect(blocked.reason).toBe("blocked_or_failed");
|
||||
});
|
||||
|
||||
it("dry-runs alert without sending and redacts recipients", async () => {
|
||||
const sendMessageImpl = vi.fn();
|
||||
const report = await executeOpsAlert(baseOptions(), {
|
||||
@@ -94,8 +140,9 @@ describe("send-wecom-ops-alert", () => {
|
||||
generatedAt: "2026-05-20T04:00:00.000Z",
|
||||
});
|
||||
|
||||
expect(report.status).toBe("PASS_ALERT_DRY_RUN_READY");
|
||||
expect(report.delivery.status).toBe("DRY_RUN_READY");
|
||||
expect(report.status).toBe("PASS_ALERT_SKIPPED_NO_FINDINGS");
|
||||
expect(report.delivery.status).toBe("SKIPPED_NO_FINDINGS");
|
||||
expect(report.alertDecision.reason).toBe("baseline_recorded");
|
||||
expect(sendMessageImpl).not.toHaveBeenCalled();
|
||||
const text = JSON.stringify(report);
|
||||
expect(text).not.toContain(SENSITIVE_USER_ID);
|
||||
@@ -119,6 +166,8 @@ describe("send-wecom-ops-alert", () => {
|
||||
sendMessageImpl,
|
||||
env: { WARCH3_OPS_ALERT_SEND: "1" },
|
||||
config: { wecomRequestTimeoutMs: 1000 },
|
||||
priorState: buildAlertState(baseSummary({ status: "PASS_ARCHIVE_OPS_READY", findingCount: 0, findings: [] })),
|
||||
writeAlertState: vi.fn(() => "/tmp/warch3-state.json"),
|
||||
});
|
||||
|
||||
expect(report.status).toBe("PASS_ALERT_SENT");
|
||||
@@ -127,42 +176,16 @@ describe("send-wecom-ops-alert", () => {
|
||||
expect(JSON.stringify(report)).not.toContain(SENSITIVE_USER_ID);
|
||||
});
|
||||
|
||||
it("sends to the valid admin recipient while recording alias gaps", async () => {
|
||||
const sendMessageImpl = vi.fn(async ({ recipientExternalUserIds }) => {
|
||||
expect(recipientExternalUserIds).toEqual([SENSITIVE_USER_ID]);
|
||||
return {
|
||||
ok: true,
|
||||
code: "wecom_app_sent",
|
||||
message: null,
|
||||
response: { errcode: 0, recipientCount: 1 },
|
||||
};
|
||||
});
|
||||
const report = await executeOpsAlert(baseOptions({ send: true }), {
|
||||
summary: baseSummary(),
|
||||
recipientRows: recipientRows({
|
||||
wangshunli: { employeeNo: "missing", wecomExternalUserId: "" },
|
||||
}).filter((row) => row.employeeNo !== "missing"),
|
||||
sendMessageImpl,
|
||||
env: { WARCH3_OPS_ALERT_SEND: "1" },
|
||||
config: { wecomRequestTimeoutMs: 1000 },
|
||||
});
|
||||
|
||||
expect(report.status).toBe("PASS_ALERT_SENT_WITH_CONFIG_GAPS");
|
||||
expect(report.recipientGaps.map((item) => item.id)).toContain("recipient_missing");
|
||||
expect(report.delivery.status).toBe("OK");
|
||||
expect(sendMessageImpl).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("classifies missing binding as config gap", async () => {
|
||||
const report = await executeOpsAlert(baseOptions({ send: true }), {
|
||||
summary: baseSummary(),
|
||||
recipientRows: recipientRows({
|
||||
wangshunli: { wecomExternalUserId: "" },
|
||||
HYD000006: { wecomExternalUserId: "" },
|
||||
}),
|
||||
sendMessageImpl: vi.fn(),
|
||||
env: { WARCH3_OPS_ALERT_SEND: "1" },
|
||||
config: {},
|
||||
priorState: buildAlertState(baseSummary({ status: "PASS_ARCHIVE_OPS_READY", findingCount: 0, findings: [] })),
|
||||
});
|
||||
|
||||
expect(report.status).toBe("PARTIAL_ALERT_NEEDS_CONFIG");
|
||||
@@ -181,6 +204,8 @@ describe("send-wecom-ops-alert", () => {
|
||||
})),
|
||||
env: { WARCH3_OPS_ALERT_SEND: "1" },
|
||||
config: {},
|
||||
priorState: buildAlertState(baseSummary({ status: "PASS_ARCHIVE_OPS_READY", findingCount: 0, findings: [] })),
|
||||
writeAlertState: vi.fn(() => "/tmp/warch3-state.json"),
|
||||
});
|
||||
|
||||
expect(report.status).toBe("SKIP");
|
||||
|
||||
在新工单中引用
屏蔽一个用户