W1-W3 实现企业微信个人应用消息通知

这个提交包含在:
2026-05-11 09:17:00 +08:00
父节点 805e050ba8
当前提交 b1ae41fa99
共修改 12 个文件,包含 869 行新增和 111 行删除
+31 -14
查看文件
@@ -31,7 +31,7 @@ const DELIVERY_QUERY_SCHEMA = z.object({
const CHANNEL_ID_SCHEMA = z.string().uuid();
const ROLE_CODE_VALUES = Object.freeze(Object.keys(ROLE_PERMISSIONS));
const CHANNEL_TYPE_VALUES = Object.freeze(['wecom', 'wechat_work']);
const CHANNEL_MODE_VALUES = Object.freeze(['mock', 'wecom_webhook']);
const CHANNEL_MODE_VALUES = Object.freeze(['mock', 'wecom_webhook', 'wecom_app_message']);
const DOMAIN_SCOPE_VALUES = Object.freeze(Object.values(NOTIFICATION_DOMAIN_SCOPES));
const DEFAULT_TEMPLATE_JSON = Object.freeze({
title: '{{title}}',
@@ -195,15 +195,22 @@ function normalizeRouting(routing) {
}
function normalizeConfigJson(input = {}, options = {}) {
const mode = input.mode ?? 'mock';
const webhookUrl = mode === 'wecom_webhook' ? String(input.webhookUrl ?? '').trim() || null : null;
const secretFields =
mode === 'wecom_webhook'
? buildStoredSecretFields({
config: options.config,
existingConfig: options.existingConfig,
inputSecret: input.secret,
clearSecret: Boolean(input.clearSecret),
})
: {};
return {
mode: input.mode ?? 'mock',
webhookUrl: String(input.webhookUrl ?? '').trim() || null,
...buildStoredSecretFields({
config: options.config,
existingConfig: options.existingConfig,
inputSecret: input.secret,
clearSecret: Boolean(input.clearSecret),
}),
mode,
webhookUrl,
...secretFields,
timeoutMs: Math.max(100, Number(input.timeoutMs ?? 5000)),
routing: normalizeRouting(input.routing),
};
@@ -221,11 +228,17 @@ function normalizeTemplateJson(input = {}) {
function mapChannelConfigForRead(configJson, options = {}) {
const rawConfig = configJson && typeof configJson === 'object' ? configJson : {};
const secretModel = getChannelSecretReadModel({
config: options.config,
configJson: rawConfig,
logger: options.logger,
});
const secretModel =
rawConfig.mode === 'wecom_webhook'
? getChannelSecretReadModel({
config: options.config,
configJson: rawConfig,
logger: options.logger,
})
: {
secretConfigured: false,
secretMasked: null,
};
return {
mode: rawConfig.mode ?? 'mock',
@@ -693,7 +706,9 @@ async function notificationsRoutes(fastify) {
body: `测试人:${request.employee.name}(${request.employee.id})`,
targetType: 'notification_channel',
targetId: channel.id,
recipientEmployeeIds: [request.employee.id],
},
prisma: fastify.prisma,
});
await fastify.prisma.auditLog.create({
@@ -773,7 +788,9 @@ async function notificationsRoutes(fastify) {
body: `测试人:${request.employee.id}`,
targetType: 'notification_channel',
targetId: channel.id,
recipientEmployeeIds: [request.employee.id],
},
prisma: fastify.prisma,
});
await fastify.prisma.auditLog.create({
@@ -272,6 +272,7 @@ async function dispatchOutboxEvent({ prisma, config, eventType, payload }) {
config,
channel,
payload,
prisma,
});
if (!result.ok) {
+237 -1
查看文件
@@ -1,6 +1,10 @@
const crypto = require('node:crypto');
const { resolveChannelSecret } = require('../../notifications/channelConfig');
const {
getWecomAppAccessToken,
sanitizeWecomResponse,
} = require('../wecomAccessToken');
function toSafeString(value, maxLength = 2000) {
if (value == null) {
@@ -33,6 +37,30 @@ function buildWecomMarkdownMessage(message) {
return lines.filter(Boolean).join('\n\n').trim();
}
function buildWecomTextPreview(message) {
const title = toSafeString(message?.title, 200);
const body = toSafeString(message?.body, 4000);
const detailUrl = toSafeString(message?.detailUrl, 2000);
const footer = toSafeString(message?.footer, 200);
const deepLinkLabel = toSafeString(message?.deepLinkLabel || '查看详情', 50) || '查看详情';
const lines = [];
if (title) {
lines.push(title);
}
if (body) {
lines.push(body);
}
if (detailUrl) {
lines.push(`${deepLinkLabel}:${detailUrl}`);
}
if (footer) {
lines.push(footer);
}
return lines.filter(Boolean).join('\n\n').trim();
}
function buildSignedWebhookUrl(webhookUrl, secret) {
const rawUrl = String(webhookUrl ?? '').trim();
if (!rawUrl || !secret) {
@@ -73,7 +101,205 @@ function mapSecretError(result, previewMessage) {
};
}
async function sendWecomMessage({ config, channel, message }) {
function normalizeExternalUserIds(values) {
return Array.from(
new Set(
(values ?? [])
.map((value) => String(value ?? '').trim())
.filter(Boolean),
),
);
}
function normalizeAgentId(value) {
const raw = String(value ?? '').trim();
const numeric = Number(raw);
return raw && Number.isFinite(numeric) ? numeric : raw;
}
function hasInvalidRecipients(responseBody) {
return Boolean(String(responseBody?.invaliduser ?? '').trim());
}
function buildWecomAppPayload({ config, message, touser }) {
const agentid = normalizeAgentId(config?.wecomAgentId);
const title = toSafeString(message?.title || '系统通知', 200) || '系统通知';
const body = toSafeString(message?.body, 4000);
const footer = toSafeString(message?.footer, 200);
const detailUrl = toSafeString(message?.detailUrl, 2000);
const deepLinkLabel = toSafeString(message?.deepLinkLabel || '查看详情', 10) || '查看详情';
if (detailUrl) {
return {
touser,
msgtype: 'textcard',
agentid,
textcard: {
title,
description: [body, footer].filter(Boolean).join('\n\n') || title,
url: detailUrl,
btntxt: deepLinkLabel,
},
enable_id_trans: 0,
enable_duplicate_check: 1,
duplicate_check_interval: 1800,
};
}
return {
touser,
msgtype: 'text',
agentid,
text: {
content: buildWecomTextPreview(message) || title,
},
enable_id_trans: 0,
enable_duplicate_check: 1,
duplicate_check_interval: 1800,
};
}
async function sendWecomAppMessage({
config,
configJson,
message,
recipientExternalUserIds,
}) {
const previewMessage = buildWecomTextPreview(message);
const externalUserIds = normalizeExternalUserIds(recipientExternalUserIds);
if (!externalUserIds.length) {
return {
ok: true,
skipped: false,
retryable: false,
code: 'wecom_app_no_bound_recipients',
message: 'No bound WeCom recipients',
response: {
channelType: 'wecom',
mode: 'wecom_app_message',
recipientCount: 0,
},
previewMessage,
};
}
const timeoutMs = Math.max(100, Number(configJson.timeoutMs ?? 5000));
const tokenResult = await getWecomAppAccessToken({ config, timeoutMs });
if (!tokenResult.ok) {
return {
ok: false,
skipped: false,
retryable: tokenResult.retryable,
code: tokenResult.code,
message: tokenResult.message,
response: tokenResult.response,
previewMessage,
};
}
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
const url = new URL('https://qyapi.weixin.qq.com/cgi-bin/message/send');
url.searchParams.set('access_token', tokenResult.accessToken);
const touser = externalUserIds.join('|');
try {
const response = await fetch(url, {
method: 'POST',
headers: {
'content-type': 'application/json',
},
body: JSON.stringify(buildWecomAppPayload({ config, message, touser })),
signal: controller.signal,
});
const responseBody = await response.json().catch(() => null);
const sanitized = sanitizeWecomResponse(responseBody);
if (!response.ok) {
return {
ok: false,
skipped: false,
retryable: response.status >= 500 || response.status === 429,
code:
response.status >= 500
? 'wecom_app_http_5xx'
: response.status === 429
? 'wecom_app_http_429'
: 'wecom_app_http_4xx',
message: `WeCom app message HTTP ${response.status}`,
response: {
status: response.status,
body: sanitized,
},
previewMessage,
};
}
if (responseBody && Number(responseBody.errcode ?? 0) !== 0) {
return {
ok: false,
skipped: false,
retryable: false,
code: 'wecom_app_api_error',
message: `WeCom app message errcode ${responseBody.errcode}`,
response: sanitized,
previewMessage,
};
}
if (hasInvalidRecipients(responseBody)) {
return {
ok: false,
skipped: false,
retryable: false,
code: 'wecom_app_invaliduser',
message: 'WeCom app message invaliduser',
response: sanitized,
previewMessage,
};
}
return {
ok: true,
skipped: false,
retryable: false,
code: 'wecom_app_sent',
message: null,
response: {
...(sanitized ?? { errcode: 0 }),
recipientCount: externalUserIds.length,
},
previewMessage,
};
} catch (error) {
if (error?.name === 'AbortError') {
return {
ok: false,
skipped: false,
retryable: true,
code: 'wecom_app_timeout',
message: `WeCom app message timeout (${timeoutMs}ms)`,
response: null,
previewMessage,
};
}
return {
ok: false,
skipped: false,
retryable: true,
code: 'wecom_app_network_error',
message: String(error?.message ?? error ?? 'WeCom app message network error'),
response: null,
previewMessage,
};
} finally {
clearTimeout(timer);
}
}
async function sendWecomMessage({ config, channel, message, recipientExternalUserIds }) {
const configJson = channel?.config_json && typeof channel.config_json === 'object' ? channel.config_json : {};
const mode = String(configJson.mode ?? 'mock').trim() || 'mock';
const previewMessage = buildWecomMarkdownMessage(message);
@@ -93,6 +319,15 @@ async function sendWecomMessage({ config, channel, message }) {
};
}
if (mode === 'wecom_app_message') {
return sendWecomAppMessage({
config,
configJson,
message,
recipientExternalUserIds,
});
}
if (mode !== 'wecom_webhook') {
return {
ok: false,
@@ -218,5 +453,6 @@ async function sendWecomMessage({ config, channel, message }) {
module.exports = {
buildSignedWebhookUrl,
buildWecomMarkdownMessage,
buildWecomTextPreview,
sendWecomMessage,
};
@@ -0,0 +1,166 @@
const tokenCache = new Map();
function toSafeString(value, maxLength = 2000) {
if (value == null) {
return '';
}
return String(value).trim().slice(0, maxLength);
}
function sanitizeWecomResponse(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] = payload[key];
}
}
return Object.keys(result).length ? result : null;
}
function getCredentialKey(config) {
return [
toSafeString(config?.wecomCorpId, 128),
toSafeString(config?.wecomAgentId, 64),
toSafeString(config?.wecomAgentSecret, 16),
].join(':');
}
function resolveTimeoutMs(timeoutMs) {
return Math.max(100, Number(timeoutMs ?? 5000));
}
function buildMissingConfigResult(missing) {
return {
ok: false,
retryable: false,
code: 'wecom_app_missing_config',
message: `Missing WeCom app config: ${missing.join(',')}`,
response: {
missing,
},
accessToken: null,
};
}
async function getWecomAppAccessToken({ config, timeoutMs = 5000, fetchImpl = fetch }) {
const corpId = toSafeString(config?.wecomCorpId, 128);
const agentId = toSafeString(config?.wecomAgentId, 64);
const agentSecret = toSafeString(config?.wecomAgentSecret, 500);
const missing = [];
if (!corpId) missing.push('WECOM_CORP_ID');
if (!agentId) missing.push('WECOM_AGENT_ID');
if (!agentSecret) missing.push('WECOM_AGENT_SECRET');
if (missing.length) {
return buildMissingConfigResult(missing);
}
const cacheKey = getCredentialKey(config);
const cached = tokenCache.get(cacheKey);
if (cached?.accessToken && cached.expiresAt > Date.now() + 60 * 1000) {
return {
ok: true,
retryable: false,
code: 'wecom_app_token_cached',
message: null,
response: { cached: true },
accessToken: cached.accessToken,
};
}
const timeout = resolveTimeoutMs(timeoutMs);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeout);
const url = new URL('https://qyapi.weixin.qq.com/cgi-bin/gettoken');
url.searchParams.set('corpid', corpId);
url.searchParams.set('corpsecret', agentSecret);
try {
const response = await fetchImpl(url, {
method: 'GET',
signal: controller.signal,
});
const payload = await response.json().catch(() => null);
const sanitized = sanitizeWecomResponse(payload);
if (!response.ok) {
return {
ok: false,
retryable: response.status >= 500 || response.status === 429,
code:
response.status >= 500
? 'wecom_app_token_http_5xx'
: response.status === 429
? 'wecom_app_token_http_429'
: 'wecom_app_token_http_4xx',
message: `WeCom app token HTTP ${response.status}`,
response: {
status: response.status,
body: sanitized,
},
accessToken: null,
};
}
if (!payload || Number(payload.errcode ?? 0) !== 0 || !payload.access_token) {
return {
ok: false,
retryable: false,
code: 'wecom_app_token_api_error',
message: `WeCom app token errcode ${payload?.errcode ?? 'missing_payload'}`,
response: sanitized,
accessToken: null,
};
}
const expiresInSeconds = Math.max(300, Number(payload.expires_in ?? 7200));
tokenCache.set(cacheKey, {
accessToken: String(payload.access_token),
expiresAt: Date.now() + Math.max(60, expiresInSeconds - 120) * 1000,
});
return {
ok: true,
retryable: false,
code: 'wecom_app_token_fetched',
message: null,
response: { cached: false, expiresIn: expiresInSeconds },
accessToken: String(payload.access_token),
};
} catch (error) {
if (error?.name === 'AbortError') {
return {
ok: false,
retryable: true,
code: 'wecom_app_token_timeout',
message: `WeCom app token timeout (${timeout}ms)`,
response: null,
accessToken: null,
};
}
return {
ok: false,
retryable: true,
code: 'wecom_app_token_network_error',
message: String(error?.message ?? error ?? 'WeCom app token network error'),
response: null,
accessToken: null,
};
} finally {
clearTimeout(timer);
}
}
function clearWecomAppAccessTokenCache() {
tokenCache.clear();
}
module.exports = {
clearWecomAppAccessTokenCache,
getWecomAppAccessToken,
sanitizeWecomResponse,
};
+16 -5
查看文件
@@ -61,6 +61,11 @@ async function resolveEligibleRecipients(prisma, recipientEmployeeIds) {
role_code: true,
},
},
wecom_identity_binding: {
select: {
external_user_id: true,
},
},
},
});
}
@@ -159,6 +164,9 @@ async function dispatchNotificationBatch({
const recipientRoleCodes = uniq(
eligibleRecipients.flatMap((recipient) => recipient.roles.map((role) => role.role_code)),
);
const recipientExternalUserIds = uniq(
eligibleRecipients.map((recipient) => recipient.wecom_identity_binding?.external_user_id ?? null),
);
const domainScope = resolveNotificationDomainScope(type, targetType ?? anchorNotification.target_type ?? null);
if (!domainScope) {
logger?.warn?.(
@@ -225,11 +233,14 @@ async function dispatchNotificationBatch({
payload: {
title,
body,
targetType,
targetId,
detailUrl,
},
});
targetType,
targetId,
detailUrl,
recipientEmployeeIds: recipientIds,
recipientExternalUserIds,
},
prisma,
});
} catch (error) {
result = {
ok: false,
+48 -2
查看文件
@@ -39,7 +39,40 @@ function buildNotificationMessage(channel, payload) {
};
}
async function sendInstantMessageByChannel({ config, channel, payload }) {
function uniqStrings(values) {
return Array.from(
new Set(
(values ?? [])
.map((value) => String(value ?? '').trim())
.filter(Boolean),
),
);
}
async function resolveRecipientExternalUserIds({ prisma, payload }) {
const explicitExternalUserIds = uniqStrings(payload?.recipientExternalUserIds);
if (explicitExternalUserIds.length) {
return explicitExternalUserIds;
}
const employeeIds = uniqStrings(payload?.recipientEmployeeIds);
if (!prisma?.wecomIdentityBinding?.findMany || !employeeIds.length) {
return [];
}
const bindings = await prisma.wecomIdentityBinding.findMany({
where: {
employee_id: { in: employeeIds },
},
select: {
external_user_id: true,
},
});
return uniqStrings(bindings.map((binding) => binding.external_user_id));
}
async function sendInstantMessageByChannel({ config, channel, payload, prisma }) {
const normalizedChannelType = normalizeChannelType(channel?.channel_type ?? payload?.channelType ?? '');
if (!config?.imNotifierEnabled) {
@@ -91,11 +124,24 @@ async function sendInstantMessageByChannel({ config, channel, payload }) {
}
const message = buildNotificationMessage(channel, payload);
return sendWecomMessage({ config, channel, message });
const configJson = channel?.config_json && typeof channel.config_json === 'object' ? channel.config_json : {};
const mode = String(configJson.mode ?? 'mock').trim() || 'mock';
const recipientExternalUserIds =
mode === 'wecom_app_message'
? await resolveRecipientExternalUserIds({ prisma, payload })
: [];
return sendWecomMessage({
config,
channel,
message,
recipientExternalUserIds,
});
}
module.exports = {
applyTemplate,
buildNotificationMessage,
resolveRecipientExternalUserIds,
sendInstantMessageByChannel,
};
@@ -262,6 +262,9 @@ function createPrismaState({ channels = [], deliveries = [], notifications = []
return { id: `audit-${auditEntries.length}` };
}),
},
wecomIdentityBinding: {
findMany: vi.fn().mockResolvedValue([]),
},
$transaction: vi.fn().mockImplementation(async (callback) => callback(transactionClient)),
},
stateChannels,
@@ -423,6 +426,48 @@ describe('notifications channel routes', () => {
await app.close();
});
it('creates wecom app message channel without webhook or secret', async () => {
const { prisma, stateChannels } = createPrismaState();
const app = await buildApp({
prisma,
config: { fieldEncryptionKey: validFieldEncryptionKey },
});
const response = await app.inject({
method: 'POST',
url: '/api/notifications/channels',
headers: { authorization: 'Bearer test-token' },
payload: {
channelType: 'wecom',
name: '企微个人应用消息',
enabled: true,
configJson: {
mode: 'wecom_app_message',
timeoutMs: 800,
routing: {
roleCodes: ['manager'],
domainScopes: ['workflow'],
allowedTypes: ['workflow.task_assigned'],
},
},
},
});
expect(response.statusCode).toBe(200);
expect(response.json().data.channel.configJson).toEqual(
expect.objectContaining({
mode: 'wecom_app_message',
webhookUrl: null,
secretConfigured: false,
secretMasked: null,
}),
);
expect(stateChannels[0].config_json.secretEncrypted).toBeUndefined();
expect(stateChannels[0].config_json.webhookUrl).toBeNull();
await app.close();
});
it('rejects secret write when FIELD_ENCRYPTION_KEY is missing', async () => {
const { prisma } = createPrismaState();
const app = await buildApp({ prisma, config: { fieldEncryptionKey: '' } });
@@ -585,6 +630,44 @@ describe('notifications channel routes', () => {
await app.close();
});
it('tests wecom app message channel against current bound admin when available', async () => {
const { prisma } = createPrismaState({
channels: [
buildChannelRecord({
config_json: {
mode: 'wecom_app_message',
webhookUrl: null,
timeoutMs: 800,
routing: {},
},
}),
],
});
const app = await buildApp({ prisma });
const response = await app.inject({
method: 'POST',
url: '/api/notifications/channels/11111111-1111-4111-8111-111111111111/test',
headers: { authorization: 'Bearer test-token' },
payload: {},
});
expect(response.statusCode).toBe(200);
expect(response.json().data.testResult).toEqual(
expect.objectContaining({
ok: true,
code: 'wecom_app_no_bound_recipients',
}),
);
expect(prisma.wecomIdentityBinding.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { employee_id: { in: ['employee-1'] } },
}),
);
await app.close();
});
it('lists grouped delivery summaries with latest status and attempt count', async () => {
const notification = buildNotificationRecord({
id: '55555555-5555-4555-8555-555555555555',
@@ -1,6 +1,12 @@
const dispatcher = require("../../../../src/services/integrations/dispatcher");
const { clearWecomAppAccessTokenCache } = require("../../../../src/services/integrations/wecomAccessToken");
describe("Integration Dispatcher", () => {
afterEach(() => {
clearWecomAppAccessTokenCache();
vi.restoreAllMocks();
});
it("dispatches erp.reimbursement.paid", async () => {
const result = await dispatcher.dispatchOutboxEvent({
config: { erpSyncEnabled: true, erpSyncProvider: "mock" },
@@ -72,6 +78,112 @@ describe("Integration Dispatcher", () => {
});
});
it("dispatches notification.im app message as no-bound when recipient has no wecom binding", async () => {
const prisma = {
notificationChannel: {
findUnique: vi.fn().mockResolvedValue({
id: "channel-app",
channel_type: "wecom",
name: "app-message",
enabled: true,
config_json: { mode: "wecom_app_message", routing: {} },
template_json: null,
}),
},
wecomIdentityBinding: {
findMany: vi.fn().mockResolvedValue([]),
},
};
const result = await dispatcher.dispatchOutboxEvent({
prisma,
config: { imNotifierEnabled: true },
eventType: "notification.im",
payload: {
channelId: "channel-app",
channelType: "wecom",
title: "title",
body: "body",
recipientEmployeeIds: ["employee-1"],
},
});
expect(result.channel).toBe("im");
expect(result.code).toBe("wecom_app_no_bound_recipients");
expect(prisma.wecomIdentityBinding.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { employee_id: { in: ["employee-1"] } },
}),
);
});
it("dispatches notification.im app message to bound wecom member", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce({
ok: true,
status: 200,
json: vi.fn().mockResolvedValue({ errcode: 0, access_token: "token-1", expires_in: 7200 }),
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: vi.fn().mockResolvedValue({ errcode: 0, errmsg: "ok" }),
});
vi.stubGlobal("fetch", fetchMock);
const result = await dispatcher.dispatchOutboxEvent({
prisma: {
notificationChannel: {
findUnique: vi.fn().mockResolvedValue({
id: "channel-app",
channel_type: "wecom",
name: "app-message",
enabled: true,
config_json: { mode: "wecom_app_message", timeoutMs: 500, routing: {} },
template_json: null,
}),
},
wecomIdentityBinding: {
findMany: vi.fn().mockResolvedValue([{ external_user_id: "oa_smoke_02" }]),
},
},
config: {
imNotifierEnabled: true,
wecomCorpId: "corp-1",
wecomAgentId: "1000002",
wecomAgentSecret: "secret-1",
},
eventType: "notification.im",
payload: {
channelId: "channel-app",
channelType: "wecom",
title: "待办通知",
body: "请处理审批",
detailUrl: "https://oa.example.com/api/auth/deeplink?token=abc",
recipientEmployeeIds: ["employee-1"],
},
});
expect(result.channel).toBe("im");
expect(result.code).toBe("wecom_app_sent");
expect(fetchMock).toHaveBeenCalledTimes(2);
const sendBody = JSON.parse(fetchMock.mock.calls[1][1].body);
expect(sendBody).toEqual(
expect.objectContaining({
touser: "oa_smoke_02",
msgtype: "textcard",
agentid: 1000002,
textcard: expect.objectContaining({
title: "待办通知",
url: "https://oa.example.com/api/auth/deeplink?token=abc",
}),
}),
);
expect(String(fetchMock.mock.calls[1][0])).toContain("/cgi-bin/message/send");
expect(JSON.stringify(result.response)).not.toContain("token-1");
});
it("throws error for unsupported event", async () => {
await expect(
dispatcher.dispatchOutboxEvent({
@@ -41,6 +41,9 @@ function createPrismaState({ employees = [], channels = [] } = {}) {
.map((item) => ({
id: item.id,
roles: (item.roles ?? []).map((roleCode) => ({ role_code: roleCode })),
wecom_identity_binding: item.externalUserId
? { external_user_id: item.externalUserId }
: null,
}));
}),
},
@@ -186,6 +189,65 @@ describe('notificationDispatcher', () => {
expect(deliveryState[0]).toEqual(expect.objectContaining({ status: 'success' }));
});
it('passes bound wecom user ids to channel sender', async () => {
const { prisma } = createPrismaState({
employees: [
{
id: 'manager-1',
status: 'active',
notification_push_enabled: true,
roles: ['manager'],
externalUserId: 'oa_smoke_02',
},
],
channels: [buildChannel({ config_json: { mode: 'wecom_app_message', routing: {} } })],
});
const sendMessage = vi.fn().mockResolvedValue({
ok: true,
retryable: false,
code: 'wecom_app_sent',
message: null,
response: { errcode: 0 },
});
await dispatchNotificationBatch({
prisma,
config: {
imNotifierEnabled: true,
appOrigin: 'https://oa.example.com',
jwtSecret: 'test-secret',
},
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
requestId: 'req-wecom-bound',
notifications: [
{
id: 'notification-1',
recipient_employee_id: 'manager-1',
target_type: 'workflow_task',
},
],
type: 'workflow.task_assigned',
title: '测试待办',
body: '请处理审批',
targetType: 'workflow_task',
targetId: 'task-1',
targetPath: '/workflow/tasks/task-1',
sendMessage,
retryDelayMs: 0,
});
expect(sendMessage).toHaveBeenCalledTimes(1);
expect(sendMessage.mock.calls[0][0]).toEqual(
expect.objectContaining({
prisma,
payload: expect.objectContaining({
recipientEmployeeIds: ['manager-1'],
recipientExternalUserIds: ['oa_smoke_02'],
}),
}),
);
});
it('retries retryable failures until a later success', async () => {
const { prisma, deliveryState } = createPrismaState({
employees: [