feat: notify on store account creation
This commit is contained in:
parent
865be95b6e
commit
916930a714
@ -92,6 +92,7 @@ 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
|
||||
STORE_ACCOUNT_NOTIFICATION_RETRY_SECRET=
|
||||
|
||||
# 产品开发待办定时任务(仅服务端/部署 cron 使用,须与其他任务密钥分开)
|
||||
PRODUCT_DEVELOPMENT_TODO_CRON_SECRET=
|
||||
|
||||
@ -0,0 +1,24 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
import { ErrorCode } from '@/server/core/errors'
|
||||
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
|
||||
import { storeAccountNotificationService } from '@/server/services/store-account-notification.service'
|
||||
|
||||
function hasRetrySecret(request: NextRequest) {
|
||||
const expected = process.env.STORE_ACCOUNT_NOTIFICATION_RETRY_SECRET?.trim()
|
||||
const received = request.headers.get('x-store-account-notification-secret')?.trim()
|
||||
return Boolean(expected && received && expected === received)
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
return handleRequest(request, async (ctx) => {
|
||||
if (!hasRetrySecret(request)) {
|
||||
return jsonError(
|
||||
ErrorCode.AUTH_INVALID_TOKEN,
|
||||
'店铺账号通知重试密钥无效',
|
||||
401,
|
||||
ctx.requestId,
|
||||
)
|
||||
}
|
||||
return jsonSuccess(await storeAccountNotificationService.retryFailed(), 200, ctx.requestId)
|
||||
}, { requireAuth: false })
|
||||
}
|
||||
@ -82,6 +82,23 @@ class NotificationDeliveryRepository {
|
||||
return prisma.notificationDeliveryLog.update({ where: { id }, data })
|
||||
}
|
||||
|
||||
findRetryable(options: {
|
||||
businessEvent: string
|
||||
before: Date
|
||||
take: number
|
||||
}) {
|
||||
return prisma.notificationDeliveryLog.findMany({
|
||||
where: {
|
||||
businessEvent: options.businessEvent,
|
||||
status: 'failed',
|
||||
externalDeliveryId: null,
|
||||
updatedAt: { lte: options.before },
|
||||
},
|
||||
orderBy: { updatedAt: 'asc' },
|
||||
take: options.take,
|
||||
})
|
||||
}
|
||||
|
||||
async findMany(options: {
|
||||
filter: NotificationDeliveryFilter
|
||||
page: number
|
||||
|
||||
@ -0,0 +1,35 @@
|
||||
import 'server-only'
|
||||
|
||||
import type { BusinessAccountType, UserStatus } from '@prisma/client'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
export type StoreAccountNotificationRecipient = {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
status: UserStatus
|
||||
businessRoles: BusinessAccountType[]
|
||||
dingtalkUserId: string | null
|
||||
}
|
||||
|
||||
class StoreAccountNotificationRepository {
|
||||
findUsersByNames(names: string[]): Promise<StoreAccountNotificationRecipient[]> {
|
||||
const normalized = [...new Set(names.map((name) => name.trim()).filter(Boolean))]
|
||||
if (normalized.length === 0) return Promise.resolve([])
|
||||
|
||||
return prisma.user.findMany({
|
||||
where: { name: { in: normalized } },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
status: true,
|
||||
businessRoles: true,
|
||||
dingtalkUserId: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const storeAccountNotificationRepository = new StoreAccountNotificationRepository()
|
||||
export { StoreAccountNotificationRepository }
|
||||
@ -0,0 +1,159 @@
|
||||
/** @jest-environment node */
|
||||
|
||||
const mockFindUsersByNames = jest.fn()
|
||||
const mockRecordUndeliverable = jest.fn()
|
||||
const mockSendToEmployee = jest.fn()
|
||||
const mockFindRetryable = jest.fn()
|
||||
|
||||
jest.mock('@/server/repositories/store-account-notification.repository', () => ({
|
||||
storeAccountNotificationRepository: {
|
||||
findUsersByNames: (...args: unknown[]) => mockFindUsersByNames(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
jest.mock('@/server/repositories/notification-delivery.repository', () => ({
|
||||
notificationDeliveryRepository: {
|
||||
findRetryable: (...args: unknown[]) => mockFindRetryable(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
jest.mock('@/server/services/notification-delivery.service', () => ({
|
||||
notificationDeliveryService: {
|
||||
recordUndeliverable: (...args: unknown[]) => mockRecordUndeliverable(...args),
|
||||
sendToEmployee: (...args: unknown[]) => mockSendToEmployee(...args),
|
||||
},
|
||||
NotificationDeliveryError: class NotificationDeliveryError extends Error {
|
||||
constructor(message: string, readonly code: string) {
|
||||
super(message)
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/store-account-crypto', () => ({
|
||||
decryptStoreAccountPassword: () => 'secret-password',
|
||||
}))
|
||||
|
||||
import type { StoreAccountRecord } from '@/server/repositories/store-account-management.repository'
|
||||
import {
|
||||
formatStoreAccountNotification,
|
||||
getStoreAccountNotificationTargets,
|
||||
storeAccountNotificationService,
|
||||
} from '../store-account-notification.service'
|
||||
|
||||
const record = {
|
||||
id: 'account-1',
|
||||
storeName: '阿里巴巴-广州沃来贸易有限公司',
|
||||
operationOwner: '',
|
||||
operationDepartmentHead: '',
|
||||
operationDirector: '',
|
||||
profitCenter: '',
|
||||
accountType: 'MAIN',
|
||||
account: '广州沃来家具',
|
||||
passwordCiphertext: 'ciphertext',
|
||||
boundPhone: '17300000000',
|
||||
verificationContact: '玄机',
|
||||
jstOrderService: '',
|
||||
accountGroupName: '',
|
||||
subject: '',
|
||||
boundBankAccountNumberCiphertext: '',
|
||||
createdAt: new Date('2026-08-31T00:00:00.000Z'),
|
||||
updatedAt: new Date('2026-08-31T00:00:00.000Z'),
|
||||
} satisfies StoreAccountRecord
|
||||
|
||||
const users = [
|
||||
'梦汐',
|
||||
'扬灵',
|
||||
'如懿',
|
||||
].map((name, index) => ({
|
||||
id: `user-${index}`,
|
||||
name,
|
||||
email: `${index}@example.com`,
|
||||
status: 'ACTIVE' as const,
|
||||
businessRoles: [],
|
||||
dingtalkUserId: `ding-${index}`,
|
||||
}))
|
||||
|
||||
describe('store account notification formatting', () => {
|
||||
it('uses the store name in the title and keeps each field on its own line', () => {
|
||||
expect(formatStoreAccountNotification({
|
||||
storeName: record.storeName,
|
||||
account: record.account,
|
||||
password: 'secret-password',
|
||||
boundPhone: record.boundPhone,
|
||||
verificationContact: record.verificationContact,
|
||||
includeCredentials: true,
|
||||
})).toEqual({
|
||||
title: '店铺账号新增通知:阿里巴巴-广州沃来贸易有限公司',
|
||||
content: [
|
||||
'账号:广州沃来家具',
|
||||
'密码:secret-password',
|
||||
'手机号:17300000000',
|
||||
'手机号负责人:玄机',
|
||||
].join('\n\n'),
|
||||
})
|
||||
})
|
||||
|
||||
it('routes 京喜 to 胖大海 and other stores to 如懿', () => {
|
||||
expect(getStoreAccountNotificationTargets('京喜-测试店').map((item) => [item.name, item.includeCredentials])).toEqual([
|
||||
['梦汐', false],
|
||||
['扬灵', false],
|
||||
['胖大海', true],
|
||||
])
|
||||
expect(getStoreAccountNotificationTargets('阿里巴巴-测试店').map((item) => [item.name, item.includeCredentials])).toEqual([
|
||||
['梦汐', false],
|
||||
['扬灵', false],
|
||||
['如懿', true],
|
||||
])
|
||||
})
|
||||
|
||||
it('sends short notifications to 梦汐/扬灵 and full credentials to the platform owner', async () => {
|
||||
mockFindUsersByNames.mockResolvedValue(users)
|
||||
mockSendToEmployee.mockImplementation(async (input: { recipientUserId: string; content: string }) => ({
|
||||
id: `delivery-${input.recipientUserId}`,
|
||||
}))
|
||||
|
||||
const result = await storeAccountNotificationService.notifyCreated(record)
|
||||
|
||||
expect(mockFindUsersByNames).toHaveBeenCalledWith(['梦汐', '扬灵', '如懿'])
|
||||
expect(mockSendToEmployee).toHaveBeenCalledTimes(3)
|
||||
expect(mockSendToEmployee.mock.calls[0][0].content).toBe('账号:广州沃来家具')
|
||||
expect(mockSendToEmployee.mock.calls[2][0].content).toContain('密码:secret-password')
|
||||
expect(result.every((item) => item.status === 'sent')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('store account notification retry', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
})
|
||||
|
||||
it('only retries failed records after the five-minute cutoff', async () => {
|
||||
mockFindRetryable.mockResolvedValue([{
|
||||
id: 'log-1',
|
||||
idempotencyKey: 'store_account_created:account-1:如懿',
|
||||
businessEvent: 'store_account_created',
|
||||
businessRecordId: 'account-1',
|
||||
source: 'USER',
|
||||
initiatedById: 'admin-1',
|
||||
initiatedByName: '管理员',
|
||||
recipientUserId: 'user-2',
|
||||
title: '店铺账号新增通知:测试店铺',
|
||||
content: '账号:test',
|
||||
actionTitle: '查看店铺账号',
|
||||
actionUrl: 'https://cp.jiansun.vip/supply-chain/database/store-account-management',
|
||||
}])
|
||||
mockSendToEmployee.mockResolvedValue({ id: 'delivery-1' })
|
||||
|
||||
const result = await storeAccountNotificationService.retryFailed(new Date('2026-08-31T00:10:00.000Z'))
|
||||
|
||||
expect(mockFindRetryable).toHaveBeenCalledWith(expect.objectContaining({
|
||||
businessEvent: 'store_account_created',
|
||||
before: new Date('2026-08-31T00:05:00.000Z'),
|
||||
}))
|
||||
expect(mockSendToEmployee).toHaveBeenCalledWith(expect.objectContaining({
|
||||
idempotencyKey: 'store_account_created:account-1:如懿',
|
||||
retryExisting: true,
|
||||
}))
|
||||
expect(result).toEqual({ eligible: 1, retried: 1, sent: 1, failed: 0 })
|
||||
})
|
||||
})
|
||||
@ -42,6 +42,7 @@ import {
|
||||
type StoreAccountManagementRepository,
|
||||
type StoreAccountRecord,
|
||||
} from "@/server/repositories/store-account-management.repository";
|
||||
import { storeAccountNotificationService } from "@/server/services/store-account-notification.service";
|
||||
|
||||
const DEFAULT_MAX_PAGE_SIZE = 200;
|
||||
|
||||
@ -266,6 +267,12 @@ export class StoreAccountManagementService {
|
||||
actorId: user.id,
|
||||
});
|
||||
await this.log(record, "CREATE", user, this.changeSet({}, values, access));
|
||||
// 账号已落库后再发送通知;通知失败只保留失败审计,不回滚已创建的账号。
|
||||
try {
|
||||
await storeAccountNotificationService.notifyCreated(record, user);
|
||||
} catch {
|
||||
// 通知链路不可用时由通知重试入口处理,不能阻断账号创建响应。
|
||||
}
|
||||
return this.toRow(record, access);
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,217 @@
|
||||
import 'server-only'
|
||||
|
||||
import type { AuthUser } from '@/server/core/types'
|
||||
import type { StoreAccountRecord } from '@/server/repositories/store-account-management.repository'
|
||||
import {
|
||||
storeAccountNotificationRepository,
|
||||
type StoreAccountNotificationRecipient,
|
||||
} from '@/server/repositories/store-account-notification.repository'
|
||||
import { notificationDeliveryRepository } from '@/server/repositories/notification-delivery.repository'
|
||||
import {
|
||||
notificationDeliveryService,
|
||||
NotificationDeliveryError,
|
||||
} from '@/server/services/notification-delivery.service'
|
||||
import { decryptStoreAccountPassword } from '@/lib/store-account-crypto'
|
||||
|
||||
export const STORE_ACCOUNT_CREATED_NOTIFICATION_EVENT = 'store_account_created'
|
||||
export const STORE_ACCOUNT_NOTIFICATION_ACTION_TITLE = '查看店铺账号'
|
||||
export const STORE_ACCOUNT_NOTIFICATION_ACTION_URL =
|
||||
'https://cp.jiansun.vip/supply-chain/database/store-account-management'
|
||||
export const STORE_ACCOUNT_NOTIFICATION_RETRY_AFTER_MS = 5 * 60 * 1000
|
||||
|
||||
const DREAMXI_NAME = '梦汐'
|
||||
const YANGLING_NAME = '扬灵'
|
||||
const JINGXI_OWNER_NAME = '胖大海'
|
||||
const NON_JINGXI_OWNER_NAME = '如懿'
|
||||
|
||||
type NotificationTarget = {
|
||||
key: string
|
||||
name: string
|
||||
includeCredentials: boolean
|
||||
}
|
||||
|
||||
export type StoreAccountNotificationResult = {
|
||||
recipientName: string
|
||||
status: 'sent' | 'failed'
|
||||
deliveryId: string | null
|
||||
errorCode: string | null
|
||||
}
|
||||
|
||||
function displayValue(value: string) {
|
||||
return value.trim() || '未填写'
|
||||
}
|
||||
|
||||
export function getStoreAccountNotificationTargets(storeName: string): NotificationTarget[] {
|
||||
const isJingxi = storeName.trim().startsWith('京喜-')
|
||||
return [
|
||||
{ key: 'mengxi', name: DREAMXI_NAME, includeCredentials: false },
|
||||
{ key: 'yangling', name: YANGLING_NAME, includeCredentials: false },
|
||||
{
|
||||
key: isJingxi ? 'jingxi-owner' : 'non-jingxi-owner',
|
||||
name: isJingxi ? JINGXI_OWNER_NAME : NON_JINGXI_OWNER_NAME,
|
||||
includeCredentials: true,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
export function formatStoreAccountNotification(input: {
|
||||
storeName: string
|
||||
account: string
|
||||
password?: string
|
||||
boundPhone: string
|
||||
verificationContact: string
|
||||
includeCredentials: boolean
|
||||
}) {
|
||||
const title = `店铺账号新增通知:${displayValue(input.storeName)}`
|
||||
const lines = [`账号:${displayValue(input.account)}`]
|
||||
if (input.includeCredentials) {
|
||||
lines.push(
|
||||
`密码:${displayValue(input.password ?? '')}`,
|
||||
`手机号:${displayValue(input.boundPhone)}`,
|
||||
`手机号负责人:${displayValue(input.verificationContact)}`,
|
||||
)
|
||||
}
|
||||
// DingTalk renders the notification body as Markdown. A single newline is
|
||||
// treated as whitespace by that renderer, so separate fields with blank
|
||||
// lines to keep each field visibly on its own line.
|
||||
return { title, content: lines.join('\n\n') }
|
||||
}
|
||||
|
||||
function toRecipientAudit(recipient: NotificationTarget) {
|
||||
return {
|
||||
id: `missing:store-account:${recipient.key}`,
|
||||
name: recipient.name,
|
||||
email: '',
|
||||
dingtalkUserId: '',
|
||||
}
|
||||
}
|
||||
|
||||
function sendInput(input: {
|
||||
record: StoreAccountRecord
|
||||
target: NotificationTarget
|
||||
recipient: StoreAccountNotificationRecipient
|
||||
title: string
|
||||
content: string
|
||||
initiatedBy?: AuthUser
|
||||
}) {
|
||||
return {
|
||||
idempotencyKey: `${STORE_ACCOUNT_CREATED_NOTIFICATION_EVENT}:${input.record.id}:${input.target.key}`,
|
||||
businessEvent: STORE_ACCOUNT_CREATED_NOTIFICATION_EVENT,
|
||||
businessRecordId: input.record.id,
|
||||
source: input.initiatedBy ? 'USER' as const : 'SYSTEM' as const,
|
||||
initiatedById: input.initiatedBy?.id,
|
||||
initiatedByName: input.initiatedBy?.name ?? '新增店铺账号通知',
|
||||
recipientUserId: input.recipient.id,
|
||||
title: input.title,
|
||||
content: input.content,
|
||||
actionTitle: STORE_ACCOUNT_NOTIFICATION_ACTION_TITLE,
|
||||
actionUrl: STORE_ACCOUNT_NOTIFICATION_ACTION_URL,
|
||||
retryExisting: false,
|
||||
}
|
||||
}
|
||||
|
||||
class StoreAccountNotificationService {
|
||||
async notifyCreated(record: StoreAccountRecord, initiatedBy?: AuthUser) {
|
||||
const targets = getStoreAccountNotificationTargets(record.storeName)
|
||||
const recipients = await storeAccountNotificationRepository.findUsersByNames(
|
||||
targets.map((target) => target.name),
|
||||
)
|
||||
const recipientByName = new Map(recipients.map((recipient) => [recipient.name, recipient]))
|
||||
const password = decryptStoreAccountPassword(record.passwordCiphertext)
|
||||
const results: StoreAccountNotificationResult[] = []
|
||||
|
||||
for (const target of targets) {
|
||||
const formatted = formatStoreAccountNotification({
|
||||
storeName: record.storeName,
|
||||
account: record.account,
|
||||
password,
|
||||
boundPhone: record.boundPhone,
|
||||
verificationContact: record.verificationContact,
|
||||
includeCredentials: target.includeCredentials,
|
||||
})
|
||||
const recipient = recipientByName.get(target.name)
|
||||
if (!recipient) {
|
||||
try {
|
||||
const audit = await notificationDeliveryService.recordUndeliverable({
|
||||
...sendInput({
|
||||
record,
|
||||
target,
|
||||
recipient: {
|
||||
id: toRecipientAudit(target).id,
|
||||
name: target.name,
|
||||
email: '',
|
||||
status: 'INACTIVE',
|
||||
businessRoles: [],
|
||||
dingtalkUserId: null,
|
||||
},
|
||||
title: formatted.title,
|
||||
content: formatted.content,
|
||||
initiatedBy,
|
||||
}),
|
||||
recipient: toRecipientAudit(target),
|
||||
errorCode: 'RECIPIENT_NOT_FOUND',
|
||||
errorMessage: '正式站未找到对应的内部收件人账号',
|
||||
})
|
||||
results.push({ recipientName: target.name, status: 'failed', deliveryId: audit.id, errorCode: 'RECIPIENT_NOT_FOUND' })
|
||||
} catch {
|
||||
results.push({ recipientName: target.name, status: 'failed', deliveryId: null, errorCode: 'RECIPIENT_NOT_FOUND' })
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const delivery = await notificationDeliveryService.sendToEmployee(
|
||||
sendInput({ record, target, recipient, title: formatted.title, content: formatted.content, initiatedBy }),
|
||||
)
|
||||
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',
|
||||
})
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
async retryFailed(now = new Date()) {
|
||||
const before = new Date(now.getTime() - STORE_ACCOUNT_NOTIFICATION_RETRY_AFTER_MS)
|
||||
const records = await notificationDeliveryRepository.findRetryable({
|
||||
businessEvent: STORE_ACCOUNT_CREATED_NOTIFICATION_EVENT,
|
||||
before,
|
||||
take: 100,
|
||||
})
|
||||
let retried = 0
|
||||
let sent = 0
|
||||
let failed = 0
|
||||
for (const record of records) {
|
||||
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,
|
||||
})
|
||||
sent += 1
|
||||
} catch {
|
||||
failed += 1
|
||||
}
|
||||
}
|
||||
return { eligible: records.length, retried, sent, failed }
|
||||
}
|
||||
}
|
||||
|
||||
export const storeAccountNotificationService = new StoreAccountNotificationService()
|
||||
export { StoreAccountNotificationService }
|
||||
Loading…
x
Reference in New Issue
Block a user