feat(supply-chain): 店铺账号授权提醒推送(正常+未授权店铺推送给运营负责人,离职顺延)

This commit is contained in:
WuYongJie 2026-10-08 10:59:54 +08:00
parent 2528229c08
commit 12feabd17f
6 changed files with 911 additions and 0 deletions

View File

@ -0,0 +1,27 @@
import { NextRequest } from 'next/server'
import { z } from 'zod'
import { ErrorCode } from '@/server/core/errors'
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
import { storeAccountAuthorizationNoticeService } from '@/server/services/store-account-authorization-notice.service'
const schema = z.object({
ids: z.array(z.string().trim().min(1)).min(1).max(500),
testRecipientName: z.string().trim().max(60).optional(),
}).strict()
export async function POST(request: NextRequest) {
return handleRequest(request, async (ctx) => {
const parsed = schema.safeParse(await request.json().catch(() => null))
if (!parsed.success) {
return jsonError(ErrorCode.VALIDATION_INVALID_FORMAT, '授权提醒推送内容不正确', 400, ctx.requestId)
}
return jsonSuccess(
await storeAccountAuthorizationNoticeService.notifyUnauthorizedStores({
...parsed.data,
user: ctx.user,
}),
200,
ctx.requestId,
)
}, { requireAuth: true })
}

View File

