diff --git a/furniture-3d-system/.env.example b/furniture-3d-system/.env.example index 132c2cc9..8ecac7f7 100644 --- a/furniture-3d-system/.env.example +++ b/furniture-3d-system/.env.example @@ -92,6 +92,12 @@ YYD_NOTIFICATION_CENTER_URL=https://sc.jiansun.vip/notification-center YYD_NOTIFICATION_PROJECT_CODE=furniture_platform YYD_NOTIFICATION_PROJECT_SECRET= YYD_NOTIFICATION_TIMEOUT_MS=10000 + +# 店铺账号新增时,梦汐/扬灵群使用的钉钉“由壹点”群机器人。 +# webhook 地址和签名密钥只配置在本地/正式站运行环境,不要提交真实值。 +DINGTALK_STORE_ACCOUNT_GROUP_WEBHOOK_URL= +DINGTALK_STORE_ACCOUNT_GROUP_WEBHOOK_SECRET= +DINGTALK_STORE_ACCOUNT_GROUP_WEBHOOK_TIMEOUT_MS=10000 STORE_ACCOUNT_NOTIFICATION_RETRY_SECRET= # 产品开发待办定时任务(仅服务端/部署 cron 使用,须与其他任务密钥分开) diff --git a/furniture-3d-system/src/server/integrations/dingtalk-webhook/client.test.ts b/furniture-3d-system/src/server/integrations/dingtalk-webhook/client.test.ts new file mode 100644 index 00000000..899b0794 --- /dev/null +++ b/furniture-3d-system/src/server/integrations/dingtalk-webhook/client.test.ts @@ -0,0 +1,73 @@ +/** @jest-environment node */ + +import { DingTalkWebhookClient, DingTalkWebhookRequestError } from './client' + +describe('DingTalkWebhookClient', () => { + it('sends plain text to the configured webhook without exposing the token in the body', async () => { + const fetchImpl = jest.fn().mockResolvedValue(new Response( + JSON.stringify({ errcode: 0, errmsg: 'ok' }), + { status: 200, headers: { 'content-type': 'application/json' } }, + )) + const client = new DingTalkWebhookClient({ + webhookUrl: 'https://oapi.dingtalk.com/robot/send?access_token=test-token', + fetchImpl, + now: () => 1_700_000_000_000, + }) + + await client.sendText('店铺账号新增通知:测试店铺') + + expect(fetchImpl).toHaveBeenCalledWith( + 'https://oapi.dingtalk.com/robot/send?access_token=test-token', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + msgtype: 'text', + text: { content: '店铺账号新增通知:测试店铺' }, + }), + }), + ) + }) + + it('adds a DingTalk signature when a secret is configured', async () => { + const fetchImpl = jest.fn().mockResolvedValue(new Response( + JSON.stringify({ errcode: 0, errmsg: 'ok' }), + { status: 200, headers: { 'content-type': 'application/json' } }, + )) + const client = new DingTalkWebhookClient({ + webhookUrl: 'https://oapi.dingtalk.com/robot/send?access_token=test-token', + secret: 'test-secret', + fetchImpl, + now: () => 1_700_000_000_000, + }) + + await client.sendText('测试') + + const calledUrl = new URL(fetchImpl.mock.calls[0][0]) + expect(calledUrl.searchParams.get('timestamp')).toBe('1700000000000') + expect(calledUrl.searchParams.get('sign')).toBeTruthy() + }) + + it('surfaces DingTalk business errors for retry handling', async () => { + const fetchImpl = jest.fn().mockResolvedValue(new Response( + JSON.stringify({ errcode: 310000, errmsg: 'token invalid' }), + { status: 200, headers: { 'content-type': 'application/json' } }, + )) + const client = new DingTalkWebhookClient({ + webhookUrl: 'https://oapi.dingtalk.com/robot/send?access_token=test-token', + fetchImpl, + }) + + await expect(client.sendText('测试')).rejects.toMatchObject({ + code: '310000', + }) + }) + + it.each([ + '', + 'http://oapi.dingtalk.com/robot/send?access_token=test-token', + 'https://example.com/robot/send?access_token=test-token', + 'https://oapi.dingtalk.com/robot/send', + ])('rejects an invalid webhook URL: %s', (webhookUrl) => { + expect(() => new DingTalkWebhookClient({ webhookUrl })).toThrow() + }) +}) diff --git a/furniture-3d-system/src/server/integrations/dingtalk-webhook/client.ts b/furniture-3d-system/src/server/integrations/dingtalk-webhook/client.ts new file mode 100644 index 00000000..a5d91910 --- /dev/null +++ b/furniture-3d-system/src/server/integrations/dingtalk-webhook/client.ts @@ -0,0 +1,130 @@ +import 'server-only' + +import { createHmac } from 'node:crypto' + +type DingTalkWebhookClientConfig = { + webhookUrl: string + secret?: string + timeoutMs?: number + fetchImpl?: typeof fetch + now?: () => number +} + +type DingTalkWebhookResponse = { + errcode?: number + errmsg?: string +} + +export class DingTalkWebhookRequestError extends Error { + constructor( + message: string, + readonly status?: number, + readonly code?: string, + ) { + super(message) + this.name = 'DingTalkWebhookRequestError' + } +} + +function normalizeWebhookUrl(value: string) { + let url: URL + try { + url = new URL(value.trim()) + } catch { + throw new Error('钉钉群机器人 webhook 地址格式不正确') + } + if ( + url.protocol !== 'https:' + || url.hostname.toLowerCase() !== 'oapi.dingtalk.com' + || url.pathname !== '/robot/send' + || url.username + || url.password + || url.hash + || !url.searchParams.get('access_token')?.trim() + ) { + throw new Error('钉钉群机器人 webhook 地址必须是 oapi.dingtalk.com/robot/send 地址') + } + return url +} + +function signWebhookUrl(url: URL, secret: string, timestamp: number) { + const stringToSign = `${timestamp}\n${secret}` + const sign = createHmac('sha256', secret).update(stringToSign).digest('base64') + url.searchParams.set('timestamp', String(timestamp)) + url.searchParams.set('sign', sign) + return url +} + +export class DingTalkWebhookClient { + private readonly webhookUrl: URL + private readonly secret: string + private readonly timeoutMs: number + private readonly fetchImpl: typeof fetch + private readonly now: () => number + + constructor(config: DingTalkWebhookClientConfig) { + this.webhookUrl = normalizeWebhookUrl(config.webhookUrl) + this.secret = config.secret?.trim() ?? '' + this.timeoutMs = Math.min(60_000, Math.max(1_000, config.timeoutMs ?? 10_000)) + this.fetchImpl = config.fetchImpl ?? fetch + this.now = config.now ?? Date.now + } + + async sendText(content: string) { + const normalizedContent = content.trim() + if (!normalizedContent) throw new Error('钉钉群机器人消息不能为空') + if (normalizedContent.length > 20_000) throw new Error('钉钉群机器人消息不能超过 20000 个字符') + + const timestamp = this.now() + const url = this.secret + ? signWebhookUrl(new URL(this.webhookUrl.toString()), this.secret, timestamp) + : this.webhookUrl + + let response: Response + try { + response = await this.fetchImpl(url.toString(), { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + msgtype: 'text', + text: { content: normalizedContent }, + }), + cache: 'no-store', + signal: AbortSignal.timeout(this.timeoutMs), + }) + } catch (error) { + throw new DingTalkWebhookRequestError( + error instanceof Error ? error.message : '钉钉群机器人连接失败', + ) + } + + const payload = (await response.json().catch(() => null)) as DingTalkWebhookResponse | null + if (!response.ok) { + throw new DingTalkWebhookRequestError( + payload?.errmsg || `钉钉群机器人返回 HTTP ${response.status}`, + response.status, + payload?.errcode === undefined ? undefined : String(payload.errcode), + ) + } + if (!payload || payload.errcode !== 0) { + throw new DingTalkWebhookRequestError( + payload?.errmsg || '钉钉群机器人返回了无效响应', + response.status, + payload?.errcode === undefined ? 'DINGTALK_WEBHOOK_INVALID_RESPONSE' : String(payload.errcode), + ) + } + return payload + } +} + +export function isDingTalkStoreAccountWebhookConfigured(env: NodeJS.ProcessEnv = process.env) { + return Boolean(env.DINGTALK_STORE_ACCOUNT_GROUP_WEBHOOK_URL?.trim()) +} + +export function createDingTalkStoreAccountWebhookClientFromEnv(env: NodeJS.ProcessEnv = process.env) { + return new DingTalkWebhookClient({ + webhookUrl: env.DINGTALK_STORE_ACCOUNT_GROUP_WEBHOOK_URL?.trim() ?? '', + secret: env.DINGTALK_STORE_ACCOUNT_GROUP_WEBHOOK_SECRET?.trim(), + timeoutMs: Number(env.DINGTALK_STORE_ACCOUNT_GROUP_WEBHOOK_TIMEOUT_MS) || 10_000, + }) +} diff --git a/furniture-3d-system/src/server/services/__tests__/notification-delivery.service.test.ts b/furniture-3d-system/src/server/services/__tests__/notification-delivery.service.test.ts index cfe809c0..e873c8bc 100644 --- a/furniture-3d-system/src/server/services/__tests__/notification-delivery.service.test.ts +++ b/furniture-3d-system/src/server/services/__tests__/notification-delivery.service.test.ts @@ -6,6 +6,7 @@ const mockCreate = jest.fn() const mockUpdate = jest.fn() const mockFindMany = jest.fn() const mockSend = jest.fn() +const mockSendWebhookText = jest.fn() jest.mock('@/server/repositories/notification-delivery.repository', () => ({ notificationDeliveryRepository: { @@ -23,6 +24,11 @@ jest.mock('@/server/integrations/notification-center/client', () => ({ NotificationCenterRequestError: class NotificationCenterRequestError extends Error {}, })) +jest.mock('@/server/integrations/dingtalk-webhook/client', () => ({ + createDingTalkStoreAccountWebhookClientFromEnv: () => ({ sendText: (...args: unknown[]) => mockSendWebhookText(...args) }), + DingTalkWebhookRequestError: class DingTalkWebhookRequestError extends Error {}, +})) + import { NotificationDeliveryError, NotificationDeliveryService } from '../notification-delivery.service' const now = new Date('2026-08-26T07:00:00.000Z') @@ -104,9 +110,10 @@ describe('NotificationDeliveryService', () => { mockUpdate.mockImplementation(async (_id, data) => storedRecord({ ...data, externalDeliveryId: 'delivery-1', - renderedTitle: '【家具业务系统】通知客户端测试', - status: 'pending', + renderedTitle: data.renderedTitle ?? '【家具业务系统】通知客户端测试', + status: data.status ?? 'pending', })) + mockSendWebhookText.mockResolvedValue({ errcode: 0, errmsg: 'ok' }) }) it('sends to the bound DingTalk user and stores the full audit payload', async () => { @@ -246,4 +253,29 @@ describe('NotificationDeliveryService', () => { expect(mockSend).not.toHaveBeenCalled() expect(result.status).toBe('failed') }) + + it('sends a webhook notification and audits it without a user recipient', async () => { + const service = new NotificationDeliveryService() + const result = await service.sendToWebhook({ + idempotencyKey: 'store_account_created:account-1:mengxi-yangling-group', + businessEvent: 'store_account_created', + businessRecordId: 'account-1', + source: 'USER', + initiatedById: 'admin-1', + initiatedByName: '管理员', + recipientKey: 'mengxi-yangling-group', + recipientName: '梦汐/扬灵群(由壹点)', + title: '店铺账号新增通知:测试店铺', + content: '账号:demo\n\n查看店铺账号', + }) + + expect(mockCreate).toHaveBeenCalledWith(expect.objectContaining({ + recipientUserId: 'webhook:mengxi-yangling-group', + recipientName: '梦汐/扬灵群(由壹点)', + recipientDingtalkUserId: 'webhook:mengxi-yangling-group', + })) + expect(mockSendWebhookText).toHaveBeenCalledWith('店铺账号新增通知:测试店铺\n\n账号:demo\n\n查看店铺账号') + expect(mockUpdate).toHaveBeenCalledWith('local-log-1', expect.objectContaining({ status: 'sent' })) + expect(result.status).toBe('sent') + }) }) diff --git a/furniture-3d-system/src/server/services/__tests__/store-account-notification.service.test.ts b/furniture-3d-system/src/server/services/__tests__/store-account-notification.service.test.ts index 8e85729a..fb754b95 100644 --- a/furniture-3d-system/src/server/services/__tests__/store-account-notification.service.test.ts +++ b/furniture-3d-system/src/server/services/__tests__/store-account-notification.service.test.ts @@ -3,6 +3,7 @@ const mockFindUsersByNames = jest.fn() const mockRecordUndeliverable = jest.fn() const mockSendToEmployee = jest.fn() +const mockSendToWebhook = jest.fn() const mockFindRetryable = jest.fn() jest.mock('@/server/repositories/store-account-notification.repository', () => ({ @@ -21,6 +22,7 @@ jest.mock('@/server/services/notification-delivery.service', () => ({ notificationDeliveryService: { recordUndeliverable: (...args: unknown[]) => mockRecordUndeliverable(...args), sendToEmployee: (...args: unknown[]) => mockSendToEmployee(...args), + sendToWebhook: (...args: unknown[]) => mockSendToWebhook(...args), }, NotificationDeliveryError: class NotificationDeliveryError extends Error { constructor(message: string, readonly code: string) { @@ -113,31 +115,36 @@ describe('store account notification formatting', () => { }) it('routes 京喜 to 胖大海 and other stores to 如懿', () => { - expect(getStoreAccountNotificationTargets('京喜-测试店').map((item) => [item.name, item.includeAccount, item.storeNameOnly, item.includeCredentials])).toEqual([ - ['梦汐', true, undefined, false], - ['扬灵', true, undefined, false], - ['如懿', false, true, false], - ['胖大海', true, undefined, true], + expect(getStoreAccountNotificationTargets('京喜-测试店').map((item) => [item.name, item.delivery, item.includeAccount, item.storeNameOnly, item.includeCredentials])).toEqual([ + ['梦汐/扬灵群(由壹点)', 'webhook', true, undefined, false], + ['如懿', 'employee', false, true, false], + ['胖大海', 'employee', true, undefined, true], ]) - expect(getStoreAccountNotificationTargets('阿里巴巴-测试店').map((item) => [item.name, item.includeAccount, item.storeNameOnly, item.includeCredentials])).toEqual([ - ['梦汐', true, undefined, false], - ['扬灵', true, undefined, false], - ['如懿', true, undefined, true], + expect(getStoreAccountNotificationTargets('阿里巴巴-测试店').map((item) => [item.name, item.delivery, item.includeAccount, item.storeNameOnly, item.includeCredentials])).toEqual([ + ['梦汐/扬灵群(由壹点)', 'webhook', true, undefined, false], + ['如懿', 'employee', true, undefined, true], ]) }) - it('sends short notifications to 梦汐/扬灵 and full credentials to the platform owner', async () => { + it('sends one short group notification and full credentials to the platform owner', async () => { mockFindUsersByNames.mockResolvedValue(users) mockSendToEmployee.mockImplementation(async (input: { recipientUserId: string; content: string }) => ({ id: `delivery-${input.recipientUserId}`, })) + mockSendToWebhook.mockResolvedValue({ id: 'group-delivery-1' }) const result = await storeAccountNotificationService.notifyCreated(record) - expect(mockFindUsersByNames).toHaveBeenCalledWith(['梦汐', '扬灵', '如懿']) - expect(mockSendToEmployee).toHaveBeenCalledTimes(3) - expect(mockSendToEmployee.mock.calls[0][0].content).toBe('账号:广州沃来家具\n\n查看店铺账号') - expect(mockSendToEmployee.mock.calls[2][0].content).toContain('密码:secret-password') + expect(mockFindUsersByNames).toHaveBeenCalledWith(['如懿']) + expect(mockSendToWebhook).toHaveBeenCalledTimes(1) + expect(mockSendToWebhook.mock.calls[0][0]).toMatchObject({ + recipientKey: 'mengxi-yangling-group', + recipientName: '梦汐/扬灵群(由壹点)', + title: '店铺账号新增通知:阿里巴巴-广州沃来贸易有限公司', + content: '账号:广州沃来家具\n\n查看店铺账号', + }) + expect(mockSendToEmployee).toHaveBeenCalledTimes(1) + expect(mockSendToEmployee.mock.calls[0][0].content).toContain('密码:secret-password') for (const [notification] of mockSendToEmployee.mock.calls) { expect(notification).not.toHaveProperty('actionTitle') expect(notification).not.toHaveProperty('actionUrl') @@ -150,10 +157,12 @@ describe('store account notification formatting', () => { mockSendToEmployee.mockImplementation(async (input: { recipientUserId: string }) => ({ id: `delivery-${input.recipientUserId}`, })) + mockSendToWebhook.mockResolvedValue({ id: 'group-delivery-1' }) await storeAccountNotificationService.notifyCreated({ ...record, storeName: '京喜-测试店' }) - expect(mockFindUsersByNames).toHaveBeenCalledWith(['梦汐', '扬灵', '如懿', '胖大海']) + expect(mockFindUsersByNames).toHaveBeenCalledWith(['如懿', '胖大海']) + expect(mockSendToWebhook).toHaveBeenCalledTimes(1) const ruyiNotification = mockSendToEmployee.mock.calls.find(([input]) => input.recipientUserId === 'user-2')?.[0] const pangdahaiNotification = mockSendToEmployee.mock.calls.find(([input]) => input.recipientUserId === 'user-3')?.[0] expect(ruyiNotification).toMatchObject({ @@ -203,4 +212,33 @@ describe('store account notification retry', () => { })) expect(result).toEqual({ eligible: 1, retried: 1, sent: 1, failed: 0 }) }) + + it('retries failed group webhook records through the webhook client', async () => { + mockFindRetryable.mockResolvedValue([{ + id: 'log-group-1', + idempotencyKey: 'store_account_created:account-1:mengxi-yangling-group', + businessEvent: 'store_account_created', + businessRecordId: 'account-1', + source: 'SYSTEM', + initiatedById: null, + initiatedByName: '新增店铺账号通知', + recipientUserId: 'webhook:mengxi-yangling-group', + recipientName: '梦汐/扬灵群(由壹点)', + title: '店铺账号新增通知:测试店铺', + content: '账号:test\n\n查看店铺账号', + actionTitle: '', + actionUrl: '', + }]) + mockSendToWebhook.mockResolvedValue({ id: 'group-delivery-2' }) + + const result = await storeAccountNotificationService.retryFailed(new Date('2026-08-31T00:10:00.000Z')) + + expect(mockSendToWebhook).toHaveBeenCalledWith(expect.objectContaining({ + recipientKey: 'mengxi-yangling-group', + recipientName: '梦汐/扬灵群(由壹点)', + retryExisting: true, + })) + expect(mockSendToEmployee).not.toHaveBeenCalled() + expect(result).toEqual({ eligible: 1, retried: 1, sent: 1, failed: 0 }) + }) }) diff --git a/furniture-3d-system/src/server/services/notification-delivery.service.ts b/furniture-3d-system/src/server/services/notification-delivery.service.ts index b07cf594..87a8267e 100644 --- a/furniture-3d-system/src/server/services/notification-delivery.service.ts +++ b/furniture-3d-system/src/server/services/notification-delivery.service.ts @@ -7,6 +7,10 @@ import { isNotificationCenterConfigured, NotificationCenterRequestError, } from '@/server/integrations/notification-center/client' +import { + createDingTalkStoreAccountWebhookClientFromEnv, + DingTalkWebhookRequestError, +} from '@/server/integrations/dingtalk-webhook/client' import { NOTIFICATION_DELIVERY_STATUSES, type NotificationCenterDelivery, @@ -43,6 +47,14 @@ export type SendEmployeeNotificationInput = { retryExisting?: boolean } +export type SendWebhookNotificationInput = Omit< + SendEmployeeNotificationInput, + 'recipientUserId' +> & { + recipientKey: string + recipientName: string +} + export type RecordUndeliverableNotificationInput = Omit< SendEmployeeNotificationInput, 'recipientUserId' | 'retryExisting' @@ -288,6 +300,85 @@ class NotificationDeliveryService { } } + async sendToWebhook(input: SendWebhookNotificationInput) { + const recipientKey = requiredText(input.recipientKey, '群机器人标识', 100) + const recipientName = requiredText(input.recipientName, '群机器人名称', 100) + const idempotencyKey = requiredText(input.idempotencyKey, '幂等键', 160) + const businessEvent = requiredText(input.businessEvent, '业务事件', 100) + const title = requiredText(input.title, '通知标题', 100) + const content = requiredText(normalizeNotificationLineBreaks(input.content), '通知正文', 4_600) + const { actionTitle, actionUrl } = normalizeNotificationAction(input) + if (/^【[^】]+】/.test(title)) { + throw new NotificationDeliveryError('通知标题不要重复添加项目名称前缀', 'INVALID_INPUT') + } + + const recipientUserId = `webhook:${recipientKey}` + const payloadHash = createPayloadHash({ + recipientDingtalkUserId: recipientUserId, + title, + content, + actionTitle, + actionUrl, + }) + let record = await notificationDeliveryRepository.findByIdempotencyKey(idempotencyKey) + if (record && input.retryExisting === false) return toDto(record) + if (record && record.payloadHash !== payloadHash) { + throw new NotificationDeliveryError( + '同一幂等键对应了不同通知内容', + 'IDEMPOTENCY_CONFLICT', + ) + } + if (record && ACTIVE_DELIVERY_STATUSES.has(record.status as NotificationDeliveryStatus)) { + return toDto(record) + } + + if (!record) { + record = await notificationDeliveryRepository.create({ + idempotencyKey, + payloadHash, + businessEvent, + businessRecordId: input.businessRecordId?.trim() || null, + source: input.source ?? 'SYSTEM', + initiatedById: input.initiatedById?.trim() || null, + initiatedByName: input.initiatedByName?.trim() || '系统任务', + recipientUserId, + recipientName, + recipientEmail: '', + recipientDingtalkUserId: recipientUserId, + title, + content, + actionTitle, + actionUrl, + }) + } + + try { + const deliveryContent = [title, content].filter(Boolean).join('\n\n') + await createDingTalkStoreAccountWebhookClientFromEnv().sendText(deliveryContent) + const updated = await notificationDeliveryRepository.update(record.id, { + renderedTitle: title, + status: 'sent', + sentAt: new Date(), + lastErrorCode: null, + lastErrorMessage: null, + lastSyncedAt: new Date(), + }) + return toDto(updated) + } catch (error) { + const requestError = error instanceof DingTalkWebhookRequestError ? error : null + await notificationDeliveryRepository.update(record.id, { + status: 'failed', + lastErrorCode: requestError?.code || 'DINGTALK_WEBHOOK_REQUEST_FAILED', + lastErrorMessage: error instanceof Error ? error.message : '钉钉群机器人调用失败', + lastSyncedAt: new Date(), + }) + throw new NotificationDeliveryError( + '钉钉群机器人调用失败,请在通知记录中查看原因', + requestError?.code || 'DINGTALK_WEBHOOK_REQUEST_FAILED', + ) + } + } + async recordUndeliverable(input: RecordUndeliverableNotificationInput) { const idempotencyKey = requiredText(input.idempotencyKey, '幂等键', 160) const existing = await notificationDeliveryRepository.findByIdempotencyKey(idempotencyKey) diff --git a/furniture-3d-system/src/server/services/store-account-notification.service.ts b/furniture-3d-system/src/server/services/store-account-notification.service.ts index 99b014d7..f46d0cf3 100644 --- a/furniture-3d-system/src/server/services/store-account-notification.service.ts +++ b/furniture-3d-system/src/server/services/store-account-notification.service.ts @@ -25,6 +25,7 @@ const NON_JINGXI_OWNER_NAME = '如懿' type NotificationTarget = { key: string name: string + delivery: 'employee' | 'webhook' includeAccount: boolean storeNameOnly?: boolean includeCredentials: boolean @@ -44,14 +45,27 @@ function displayValue(value: string) { export function getStoreAccountNotificationTargets(storeName: string): NotificationTarget[] { const isJingxi = storeName.trim().startsWith('京喜-') return [ - { key: 'mengxi', name: DREAMXI_NAME, includeAccount: true, includeCredentials: false }, - { key: 'yangling', name: YANGLING_NAME, includeAccount: true, includeCredentials: false }, + { + key: 'mengxi-yangling-group', + name: `${DREAMXI_NAME}/${YANGLING_NAME}群(由壹点)`, + delivery: 'webhook', + includeAccount: true, + includeCredentials: false, + }, ...(isJingxi - ? [{ key: 'jingxi-ruyi', name: NON_JINGXI_OWNER_NAME, includeAccount: false, storeNameOnly: true, includeCredentials: false }] + ? [{ + key: 'jingxi-ruyi', + name: NON_JINGXI_OWNER_NAME, + delivery: 'employee' as const, + includeAccount: false, + storeNameOnly: true, + includeCredentials: false, + }] : []), { key: isJingxi ? 'jingxi-owner' : 'non-jingxi-owner', name: isJingxi ? JINGXI_OWNER_NAME : NON_JINGXI_OWNER_NAME, + delivery: 'employee', includeAccount: true, includeCredentials: true, }, @@ -122,8 +136,9 @@ function sendInput(input: { class StoreAccountNotificationService { async notifyCreated(record: StoreAccountRecord, initiatedBy?: AuthUser) { const targets = getStoreAccountNotificationTargets(record.storeName) + const employeeTargets = targets.filter((target) => target.delivery === 'employee') const recipients = await storeAccountNotificationRepository.findUsersByNames( - targets.map((target) => target.name), + employeeTargets.map((target) => target.name), ) const recipientByName = new Map(recipients.map((recipient) => [recipient.name, recipient])) const password = decryptStoreAccountPassword(record.passwordCiphertext) @@ -135,12 +150,39 @@ class StoreAccountNotificationService { account: record.account, password, boundPhone: record.boundPhone, - verificationContact: record.verificationContact, - includeAccount: target.includeAccount, - storeNameOnly: target.storeNameOnly, - includeCredentials: target.includeCredentials, + verificationContact: record.verificationContact, + includeAccount: target.includeAccount, + storeNameOnly: target.storeNameOnly, + includeCredentials: target.includeCredentials, }) const recipient = recipientByName.get(target.name) + if (target.delivery === 'webhook') { + try { + const delivery = await notificationDeliveryService.sendToWebhook({ + idempotencyKey: `${STORE_ACCOUNT_CREATED_NOTIFICATION_EVENT}:${record.id}:${target.key}`, + businessEvent: STORE_ACCOUNT_CREATED_NOTIFICATION_EVENT, + businessRecordId: record.id, + source: initiatedBy ? 'USER' : 'SYSTEM', + initiatedById: initiatedBy?.id, + initiatedByName: initiatedBy?.name ?? '新增店铺账号通知', + recipientKey: target.key, + recipientName: target.name, + title: formatted.title, + content: formatted.content, + retryExisting: false, + }) + results.push({ recipientName: target.name, status: 'sent', deliveryId: delivery.id, errorCode: null }) + } catch (error) { + results.push({ + recipientName: target.name, + status: 'failed', + deliveryId: null, + errorCode: error instanceof NotificationDeliveryError ? error.code : 'STORE_ACCOUNT_NOTIFICATION_FAILED', + }) + } + continue + } + if (!recipient) { try { const audit = await notificationDeliveryService.recordUndeliverable({ @@ -201,20 +243,38 @@ class StoreAccountNotificationService { if (record.recipientUserId.startsWith('missing:')) continue retried += 1 try { - await notificationDeliveryService.sendToEmployee({ - idempotencyKey: record.idempotencyKey, - businessEvent: record.businessEvent, - businessRecordId: record.businessRecordId ?? undefined, - source: record.source === 'USER' ? 'USER' : 'SYSTEM', - initiatedById: record.initiatedById ?? undefined, - initiatedByName: record.initiatedByName, - recipientUserId: record.recipientUserId, - title: record.title, - content: record.content, - actionTitle: record.actionTitle, - actionUrl: record.actionUrl, - retryExisting: true, - }) + if (record.recipientUserId.startsWith('webhook:')) { + await notificationDeliveryService.sendToWebhook({ + idempotencyKey: record.idempotencyKey, + businessEvent: record.businessEvent, + businessRecordId: record.businessRecordId ?? undefined, + source: record.source === 'USER' ? 'USER' : 'SYSTEM', + initiatedById: record.initiatedById ?? undefined, + initiatedByName: record.initiatedByName, + recipientKey: record.recipientUserId.slice('webhook:'.length), + recipientName: record.recipientName, + title: record.title, + content: record.content, + actionTitle: record.actionTitle, + actionUrl: record.actionUrl, + retryExisting: true, + }) + } else { + await notificationDeliveryService.sendToEmployee({ + idempotencyKey: record.idempotencyKey, + businessEvent: record.businessEvent, + businessRecordId: record.businessRecordId ?? undefined, + source: record.source === 'USER' ? 'USER' : 'SYSTEM', + initiatedById: record.initiatedById ?? undefined, + initiatedByName: record.initiatedByName, + recipientUserId: record.recipientUserId, + title: record.title, + content: record.content, + actionTitle: record.actionTitle, + actionUrl: record.actionUrl, + retryExisting: true, + }) + } sent += 1 } catch { failed += 1