文件
hyb-oa/scripts/qa/check-wecom-capability-poc.mjs

1097 行
36 KiB
JavaScript

#!/usr/bin/env node
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
const DEFAULT_REPORT_ROOT = "artifacts/qa-flow";
const DEFAULT_TEST_USERID = "HRWDTEST-20260517214548";
const HCLT_LIVE_VERIFIED_MARKER = "HRWDTEST-20260517214548";
const HCLT_LIVE_VERIFIED_REPORT = "artifacts/qa-flow/hrwd-contact-live-test-20260517T134548Z/summary.json";
const WECOM_API_BASE = "https://qyapi.weixin.qq.com";
const CAPABILITY_STATUSES = new Set([
"CAN_REUSE_NOW",
"NEEDS_CONFIG",
"NEEDS_API_POC",
"KEEP_OA_PRIMARY",
"NOT_SUPPORTED_BY_API",
]);
const FINAL_CONCLUSIONS = new Set([
"GO_TO_HR_MASTER_SYNC",
"PAUSE_FOR_CONFIG",
"PAUSE_FOR_API_POC",
"NO_GO_REVISE_ROADMAP",
]);
const ENV_WHITELIST = new Set([
"WECOM_CORP_ID",
"WECOM_AGENT_ID",
"WECOM_AGENT_SECRET",
"WECOM_CHECKIN_SYNC_ENABLED",
"WECOM_CHECKIN_SECRET",
"WECOM_CONTACT_SYNC_ENABLED",
"WECOM_CONTACT_SECRET",
"WECOM_CONTACT_DEFAULT_DEPARTMENT_ID",
"WECOM_OAUTH_ENABLED",
"WECOM_REDIRECT_URI",
"WECOM_ALLOWED_RETURN_HOSTS",
"WECOM_HR_ASSISTANT_SECRET",
"WECOM_APPROVAL_SECRET",
"WECOM_APPROVAL_TEMPLATE_ID",
"WECOM_APPROVAL_TEMPLATE_IDS",
"WECOM_APPROVAL_INSTANCE_ID",
"WECOM_APPROVAL_SP_NO",
"WECOM_APPROVAL_CALLBACK_URL",
"WECOM_CALLBACK_URL",
"WECOM_CALLBACK_TOKEN",
"WECOM_CALLBACK_AES_KEY",
"WECOM_CAPABILITY_POC_LIVE_READ",
"WECOM_CAPABILITY_POC_TEST_USERID",
"WECOM_CAPABILITY_POC_DAYS",
"WECOM_CAPABILITY_POC_JSON",
]);
const DOC_REFERENCES = Object.freeze({
contactCreate: "https://developer.work.weixin.qq.com/document/path/90195",
checkinOverview: "https://developer.work.weixin.qq.com/document/path/90261",
checkinData: "https://developer.work.weixin.qq.com/document/path/90262",
checkinRule: "https://developer.work.weixin.qq.com/document/path/90263",
approvalOverview: "https://developer.work.weixin.qq.com/document/path/91854",
approvalSubmit: "https://developer.work.weixin.qq.com/document/path/91853",
approvalTemplate: "https://developer.work.weixin.qq.com/document/path/91982",
approvalDetail: "https://developer.work.weixin.qq.com/document/path/91983",
hrOverview: "https://developer.work.weixin.qq.com/document/path/99130",
hrFields: "https://developer.work.weixin.qq.com/document/path/99131",
hrStaffRead: "https://developer.work.weixin.qq.com/document/path/99132",
hrStaffUpdate: "https://developer.work.weixin.qq.com/document/path/99133",
});
function parseArgs(argv) {
const options = {
envFile: "",
json: process.env.WECOM_CAPABILITY_POC_JSON === "1",
liveRead: process.env.WECOM_CAPABILITY_POC_LIVE_READ === "1",
days: Number(process.env.WECOM_CAPABILITY_POC_DAYS || 1),
testUserId: process.env.WECOM_CAPABILITY_POC_TEST_USERID || DEFAULT_TEST_USERID,
issueId: process.env.QA_ISSUE_ID || "WCP1",
reportDir: process.env.WECOM_CAPABILITY_POC_REPORT_DIR || "",
};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (arg === "--json") {
options.json = true;
} else if (arg === "--live-read") {
options.liveRead = true;
} else if (arg === "--no-live-read") {
options.liveRead = false;
} else if (arg === "--env-file" || arg === "--poc-env-file") {
options.envFile = argv[index + 1] || "";
index += 1;
} else if (arg.startsWith("--env-file=")) {
options.envFile = arg.slice("--env-file=".length);
} else if (arg.startsWith("--poc-env-file=")) {
options.envFile = arg.slice("--poc-env-file=".length);
} else if (arg === "--test-userid") {
options.testUserId = argv[index + 1] || "";
index += 1;
} else if (arg.startsWith("--test-userid=")) {
options.testUserId = arg.slice("--test-userid=".length);
} else if (arg === "--days") {
options.days = Number(argv[index + 1] || 1);
index += 1;
} else if (arg.startsWith("--days=")) {
options.days = Number(arg.slice("--days=".length));
} else if (arg.startsWith("--report-dir=")) {
options.reportDir = arg.slice("--report-dir=".length);
}
}
options.days = Number.isFinite(options.days) ? Math.max(1, Math.min(7, Math.floor(options.days))) : 1;
options.testUserId = String(options.testUserId || "").trim();
if (!options.reportDir) {
const stamp = new Date().toISOString().replace(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z");
options.reportDir = join(DEFAULT_REPORT_ROOT, `wecom-capability-poc-${stamp}`);
}
return options;
}
function unquoteEnvValue(value) {
const trimmed = String(value ?? "").trim();
if (
(trimmed.startsWith('"') && trimmed.endsWith('"'))
|| (trimmed.startsWith("'") && trimmed.endsWith("'"))
) {
return trimmed.slice(1, -1);
}
return trimmed;
}
function loadEnvFile(filePath) {
if (!filePath) return { loaded: false, path: null, keys: [] };
if (!existsSync(filePath)) {
throw new Error(`env file not found: ${filePath}`);
}
const loadedKeys = [];
const text = readFileSync(filePath, "utf8");
for (const rawLine of text.split(/\r?\n/)) {
const line = rawLine.trim();
if (!line || line.startsWith("#")) continue;
const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
if (!match) continue;
const [, key, rawValue] = match;
if (!ENV_WHITELIST.has(key)) continue;
if (process.env[key] == null) {
process.env[key] = unquoteEnvValue(rawValue);
loadedKeys.push(key);
}
}
return { loaded: true, path: filePath, keys: loadedKeys.sort() };
}
function envFlag(name) {
return process.env[name] === "true" || process.env[name] === "1";
}
function envValue(name) {
return String(process.env[name] || "").trim();
}
function maskId(value) {
const raw = String(value ?? "").trim();
if (!raw) return null;
if (raw.length <= 6) return `${raw.slice(0, 2)}***`;
return `${raw.slice(0, Math.min(8, raw.length))}***`;
}
function maskHostLike(value) {
const raw = String(value ?? "").trim();
if (!raw) return null;
try {
const url = new URL(raw);
return `${url.protocol}//${url.host}${url.pathname ? "/..." : ""}`;
} catch {
return raw.length > 32 ? `${raw.slice(0, 16)}...` : raw;
}
}
function safeMessage(value) {
return String(value ?? "")
.replace(/[A-Za-z0-9_-]{24,}/g, "***")
.replace(/access_token=[^&\s]+/gi, "access_token=***")
.slice(0, 160);
}
function sanitizeWecomPayload(payload) {
if (!payload || typeof payload !== "object") return null;
const result = {};
for (const key of ["errcode", "errmsg", "invaliduser", "invalidparty", "invalidtag"]) {
if (payload[key] != null) {
result[key] = key === "errmsg" ? safeMessage(payload[key]) : payload[key];
}
}
if (Array.isArray(payload.department)) result.departmentCount = payload.department.length;
if (Array.isArray(payload.checkindata)) result.checkindataCount = payload.checkindata.length;
if (Array.isArray(payload.fields)) result.fieldsCount = payload.fields.length;
if (Array.isArray(payload.field_info)) result.fieldsCount = payload.field_info.length;
if (Array.isArray(payload.info)) result.infoCount = payload.info.length;
if (Array.isArray(payload.staff_info)) result.staffInfoCount = payload.staff_info.length;
if (Array.isArray(payload.vacation_quota_list)) result.vacationQuotaCount = payload.vacation_quota_list.length;
if (payload.userid != null) result.useridPrefix = maskId(payload.userid);
if (payload.enable != null) result.enable = payload.enable;
return Object.keys(result).length ? result : null;
}
function capability(input) {
if (!CAPABILITY_STATUSES.has(input.status)) {
throw new Error(`invalid capability status for ${input.id}: ${input.status}`);
}
return {
id: input.id,
domain: input.domain,
status: input.status,
reason: input.reason,
evidence: input.evidence || {},
readOnly: input.readOnly !== false,
blockingForHrMasterSync: Boolean(input.blockingForHrMasterSync),
docs: input.docs || [],
};
}
function classifyApiError(apiResult, defaultStatus = "NEEDS_CONFIG") {
if (apiResult.networkError) {
return {
status: "NEEDS_API_POC",
reason: apiResult.timeout ? "api_timeout_or_network_unstable" : "api_network_error",
};
}
const errcode = Number(apiResult.payload?.errcode);
if ([48002, 48003, 48004, 60011, 60020, 301002, 40001, 40014, 40029, 60111, 60121, 60203].includes(errcode)) {
return { status: "NEEDS_CONFIG", reason: `wecom_errcode_${errcode}` };
}
if (apiResult.httpStatus >= 500 || apiResult.httpStatus === 429) {
return { status: "NEEDS_API_POC", reason: `wecom_http_${apiResult.httpStatus}` };
}
if (Number.isFinite(errcode)) {
return { status: defaultStatus, reason: `wecom_errcode_${errcode}` };
}
return { status: "NEEDS_API_POC", reason: "missing_or_unexpected_wecom_response" };
}
function isWecomApiOk(result) {
return Boolean(result?.ok && result.payload && Number(result.payload.errcode ?? 0) === 0);
}
function isWecomErrcode(result, errcode) {
return Number(result?.payload?.errcode) === errcode;
}
async function fetchJson(url, init = {}) {
try {
const response = await fetch(url, {
...init,
signal: AbortSignal.timeout(18_000),
});
const text = await response.text();
let payload = null;
try {
payload = text ? JSON.parse(text) : null;
} catch {
payload = null;
}
return { ok: response.ok, httpStatus: response.status, payload };
} catch (error) {
return {
ok: false,
httpStatus: null,
payload: null,
networkError: true,
timeout: error?.name === "AbortError",
errorMessage: safeMessage(error?.message || error),
};
}
}
async function getWecomToken({ corpId, secret, label }) {
if (!corpId || !secret) {
return {
ok: false,
code: "missing_config",
reason: `missing_${label}_token_config`,
response: null,
token: null,
};
}
const url = new URL(`${WECOM_API_BASE}/cgi-bin/gettoken`);
url.searchParams.set("corpid", corpId);
url.searchParams.set("corpsecret", secret);
const result = await fetchJson(url);
const errcode = Number(result.payload?.errcode ?? 0);
if (!result.ok || !result.payload || errcode !== 0 || typeof result.payload.access_token !== "string") {
const classified = classifyApiError(result);
return {
ok: false,
code: "token_failed",
reason: classified.reason,
response: sanitizeWecomPayload(result.payload),
token: null,
};
}
return {
ok: true,
code: "token_fetched",
reason: null,
response: { errcode: 0, expiresIn: Number(result.payload.expires_in ?? 0) || null },
token: result.payload.access_token,
};
}
async function wecomGet(pathName, token, query = {}) {
const url = new URL(`${WECOM_API_BASE}${pathName}`);
url.searchParams.set("access_token", token);
for (const [key, value] of Object.entries(query)) {
if (value != null && value !== "") url.searchParams.set(key, String(value));
}
return fetchJson(url);
}
async function wecomPost(pathName, token, body = {}) {
const url = new URL(`${WECOM_API_BASE}${pathName}`);
url.searchParams.set("access_token", token);
return fetchJson(url, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
}
function toUnixSeconds(date) {
return Math.floor(date.getTime() / 1000);
}
function shanghaiDateAt(dateString, endOfDay = false) {
return new Date(`${dateString}T${endOfDay ? "23:59:59" : "00:00:00"}+08:00`);
}
function shanghaiDateString(date) {
return new Intl.DateTimeFormat("en-CA", {
timeZone: "Asia/Shanghai",
year: "numeric",
month: "2-digit",
day: "2-digit",
}).format(date);
}
function resolveWindow(days) {
const end = new Date();
const start = new Date(end.getTime() - (days - 1) * 24 * 60 * 60 * 1000);
return {
startDate: shanghaiDateString(start),
endDate: shanghaiDateString(end),
};
}
function firstCsvValue(value) {
return String(value || "")
.split(",")
.map((item) => item.trim())
.find(Boolean) || "";
}
function addTokenCapability(capabilities, { id, domain, tokenResult, secretPresent, docs, blockingForHrMasterSync = false }) {
if (tokenResult.ok) {
capabilities.push(capability({
id,
domain,
status: "CAN_REUSE_NOW",
reason: "access_token_fetch_ok",
evidence: tokenResult.response,
docs,
blockingForHrMasterSync,
}));
return;
}
capabilities.push(capability({
id,
domain,
status: secretPresent ? "NEEDS_CONFIG" : "NEEDS_CONFIG",
reason: tokenResult.reason,
evidence: tokenResult.response || { missingSecret: !secretPresent },
docs,
blockingForHrMasterSync,
}));
}
function addApiCapability(capabilities, input) {
const errcode = Number(input.result.payload?.errcode ?? 0);
if (input.result.ok && input.result.payload && errcode === 0) {
capabilities.push(capability({
id: input.id,
domain: input.domain,
status: "CAN_REUSE_NOW",
reason: input.successReason,
evidence: {
endpoint: input.endpoint,
...input.successEvidence,
response: sanitizeWecomPayload(input.result.payload),
},
docs: input.docs,
blockingForHrMasterSync: input.blockingForHrMasterSync,
}));
return;
}
const classified = classifyApiError(input.result, input.defaultStatus);
capabilities.push(capability({
id: input.id,
domain: input.domain,
status: classified.status,
reason: classified.reason,
evidence: {
endpoint: input.endpoint,
response: sanitizeWecomPayload(input.result.payload),
httpStatus: input.result.httpStatus,
networkError: Boolean(input.result.networkError),
},
docs: input.docs,
blockingForHrMasterSync: input.blockingForHrMasterSync,
}));
}
function addLiveReadDisabled(capabilities, id, domain, docs = [], blockingForHrMasterSync = false) {
capabilities.push(capability({
id,
domain,
status: "NEEDS_API_POC",
reason: "live_read_disabled",
evidence: { runWith: "WECOM_CAPABILITY_POC_LIVE_READ=1" },
docs,
blockingForHrMasterSync,
}));
}
function addMissingConfig(capabilities, input) {
capabilities.push(capability({
id: input.id,
domain: input.domain,
status: "NEEDS_CONFIG",
reason: input.reason,
evidence: input.evidence || {},
docs: input.docs || [],
blockingForHrMasterSync: Boolean(input.blockingForHrMasterSync),
}));
}
async function probeContact(capabilities, options, config) {
const docs = [DOC_REFERENCES.contactCreate];
const hcltPath = HCLT_LIVE_VERIFIED_REPORT;
if (existsSync(hcltPath)) {
const hclt = JSON.parse(readFileSync(hcltPath, "utf8"));
capabilities.push(capability({
id: "contact.hclt_create_update_disable",
domain: "contact",
status: hclt.status === "PASS" ? "CAN_REUSE_NOW" : "NEEDS_API_POC",
reason: hclt.status === "PASS" ? "hclt_live_verified" : "hclt_evidence_not_pass",
evidence: {
marker: hclt.marker,
status: hclt.status,
report: hcltPath,
finalPolicy: hclt.finalPolicy,
},
docs,
blockingForHrMasterSync: true,
}));
} else {
capabilities.push(capability({
id: "contact.hclt_create_update_disable",
domain: "contact",
status: "CAN_REUSE_NOW",
reason: "documented_hclt_live_verified",
evidence: {
marker: HCLT_LIVE_VERIFIED_MARKER,
expectedReport: hcltPath,
artifactUnavailableInRuntime: true,
note: "HCLT evidence is a project fact; local repo keeps the full artifact.",
},
docs,
blockingForHrMasterSync: true,
}));
}
if (!options.liveRead) {
addLiveReadDisabled(capabilities, "contact.department_read", "contact", docs, true);
addLiveReadDisabled(capabilities, "contact.member_read", "contact", docs, true);
return;
}
const tokenResult = await getWecomToken({
corpId: config.corpId,
secret: config.contactSecret,
label: "contact",
});
addTokenCapability(capabilities, {
id: "contact.token",
domain: "contact",
tokenResult,
secretPresent: Boolean(config.contactSecret),
docs,
blockingForHrMasterSync: true,
});
if (!tokenResult.ok) {
addMissingConfig(capabilities, {
id: "contact.department_read",
domain: "contact",
reason: "contact_token_unavailable",
evidence: { missingSecret: !config.contactSecret },
docs,
blockingForHrMasterSync: true,
});
addMissingConfig(capabilities, {
id: "contact.member_read",
domain: "contact",
reason: "contact_token_unavailable",
evidence: { missingTestUserId: !options.testUserId },
docs,
blockingForHrMasterSync: true,
});
return;
}
let agentFallbackToken = null;
async function getAgentFallbackToken() {
if (agentFallbackToken) return agentFallbackToken;
if (!config.agentSecret) return null;
const fallback = await getWecomToken({
corpId: config.corpId,
secret: config.agentSecret,
label: "contact_agent_fallback",
});
agentFallbackToken = fallback.ok ? fallback.token : null;
return agentFallbackToken;
}
let department = await wecomGet("/cgi-bin/department/list", tokenResult.token);
let departmentEvidence = {};
if (!isWecomApiOk(department) && isWecomErrcode(department, 48009)) {
const fallbackToken = await getAgentFallbackToken();
if (fallbackToken) {
const fallbackDepartment = await wecomGet("/cgi-bin/department/list", fallbackToken);
if (isWecomApiOk(fallbackDepartment)) {
department = fallbackDepartment;
departmentEvidence = { tokenSource: "WECOM_AGENT_SECRET_FALLBACK" };
}
}
}
addApiCapability(capabilities, {
id: "contact.department_read",
domain: "contact",
result: department,
endpoint: "/cgi-bin/department/list",
successReason: "department_list_read_ok",
successEvidence: departmentEvidence,
docs,
blockingForHrMasterSync: true,
});
if (!options.testUserId) {
addMissingConfig(capabilities, {
id: "contact.member_read",
domain: "contact",
reason: "missing_test_userid",
docs,
blockingForHrMasterSync: true,
});
return;
}
let member = await wecomGet("/cgi-bin/user/get", tokenResult.token, { userid: options.testUserId });
let memberEvidence = { testUserIdPrefix: maskId(options.testUserId) };
if (!isWecomApiOk(member) && isWecomErrcode(member, 48009)) {
const fallbackToken = await getAgentFallbackToken();
if (fallbackToken) {
const fallbackMember = await wecomGet("/cgi-bin/user/get", fallbackToken, { userid: options.testUserId });
if (isWecomApiOk(fallbackMember)) {
member = fallbackMember;
memberEvidence = { ...memberEvidence, tokenSource: "WECOM_AGENT_SECRET_FALLBACK" };
}
}
}
addApiCapability(capabilities, {
id: "contact.member_read",
domain: "contact",
result: member,
endpoint: "/cgi-bin/user/get",
successReason: "test_member_read_ok",
successEvidence: memberEvidence,
docs,
blockingForHrMasterSync: true,
});
}
async function probeHrAssistant(capabilities, options, config) {
const docs = [DOC_REFERENCES.hrOverview, DOC_REFERENCES.hrFields, DOC_REFERENCES.hrStaffRead, DOC_REFERENCES.hrStaffUpdate];
if (!options.liveRead) {
addLiveReadDisabled(capabilities, "hr.fields_read", "hr_assistant", docs, true);
addLiveReadDisabled(capabilities, "hr.staff_read", "hr_assistant", docs, true);
capabilities.push(capability({
id: "hr.staff_update_classification",
domain: "hr_assistant",
status: "NEEDS_API_POC",
reason: "write_api_requires_separate_approval",
evidence: { endpoint: "/cgi-bin/hr/update_staff_info", readOnlyPoc: true },
docs,
}));
return;
}
const hrSecret = config.hrAssistantSecret || config.agentSecret;
const tokenResult = await getWecomToken({
corpId: config.corpId,
secret: hrSecret,
label: "hr_assistant",
});
addTokenCapability(capabilities, {
id: "hr.token",
domain: "hr_assistant",
tokenResult,
secretPresent: Boolean(hrSecret),
docs,
blockingForHrMasterSync: true,
});
if (!tokenResult.ok) {
addMissingConfig(capabilities, {
id: "hr.fields_read",
domain: "hr_assistant",
reason: "hr_token_unavailable",
evidence: { missingSecret: !hrSecret },
docs,
blockingForHrMasterSync: true,
});
addMissingConfig(capabilities, {
id: "hr.staff_read",
domain: "hr_assistant",
reason: "hr_token_unavailable",
evidence: { missingTestUserId: !options.testUserId },
docs,
blockingForHrMasterSync: true,
});
} else {
const fields = await wecomGet("/cgi-bin/hr/get_fields", tokenResult.token);
addApiCapability(capabilities, {
id: "hr.fields_read",
domain: "hr_assistant",
result: fields,
endpoint: "/cgi-bin/hr/get_fields",
successReason: "hr_fields_read_ok",
successEvidence: { tokenSource: config.hrAssistantSecret ? "WECOM_HR_ASSISTANT_SECRET" : "WECOM_AGENT_SECRET" },
docs,
blockingForHrMasterSync: true,
});
if (!options.testUserId) {
addMissingConfig(capabilities, {
id: "hr.staff_read",
domain: "hr_assistant",
reason: "missing_test_userid",
docs,
blockingForHrMasterSync: true,
});
} else {
const staff = await wecomPost("/cgi-bin/hr/get_staff_info", tokenResult.token, {
userid: options.testUserId,
get_all: true,
});
addApiCapability(capabilities, {
id: "hr.staff_read",
domain: "hr_assistant",
result: staff,
endpoint: "/cgi-bin/hr/get_staff_info",
successReason: "hr_staff_read_ok",
successEvidence: { testUserIdPrefix: maskId(options.testUserId) },
docs,
blockingForHrMasterSync: true,
});
}
}
capabilities.push(capability({
id: "hr.staff_update_classification",
domain: "hr_assistant",
status: "NEEDS_API_POC",
reason: "write_api_requires_separate_approval",
evidence: { endpoint: "/cgi-bin/hr/update_staff_info", readOnlyPoc: true },
docs,
}));
}
async function probeCheckin(capabilities, options, config) {
const docs = [DOC_REFERENCES.checkinOverview, DOC_REFERENCES.checkinData, DOC_REFERENCES.checkinRule];
if (!options.liveRead) {
addLiveReadDisabled(capabilities, "checkin.records_read", "checkin", docs);
addLiveReadDisabled(capabilities, "checkin.rules_read", "checkin", docs);
return;
}
if (!config.checkinSyncEnabled) {
addMissingConfig(capabilities, {
id: "checkin.records_read",
domain: "checkin",
reason: "wecom_checkin_sync_disabled",
evidence: { env: "WECOM_CHECKIN_SYNC_ENABLED" },
docs,
});
addMissingConfig(capabilities, {
id: "checkin.rules_read",
domain: "checkin",
reason: "wecom_checkin_sync_disabled",
evidence: { env: "WECOM_CHECKIN_SYNC_ENABLED" },
docs,
});
return;
}
const checkinSecret = config.checkinSecret || config.agentSecret;
const tokenResult = await getWecomToken({
corpId: config.corpId,
secret: checkinSecret,
label: "checkin",
});
addTokenCapability(capabilities, {
id: "checkin.token",
domain: "checkin",
tokenResult,
secretPresent: Boolean(checkinSecret),
docs,
});
if (!tokenResult.ok || !options.testUserId) {
addMissingConfig(capabilities, {
id: "checkin.records_read",
domain: "checkin",
reason: !tokenResult.ok ? "checkin_token_unavailable" : "missing_test_userid",
evidence: { missingSecret: !checkinSecret, missingTestUserId: !options.testUserId },
docs,
});
addMissingConfig(capabilities, {
id: "checkin.rules_read",
domain: "checkin",
reason: !tokenResult.ok ? "checkin_token_unavailable" : "missing_test_userid",
evidence: { missingSecret: !checkinSecret, missingTestUserId: !options.testUserId },
docs,
});
return;
}
const window = resolveWindow(options.days);
const records = await wecomPost("/cgi-bin/checkin/getcheckindata", tokenResult.token, {
opencheckindatatype: 3,
starttime: toUnixSeconds(shanghaiDateAt(window.startDate, false)),
endtime: toUnixSeconds(shanghaiDateAt(window.endDate, true)),
useridlist: [options.testUserId],
});
addApiCapability(capabilities, {
id: "checkin.records_read",
domain: "checkin",
result: records,
endpoint: "/cgi-bin/checkin/getcheckindata",
successReason: "checkin_short_window_read_ok",
successEvidence: {
window,
testUserIdPrefix: maskId(options.testUserId),
emptyDataAllowed: true,
},
docs,
});
const rules = await wecomPost("/cgi-bin/checkin/getcheckinoption", tokenResult.token, {
datetime: toUnixSeconds(shanghaiDateAt(window.endDate, false)),
useridlist: [options.testUserId],
});
addApiCapability(capabilities, {
id: "checkin.rules_read",
domain: "checkin",
result: rules,
endpoint: "/cgi-bin/checkin/getcheckinoption",
successReason: "checkin_rule_read_ok",
successEvidence: {
window,
testUserIdPrefix: maskId(options.testUserId),
},
docs,
});
}
async function probeApprovalAndVacation(capabilities, options, config) {
const docs = [
DOC_REFERENCES.approvalOverview,
DOC_REFERENCES.approvalSubmit,
DOC_REFERENCES.approvalTemplate,
DOC_REFERENCES.approvalDetail,
];
const approvalSecret = config.approvalSecret || config.agentSecret;
if (!options.liveRead) {
addLiveReadDisabled(capabilities, "approval.template_detail", "approval", docs);
addLiveReadDisabled(capabilities, "approval.instance_detail", "approval", docs);
addLiveReadDisabled(capabilities, "approval.vacation_quota", "approval", docs);
} else {
const tokenResult = await getWecomToken({
corpId: config.corpId,
secret: approvalSecret,
label: "approval",
});
addTokenCapability(capabilities, {
id: "approval.token",
domain: "approval",
tokenResult,
secretPresent: Boolean(approvalSecret),
docs,
});
if (!tokenResult.ok) {
for (const id of ["approval.template_detail", "approval.instance_detail", "approval.vacation_quota"]) {
addMissingConfig(capabilities, {
id,
domain: "approval",
reason: "approval_token_unavailable",
evidence: { missingSecret: !approvalSecret },
docs,
});
}
} else {
const templateId = config.approvalTemplateId;
if (!templateId) {
addMissingConfig(capabilities, {
id: "approval.template_detail",
domain: "approval",
reason: "missing_approval_template_id",
evidence: { env: "WECOM_APPROVAL_TEMPLATE_ID" },
docs,
});
} else {
const template = await wecomPost("/cgi-bin/oa/gettemplatedetail", tokenResult.token, {
template_id: templateId,
});
addApiCapability(capabilities, {
id: "approval.template_detail",
domain: "approval",
result: template,
endpoint: "/cgi-bin/oa/gettemplatedetail",
successReason: "approval_template_read_ok",
successEvidence: { templateIdPrefix: maskId(templateId) },
docs,
});
}
const spNo = config.approvalInstanceId;
if (!spNo) {
addMissingConfig(capabilities, {
id: "approval.instance_detail",
domain: "approval",
reason: "missing_approval_instance_id",
evidence: { env: "WECOM_APPROVAL_INSTANCE_ID or WECOM_APPROVAL_SP_NO" },
docs,
});
} else {
const detail = await wecomPost("/cgi-bin/oa/getapprovaldetail", tokenResult.token, { sp_no: spNo });
addApiCapability(capabilities, {
id: "approval.instance_detail",
domain: "approval",
result: detail,
endpoint: "/cgi-bin/oa/getapprovaldetail",
successReason: "approval_instance_read_ok",
successEvidence: { spNoPrefix: maskId(spNo) },
docs,
});
}
if (!options.testUserId) {
addMissingConfig(capabilities, {
id: "approval.vacation_quota",
domain: "approval",
reason: "missing_test_userid",
docs,
});
} else {
const vacation = await wecomPost("/cgi-bin/oa/vacation/getuservacationquota", tokenResult.token, {
userid: options.testUserId,
});
addApiCapability(capabilities, {
id: "approval.vacation_quota",
domain: "approval",
result: vacation,
endpoint: "/cgi-bin/oa/vacation/getuservacationquota",
successReason: "vacation_quota_read_ok",
successEvidence: { testUserIdPrefix: maskId(options.testUserId) },
docs,
});
}
}
}
const callbackReady = Boolean(config.callbackUrl && config.callbackToken && config.callbackAesKey);
capabilities.push(capability({
id: "callback.config",
domain: "callback",
status: callbackReady ? "CAN_REUSE_NOW" : "NEEDS_CONFIG",
reason: callbackReady ? "callback_config_present" : "callback_config_missing_or_manual_only",
evidence: {
callbackUrl: maskHostLike(config.callbackUrl),
hasCallbackToken: Boolean(config.callbackToken),
hasCallbackAesKey: Boolean(config.callbackAesKey),
note: "callback endpoint reachability must be verified in later mirror contract",
},
docs,
}));
capabilities.push(capability({
id: "approval.oa_primary_sensitive_domains",
domain: "approval",
status: "KEEP_OA_PRIMARY",
reason: "procurement_contract_reimbursement_finance_remain_oa_primary",
evidence: {
sensitiveDomains: ["reimbursement", "procurement", "contract", "project", "payment_batch", "ap", "accounting_handoff"],
wecomUsage: "dehydrated_message_and_oa_deeplink_only",
},
docs,
}));
}
async function probeWorkbenchAndPermissions(capabilities, options, config) {
const docs = [DOC_REFERENCES.approvalOverview, DOC_REFERENCES.contactCreate];
if (!options.liveRead) {
addLiveReadDisabled(capabilities, "workbench.app_token", "workbench", docs);
} else {
const tokenResult = await getWecomToken({
corpId: config.corpId,
secret: config.agentSecret,
label: "workbench",
});
addTokenCapability(capabilities, {
id: "workbench.app_token",
domain: "workbench",
tokenResult,
secretPresent: Boolean(config.agentSecret),
docs,
});
}
capabilities.push(capability({
id: "permissions.config_presence",
domain: "permissions",
status: config.corpId && (config.agentSecret || config.contactSecret) ? "CAN_REUSE_NOW" : "NEEDS_CONFIG",
reason: config.corpId && (config.agentSecret || config.contactSecret)
? "core_wecom_config_present"
: "core_wecom_config_missing",
evidence: {
hasCorpId: Boolean(config.corpId),
hasAgentId: Boolean(config.agentId),
hasAgentSecret: Boolean(config.agentSecret),
hasContactSecret: Boolean(config.contactSecret),
hasCheckinSecretOrAgentFallback: Boolean(config.checkinSecret || config.agentSecret),
oauthEnabled: config.oauthEnabled,
redirectUri: maskHostLike(config.redirectUri),
},
docs,
}));
}
function deriveConclusion(capabilities) {
const notSupported = capabilities.filter((item) => item.blockingForHrMasterSync && item.status === "NOT_SUPPORTED_BY_API");
if (notSupported.length) {
return "NO_GO_REVISE_ROADMAP";
}
const critical = capabilities.filter((item) => item.blockingForHrMasterSync);
if (critical.some((item) => item.status === "NEEDS_CONFIG")) {
return "PAUSE_FOR_CONFIG";
}
if (critical.some((item) => item.status === "NEEDS_API_POC")) {
return "PAUSE_FOR_API_POC";
}
return "GO_TO_HR_MASTER_SYNC";
}
function statusCounts(capabilities) {
const counts = {};
for (const status of CAPABILITY_STATUSES) counts[status] = 0;
for (const item of capabilities) counts[item.status] = (counts[item.status] || 0) + 1;
return counts;
}
function buildConfig() {
return {
corpId: envValue("WECOM_CORP_ID"),
agentId: envValue("WECOM_AGENT_ID"),
agentSecret: envValue("WECOM_AGENT_SECRET"),
contactSyncEnabled: envFlag("WECOM_CONTACT_SYNC_ENABLED"),
contactSecret: envValue("WECOM_CONTACT_SECRET"),
contactDefaultDepartmentId: envValue("WECOM_CONTACT_DEFAULT_DEPARTMENT_ID"),
checkinSyncEnabled: envFlag("WECOM_CHECKIN_SYNC_ENABLED"),
checkinSecret: envValue("WECOM_CHECKIN_SECRET"),
oauthEnabled: envFlag("WECOM_OAUTH_ENABLED"),
redirectUri: envValue("WECOM_REDIRECT_URI"),
allowedReturnHosts: envValue("WECOM_ALLOWED_RETURN_HOSTS"),
hrAssistantSecret: envValue("WECOM_HR_ASSISTANT_SECRET"),
approvalSecret: envValue("WECOM_APPROVAL_SECRET"),
approvalTemplateId: envValue("WECOM_APPROVAL_TEMPLATE_ID") || firstCsvValue(envValue("WECOM_APPROVAL_TEMPLATE_IDS")),
approvalInstanceId: envValue("WECOM_APPROVAL_INSTANCE_ID") || envValue("WECOM_APPROVAL_SP_NO"),
callbackUrl: envValue("WECOM_APPROVAL_CALLBACK_URL") || envValue("WECOM_CALLBACK_URL"),
callbackToken: envValue("WECOM_CALLBACK_TOKEN"),
callbackAesKey: envValue("WECOM_CALLBACK_AES_KEY"),
};
}
async function buildReport(options, envLoadResult) {
const config = buildConfig();
const capabilities = [];
capabilities.push(capability({
id: "poc.scope",
domain: "poc",
status: "CAN_REUSE_NOW",
reason: "qa_only_read_only_scope",
evidence: {
liveRead: options.liveRead,
days: options.days,
testUserIdPrefix: maskId(options.testUserId),
noProductionWrites: true,
},
docs: Object.values(DOC_REFERENCES),
}));
await probeWorkbenchAndPermissions(capabilities, options, config);
await probeContact(capabilities, options, config);
await probeHrAssistant(capabilities, options, config);
await probeCheckin(capabilities, options, config);
await probeApprovalAndVacation(capabilities, options, config);
const conclusion = deriveConclusion(capabilities);
if (!FINAL_CONCLUSIONS.has(conclusion)) {
throw new Error(`invalid conclusion: ${conclusion}`);
}
const blockingCapabilities = capabilities
.filter((item) => item.blockingForHrMasterSync && item.status !== "CAN_REUSE_NOW")
.map((item) => ({ id: item.id, status: item.status, reason: item.reason }));
return {
checkedAt: new Date().toISOString(),
issueId: options.issueId,
status: "PASS",
conclusion,
liveRead: options.liveRead,
readOnly: true,
noProductionWrites: true,
days: options.days,
testUserIdPrefix: maskId(options.testUserId),
envFile: envLoadResult.loaded
? {
loaded: true,
keyCount: envLoadResult.keys.length,
// 不输出 env 文件路径,避免暴露部署目录结构以外的信息。
}
: { loaded: false },
statusCounts: statusCounts(capabilities),
blockingCapabilities,
nextStageAllowed: conclusion === "GO_TO_HR_MASTER_SYNC",
nonBlockingNote: "Approval, checkin, callback, recovery writeback and RuleOps still require their own later contracts even when HR master sync can proceed.",
docReferences: DOC_REFERENCES,
capabilities,
};
}
function printHuman(summary) {
console.log(`[wecom-capability-poc] status=${summary.status} conclusion=${summary.conclusion} liveRead=${summary.liveRead}`);
console.log(`[wecom-capability-poc] reportDir=${summary.reportDir}`);
for (const item of summary.capabilities) {
console.log(`[wecom-capability-poc] ${item.id} status=${item.status} reason=${item.reason}`);
}
}
async function main() {
const options = parseArgs(process.argv.slice(2));
const envLoadResult = loadEnvFile(options.envFile);
mkdirSync(options.reportDir, { recursive: true });
const summary = await buildReport(options, envLoadResult);
summary.reportDir = options.reportDir;
const summaryPath = join(options.reportDir, "summary.json");
writeFileSync(summaryPath, `${JSON.stringify(summary, null, 2)}\n`);
writeFileSync(join(options.reportDir, "capability-matrix.json"), `${JSON.stringify(summary.capabilities, null, 2)}\n`);
if (options.json) {
console.log(JSON.stringify({
status: summary.status,
conclusion: summary.conclusion,
reportDir: options.reportDir,
statusCounts: summary.statusCounts,
blockingCapabilities: summary.blockingCapabilities,
nextStageAllowed: summary.nextStageAllowed,
}, null, 2));
} else {
printHuman(summary);
}
}
main().catch((error) => {
console.error(`[wecom-capability-poc][ERROR] ${safeMessage(error instanceof Error ? error.message : String(error))}`);
process.exit(1);
});