@ -0,0 +1,290 @@
'use client'
import { useState } from 'react'
import { X } from 'lucide-react'
import {
formatStoreAccountAuthorizationStatus,
formatStoreAccountShopState,
type StoreAccountAuthorizationNoticeItemStatus,
type StoreAccountAuthorizationNoticeResult,
type StoreAccountAuthorizationStatus,
} from '@/lib/store-account-management'
type NoticePreviewRow = {
id: string
storeName: string
shopState: number | null
authorizationStatus: StoreAccountAuthorizationStatus | null
}
type Props = {
count: number
rows: NoticePreviewRow[]
offPageCount: number
onClose: (pushed: boolean) => void
onPush: (testRecipientName: string) => Promise<StoreAccountAuthorizationNoticeResult>
}
const RESULT_STATUS_LABELS: Record<StoreAccountAuthorizationNoticeItemStatus, string> = {
sent: '已推送',
skipped: '已跳过',
failed: '失败',
}
export function StoreAccountAuthorizationNoticeDialog({
count,
rows,
offPageCount,
onClose,
onPush,
}: Props) {
const [testRecipientName, setTestRecipientName] = useState('')
const [pushing, setPushing] = useState(false)
const [result, setResult] = useState<StoreAccountAuthorizationNoticeResult | null>(null)
const [error, setError] = useState('')
const confirm = async () => {
setPushing(true)
setError('')
try {
setResult(await onPush(testRecipientName.trim()))
} catch (pushError) {
setError(pushError instanceof Error ? pushError.message : '推送失败')
} finally {
setPushing(false)
}
}
const close = () => onClose(Boolean(result))
return (
<div
className="modal-backdrop"
onMouseDown={(event) => {
if (event.target === event.currentTarget && !pushing) close()
}}
>
<div className="modal notice-modal">
<header>
<div>
<h2>推送店铺授权提醒</h2>
<p>提醒店铺负责人尽快完成店铺授权。</p>
</div>
<button type="button" onClick={close} aria-label="关闭" disabled={pushing}>
<X size={17} />
</button>
</header>
{result ? (
<>
<p className="notice-summary">
共 {result.summary.total} 条:已推送 {result.summary.sent}、已跳过{' '}
{result.summary.skipped}、失败 {result.summary.failed}。
</p>
<div className="notice-result-list">
{result.items.map((item, index) => (
<p
key={`${item.storeName}-${index}`}
title={item.reason || undefined}
>
<span className={`notice-status ${item.status}`}>
{RESULT_STATUS_LABELS[item.status]}
</span>
<span className="notice-store">{item.storeName || '(账号已不存在)'}</span>
{item.recipientName && (
<span className="notice-recipient">
收件人:{item.recipientName}({item.recipientRole})
</span>
)}
{item.reason && <span className="notice-reason">{item.reason}</span>}
</p>
))}
</div>
{error && <p className="notice-error">{error}</p>}
<div className="modal-actions">
<button type="button" onClick={close}>
关闭
</button>
</div>
</>
) : (
<>
<p className="notice-rules">
仅推送「店铺状态=正常 且 授权状态=未授权」的账号;正常推送给店铺运营负责人,
负责人离职或无法接收时按 店铺运营部门负责人 → 店铺运营总监 顺延。
</p>
<div className="notice-preview-list">
{rows.length === 0 ? (
<p className="notice-preview-empty">所选账号均不在当前页。</p>
) : (
rows.map((row) => (
<p key={row.id}>
{row.storeName || '(未填店铺名称)'} ·{' '}
{formatStoreAccountShopState(row.shopState)} ·{' '}
{formatStoreAccountAuthorizationStatus(row.authorizationStatus)}
</p>
))
)}
</div>
{offPageCount > 0 && (
<p className="notice-offpage-hint">
另有 {offPageCount} 条所选账号不在当前页,将一并按规则处理。
</p>
)}
<label className="notice-test-recipient">
测试收件人(可选)
<input
value={testRecipientName}
onChange={(event) => setTestRecipientName(event.target.value)}
placeholder="填写后本次全部推送给该人员,例如:徐霞客"
maxLength={60}
/>
</label>
{error && <p className="notice-error">{error}</p>}
<div className="modal-actions">
<button type="button" onClick={close} disabled={pushing}>
取消
</button>
<button
type="button"
className="primary"
disabled={pushing || count === 0}
onClick={() => void confirm()}
>
{pushing ? '推送中...' : `确认推送(${count})`}
</button>
</div>
</>
)}
</div>
<style jsx>{`
.notice-modal {
width: min(560px, calc(100vw - 32px));
}
header > div {
min-width: 0;
}
header p {
margin: 5px 0 0;
color: #71717a;
font-size: 12px;
}
.notice-rules {
margin: 0 0 10px;
color: #52525b;
font-size: 12px;
line-height: 1.7;
}
.notice-preview-list {
max-height: 240px;
overflow: auto;
padding: 6px 12px;
border: 1px solid #e4e4e7;
border-radius: 5px;
}
.notice-preview-list p {
height: 28px;
margin: 0;
color: #3f3f46;
font-size: 12px;
line-height: 28px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.notice-preview-empty {
color: #a1a1aa;
text-align: center;
}
.notice-offpage-hint {
margin: 8px 0 0;
color: #71717a;
font-size: 12px;
}
.notice-test-recipient {
display: flex;
flex-direction: column;
gap: 6px;
margin-top: 14px;
color: #52525b;
font-size: 12px;
}
.notice-test-recipient input {
height: 36px;
padding: 0 9px;
border: 1px solid #d4d4d8;
border-radius: 5px;
background: #fff;
outline: 0;
color: #27272a;
font-size: 13px;
}
.notice-test-recipient input:focus {
border-color: #52525b;
}
.notice-summary {
margin: 0;
color: #27272a;
font-size: 13px;
font-weight: 600;
}
.notice-result-list {
max-height: 320px;
overflow: auto;
margin-top: 10px;
padding: 6px 12px;
border: 1px solid #e4e4e7;
border-radius: 5px;
}
.notice-result-list p {
display: flex;
align-items: center;
gap: 8px;
min-height: 30px;
margin: 0;
color: #3f3f46;
font-size: 12px;
}
.notice-status {
flex: 0 0 auto;
font-weight: 600;
}
.notice-status.sent {
color: #15803d;
}
.notice-status.skipped {
color: #a1a1aa;
}
.notice-status.failed {
color: #b91c1c;
}
.notice-store {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.notice-recipient {
flex: 0 0 auto;
color: #52525b;
}
.notice-reason {
flex: 1 1 auto;
overflow: hidden;
color: #a1a1aa;
text-overflow: ellipsis;
white-space: nowrap;
}
.notice-error {
margin: 10px 0 0;
color: #b91c1c;
font-size: 12px;
}
button:disabled {
opacity: 0.45;
cursor: not-allowed;
}
`}</style>
</div>
)
}

View File

