文件
hyb-oa/server/src/services/integrations/wecomApproval.js
T

675 行
21 KiB
JavaScript

const { getWecomAppAccessToken, sanitizeWecomResponse } = require("./wecomAccessToken");
const WECOM_API_BASE = "https://qyapi.weixin.qq.com";
const APPROVAL_GET_INFO_ENDPOINT = "/cgi-bin/oa/getapprovalinfo";
const APPROVAL_CREATE_TEMPLATE_ENDPOINT = "/cgi-bin/oa/approval/create_template";
const APPROVAL_APPLY_EVENT_ENDPOINT = "/cgi-bin/oa/applyevent";
const APPROVAL_POC_TARGETS = Object.freeze([
{ key: "leave", label: "请假" },
{ key: "missed_punch", label: "补卡" },
]);
const APPROVAL_HR_ATTENDANCE_TARGETS = Object.freeze([
...APPROVAL_POC_TARGETS,
{ key: "outing", label: "外出" },
{ key: "overtime", 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),
spName: toSafeString(info.sp_name ?? info.spName, 120) || null,
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 extractApprovalInfoSpNos(payload) {
if (!payload || typeof payload !== "object") return [];
const candidates = [
payload.sp_no_list,
payload.spNoList,
payload.info?.sp_no_list,
];
const list = candidates.find((item) => Array.isArray(item)) || [];
return list.map((item) => toSafeString(item, 160)).filter(Boolean);
}
function extractApprovalInfoNextCursor(payload) {
if (!payload || typeof payload !== "object") return "";
return toSafeString(payload.next_cursor ?? payload.nextCursor ?? payload.info?.next_cursor, 160);
}
function summarizeApprovalInfoPayload(payload) {
const spNos = extractApprovalInfoSpNos(payload);
const nextCursor = extractApprovalInfoNextCursor(payload);
return {
spNoCount: spNos.length,
spNoPrefixes: spNos.map(maskApprovalIdentifier),
nextCursorPresent: Boolean(nextCursor),
};
}
function sanitizeApprovalApiResponse(payload) {
if (!payload || typeof payload !== "object") return null;
const result = sanitizeWecomResponse(payload) ?? {};
if (payload.template_id || payload.templateId) {
result.templateIdPrefix = maskApprovalIdentifier(payload.template_id ?? payload.templateId);
}
if (payload.sp_no || payload.spNo) {
result.spNoPrefix = maskApprovalIdentifier(payload.sp_no ?? payload.spNo);
}
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;
}
if (Array.isArray(payload.sp_no_list) || Array.isArray(payload.spNoList)) {
const summary = summarizeApprovalInfoPayload(payload);
result.spNoCount = summary.spNoCount;
result.spNoPrefixes = summary.spNoPrefixes;
result.nextCursorPresent = summary.nextCursorPresent;
}
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,
includeRawPayload = false,
}) {
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),
rawPayload: includeRawPayload ? result.payload : undefined,
templateId: toSafeString(result.payload?.info?.template_id ?? result.payload?.info?.templateId, 160),
spName: toSafeString(result.payload?.info?.sp_name ?? result.payload?.info?.spName, 120),
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,
};
}
}
function normalizeApprovalInfoFilter(filter) {
if (!filter || typeof filter !== "object") return null;
const key = toSafeString(filter.key, 80);
const value = toSafeString(filter.value, 200);
if (!key || !value) return null;
return { key, value };
}
function normalizeApprovalInfoRequest({
startTime,
endTime,
size,
nextCursor,
filters,
}) {
const normalizedStart = Math.floor(Number(startTime));
const normalizedEnd = Math.floor(Number(endTime));
const normalizedSize = Math.max(1, Math.min(100, Math.floor(Number(size || 100))));
const body = {
starttime: normalizedStart,
endtime: normalizedEnd,
size: normalizedSize,
};
const cursor = toSafeString(nextCursor, 160);
if (cursor) body.next_cursor = cursor;
const normalizedFilters = Array.isArray(filters)
? filters.map(normalizeApprovalInfoFilter).filter(Boolean)
: [];
if (normalizedFilters.length) {
body.filters = normalizedFilters;
}
return body;
}
async function fetchWecomApprovalInfoList({
config,
startTime,
endTime,
size = 100,
nextCursor = "",
filters = [],
fetchImpl = fetch,
timeoutMs = config?.wecomRequestTimeoutMs ?? 10000,
}) {
const normalizedStart = Math.floor(Number(startTime));
const normalizedEnd = Math.floor(Number(endTime));
if (!Number.isFinite(normalizedStart) || !Number.isFinite(normalizedEnd) || normalizedStart <= 0 || normalizedEnd <= 0) {
return {
ok: false,
skipped: true,
retryable: false,
code: "approval_compensation_window_invalid",
message: "Missing or invalid approval compensation time window",
tokenSource: "WECOM_AGENT_SECRET",
endpoint: `POST ${APPROVAL_GET_INFO_ENDPOINT}`,
response: null,
spNos: [],
nextCursor: "",
summary: null,
};
}
if (normalizedStart >= normalizedEnd) {
return {
ok: false,
skipped: true,
retryable: false,
code: "approval_compensation_window_invalid",
message: "Approval compensation startTime must be before endTime",
tokenSource: "WECOM_AGENT_SECRET",
endpoint: `POST ${APPROVAL_GET_INFO_ENDPOINT}`,
response: null,
spNos: [],
nextCursor: "",
summary: null,
};
}
try {
const token = await getApprovalAccessToken({ config, fetchImpl, timeoutMs });
const requestBody = normalizeApprovalInfoRequest({
startTime: normalizedStart,
endTime: normalizedEnd,
size,
nextCursor,
filters,
});
const result = await fetchWecomApprovalJson(APPROVAL_GET_INFO_ENDPOINT, token.accessToken, requestBody, {
fetchImpl,
timeoutMs,
});
if (!result.ok) {
const error = classifyWecomApprovalResult(result, "wecom_approval_info_failed");
return {
ok: false,
skipped: false,
retryable: false,
code: error.code,
message: error.message,
tokenSource: token.tokenSource,
endpoint: `POST ${APPROVAL_GET_INFO_ENDPOINT}`,
response: error.response,
spNos: [],
nextCursor: "",
summary: null,
};
}
const spNos = extractApprovalInfoSpNos(result.payload);
const nextCursorValue = extractApprovalInfoNextCursor(result.payload);
return {
ok: true,
skipped: false,
retryable: false,
code: "wecom_approval_info_fetched",
message: null,
tokenSource: token.tokenSource,
endpoint: `POST ${APPROVAL_GET_INFO_ENDPOINT}`,
response: sanitizeApprovalApiResponse(result.payload),
spNos,
nextCursor: nextCursorValue,
summary: summarizeApprovalInfoPayload(result.payload),
};
} catch (error) {
return {
ok: false,
skipped: false,
retryable: Boolean(error?.details?.retryable),
code: error?.code || "wecom_approval_info_failed",
message: sanitizeErrorMessage(error?.message || error),
tokenSource: "WECOM_AGENT_SECRET",
endpoint: `POST ${APPROVAL_GET_INFO_ENDPOINT}`,
response: error?.details?.response || null,
spNos: [],
nextCursor: "",
summary: null,
};
}
}
function extractTemplateId(payload) {
return toSafeString(payload?.template_id ?? payload?.templateId, 160);
}
function extractSpNo(payload) {
return toSafeString(payload?.sp_no ?? payload?.spNo, 160);
}
async function fetchWecomApprovalTemplateCreate({
config,
payload,
fetchImpl = fetch,
timeoutMs = config?.wecomRequestTimeoutMs ?? 10000,
}) {
try {
const token = await getApprovalAccessToken({ config, fetchImpl, timeoutMs });
const result = await fetchWecomApprovalJson(APPROVAL_CREATE_TEMPLATE_ENDPOINT, token.accessToken, payload, {
fetchImpl,
timeoutMs,
});
if (!result.ok) {
const error = classifyWecomApprovalResult(result, "wecom_approval_create_template_failed");
return {
ok: false,
skipped: false,
retryable: false,
code: error.code,
message: error.message,
tokenSource: token.tokenSource,
endpoint: `POST ${APPROVAL_CREATE_TEMPLATE_ENDPOINT}`,
response: error.response,
templateId: null,
summary: null,
};
}
const templateId = extractTemplateId(result.payload);
return {
ok: true,
skipped: false,
retryable: false,
code: "wecom_approval_template_created",
message: null,
tokenSource: token.tokenSource,
endpoint: `POST ${APPROVAL_CREATE_TEMPLATE_ENDPOINT}`,
response: sanitizeApprovalApiResponse(result.payload),
templateId,
summary: {
templateIdPrefix: maskApprovalIdentifier(templateId),
},
};
} catch (error) {
return {
ok: false,
skipped: false,
retryable: Boolean(error?.details?.retryable),
code: error?.code || "wecom_approval_create_template_failed",
message: sanitizeErrorMessage(error?.message || error),
tokenSource: "WECOM_AGENT_SECRET",
endpoint: `POST ${APPROVAL_CREATE_TEMPLATE_ENDPOINT}`,
response: error?.details?.response || null,
templateId: null,
summary: null,
};
}
}
async function fetchWecomApprovalApplyEvent({
config,
payload,
fetchImpl = fetch,
timeoutMs = config?.wecomRequestTimeoutMs ?? 10000,
}) {
try {
const token = await getApprovalAccessToken({ config, fetchImpl, timeoutMs });
const result = await fetchWecomApprovalJson(APPROVAL_APPLY_EVENT_ENDPOINT, token.accessToken, payload, {
fetchImpl,
timeoutMs,
});
if (!result.ok) {
const error = classifyWecomApprovalResult(result, "wecom_approval_apply_event_failed");
return {
ok: false,
skipped: false,
retryable: false,
code: error.code,
message: error.message,
tokenSource: token.tokenSource,
endpoint: `POST ${APPROVAL_APPLY_EVENT_ENDPOINT}`,
response: error.response,
spNo: null,
summary: null,
};
}
const spNo = extractSpNo(result.payload);
return {
ok: true,
skipped: false,
retryable: false,
code: "wecom_approval_apply_event_submitted",
message: null,
tokenSource: token.tokenSource,
endpoint: `POST ${APPROVAL_APPLY_EVENT_ENDPOINT}`,
response: sanitizeApprovalApiResponse(result.payload),
spNo,
summary: {
spNoPrefix: maskApprovalIdentifier(spNo),
},
};
} catch (error) {
return {
ok: false,
skipped: false,
retryable: Boolean(error?.details?.retryable),
code: error?.code || "wecom_approval_apply_event_failed",
message: sanitizeErrorMessage(error?.message || error),
tokenSource: "WECOM_AGENT_SECRET",
endpoint: `POST ${APPROVAL_APPLY_EVENT_ENDPOINT}`,
response: error?.details?.response || null,
spNo: null,
summary: null,
};
}
}
module.exports = {
APPROVAL_HR_ATTENDANCE_TARGETS,
APPROVAL_POC_TARGETS,
APPROVAL_APPLY_EVENT_ENDPOINT,
APPROVAL_CREATE_TEMPLATE_ENDPOINT,
APPROVAL_GET_INFO_ENDPOINT,
WecomApprovalPocError,
fetchWecomApprovalApplyEvent,
fetchWecomApprovalDetail,
fetchWecomApprovalInfoList,
fetchWecomApprovalTemplateCreate,
fetchWecomApprovalTemplateDetail,
maskApprovalIdentifier,
sanitizeApprovalApiResponse,
summarizeApprovalInfoPayload,
summarizeApprovalPayload,
summarizeTemplatePayload,
};