实现企微原生材料预览(07-27-yang-jun-reimbursement-readiness)
这个提交包含在:
@@ -0,0 +1,110 @@
|
||||
const crypto = require("node:crypto");
|
||||
|
||||
const { getWecomAppAccessToken } = require("./wecomAccessToken");
|
||||
|
||||
const ticketCache = new Map();
|
||||
|
||||
function toSafeString(value, maxLength = 2000) {
|
||||
if (value == null) return "";
|
||||
return String(value).trim().slice(0, maxLength);
|
||||
}
|
||||
|
||||
function credentialKey(config) {
|
||||
return crypto
|
||||
.createHash("sha256")
|
||||
.update([
|
||||
toSafeString(config?.wecomCorpId, 128),
|
||||
toSafeString(config?.wecomAgentId, 64),
|
||||
toSafeString(config?.wecomAgentSecret, 500),
|
||||
].join(":"))
|
||||
.digest("hex");
|
||||
}
|
||||
|
||||
function sanitizeTicketResponse(payload) {
|
||||
if (!payload || typeof payload !== "object") return null;
|
||||
const result = {};
|
||||
for (const key of ["errcode", "errmsg"]) {
|
||||
if (payload[key] != null) result[key] = payload[key];
|
||||
}
|
||||
return Object.keys(result).length ? result : null;
|
||||
}
|
||||
|
||||
async function getWecomJsApiTicket({ config, timeoutMs, fetchImpl = fetch }) {
|
||||
const cacheKey = credentialKey(config);
|
||||
const cached = ticketCache.get(cacheKey);
|
||||
if (cached?.ticket && cached.expiresAt > Date.now() + 60 * 1000) {
|
||||
return { ok: true, code: "wecom_jsapi_ticket_cached", ticket: cached.ticket };
|
||||
}
|
||||
|
||||
const tokenResult = await getWecomAppAccessToken({ config, timeoutMs, fetchImpl });
|
||||
if (!tokenResult.ok) {
|
||||
return { ok: false, code: tokenResult.code, ticket: null };
|
||||
}
|
||||
|
||||
const timeout = Math.max(100, Number(timeoutMs ?? config?.wecomRequestTimeoutMs ?? 5000));
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeout);
|
||||
const url = new URL("https://qyapi.weixin.qq.com/cgi-bin/get_jsapi_ticket");
|
||||
url.searchParams.set("access_token", tokenResult.accessToken);
|
||||
|
||||
try {
|
||||
const response = await fetchImpl(url, { method: "GET", signal: controller.signal });
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
code: response.status >= 500 ? "wecom_jsapi_ticket_http_5xx" : "wecom_jsapi_ticket_http_error",
|
||||
response: { status: response.status, body: sanitizeTicketResponse(payload) },
|
||||
ticket: null,
|
||||
};
|
||||
}
|
||||
if (!payload || Number(payload.errcode ?? 0) !== 0 || !payload.ticket) {
|
||||
return {
|
||||
ok: false,
|
||||
code: "wecom_jsapi_ticket_api_error",
|
||||
response: sanitizeTicketResponse(payload),
|
||||
ticket: null,
|
||||
};
|
||||
}
|
||||
|
||||
const expiresIn = Math.max(300, Number(payload.expires_in ?? 7200));
|
||||
const ticket = String(payload.ticket);
|
||||
ticketCache.set(cacheKey, {
|
||||
ticket,
|
||||
expiresAt: Date.now() + Math.max(60, expiresIn - 120) * 1000,
|
||||
});
|
||||
return { ok: true, code: "wecom_jsapi_ticket_fetched", ticket };
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
code: error?.name === "AbortError" ? "wecom_jsapi_ticket_timeout" : "wecom_jsapi_ticket_network_error",
|
||||
ticket: null,
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
function signWecomJsSdkUrl({ ticket, url, timestamp = Math.floor(Date.now() / 1000), nonceStr = crypto.randomUUID() }) {
|
||||
const canonical = [
|
||||
`jsapi_ticket=${ticket}`,
|
||||
`noncestr=${nonceStr}`,
|
||||
`timestamp=${timestamp}`,
|
||||
`url=${url}`,
|
||||
].join("&");
|
||||
return {
|
||||
timestamp,
|
||||
nonceStr,
|
||||
signature: crypto.createHash("sha1").update(canonical).digest("hex"),
|
||||
};
|
||||
}
|
||||
|
||||
function clearWecomJsApiTicketCache() {
|
||||
ticketCache.clear();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
clearWecomJsApiTicketCache,
|
||||
getWecomJsApiTicket,
|
||||
signWecomJsSdkUrl,
|
||||
};
|
||||
在新工单中引用
屏蔽一个用户