feat: notify on store account creation

This commit is contained in:
WuYongJie 2026-08-31 10:38:33 +08:00
parent 865be95b6e
commit 916930a714
7 changed files with 460 additions and 0 deletions

View File

@ -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=

View File

@ -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 })
}

View File

@ -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

View File

@ -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 }

View File

@ -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 })
})
})

View File

@ -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);
}

View File

@ -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 }