WAPC2 实现企微审批只读 POC
这个提交包含在:
@@ -0,0 +1,345 @@
|
||||
const { getWecomAppAccessToken, sanitizeWecomResponse } = require("./wecomAccessToken");
|
||||
|
||||
const WECOM_API_BASE = "https://qyapi.weixin.qq.com";
|
||||
|
||||
const APPROVAL_POC_TARGETS = Object.freeze([
|
||||
{ key: "leave", label: "请假" },
|
||||
{ key: "missed_punch", label: "补卡" },
|
||||
]);
|
||||
|
||||
class WecomApprovalPocError extends Error {
|
||||
constructor(code, message, details = {}) {
|
||||
super(message);
|
||||
this.name = "WecomApprovalPocError";
|
||||
this.code = code;
|
||||
this.details = details;
|
||||
}
|
||||
}
|
||||
|
||||
function toSafeString(value, maxLength = 500) {
|
||||
if (value == null) return "";
|
||||
return String(value).trim().slice(0, maxLength);
|
||||
}
|
||||
|
||||
function sanitizeErrorMessage(value) {
|
||||
return toSafeString(value, 500)
|
||||
.replace(/access_token=[^&\s]+/gi, "access_token=***")
|
||||
.replace(/[A-Za-z0-9_-]{24,}/g, "***")
|
||||
.slice(0, 240);
|
||||
}
|
||||
|
||||
function maskApprovalIdentifier(value) {
|
||||
const raw = toSafeString(value, 160);
|
||||
if (!raw) return null;
|
||||
if (raw.length <= 6) return `${raw.slice(0, 2)}***`;
|
||||
return `${raw.slice(0, Math.min(8, raw.length))}***`;
|
||||
}
|
||||
|
||||
function countBy(items, selector) {
|
||||
const counts = {};
|
||||
for (const item of items) {
|
||||
const key = toSafeString(selector(item), 80) || "unknown";
|
||||
counts[key] = (counts[key] || 0) + 1;
|
||||
}
|
||||
return counts;
|
||||
}
|
||||
|
||||
function normalizeControls(value) {
|
||||
if (!value) return [];
|
||||
if (Array.isArray(value)) return value.filter((item) => item && typeof item === "object");
|
||||
if (typeof value === "object") {
|
||||
for (const key of ["controls", "contents", "items", "children"]) {
|
||||
if (Array.isArray(value[key])) return normalizeControls(value[key]);
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function controlType(control) {
|
||||
if (!control || typeof control !== "object") return "unknown";
|
||||
return (
|
||||
control.control
|
||||
|| control.control_type
|
||||
|| control.controlType
|
||||
|| control.type
|
||||
|| control.property?.control
|
||||
|| control.property?.control_type
|
||||
|| control.property?.type
|
||||
|| "unknown"
|
||||
);
|
||||
}
|
||||
|
||||
function summarizeTemplatePayload(payload) {
|
||||
const templateId = payload?.template_id ?? payload?.templateId;
|
||||
const controls = normalizeControls(payload?.template_content?.controls)
|
||||
.concat(normalizeControls(payload?.template_content?.contents))
|
||||
.concat(normalizeControls(payload?.controls));
|
||||
const dedupedControls = controls.length ? controls : normalizeControls(payload?.template_content);
|
||||
|
||||
return {
|
||||
templateIdPrefix: maskApprovalIdentifier(templateId),
|
||||
fieldCount: dedupedControls.length,
|
||||
fieldTypeCounts: countBy(dedupedControls, controlType),
|
||||
hasTemplateNames: Array.isArray(payload?.template_names) && payload.template_names.length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeApprovalPayload(payload) {
|
||||
const info = payload?.info && typeof payload.info === "object" ? payload.info : {};
|
||||
const applyData = info.apply_data && typeof info.apply_data === "object" ? info.apply_data : {};
|
||||
const contents = Array.isArray(applyData.contents) ? applyData.contents : [];
|
||||
const records = Array.isArray(info.sp_record) ? info.sp_record : [];
|
||||
const notifyers = Array.isArray(info.notifyer) ? info.notifyer : [];
|
||||
const comments = Array.isArray(info.comments) ? info.comments : [];
|
||||
|
||||
return {
|
||||
spNoPrefix: maskApprovalIdentifier(info.sp_no ?? payload?.sp_no),
|
||||
templateIdPrefix: maskApprovalIdentifier(info.template_id),
|
||||
spStatus: info.sp_status == null ? null : toSafeString(info.sp_status, 40),
|
||||
applyFieldCount: contents.length,
|
||||
applyFieldTypeCounts: countBy(contents, controlType),
|
||||
approvalNodeCount: records.length,
|
||||
notifyerCount: notifyers.length,
|
||||
commentCount: comments.length,
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizeApprovalApiResponse(payload) {
|
||||
if (!payload || typeof payload !== "object") return null;
|
||||
const result = sanitizeWecomResponse(payload) ?? {};
|
||||
if (payload.template_content) {
|
||||
const summary = summarizeTemplatePayload(payload);
|
||||
result.fieldCount = summary.fieldCount;
|
||||
result.fieldTypeCounts = summary.fieldTypeCounts;
|
||||
}
|
||||
if (payload.info) {
|
||||
const summary = summarizeApprovalPayload(payload);
|
||||
result.spStatus = summary.spStatus;
|
||||
result.applyFieldCount = summary.applyFieldCount;
|
||||
result.approvalNodeCount = summary.approvalNodeCount;
|
||||
result.notifyerCount = summary.notifyerCount;
|
||||
result.commentCount = summary.commentCount;
|
||||
}
|
||||
return Object.keys(result).length ? result : null;
|
||||
}
|
||||
|
||||
function classifyWecomApprovalResult(result, fallbackCode = "wecom_approval_api_error") {
|
||||
if (result?.networkError) {
|
||||
return {
|
||||
code: result.errorMessage === "timeout" ? "wecom_approval_timeout" : "wecom_approval_network_error",
|
||||
message: result.errorMessage || "WeCom approval API network error",
|
||||
response: null,
|
||||
};
|
||||
}
|
||||
|
||||
const errcode = Number(result?.payload?.errcode);
|
||||
if (Number.isFinite(errcode) && errcode !== 0) {
|
||||
return {
|
||||
code: "wecom_approval_api_error",
|
||||
message: `WeCom approval API errcode ${errcode}`,
|
||||
response: sanitizeApprovalApiResponse(result.payload),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
code: result?.httpStatus ? `wecom_approval_http_${result.httpStatus}` : fallbackCode,
|
||||
message: "WeCom approval API failed",
|
||||
response: sanitizeApprovalApiResponse(result?.payload),
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchWecomApprovalJson(pathName, accessToken, body, options = {}) {
|
||||
const url = new URL(`${WECOM_API_BASE}${pathName}`);
|
||||
url.searchParams.set("access_token", accessToken);
|
||||
|
||||
const timeoutMs = Math.max(500, Number(options.timeoutMs ?? 10000));
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const response = await (options.fetchImpl ?? fetch)(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body ?? {}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
const payload = await response.json().catch(() => null);
|
||||
const errcode = payload && typeof payload === "object" ? Number(payload.errcode) : NaN;
|
||||
return {
|
||||
ok: response.ok && Number.isFinite(errcode) && errcode === 0,
|
||||
httpStatus: response.status,
|
||||
payload,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
httpStatus: null,
|
||||
payload: null,
|
||||
networkError: true,
|
||||
errorMessage: error?.name === "AbortError" ? "timeout" : sanitizeErrorMessage(error?.message ?? error),
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function getApprovalAccessToken({ config, fetchImpl, timeoutMs }) {
|
||||
const tokenResult = await getWecomAppAccessToken({
|
||||
config: {
|
||||
...config,
|
||||
wecomRequireAgentId: false,
|
||||
},
|
||||
timeoutMs,
|
||||
fetchImpl,
|
||||
});
|
||||
|
||||
if (!tokenResult.ok) {
|
||||
throw new WecomApprovalPocError(tokenResult.code, tokenResult.message || "WeCom app token unavailable", {
|
||||
response: tokenResult.response,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
accessToken: tokenResult.accessToken,
|
||||
tokenSource: "WECOM_AGENT_SECRET",
|
||||
response: tokenResult.response,
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchWecomApprovalTemplateDetail({
|
||||
config,
|
||||
templateId,
|
||||
fetchImpl = fetch,
|
||||
timeoutMs = config?.wecomRequestTimeoutMs ?? 10000,
|
||||
}) {
|
||||
const resolvedTemplateId = toSafeString(templateId, 160);
|
||||
if (!resolvedTemplateId) {
|
||||
return {
|
||||
ok: false,
|
||||
skipped: true,
|
||||
retryable: false,
|
||||
code: "approval_template_id_missing",
|
||||
message: "Missing approval template id",
|
||||
tokenSource: "WECOM_AGENT_SECRET",
|
||||
response: null,
|
||||
summary: null,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const token = await getApprovalAccessToken({ config, fetchImpl, timeoutMs });
|
||||
const result = await fetchWecomApprovalJson("/cgi-bin/oa/gettemplatedetail", token.accessToken, {
|
||||
template_id: resolvedTemplateId,
|
||||
}, { fetchImpl, timeoutMs });
|
||||
|
||||
if (!result.ok) {
|
||||
const error = classifyWecomApprovalResult(result, "wecom_approval_template_detail_failed");
|
||||
return {
|
||||
ok: false,
|
||||
skipped: false,
|
||||
retryable: false,
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
tokenSource: token.tokenSource,
|
||||
response: error.response,
|
||||
summary: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
skipped: false,
|
||||
retryable: false,
|
||||
code: "wecom_approval_template_detail_fetched",
|
||||
message: null,
|
||||
tokenSource: token.tokenSource,
|
||||
response: sanitizeApprovalApiResponse(result.payload),
|
||||
summary: summarizeTemplatePayload(result.payload),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
skipped: false,
|
||||
retryable: Boolean(error?.details?.retryable),
|
||||
code: error?.code || "wecom_approval_template_detail_failed",
|
||||
message: sanitizeErrorMessage(error?.message || error),
|
||||
tokenSource: "WECOM_AGENT_SECRET",
|
||||
response: error?.details?.response || null,
|
||||
summary: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchWecomApprovalDetail({
|
||||
config,
|
||||
spNo,
|
||||
fetchImpl = fetch,
|
||||
timeoutMs = config?.wecomRequestTimeoutMs ?? 10000,
|
||||
}) {
|
||||
const resolvedSpNo = toSafeString(spNo, 160);
|
||||
if (!resolvedSpNo) {
|
||||
return {
|
||||
ok: false,
|
||||
skipped: true,
|
||||
retryable: false,
|
||||
code: "approval_sp_no_missing",
|
||||
message: "Missing approval sp_no",
|
||||
tokenSource: "WECOM_AGENT_SECRET",
|
||||
response: null,
|
||||
summary: null,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const token = await getApprovalAccessToken({ config, fetchImpl, timeoutMs });
|
||||
const result = await fetchWecomApprovalJson("/cgi-bin/oa/getapprovaldetail", token.accessToken, {
|
||||
sp_no: resolvedSpNo,
|
||||
}, { fetchImpl, timeoutMs });
|
||||
|
||||
if (!result.ok) {
|
||||
const error = classifyWecomApprovalResult(result, "wecom_approval_detail_failed");
|
||||
return {
|
||||
ok: false,
|
||||
skipped: false,
|
||||
retryable: false,
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
tokenSource: token.tokenSource,
|
||||
response: error.response,
|
||||
summary: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
skipped: false,
|
||||
retryable: false,
|
||||
code: "wecom_approval_detail_fetched",
|
||||
message: null,
|
||||
tokenSource: token.tokenSource,
|
||||
response: sanitizeApprovalApiResponse(result.payload),
|
||||
summary: summarizeApprovalPayload(result.payload),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
skipped: false,
|
||||
retryable: Boolean(error?.details?.retryable),
|
||||
code: error?.code || "wecom_approval_detail_failed",
|
||||
message: sanitizeErrorMessage(error?.message || error),
|
||||
tokenSource: "WECOM_AGENT_SECRET",
|
||||
response: error?.details?.response || null,
|
||||
summary: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
APPROVAL_POC_TARGETS,
|
||||
WecomApprovalPocError,
|
||||
fetchWecomApprovalDetail,
|
||||
fetchWecomApprovalTemplateDetail,
|
||||
maskApprovalIdentifier,
|
||||
sanitizeApprovalApiResponse,
|
||||
summarizeApprovalPayload,
|
||||
summarizeTemplatePayload,
|
||||
};
|
||||
在新工单中引用
屏蔽一个用户