feat(P19IM1-1,P19IM1-2): 新增通知渠道配置与Outbox派发

这个提交包含在:
2026-03-08 13:14:04 +08:00
父节点 43a0af0803
当前提交 f6d9f23553
共修改 13 个文件,包含 1582 行新增和 364 行删除
@@ -0,0 +1,170 @@
const {
buildDetailUrl,
buildImDedupeKey,
enqueueImNotifications,
resolveRecipientRoleCodes,
} = require("../../../src/services/notifications/imEnqueue");
describe("IM enqueue", () => {
it("builds detail url from APP_ORIGIN", () => {
expect(buildDetailUrl({ appOrigin: "https://app.example.com" }, "/workflow/tasks/1")).toBe(
"https://app.example.com/workflow/tasks/1",
);
expect(buildDetailUrl({ appOrigin: "" }, "/workflow/tasks/1")).toBeNull();
});
it("builds dedupe key with target id first", () => {
expect(
buildImDedupeKey({
notificationType: "workflow.task_assigned",
targetType: "workflow_task",
targetId: "task-1",
targetPath: "/workflow/tasks/task-1",
channelId: "channel-1",
}),
).toBe("workflow.task_assigned:workflow_task:task-1:channel-1");
});
it("resolves unique recipient role codes", async () => {
const roles = await resolveRecipientRoleCodes(
{
employeeRole: {
findMany: vi.fn().mockResolvedValue([
{ role_code: "manager" },
{ role_code: "manager" },
{ role_code: "finance_mgr" },
]),
},
},
["employee-1", "employee-2"],
);
expect(roles).toEqual(["manager", "finance_mgr"]);
});
it("enqueues one outbox event per matched channel", async () => {
const tx = {
employeeRole: {
findMany: vi.fn().mockResolvedValue([{ role_code: "manager" }]),
},
notificationChannel: {
findMany: vi.fn().mockResolvedValue([
{
id: "channel-1",
channel_type: "wecom",
name: "manager-workflow",
enabled: true,
config_json: {
routing: {
roleCodes: ["manager"],
domainScopes: ["workflow"],
allowedTypes: ["workflow.task_assigned"],
},
},
template_json: null,
},
{
id: "channel-2",
channel_type: "wechat_work",
name: "all-workflow",
enabled: true,
config_json: {
routing: {
roleCodes: [],
domainScopes: ["workflow"],
allowedTypes: [],
},
},
template_json: null,
},
]),
},
integrationOutbox: {
findUnique: vi
.fn()
.mockResolvedValueOnce(null)
.mockResolvedValueOnce({ id: "outbox-2", status: "pending" }),
create: vi.fn().mockImplementation(async ({ data }) => ({ id: `${data.dedupe_key}-id`, status: "pending" })),
},
};
const result = await enqueueImNotifications(tx, {
config: {
imNotifierEnabled: true,
integrationOutboxMaxAttempts: 5,
appOrigin: "https://app.example.com",
},
logger: { warn: vi.fn() },
recipientEmployeeIds: ["employee-1"],
type: "workflow.task_assigned",
title: "【待办】采购申请待审批",
body: "采购申请已流转到你,请及时处理。",
targetType: "workflow_task",
targetId: "task-1",
targetPath: "/workflow/tasks/task-1",
entityType: "purchase_request",
entityId: "pr-1",
actorId: "actor-1",
});
expect(result.queued).toBe(1);
expect(result.duplicated).toBe(1);
expect(result.matchedChannels).toEqual(["channel-1", "channel-2"]);
expect(tx.integrationOutbox.create).toHaveBeenCalledTimes(1);
expect(tx.integrationOutbox.create.mock.calls[0][0].data.payload.detailUrl).toBe(
"https://app.example.com/workflow/tasks/task-1",
);
});
it("safely skips when no channel matches", async () => {
const tx = {
employeeRole: {
findMany: vi.fn().mockResolvedValue([{ role_code: "manager" }]),
},
notificationChannel: {
findMany: vi.fn().mockResolvedValue([
{
id: "channel-3",
channel_type: "wecom",
name: "finance-only",
enabled: true,
config_json: {
routing: {
roleCodes: ["finance_mgr"],
domainScopes: ["procurement"],
allowedTypes: ["purchase_request.approved"],
},
},
template_json: null,
},
]),
},
integrationOutbox: {
findUnique: vi.fn(),
create: vi.fn(),
},
};
const result = await enqueueImNotifications(tx, {
config: {
imNotifierEnabled: true,
integrationOutboxMaxAttempts: 5,
appOrigin: "https://app.example.com",
},
logger: { warn: vi.fn() },
recipientEmployeeIds: ["employee-1"],
type: "workflow.task_assigned",
title: "title",
body: "body",
targetType: "workflow_task",
targetId: "task-1",
targetPath: "/workflow/tasks/task-1",
entityType: "purchase_request",
entityId: "pr-1",
actorId: "actor-1",
});
expect(result.skippedReason).toBe("no_matching_channel");
expect(tx.integrationOutbox.create).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,91 @@
const {
isImChannelTypeSupported,
matchesNotificationChannel,
normalizeChannelType,
normalizeRouting,
} = require("../../../src/services/notifications/imRouting");
describe("IM routing", () => {
it("normalizes wechat_work alias to wecom", () => {
expect(normalizeChannelType("wechat_work")).toBe("wecom");
expect(isImChannelTypeSupported("wecom")).toBe(true);
expect(isImChannelTypeSupported("dingtalk")).toBe(false);
});
it("normalizes routing arrays and drops empty values", () => {
expect(
normalizeRouting({
routing: {
roleCodes: ["manager", "manager", ""],
domainScopes: ["workflow", "workflow"],
allowedTypes: ["workflow.task_assigned", ""],
},
}),
).toEqual({
roleCodes: ["manager"],
domainScopes: ["workflow"],
allowedTypes: ["workflow.task_assigned"],
});
});
it("matches channel when role scope type all hit", () => {
const matched = matchesNotificationChannel(
{
enabled: true,
channel_type: "wecom",
config_json: {
routing: {
roleCodes: ["manager"],
domainScopes: ["workflow"],
allowedTypes: ["workflow.task_assigned"],
},
},
},
{
notificationType: "workflow.task_assigned",
domainScope: "workflow",
recipientRoleCodes: ["manager", "finance_mgr"],
},
);
expect(matched).toBe(true);
});
it("rejects channel when any routing dimension does not match", () => {
const baseChannel = {
enabled: true,
channel_type: "wecom",
config_json: {
routing: {
roleCodes: ["manager"],
domainScopes: ["workflow"],
allowedTypes: ["workflow.task_assigned"],
},
},
};
expect(
matchesNotificationChannel(baseChannel, {
notificationType: "workflow.task_assigned",
domainScope: "procurement",
recipientRoleCodes: ["manager"],
}),
).toBe(false);
expect(
matchesNotificationChannel(baseChannel, {
notificationType: "workflow.task_assigned",
domainScope: "workflow",
recipientRoleCodes: ["finance_mgr"],
}),
).toBe(false);
expect(
matchesNotificationChannel(baseChannel, {
notificationType: "leave.approved",
domainScope: "workflow",
recipientRoleCodes: ["manager"],
}),
).toBe(false);
});
});
@@ -1,42 +1,46 @@
const dispatcher = require("../../../../src/services/integrations/dispatcher");
describe("Integration Dispatcher", () => {
it("dispatches erp.reimbursement.paid", async () => {
const result = await dispatcher.dispatchOutboxEvent({
config: { erpSyncEnabled: true, erpSyncProvider: "mock" },
eventType: "erp.reimbursement.paid",
payload: { reimbursementId: 1 },
});
expect(result.channel).toBe("erp");
expect(result.code).toBe("mock_synced");
it("dispatches erp.reimbursement.paid", async () => {
const result = await dispatcher.dispatchOutboxEvent({
config: { erpSyncEnabled: true, erpSyncProvider: "mock" },
eventType: "erp.reimbursement.paid",
payload: { reimbursementId: "rb-1", formNumber: "BX-001" },
});
it("throws IntegrationDispatchError if erp fails", async () => {
await expect(
dispatcher.dispatchOutboxEvent({
config: { erpSyncEnabled: false },
eventType: "erp.reimbursement.paid",
payload: { reimbursementId: 1 },
})
).rejects.toThrow(dispatcher.IntegrationDispatchError);
expect(result.channel).toBe("erp");
expect(result.code).toBe("mock_synced");
});
it("dispatches notification.im with mock channel", async () => {
const result = await dispatcher.dispatchOutboxEvent({
prisma: {
notificationChannel: {
findUnique: vi.fn().mockResolvedValue({
id: "channel-1",
channel_type: "wecom",
name: "test",
enabled: true,
config_json: { mode: "mock", routing: {} },
template_json: null,
}),
},
},
config: { imNotifierEnabled: true },
eventType: "notification.im",
payload: { channelId: "channel-1", channelType: "wecom", title: "title", body: "body" },
});
it("dispatches notification.im", async () => {
const result = await dispatcher.dispatchOutboxEvent({
config: {},
eventType: "notification.im",
payload: { channelType: "slack" },
});
expect(result.channel).toBe("im");
expect(result.response.channelType).toBe("slack");
});
expect(result.channel).toBe("im");
expect(result.code).toBe("mock_sent");
});
it("throws error for unsupported event", async () => {
await expect(
dispatcher.dispatchOutboxEvent({
config: {},
eventType: "unknown.event",
})
).rejects.toThrow("unsupported_outbox_event:unknown.event");
});
it("throws error for unsupported event", async () => {
await expect(
dispatcher.dispatchOutboxEvent({
config: {},
eventType: "unknown.event",
}),
).rejects.toThrow("unsupported_outbox_event:unknown.event");
});
});