@ -9,6 +9,7 @@ import {
import {
ArrowLeft,
ArrowRight,
BellRing,
ChevronDown,
FileDown,
History,
@ -29,6 +30,7 @@ import type {
StoreAccountPage,
StoreAccountRow,
StoreAccountFilters,
StoreAccountAuthorizationNoticeResult,
} from "@/lib/store-account-management";
import type { ColumnHeaderFilterSelection } from "@/lib/column-header-filters";
import {
@ -47,6 +49,7 @@ import {
import { ColumnHeaderFilter } from "@/components/common/ColumnHeaderFilter";
import { StoreAccountBatchUpdateDialog } from "./StoreAccountBatchUpdateDialog";
import { StoreAccountBatchDeleteDialog } from "./StoreAccountBatchDeleteDialog";
import { StoreAccountAuthorizationNoticeDialog } from "./StoreAccountAuthorizationNoticeDialog";
import { StoreAccountImportProgress } from "./StoreAccountImportProgress";
import { StoreAccountLogDialog } from "./StoreAccountLogDialog";
import { StoreAccountPermissionDialog } from "./StoreAccountPermissionDialog";
@ -185,6 +188,7 @@ export function StoreAccountManagementView({
const [permissionsOpen, setPermissionsOpen] = useState(false);
const [batchOpen, setBatchOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const [noticeOpen, setNoticeOpen] = useState(false);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [batchField, setBatchField] =
useState<StoreAccountBatchEditableFieldKey>("operationOwner");
@ -421,6 +425,21 @@ export function StoreAccountManagementView({
);
};
const pushAuthorizationNotice = async (testRecipientName: string) => {
const body: Record<string, unknown> = { ids: selectedIds };
if (testRecipientName) body.testRecipientName = testRecipientName;
return (await readResponse(
await fetch(
"/api/supply-chain/store-account-management/authorization-notice",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
},
),
)) as StoreAccountAuthorizationNoticeResult;
};
const openLogs = async (row: StoreAccountRow) => {
const result = await readResponse(
await fetch(
@ -469,6 +488,15 @@ export function StoreAccountManagementView({
<Trash2 size={15} /> 批量删除
{selectedIds.length > 0 ? `(${selectedIds.length})` : ""}
</button>
<button
type="button"
disabled={selectedIds.length === 0}
onClick={() => setNoticeOpen(true)}
title="提醒店铺负责人去完成授权"
>
<BellRing size={15} /> 推送授权提醒
{selectedIds.length > 0 ? `(${selectedIds.length})` : ""}
</button>
<button type="button" onClick={() => void downloadTemplate()}>
<FileDown size={15} /> 下载模板
</button>
@ -893,6 +921,23 @@ export function StoreAccountManagementView({
onConfirm={batchDelete}
/>
)}
{noticeOpen && (
<StoreAccountAuthorizationNoticeDialog
count={selectedIds.length}
rows={selectedRowsOnPage.map((row) => ({
id: row.id,
storeName: row.storeName,
shopState: row.shopState,
authorizationStatus: row.authorizationStatus,
}))}
offPageCount={offPageSelectedCount}
onClose={(pushed) => {
setNoticeOpen(false);
if (pushed) setSelectedIds([]);
}}
onPush={pushAuthorizationNotice}
/>
)}
<StoreAccountImportProgress
state={storeAccountImport.state}
onDismiss={storeAccountImport.dismiss}

View File

@ -342,6 +342,29 @@ export function formatStoreAccountAuthorizationStatus(
return "-";
}
export type StoreAccountAuthorizationNoticeItemStatus =
| "sent"
| "skipped"
| "failed";
export type StoreAccountAuthorizationNoticeItem = {
storeName: string;
recipientName: string;
recipientRole: string;
status: StoreAccountAuthorizationNoticeItemStatus;
reason: string;
};
export type StoreAccountAuthorizationNoticeResult = {
items: StoreAccountAuthorizationNoticeItem[];
summary: {
total: number;
sent: number;
skipped: number;
failed: number;
};
};
export function getStoreAccountLogOperationLabel(operation: string) {
return STORE_ACCOUNT_LOG_OPERATION_LABELS[operation] ?? "其他操作";
}

View File

@ -0,0 +1,261 @@
/** @jest-environment node */
const mockGetUserAccess = jest.fn()
jest.mock('@/server/services/supply-chain-permission-settings.service', () => ({
supplyChainPermissionSettingsService: {
getUserAccess: (...args: unknown[]) => mockGetUserAccess(...args),
},
}))
jest.mock('@/server/repositories/store-account-management.repository', () => ({
storeAccountManagementRepository: { listByIds: jest.fn() },
}))
jest.mock('@/server/repositories/store-account-notification.repository', () => ({
storeAccountNotificationRepository: { findUsersByNames: jest.fn() },
}))
jest.mock('@/server/services/notification-delivery.service', () => ({
notificationDeliveryService: { sendToEmployee: jest.fn(), recordUndeliverable: jest.fn() },
}))
jest.mock('@/server/services/store-account-authorization.service', () => ({
storeAccountAuthorizationService: { getStatusMap: jest.fn() },
}))
import type { AuthUser } from '@/server/core/types'
import type { StoreAccountRecord } from '@/server/repositories/store-account-management.repository'
import type { StoreAccountNotificationRecipient } from '@/server/repositories/store-account-notification.repository'
import {
StoreAccountAuthorizationNoticeService,
STORE_ACCOUNT_AUTHORIZATION_NOTICE_EVENT,
} from '../store-account-authorization-notice.service'
const user = {
id: 'user-1',
email: 'xuxiake@example.com',
name: '徐霞客',
role: 'ADMIN' as const,
status: 'ACTIVE' as const,
} as AuthUser
const now = new Date('2026-10-08T04:00:00.000Z')
function buildRecord(overrides: Partial<StoreAccountRecord> = {}): StoreAccountRecord {
return {
id: 'account-1',
storeName: '拼多多-测试店铺',
operationOwner: '张三',
operationDepartmentHead: '李四',
operationDirector: '王五',
profitCenter: '',
accountType: 'MAIN',
account: '测试账号',
passwordCiphertext: 'ciphertext',
boundPhone: '',
verificationContact: '',
jstOrderService: '',
accountGroupName: '',
shopState: 0,
subject: '',
boundBankAccountNumberCiphertext: '',
createdAt: new Date('2026-10-01T00:00:00.000Z'),
updatedAt: new Date('2026-10-01T00:00:00.000Z'),
...overrides,
}
}
function buildRecipient(overrides: Partial<StoreAccountNotificationRecipient> = {}): StoreAccountNotificationRecipient {
return {
id: 'recipient-1',
name: '张三',
email: 'zhangsan@example.com',
status: 'ACTIVE',
businessRoles: [],
dingtalkUserId: 'ding-1',
...overrides,
}
}
function setupService(records: StoreAccountRecord[], recipients: StoreAccountNotificationRecipient[], options?: {
statusByStoreName?: Map<string, string | null>
sendError?: Error
}) {
const listByIds = jest.fn().mockResolvedValue(records)
const findUsersByNames = jest.fn().mockResolvedValue(recipients)
const getStatusMap = jest.fn().mockResolvedValue(
options?.statusByStoreName ?? new Map([['拼多多-测试店铺', 'UNAUTHORIZED']]),
)
const sendToEmployee = jest.fn().mockImplementation(() => {
if (options?.sendError) throw options.sendError
return { id: 'delivery-1' }
})
const recordUndeliverable = jest.fn().mockResolvedValue({ id: 'audit-1' })
const service = new StoreAccountAuthorizationNoticeService(
{ listByIds },
{ findUsersByNames },
{ getStatusMap },
{ sendToEmployee, recordUndeliverable },
)
return { service, listByIds, findUsersByNames, getStatusMap, sendToEmployee, recordUndeliverable }
}
beforeEach(() => {
jest.clearAllMocks()
mockGetUserAccess.mockResolvedValue({ view: true, edit: true })
})
describe('store account authorization notice permissions', () => {
it('rejects users without store account edit access', async () => {
mockGetUserAccess.mockResolvedValue({ view: true, edit: false })
const { service, sendToEmployee } = setupService([buildRecord()], [])
await expect(
service.notifyUnauthorizedStores({ ids: ['account-1'], user }),
).rejects.toThrow('当前账号没有触发店铺授权提醒推送的权限')
expect(sendToEmployee).not.toHaveBeenCalled()
})
})
describe('store account authorization notice validation', () => {
it('skips accounts whose shop state is not normal', async () => {
const record = buildRecord({ id: 'account-1', shopState: 6 })
const { service, sendToEmployee } = setupService([record], [])
const result = await service.notifyUnauthorizedStores({ ids: ['account-1'], user, now })
expect(result.items[0]).toMatchObject({ status: 'skipped' })
expect(result.items[0].reason).toContain('店铺状态不是正常')
expect(sendToEmployee).not.toHaveBeenCalled()
})
it('skips accounts whose authorization status is not unauthorized', async () => {
const record = buildRecord()
const { service } = setupService([record], [], {
statusByStoreName: new Map([['拼多多-测试店铺', 'AUTHORIZED']]),
})
const result = await service.notifyUnauthorizedStores({ ids: ['account-1'], user, now })
expect(result.items[0]).toMatchObject({ status: 'skipped' })
expect(result.items[0].reason).toContain('授权状态不是未授权')
})
it('skips missing accounts', async () => {
const { service } = setupService([], [])
const result = await service.notifyUnauthorizedStores({ ids: ['missing'], user, now })
expect(result.items[0]).toMatchObject({ status: 'skipped', reason: '店铺账号不存在或已被删除' })
})
})
describe('store account authorization notice recipients', () => {
it('pushes to the operation owner with text-only content', async () => {
const record = buildRecord()
const owner = buildRecipient()
const { service, sendToEmployee } = setupService([record], [owner])
const result = await service.notifyUnauthorizedStores({ ids: ['account-1'], user, now })
expect(result.summary).toEqual({ total: 1, sent: 1, skipped: 0, failed: 0 })
expect(result.items[0]).toMatchObject({
storeName: '拼多多-测试店铺',
recipientName: '张三',
recipientRole: '店铺运营负责人',
status: 'sent',
})
expect(sendToEmployee).toHaveBeenCalledTimes(1)
expect(sendToEmployee).toHaveBeenCalledWith(expect.objectContaining({
businessEvent: STORE_ACCOUNT_AUTHORIZATION_NOTICE_EVENT,
businessRecordId: 'account-1',
recipientUserId: 'recipient-1',
title: '店铺授权提醒',
content: '店铺名称:拼多多-测试店铺\n\n授权状态:未授权',
idempotencyKey: `${STORE_ACCOUNT_AUTHORIZATION_NOTICE_EVENT}:account-1:2026-10-08`,
retryExisting: false,
}))
})
it('falls back to the department head when the owner has left', async () => {
const record = buildRecord()
const departedOwner = buildRecipient({ id: 'owner-1', name: '张三', status: 'INACTIVE' })
const head = buildRecipient({ id: 'head-1', name: '李四', dingtalkUserId: 'ding-2' })
const { service, sendToEmployee } = setupService([record], [departedOwner, head])
const result = await service.notifyUnauthorizedStores({ ids: ['account-1'], user, now })
expect(result.items[0]).toMatchObject({
recipientName: '李四',
recipientRole: '店铺运营部门负责人',
status: 'sent',
})
expect(sendToEmployee).toHaveBeenCalledWith(expect.objectContaining({ recipientUserId: 'head-1' }))
})
it('falls back to the director when owner and head are both unavailable', async () => {
const record = buildRecord({ operationOwner: '张三', operationDepartmentHead: '李四' })
const departedOwner = buildRecipient({ id: 'owner-1', name: '张三', status: 'INACTIVE' })
const unboundHead = buildRecipient({ id: 'head-1', name: '李四', dingtalkUserId: null })
const director = buildRecipient({ id: 'director-1', name: '王五', dingtalkUserId: 'ding-3' })
const { service, sendToEmployee } = setupService(
[record],
[departedOwner, unboundHead, director],
)
const result = await service.notifyUnauthorizedStores({ ids: ['account-1'], user, now })
expect(result.items[0]).toMatchObject({
recipientName: '王五',
recipientRole: '店铺运营总监',
status: 'sent',
})
expect(sendToEmployee).toHaveBeenCalledWith(expect.objectContaining({ recipientUserId: 'director-1' }))
})
it('marks failure with an audit record when nobody in the chain can receive', async () => {
const record = buildRecord()
const { service, sendToEmployee, recordUndeliverable } = setupService([record], [])
const result = await service.notifyUnauthorizedStores({ ids: ['account-1'], user, now })
expect(result.items[0]).toMatchObject({ status: 'failed' })
expect(result.items[0].reason).toContain('均无法接收')
expect(sendToEmployee).not.toHaveBeenCalled()
expect(recordUndeliverable).toHaveBeenCalledWith(expect.objectContaining({
errorCode: 'RECIPIENT_NOT_FOUND',
}))
})
it('reports delivery failures per account without stopping the batch', async () => {
const first = buildRecord({ id: 'account-1', storeName: '拼多多-测试店铺' })
const second = buildRecord({ id: 'account-2', storeName: '拼多多-另一家店', operationOwner: '李四' })
const owner = buildRecipient()
const head = buildRecipient({ id: 'head-1', name: '李四' })
const { service } = setupService([first, second], [owner, head], {
statusByStoreName: new Map([
['拼多多-测试店铺', 'UNAUTHORIZED'],
['拼多多-另一家店', 'UNAUTHORIZED'],
]),
sendError: new Error('通知中心调用失败'),
})
const result = await service.notifyUnauthorizedStores({ ids: ['account-1', 'account-2'], user, now })
expect(result.summary).toEqual({ total: 2, sent: 0, skipped: 0, failed: 2 })
expect(result.items.every((item) => item.reason === '通知中心调用失败')).toBe(true)
})
})
describe('store account authorization notice test recipient', () => {
it('pushes everything to the test recipient with a dedicated idempotency key', async () => {
const record = buildRecord()
const tester = buildRecipient({ id: 'tester-1', name: '徐霞客' })
const { service, sendToEmployee, findUsersByNames } = setupService([record], [tester])
const result = await service.notifyUnauthorizedStores({
ids: ['account-1'],
testRecipientName: '徐霞客',
user,
now,
})
expect(result.items[0]).toMatchObject({ recipientName: '徐霞客', recipientRole: '测试收件人', status: 'sent' })
expect(findUsersByNames).toHaveBeenCalledWith(['徐霞客'])
expect(sendToEmployee).toHaveBeenCalledWith(expect.objectContaining({
recipientUserId: 'tester-1',
idempotencyKey: `${STORE_ACCOUNT_AUTHORIZATION_NOTICE_EVENT}:account-1:2026-10-08:test:tester-1`,
}))
})
it('rejects test recipients that cannot receive notifications', async () => {
const record = buildRecord()
const { service, sendToEmployee } = setupService([record], [])
await expect(
service.notifyUnauthorizedStores({ ids: ['account-1'], testRecipientName: '不存在的人', user, now }),
).rejects.toThrow('不存在、已离职或未绑定钉钉')
expect(sendToEmployee).not.toHaveBeenCalled()
})
})

View File

@ -0,0 +1,265 @@
import 'server-only'
import type { AuthUser } from '@/server/core/types'
import { AppError, ErrorCode } from '@/server/core/errors'
import {
formatStoreAccountAuthorizationStatus,
formatStoreAccountShopState,
type StoreAccountAuthorizationNoticeItem,
type StoreAccountAuthorizationNoticeResult,
} from '@/lib/store-account-management'
import { supplyChainPermissionSettingsService } from './supply-chain-permission-settings.service'
import {
storeAccountManagementRepository,
type StoreAccountManagementRepository,
type StoreAccountRecord,
} from '@/server/repositories/store-account-management.repository'
import {
storeAccountNotificationRepository,
type StoreAccountNotificationRecipient,
} from '@/server/repositories/store-account-notification.repository'
import {
notificationDeliveryService,
type NotificationDeliveryService,
} from '@/server/services/notification-delivery.service'
import {
storeAccountAuthorizationService,
type StoreAccountAuthorizationReader,
} from '@/server/services/store-account-authorization.service'
export const STORE_ACCOUNT_AUTHORIZATION_NOTICE_EVENT = 'store_account_authorization_notice'
// 推送目标解析顺序:正常只推店铺运营负责人;负责人离职或无在职内部账号时按部门负责人、运营总监顺延。
const RECIPIENT_CHAIN = [
{ key: 'operationOwner', label: '店铺运营负责人' },
{ key: 'operationDepartmentHead', label: '店铺运营部门负责人' },
{ key: 'operationDirector', label: '店铺运营总监' },
] as const
const EXTERNAL_ACCOUNT_ROLES = new Set(['SUPPLIER', 'EXTERNAL_INTAKE'])
type AccountRecordReader = Pick<StoreAccountManagementRepository, 'listByIds'>
type AccountUserReader = Pick<typeof storeAccountNotificationRepository, 'findUsersByNames'>
type AccountDelivery = Pick<NotificationDeliveryService, 'sendToEmployee' | 'recordUndeliverable'>
function isDeliverableRecipient(recipient: StoreAccountNotificationRecipient | undefined) {
return Boolean(
recipient
&& recipient.status === 'ACTIVE'
&& !recipient.businessRoles.some((role) => EXTERNAL_ACCOUNT_ROLES.has(role))
&& recipient.dingtalkUserId?.trim(),
)
}
function formatDayBucket(now: Date) {
const pad = (value: number) => String(value).padStart(2, '0')
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`
}
function formatNoticeContent(storeName: string) {
// 钉钉正文按 Markdown 渲染,字段间用空行分隔,保证各占一行。
return `店铺名称:${storeName.trim() || '未填写'}\n\n授权状态:未授权`
}
export class StoreAccountAuthorizationNoticeService {
constructor(
private readonly repository: AccountRecordReader = storeAccountManagementRepository,
private readonly userReader: AccountUserReader = storeAccountNotificationRepository,
private readonly authorizationReader: StoreAccountAuthorizationReader = storeAccountAuthorizationService,
private readonly delivery: AccountDelivery = notificationDeliveryService,
) {}
async notifyUnauthorizedStores(params: {
ids: string[]
testRecipientName?: string
user: AuthUser
now?: Date
}): Promise<StoreAccountAuthorizationNoticeResult> {
const access = await supplyChainPermissionSettingsService.getUserAccess(
params.user,
'storeAccountManagement',
)
if (!access.edit) {
throw new AppError(ErrorCode.AUTH_PERMISSION_DENIED, '当前账号没有触发店铺授权提醒推送的权限')
}
const now = params.now ?? new Date()
const day = formatDayBucket(now)
const ids = [...new Set(params.ids.map((id) => id.trim()).filter(Boolean))]
const records = ids.length > 0 ? await this.repository.listByIds(ids) : []
const recordById = new Map(records.map((record) => [record.id, record]))
const authorizationStatusByStoreName = await this.authorizationReader.getStatusMap(
records.map((record) => record.storeName),
)
const testRecipient = await this.resolveTestRecipient(params.testRecipientName)
const deliverableUserByName = testRecipient
? new Map<string, StoreAccountNotificationRecipient>()
: await this.loadDeliverableUsers(records)
const items: StoreAccountAuthorizationNoticeItem[] = []
for (const id of ids) {
const record = recordById.get(id)
if (!record) {
items.push({
storeName: '',
recipientName: '',
recipientRole: '',
status: 'skipped',
reason: '店铺账号不存在或已被删除',
})
continue
}
const status = authorizationStatusByStoreName.get(record.storeName.trim()) ?? null
if (record.shopState !== 0) {
items.push({
storeName: record.storeName,
recipientName: '',
recipientRole: '',
status: 'skipped',
reason: `店铺状态不是正常(${formatStoreAccountShopState(record.shopState)})`,
})
continue
}
if (status !== 'UNAUTHORIZED') {
items.push({
storeName: record.storeName,
recipientName: '',
recipientRole: '',
status: 'skipped',
reason: `授权状态不是未授权(${formatStoreAccountAuthorizationStatus(status)})`,
})
continue
}
const target = testRecipient
? { recipient: testRecipient, role: '测试收件人' }
: this.resolveChainRecipient(record, deliverableUserByName)
if (!target) {
await this.recordNoRecipientAudit(record, day, params.user)
items.push({
storeName: record.storeName,
recipientName: '',
recipientRole: '',
status: 'failed',
reason: '店铺运营负责人、部门负责人、运营总监均无法接收(离职或未绑定钉钉)',
})
continue
}
const idempotencyKey = [
STORE_ACCOUNT_AUTHORIZATION_NOTICE_EVENT,
record.id,
day,
...(testRecipient ? ['test', testRecipient.id] : []),
].join(':')
try {
await this.delivery.sendToEmployee({
idempotencyKey,
businessEvent: STORE_ACCOUNT_AUTHORIZATION_NOTICE_EVENT,
businessRecordId: record.id,
source: 'USER',
initiatedById: params.user.id,
initiatedByName: params.user.name,
recipientUserId: target.recipient.id,
title: '店铺授权提醒',
content: formatNoticeContent(record.storeName),
retryExisting: false,
})
items.push({
storeName: record.storeName,
recipientName: target.recipient.name,
recipientRole: target.role,
status: 'sent',
reason: '',
})
} catch (error) {
items.push({
storeName: record.storeName,
recipientName: target.recipient.name,
recipientRole: target.role,
status: 'failed',
reason: error instanceof Error ? error.message : '通知投递失败',
})
}
}
const summary = { total: items.length, sent: 0, skipped: 0, failed: 0 }
for (const item of items) summary[item.status] += 1
return { items, summary }
}
private async resolveTestRecipient(name?: string) {
const normalized = (name ?? '').trim()
if (!normalized) return null
const users = await this.userReader.findUsersByNames([normalized])
const matched = users.find((user) => user.name === normalized && isDeliverableRecipient(user))
if (!matched) {
throw new AppError(
ErrorCode.VALIDATION_FAILED,
`测试收件人「${normalized}」不存在、已离职或未绑定钉钉,无法推送`,
)
}
return matched
}
private async loadDeliverableUsers(records: StoreAccountRecord[]) {
const names = [
...new Set(
records
.flatMap((record) => RECIPIENT_CHAIN.map(({ key }) => record[key].trim()))
.filter(Boolean),
),
]
const users = names.length > 0 ? await this.userReader.findUsersByNames(names) : []
const deliverableByName = new Map<string, StoreAccountNotificationRecipient>()
for (const user of users) {
if (!isDeliverableRecipient(user)) continue
if (!deliverableByName.has(user.name)) deliverableByName.set(user.name, user)
}
return deliverableByName
}
private resolveChainRecipient(
record: StoreAccountRecord,
deliverableUserByName: Map<string, StoreAccountNotificationRecipient>,
) {
for (const { key, label } of RECIPIENT_CHAIN) {
const name = record[key].trim()
if (!name) continue
const recipient = deliverableUserByName.get(name)
if (recipient) return { recipient, role: label }
}
return null
}
private async recordNoRecipientAudit(record: StoreAccountRecord, day: string, user: AuthUser) {
const chainNames = RECIPIENT_CHAIN.map(({ key }) => record[key].trim()).filter(Boolean)
const auditId = `missing:store-account-auth-notice:${record.id}`
try {
await this.delivery.recordUndeliverable({
// 用独立后缀,避免占用正常投递的幂等键,负责人补齐后当天仍可正常推送。
idempotencyKey: `${STORE_ACCOUNT_AUTHORIZATION_NOTICE_EVENT}:${record.id}:${day}:no-recipient`,
businessEvent: STORE_ACCOUNT_AUTHORIZATION_NOTICE_EVENT,
businessRecordId: record.id,
source: 'USER',
initiatedById: user.id,
initiatedByName: user.name,
recipient: {
id: auditId,
name: chainNames[chainNames.length - 1] ?? '未维护负责人',
email: '',
dingtalkUserId: '',
},
title: '店铺授权提醒',
content: formatNoticeContent(record.storeName),
errorCode: 'RECIPIENT_NOT_FOUND',
errorMessage: '店铺运营负责人、部门负责人、运营总监均无法接收(离职或未绑定钉钉)',
})
} catch {
// 审计写入失败不影响推送结果,结果明细里已按失败记录原因。
}
}
}
export const storeAccountAuthorizationNoticeService = new StoreAccountAuthorizationNoticeService()