feat: 企微回调服务基建(加解密+事件分发,默认关闭)
This commit is contained in:
125
app/api/wecom-callback/route.ts
Normal file
125
app/api/wecom-callback/route.ts
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import {
|
||||||
|
decryptWecomCallback,
|
||||||
|
verifyWecomCallbackSignature,
|
||||||
|
wecomSignaturesMatch,
|
||||||
|
WecomCallbackCryptoError,
|
||||||
|
} from "../../../lib/wecom-callback-crypto";
|
||||||
|
import {
|
||||||
|
extractWecomEncryptField,
|
||||||
|
handleWecomCallbackEvent,
|
||||||
|
parseWecomCallbackXml,
|
||||||
|
} from "../../../lib/wecom-callback-events";
|
||||||
|
import { isEnabled, getRuntimeEnv } from "../../../lib/runtime-env";
|
||||||
|
|
||||||
|
type CallbackConfig = {
|
||||||
|
token: string;
|
||||||
|
encodingAesKey: string;
|
||||||
|
corpId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function resolveCallbackConfig(): CallbackConfig | null {
|
||||||
|
const env = getRuntimeEnv();
|
||||||
|
if (!isEnabled(env.WECOM_CALLBACK_ENABLED, false)) return null;
|
||||||
|
const token = String(env.WECOM_CALLBACK_TOKEN ?? "").trim();
|
||||||
|
const encodingAesKey = String(
|
||||||
|
env.WECOM_CALLBACK_ENCODING_AES_KEY ?? "",
|
||||||
|
).trim();
|
||||||
|
if (!token || !encodingAesKey) return null;
|
||||||
|
return {
|
||||||
|
token,
|
||||||
|
encodingAesKey,
|
||||||
|
corpId: String(env.WECOM_CORP_ID ?? "").trim(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function readCallbackQuery(request: Request) {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
return {
|
||||||
|
msgSignature: url.searchParams.get("msg_signature") ?? "",
|
||||||
|
timestamp: url.searchParams.get("timestamp") ?? "",
|
||||||
|
nonce: url.searchParams.get("nonce") ?? "",
|
||||||
|
echostr: url.searchParams.get("echostr") ?? "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: Request) {
|
||||||
|
const config = resolveCallbackConfig();
|
||||||
|
if (!config) {
|
||||||
|
return new Response("企业微信回调未启用", { status: 403 });
|
||||||
|
}
|
||||||
|
const { msgSignature, timestamp, nonce, echostr } =
|
||||||
|
readCallbackQuery(request);
|
||||||
|
if (!msgSignature || !timestamp || !nonce || !echostr) {
|
||||||
|
return new Response("缺少回调校验参数", { status: 400 });
|
||||||
|
}
|
||||||
|
const expected = verifyWecomCallbackSignature(
|
||||||
|
config.token,
|
||||||
|
timestamp,
|
||||||
|
nonce,
|
||||||
|
echostr,
|
||||||
|
);
|
||||||
|
if (!wecomSignaturesMatch(expected, msgSignature)) {
|
||||||
|
return new Response("签名校验失败", { status: 403 });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { message } = decryptWecomCallback(echostr, config.encodingAesKey);
|
||||||
|
return new Response(message, {
|
||||||
|
status: 200,
|
||||||
|
headers: { "Content-Type": "text/plain; charset=utf-8" },
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof WecomCallbackCryptoError) {
|
||||||
|
console.warn("[KOC LOOP] wecom callback echostr decrypt failed", error);
|
||||||
|
return new Response(error.message, { status: 400 });
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const config = resolveCallbackConfig();
|
||||||
|
if (!config) {
|
||||||
|
return new Response("企业微信回调未启用", { status: 403 });
|
||||||
|
}
|
||||||
|
const { msgSignature, timestamp, nonce } = readCallbackQuery(request);
|
||||||
|
try {
|
||||||
|
const body = await request.text();
|
||||||
|
const encrypt = extractWecomEncryptField(body);
|
||||||
|
if (!encrypt) {
|
||||||
|
return new Response("回调报文缺少 Encrypt 字段", { status: 400 });
|
||||||
|
}
|
||||||
|
if (!msgSignature || !timestamp || !nonce) {
|
||||||
|
return new Response("缺少回调校验参数", { status: 400 });
|
||||||
|
}
|
||||||
|
const expected = verifyWecomCallbackSignature(
|
||||||
|
config.token,
|
||||||
|
timestamp,
|
||||||
|
nonce,
|
||||||
|
encrypt,
|
||||||
|
);
|
||||||
|
if (!wecomSignaturesMatch(expected, msgSignature)) {
|
||||||
|
return new Response("签名校验失败", { status: 403 });
|
||||||
|
}
|
||||||
|
const { message, receiveId } = decryptWecomCallback(
|
||||||
|
encrypt,
|
||||||
|
config.encodingAesKey,
|
||||||
|
);
|
||||||
|
if (config.corpId && receiveId !== config.corpId) {
|
||||||
|
console.warn(
|
||||||
|
"[KOC LOOP] wecom callback receiveid mismatch",
|
||||||
|
{ expected: config.corpId },
|
||||||
|
);
|
||||||
|
return new Response("receiveid 校验失败", { status: 403 });
|
||||||
|
}
|
||||||
|
const fields = parseWecomCallbackXml(message);
|
||||||
|
handleWecomCallbackEvent(fields);
|
||||||
|
return new Response("", { status: 200 });
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof WecomCallbackCryptoError) {
|
||||||
|
console.warn("[KOC LOOP] wecom callback decrypt failed", error);
|
||||||
|
return new Response(error.message, { status: 400 });
|
||||||
|
}
|
||||||
|
console.error("[KOC LOOP] wecom callback failed", error);
|
||||||
|
return new Response("", { status: 200 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,9 @@ export type RuntimeEnv = {
|
|||||||
WECOM_ROBOT_WEBHOOK?: string;
|
WECOM_ROBOT_WEBHOOK?: string;
|
||||||
WECOM_NOTIFY_DUE_DAYS?: string;
|
WECOM_NOTIFY_DUE_DAYS?: string;
|
||||||
WECOM_NOTIFY_ENABLED?: string;
|
WECOM_NOTIFY_ENABLED?: string;
|
||||||
|
WECOM_CALLBACK_TOKEN?: string;
|
||||||
|
WECOM_CALLBACK_ENCODING_AES_KEY?: string;
|
||||||
|
WECOM_CALLBACK_ENABLED?: string;
|
||||||
SEED_DEMO_DATA?: string;
|
SEED_DEMO_DATA?: string;
|
||||||
ENABLE_SCHEDULER?: string;
|
ENABLE_SCHEDULER?: string;
|
||||||
};
|
};
|
||||||
|
|||||||
97
lib/wecom-callback-crypto.ts
Normal file
97
lib/wecom-callback-crypto.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import {
|
||||||
|
createCipheriv,
|
||||||
|
createDecipheriv,
|
||||||
|
createHash,
|
||||||
|
randomBytes,
|
||||||
|
timingSafeEqual,
|
||||||
|
} from "node:crypto";
|
||||||
|
|
||||||
|
export class WecomCallbackCryptoError extends Error {
|
||||||
|
status: number;
|
||||||
|
|
||||||
|
constructor(message: string, status = 400) {
|
||||||
|
super(message);
|
||||||
|
this.name = "WecomCallbackCryptoError";
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveWecomCallbackAesKey(encodingAesKey: string): Buffer {
|
||||||
|
const key = String(encodingAesKey ?? "").trim();
|
||||||
|
if (!/^[A-Za-z0-9]{43}$/.test(key)) {
|
||||||
|
throw new WecomCallbackCryptoError(
|
||||||
|
"企业微信回调 EncodingAESKey 非法(需 43 位字母数字)",
|
||||||
|
500,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Buffer.from(`${key}=`, "base64");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyWecomCallbackSignature(
|
||||||
|
token: string,
|
||||||
|
timestamp: string,
|
||||||
|
nonce: string,
|
||||||
|
encrypt: string,
|
||||||
|
): string {
|
||||||
|
return createHash("sha1")
|
||||||
|
.update([token, timestamp, nonce, encrypt].sort().join(""))
|
||||||
|
.digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wecomSignaturesMatch(
|
||||||
|
expected: string,
|
||||||
|
received: string,
|
||||||
|
): boolean {
|
||||||
|
const a = Buffer.from(String(expected ?? ""));
|
||||||
|
const b = Buffer.from(String(received ?? ""));
|
||||||
|
if (a.length !== b.length || a.length === 0) return false;
|
||||||
|
return timingSafeEqual(a, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function encryptWecomCallback(
|
||||||
|
plainMessage: string,
|
||||||
|
receiveId: string,
|
||||||
|
encodingAesKey: string,
|
||||||
|
): string {
|
||||||
|
const key = resolveWecomCallbackAesKey(encodingAesKey);
|
||||||
|
const random = randomBytes(16);
|
||||||
|
const message = Buffer.from(String(plainMessage ?? ""), "utf8");
|
||||||
|
const receive = Buffer.from(String(receiveId ?? ""), "utf8");
|
||||||
|
const length = Buffer.alloc(4);
|
||||||
|
length.writeUInt32BE(message.length, 0);
|
||||||
|
const cipher = createCipheriv("aes-256-cbc", key, key.subarray(0, 16));
|
||||||
|
const encrypted = Buffer.concat([
|
||||||
|
cipher.update(Buffer.concat([random, length, message, receive])),
|
||||||
|
cipher.final(),
|
||||||
|
]);
|
||||||
|
return encrypted.toString("base64");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decryptWecomCallback(
|
||||||
|
encrypted: string,
|
||||||
|
encodingAesKey: string,
|
||||||
|
): { message: string; receiveId: string } {
|
||||||
|
const key = resolveWecomCallbackAesKey(encodingAesKey);
|
||||||
|
const data = Buffer.from(String(encrypted ?? "").trim(), "base64");
|
||||||
|
if (data.length === 0 || data.length % 16 !== 0) {
|
||||||
|
throw new WecomCallbackCryptoError("企业微信回调密文长度非法");
|
||||||
|
}
|
||||||
|
let raw: Buffer;
|
||||||
|
try {
|
||||||
|
const decipher = createDecipheriv("aes-256-cbc", key, key.subarray(0, 16));
|
||||||
|
raw = Buffer.concat([decipher.update(data), decipher.final()]);
|
||||||
|
} catch {
|
||||||
|
throw new WecomCallbackCryptoError("企业微信回调密文解密失败");
|
||||||
|
}
|
||||||
|
if (raw.length < 20) {
|
||||||
|
throw new WecomCallbackCryptoError("企业微信回调明文长度非法");
|
||||||
|
}
|
||||||
|
const messageLength = raw.readUInt32BE(16);
|
||||||
|
if (messageLength <= 0 || 20 + messageLength > raw.length) {
|
||||||
|
throw new WecomCallbackCryptoError("企业微信回调消息长度字段非法");
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
message: raw.subarray(20, 20 + messageLength).toString("utf8"),
|
||||||
|
receiveId: raw.subarray(20 + messageLength).toString("utf8"),
|
||||||
|
};
|
||||||
|
}
|
||||||
54
lib/wecom-callback-events.ts
Normal file
54
lib/wecom-callback-events.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
export type WecomCallbackFields = Record<string, string>;
|
||||||
|
|
||||||
|
export type WecomCallbackEventResult = {
|
||||||
|
recognized: boolean;
|
||||||
|
action: string;
|
||||||
|
taskId: string;
|
||||||
|
btnKey: string;
|
||||||
|
operator: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function parseWecomCallbackXml(xml: string): WecomCallbackFields {
|
||||||
|
const fields: WecomCallbackFields = {};
|
||||||
|
const source = String(xml ?? "");
|
||||||
|
const pattern =
|
||||||
|
/<([A-Za-z][A-Za-z0-9_]*)>(?:<!\[CDATA\[([\s\S]*?)\]\]>|([^<]*))<\/\1>/g;
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
while ((match = pattern.exec(source)) !== null) {
|
||||||
|
const tag = match[1];
|
||||||
|
const value = match[2] ?? match[3] ?? "";
|
||||||
|
if (!(tag in fields)) fields[tag] = value.trim();
|
||||||
|
}
|
||||||
|
return fields;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractWecomEncryptField(body: string): string {
|
||||||
|
const fields = parseWecomCallbackXml(body);
|
||||||
|
return fields.Encrypt ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handleWecomCallbackEvent(
|
||||||
|
fields: WecomCallbackFields,
|
||||||
|
): WecomCallbackEventResult {
|
||||||
|
const event = fields.Event ?? "";
|
||||||
|
const taskId = fields.TaskId ?? "";
|
||||||
|
const btnKey = fields.BtnKey ?? "";
|
||||||
|
const operator = fields.FromUserName ?? "";
|
||||||
|
|
||||||
|
if (fields.MsgType === "event" && event === "template_card_event") {
|
||||||
|
if (btnKey === "mark_handled") {
|
||||||
|
console.info(
|
||||||
|
"[KOC LOOP] wecom card ack",
|
||||||
|
{ taskId, operator },
|
||||||
|
);
|
||||||
|
return { recognized: true, action: "card_ack", taskId, btnKey, operator };
|
||||||
|
}
|
||||||
|
return { recognized: true, action: "ignored", taskId, btnKey, operator };
|
||||||
|
}
|
||||||
|
|
||||||
|
console.info("[KOC LOOP] wecom callback event ignored", {
|
||||||
|
msgType: fields.MsgType ?? "",
|
||||||
|
event,
|
||||||
|
});
|
||||||
|
return { recognized: false, action: "ignored", taskId, btnKey, operator };
|
||||||
|
}
|
||||||
114
tests/wecom-callback-crypto.test.mjs
Normal file
114
tests/wecom-callback-crypto.test.mjs
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import test from "node:test";
|
||||||
|
import {
|
||||||
|
decryptWecomCallback,
|
||||||
|
encryptWecomCallback,
|
||||||
|
resolveWecomCallbackAesKey,
|
||||||
|
verifyWecomCallbackSignature,
|
||||||
|
wecomSignaturesMatch,
|
||||||
|
WecomCallbackCryptoError,
|
||||||
|
} from "../lib/wecom-callback-crypto.ts";
|
||||||
|
import {
|
||||||
|
extractWecomEncryptField,
|
||||||
|
handleWecomCallbackEvent,
|
||||||
|
parseWecomCallbackXml,
|
||||||
|
} from "../lib/wecom-callback-events.ts";
|
||||||
|
|
||||||
|
const AES_KEY = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQ";
|
||||||
|
const CORP_ID = "wwCallbackCorp01";
|
||||||
|
const TOKEN = "callback-token-test";
|
||||||
|
|
||||||
|
test("aes key resolution accepts 43-char key and rejects invalid input", () => {
|
||||||
|
const key = resolveWecomCallbackAesKey(AES_KEY);
|
||||||
|
assert.equal(key.length, 32);
|
||||||
|
assert.throws(
|
||||||
|
() => resolveWecomCallbackAesKey("short"),
|
||||||
|
WecomCallbackCryptoError,
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() => resolveWecomCallbackAesKey("abcdefghij;klmnopqrstuvwxyzABCDEFGHIJKLMNOP"),
|
||||||
|
WecomCallbackCryptoError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("encrypt → decrypt roundtrip preserves chinese message and receiveId", () => {
|
||||||
|
const plain =
|
||||||
|
'<xml><ToUserName><![CDATA[wwCallbackCorp01]]></ToUserName><Event><![CDATA[模板卡片]]></Event></xml>';
|
||||||
|
const encrypted = encryptWecomCallback(plain, CORP_ID, AES_KEY);
|
||||||
|
const decrypted = decryptWecomCallback(encrypted, AES_KEY);
|
||||||
|
assert.equal(decrypted.message, plain);
|
||||||
|
assert.equal(decrypted.receiveId, CORP_ID);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("decrypt rejects tampered ciphertext", () => {
|
||||||
|
const encrypted = encryptWecomCallback("hello", CORP_ID, AES_KEY);
|
||||||
|
const corrupted = `${encrypted.slice(0, -4)}AAAA`;
|
||||||
|
assert.throws(
|
||||||
|
() => decryptWecomCallback(corrupted, AES_KEY),
|
||||||
|
WecomCallbackCryptoError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("decrypt rejects non-block-aligned ciphertext", () => {
|
||||||
|
assert.throws(
|
||||||
|
() => decryptWecomCallback("AAAAAAAA", AES_KEY),
|
||||||
|
WecomCallbackCryptoError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("signature = sha1 of sorted join, order independent", () => {
|
||||||
|
const encrypted = encryptWecomCallback("hello", CORP_ID, AES_KEY);
|
||||||
|
const expected = createHash("sha1")
|
||||||
|
.update([TOKEN, "1756700000", "nonce-abc", encrypted].sort().join(""))
|
||||||
|
.digest("hex");
|
||||||
|
const actual = verifyWecomCallbackSignature(
|
||||||
|
TOKEN,
|
||||||
|
"1756700000",
|
||||||
|
"nonce-abc",
|
||||||
|
encrypted,
|
||||||
|
);
|
||||||
|
assert.equal(actual, expected);
|
||||||
|
assert.ok(wecomSignaturesMatch(actual, expected));
|
||||||
|
assert.ok(!wecomSignaturesMatch(actual, `${expected}x`));
|
||||||
|
assert.ok(!wecomSignaturesMatch(actual, ""));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("parse callback xml handles CDATA and plain fields", () => {
|
||||||
|
const body =
|
||||||
|
"<xml><ToUserName><![CDATA[wwCorp]]></ToUserName><Encrypt><![CDATA[RW5jcnlwdGVkQmFzZTY0PT0=]]></Encrypt><CreateTime>1756700000</CreateTime></xml>";
|
||||||
|
const fields = parseWecomCallbackXml(body);
|
||||||
|
assert.equal(fields.ToUserName, "wwCorp");
|
||||||
|
assert.equal(fields.Encrypt, "RW5jcnlwdGVkQmFzZTY0PT0=");
|
||||||
|
assert.equal(fields.CreateTime, "1756700000");
|
||||||
|
assert.equal(extractWecomEncryptField(body), "RW5jcnlwdGVkQmFzZTY0PT0=");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("event dispatcher classifies template_card mark_handled and ignores others", () => {
|
||||||
|
const ack = handleWecomCallbackEvent({
|
||||||
|
MsgType: "event",
|
||||||
|
Event: "template_card_event",
|
||||||
|
TaskId: "task:t123:due",
|
||||||
|
BtnKey: "mark_handled",
|
||||||
|
FromUserName: "opsUser01",
|
||||||
|
});
|
||||||
|
assert.equal(ack.recognized, true);
|
||||||
|
assert.equal(ack.action, "card_ack");
|
||||||
|
assert.equal(ack.taskId, "task:t123:due");
|
||||||
|
assert.equal(ack.operator, "opsUser01");
|
||||||
|
|
||||||
|
const view = handleWecomCallbackEvent({
|
||||||
|
MsgType: "event",
|
||||||
|
Event: "template_card_event",
|
||||||
|
TaskId: "task:t123:due",
|
||||||
|
BtnKey: "view_task",
|
||||||
|
});
|
||||||
|
assert.equal(view.recognized, true);
|
||||||
|
assert.equal(view.action, "ignored");
|
||||||
|
|
||||||
|
const other = handleWecomCallbackEvent({
|
||||||
|
MsgType: "event",
|
||||||
|
Event: "change_contact",
|
||||||
|
});
|
||||||
|
assert.equal(other.recognized, false);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user