feat: route store account notices to DingTalk group
This commit is contained in:
parent
9c459e7195
commit
7f078abb7b
@ -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 使用,须与其他任务密钥分开)
|
||||
|
||||
@ -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<DingTalkWebhookRequestError>({
|
||||
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()
|
||||
})
|
||||
})
|
||||
@ -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,
|
||||
})
|
||||
}
|
||||
@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@ -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 })
|
||||
})
|
||||
})
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user