1097 行
36 KiB
JavaScript
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);
|
|
});
|