feat(notifications): add product development reminders
This commit is contained in:
parent
e48229ab22
commit
bf85258d3c
@ -87,6 +87,15 @@ DINGTALK_TODO_ESCALATION_ENABLED=false
|
||||
DINGTALK_TODO_ESCALATION_DAYS=3
|
||||
# DINGTALK_AGENT_ID=
|
||||
|
||||
# 由壹点通知中心(仅服务端使用,项目密钥禁止提交或暴露给浏览器)
|
||||
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
|
||||
|
||||
# 产品开发待办定时任务(仅服务端/部署 cron 使用,须与其他任务密钥分开)
|
||||
PRODUCT_DEVELOPMENT_TODO_CRON_SECRET=
|
||||
|
||||
# 对象存储
|
||||
# 必填,不能留空。当前生产桶使用 qiniu;只有明确切换适配器时才改为 s3/minio。
|
||||
STORAGE_PROVIDER=qiniu
|
||||
|
||||
@ -0,0 +1,48 @@
|
||||
CREATE TABLE "NotificationDeliveryLog" (
|
||||
"id" TEXT NOT NULL,
|
||||
"idempotencyKey" TEXT NOT NULL,
|
||||
"payloadHash" TEXT NOT NULL,
|
||||
"externalDeliveryId" TEXT,
|
||||
"businessEvent" TEXT NOT NULL,
|
||||
"businessRecordId" TEXT,
|
||||
"source" TEXT NOT NULL DEFAULT 'SYSTEM',
|
||||
"initiatedById" TEXT,
|
||||
"initiatedByName" TEXT NOT NULL,
|
||||
"recipientUserId" TEXT NOT NULL,
|
||||
"recipientName" TEXT NOT NULL,
|
||||
"recipientEmail" TEXT NOT NULL,
|
||||
"recipientDingtalkUserId" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"renderedTitle" TEXT,
|
||||
"content" TEXT NOT NULL,
|
||||
"actionTitle" TEXT NOT NULL,
|
||||
"actionUrl" TEXT NOT NULL,
|
||||
"status" TEXT NOT NULL DEFAULT 'pending',
|
||||
"lastErrorCode" TEXT,
|
||||
"lastErrorMessage" TEXT,
|
||||
"sentAt" TIMESTAMP(3),
|
||||
"readAt" TIMESTAMP(3),
|
||||
"lastSyncedAt" TIMESTAMP(3),
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "NotificationDeliveryLog_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "NotificationDeliveryLog_idempotencyKey_key"
|
||||
ON "NotificationDeliveryLog"("idempotencyKey");
|
||||
|
||||
CREATE UNIQUE INDEX "NotificationDeliveryLog_externalDeliveryId_key"
|
||||
ON "NotificationDeliveryLog"("externalDeliveryId");
|
||||
|
||||
CREATE INDEX "NotificationDeliveryLog_status_createdAt_idx"
|
||||
ON "NotificationDeliveryLog"("status", "createdAt");
|
||||
|
||||
CREATE INDEX "NotificationDeliveryLog_recipientUserId_createdAt_idx"
|
||||
ON "NotificationDeliveryLog"("recipientUserId", "createdAt");
|
||||
|
||||
CREATE INDEX "NotificationDeliveryLog_businessEvent_createdAt_idx"
|
||||
ON "NotificationDeliveryLog"("businessEvent", "createdAt");
|
||||
|
||||
CREATE INDEX "NotificationDeliveryLog_createdAt_idx"
|
||||
ON "NotificationDeliveryLog"("createdAt");
|
||||
@ -0,0 +1,23 @@
|
||||
CREATE TABLE "ProductDevelopmentTodoAttempt" (
|
||||
"id" TEXT NOT NULL,
|
||||
"attemptKey" TEXT NOT NULL,
|
||||
"ruleId" TEXT NOT NULL,
|
||||
"productId" TEXT NOT NULL,
|
||||
"recipientKey" TEXT NOT NULL,
|
||||
"status" TEXT NOT NULL DEFAULT 'claimed',
|
||||
"lastErrorCode" TEXT,
|
||||
"lastErrorMessage" TEXT,
|
||||
"attemptedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "ProductDevelopmentTodoAttempt_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "ProductDevelopmentTodoAttempt_attemptKey_key"
|
||||
ON "ProductDevelopmentTodoAttempt"("attemptKey");
|
||||
|
||||
CREATE INDEX "ProductDevelopmentTodoAttempt_productId_ruleId_idx"
|
||||
ON "ProductDevelopmentTodoAttempt"("productId", "ruleId");
|
||||
|
||||
CREATE INDEX "ProductDevelopmentTodoAttempt_recipientKey_attemptedAt_idx"
|
||||
ON "ProductDevelopmentTodoAttempt"("recipientKey", "attemptedAt");
|
||||
@ -3066,6 +3066,66 @@ model OperationLog {
|
||||
@@index([productId, createdAt])
|
||||
}
|
||||
|
||||
// 家具系统发往统一通知中心的本地审计副本。
|
||||
// 不建立 User 外键,确保账号后续停用或删除后仍可追溯当时的收件人与内容。
|
||||
model NotificationDeliveryLog {
|
||||
id String @id @default(cuid())
|
||||
|
||||
idempotencyKey String @unique
|
||||
payloadHash String
|
||||
externalDeliveryId String? @unique
|
||||
|
||||
businessEvent String
|
||||
businessRecordId String?
|
||||
source String @default("SYSTEM")
|
||||
initiatedById String?
|
||||
initiatedByName String
|
||||
|
||||
recipientUserId String
|
||||
recipientName String
|
||||
recipientEmail String
|
||||
recipientDingtalkUserId String
|
||||
|
||||
title String
|
||||
renderedTitle String?
|
||||
content String @db.Text
|
||||
actionTitle String
|
||||
actionUrl String @db.Text
|
||||
|
||||
status String @default("pending")
|
||||
lastErrorCode String?
|
||||
lastErrorMessage String? @db.Text
|
||||
sentAt DateTime?
|
||||
readAt DateTime?
|
||||
lastSyncedAt DateTime?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([status, createdAt])
|
||||
@@index([recipientUserId, createdAt])
|
||||
@@index([businessEvent, createdAt])
|
||||
@@index([createdAt])
|
||||
}
|
||||
|
||||
// 产品开发提醒的一次性业务尝试标记。通知正文仍由 NotificationDeliveryLog 审计。
|
||||
model ProductDevelopmentTodoAttempt {
|
||||
id String @id @default(cuid())
|
||||
|
||||
attemptKey String @unique
|
||||
ruleId String
|
||||
productId String
|
||||
recipientKey String
|
||||
status String @default("claimed")
|
||||
lastErrorCode String?
|
||||
lastErrorMessage String? @db.Text
|
||||
attemptedAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([productId, ruleId])
|
||||
@@index([recipientKey, attemptedAt])
|
||||
}
|
||||
|
||||
model ReleaseNote {
|
||||
id String @id @default(cuid())
|
||||
|
||||
|
||||
@ -0,0 +1,104 @@
|
||||
export {}
|
||||
|
||||
const mockHandleRequest = jest.fn()
|
||||
const mockJsonError = jest.fn()
|
||||
const mockJsonSuccess = jest.fn()
|
||||
const mockPreview = jest.fn()
|
||||
const mockSend = jest.fn()
|
||||
const mockCanViewOperationLogs = jest.fn()
|
||||
|
||||
jest.mock('@/server/core/response', () => ({
|
||||
handleRequest: (...args: unknown[]) => mockHandleRequest(...args),
|
||||
jsonError: (...args: unknown[]) => mockJsonError(...args),
|
||||
jsonSuccess: (...args: unknown[]) => mockJsonSuccess(...args),
|
||||
}))
|
||||
|
||||
jest.mock('@/lib/permissions', () => ({
|
||||
canViewOperationLogs: (...args: unknown[]) => mockCanViewOperationLogs(...args),
|
||||
}))
|
||||
|
||||
jest.mock('@/server/services/product-development-todo.service', () => ({
|
||||
productDevelopmentTodoService: {
|
||||
preview: (...args: unknown[]) => mockPreview(...args),
|
||||
send: (...args: unknown[]) => mockSend(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
const { POST } = require('./route')
|
||||
|
||||
function request(input: { mode?: string; secret?: string } = {}) {
|
||||
return {
|
||||
nextUrl: {
|
||||
searchParams: new URLSearchParams(input.mode ? { mode: input.mode } : {}),
|
||||
},
|
||||
headers: {
|
||||
get: (name: string) => name === 'x-product-development-todo-secret'
|
||||
? input.secret ?? null
|
||||
: null,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('POST product development todos', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
process.env.PRODUCT_DEVELOPMENT_TODO_CRON_SECRET = 'cron-secret'
|
||||
mockHandleRequest.mockImplementation(
|
||||
async (_request: unknown, handler: (ctx: unknown) => unknown) => (
|
||||
handler({ requestId: 'request-1', user: null })
|
||||
),
|
||||
)
|
||||
mockJsonError.mockImplementation((code, message, status, requestId) => ({
|
||||
code,
|
||||
message,
|
||||
status,
|
||||
requestId,
|
||||
}))
|
||||
mockJsonSuccess.mockImplementation((data, status, requestId) => ({
|
||||
data,
|
||||
status,
|
||||
requestId,
|
||||
}))
|
||||
mockPreview.mockResolvedValue({ mode: 'preview', groups: [] })
|
||||
mockSend.mockResolvedValue({ mode: 'send', attemptedCount: 1 })
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
delete process.env.PRODUCT_DEVELOPMENT_TODO_CRON_SECRET
|
||||
})
|
||||
|
||||
it('allows an administrator to preview without the cron secret', async () => {
|
||||
mockCanViewOperationLogs.mockReturnValue(true)
|
||||
mockHandleRequest.mockImplementation(
|
||||
async (_request: unknown, handler: (ctx: unknown) => unknown) => (
|
||||
handler({ requestId: 'request-1', user: { role: 'SUPER_ADMIN' } })
|
||||
),
|
||||
)
|
||||
|
||||
const response = await POST(request())
|
||||
|
||||
expect(mockPreview).toHaveBeenCalledTimes(1)
|
||||
expect(response.data.mode).toBe('preview')
|
||||
})
|
||||
|
||||
it('never sends from a signed-in session without the dedicated cron secret', async () => {
|
||||
mockCanViewOperationLogs.mockReturnValue(true)
|
||||
mockHandleRequest.mockImplementation(
|
||||
async (_request: unknown, handler: (ctx: unknown) => unknown) => (
|
||||
handler({ requestId: 'request-1', user: { role: 'SUPER_ADMIN' } })
|
||||
),
|
||||
)
|
||||
|
||||
const response = await POST(request({ mode: 'send' }))
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
expect(mockSend).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('runs the batch only with the dedicated secret', async () => {
|
||||
const response = await POST(request({ mode: 'send', secret: 'cron-secret' }))
|
||||
|
||||
expect(mockSend).toHaveBeenCalledTimes(1)
|
||||
expect(response.data.mode).toBe('send')
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,44 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
import { canViewOperationLogs } from '@/lib/permissions'
|
||||
import { ErrorCode } from '@/server/core/errors'
|
||||
import { handleRequest, jsonError, jsonSuccess } from '@/server/core/response'
|
||||
import { productDevelopmentTodoService } from '@/server/services/product-development-todo.service'
|
||||
|
||||
function hasCronSecret(request: NextRequest) {
|
||||
const expected = process.env.PRODUCT_DEVELOPMENT_TODO_CRON_SECRET?.trim()
|
||||
const received = request.headers.get('x-product-development-todo-secret')?.trim()
|
||||
return Boolean(expected && received && expected === received)
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
return handleRequest(request, async (ctx) => {
|
||||
const mode = request.nextUrl.searchParams.get('mode') === 'send' ? 'send' : 'preview'
|
||||
const cronAuthorized = hasCronSecret(request)
|
||||
|
||||
if (mode === 'send' && !cronAuthorized) {
|
||||
return jsonError(
|
||||
ErrorCode.AUTH_INVALID_TOKEN,
|
||||
'产品开发待办执行密钥无效',
|
||||
401,
|
||||
ctx.requestId,
|
||||
)
|
||||
}
|
||||
|
||||
if (mode === 'preview' && !cronAuthorized && (!ctx.user || !canViewOperationLogs(ctx.user))) {
|
||||
return jsonError(
|
||||
ErrorCode.AUTH_PERMISSION_DENIED,
|
||||
'只有管理员可以预览产品开发待办',
|
||||
403,
|
||||
ctx.requestId,
|
||||
)
|
||||
}
|
||||
|
||||
const result = mode === 'send'
|
||||
? await productDevelopmentTodoService.send()
|
||||
: await productDevelopmentTodoService.preview({
|
||||
ignoreAttemptHistory:
|
||||
request.nextUrl.searchParams.get('includeAttempted') === 'true',
|
||||
})
|
||||
return jsonSuccess(result, 200, ctx.requestId)
|
||||
}, { requireAuth: false })
|
||||
}
|
||||
@ -0,0 +1,65 @@
|
||||
import { NextRequest } from 'next/server'
|
||||
import { canViewOperationLogs } from '@/lib/permissions'
|
||||
import { NOTIFICATION_DELIVERY_STATUSES } from '@/server/integrations/notification-center/types'
|
||||
import { ErrorCode } from '@/server/core/errors'
|
||||
import { handleRequest, jsonError, jsonSuccessWithPagination } from '@/server/core/response'
|
||||
import { notificationDeliveryService } from '@/server/services/notification-delivery.service'
|
||||
|
||||
function readDate(value: string | null, endOfDay = false) {
|
||||
if (!value) return undefined
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) return undefined
|
||||
if (endOfDay && /^\d{4}-\d{2}-\d{2}$/.test(value)) {
|
||||
date.setHours(23, 59, 59, 999)
|
||||
}
|
||||
return date
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
return handleRequest(request, async (ctx) => {
|
||||
if (!canViewOperationLogs(ctx.user)) {
|
||||
return jsonError(
|
||||
ErrorCode.AUTH_PERMISSION_DENIED,
|
||||
'只有管理员可以查看通知记录',
|
||||
403,
|
||||
ctx.requestId,
|
||||
)
|
||||
}
|
||||
|
||||
const params = request.nextUrl.searchParams
|
||||
const page = Math.max(1, Number.parseInt(params.get('page') ?? '1', 10) || 1)
|
||||
const pageSize = Math.min(
|
||||
200,
|
||||
Math.max(1, Number.parseInt(params.get('pageSize') ?? '50', 10) || 50),
|
||||
)
|
||||
const requestedStatus = params.get('status')
|
||||
const status = requestedStatus && NOTIFICATION_DELIVERY_STATUSES.includes(
|
||||
requestedStatus as (typeof NOTIFICATION_DELIVERY_STATUSES)[number],
|
||||
) ? requestedStatus : undefined
|
||||
const result = await notificationDeliveryService.list({
|
||||
page,
|
||||
pageSize,
|
||||
status,
|
||||
keyword: params.get('keyword') ?? undefined,
|
||||
startDate: readDate(params.get('startDate')),
|
||||
endDate: readDate(params.get('endDate'), true),
|
||||
refresh: params.get('refresh') === 'true',
|
||||
})
|
||||
|
||||
return jsonSuccessWithPagination(
|
||||
{
|
||||
records: result.records,
|
||||
integrationConfigured: result.integrationConfigured,
|
||||
syncWarning: result.syncWarning,
|
||||
},
|
||||
{
|
||||
page: result.page,
|
||||
limit: result.pageSize,
|
||||
total: result.total,
|
||||
totalPages: result.totalPages,
|
||||
hasMore: result.page < result.totalPages,
|
||||
},
|
||||
ctx.requestId,
|
||||
)
|
||||
})
|
||||
}
|
||||
@ -1,7 +1,16 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import type { ReactNode } from 'react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import ProductDevelopmentOperationLogClient from './ProductDevelopmentOperationLogClient'
|
||||
|
||||
const replaceMock = jest.fn()
|
||||
|
||||
jest.mock('next/navigation', () => ({
|
||||
usePathname: () => '/intake/operation-logs',
|
||||
useRouter: () => ({ replace: replaceMock }),
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
}))
|
||||
|
||||
jest.mock('next/link', () => ({
|
||||
__esModule: true,
|
||||
default: ({ children, href }: { children: ReactNode; href: string }) => (
|
||||
@ -9,26 +18,36 @@ jest.mock('next/link', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
describe('ProductDevelopmentOperationLogClient', () => {
|
||||
it('shows the product development title and return link', async () => {
|
||||
global.fetch = jest.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
data: [],
|
||||
meta: { pagination: { page: 1, total: 0, totalPages: 1, hasMore: false } },
|
||||
}),
|
||||
}) as jest.Mock
|
||||
jest.mock('@/app/operation-logs/OperationLogClient', () => ({
|
||||
__esModule: true,
|
||||
default: () => <div>操作日志内容</div>,
|
||||
}))
|
||||
|
||||
jest.mock('@/app/notification-logs/NotificationLogClient', () => ({
|
||||
__esModule: true,
|
||||
default: () => <div>通知记录内容</div>,
|
||||
}))
|
||||
|
||||
describe('ProductDevelopmentOperationLogClient', () => {
|
||||
beforeEach(() => replaceMock.mockReset())
|
||||
|
||||
it('shows operation and notification logs as tabs on the same page', async () => {
|
||||
const actor = userEvent.setup()
|
||||
render(<ProductDevelopmentOperationLogClient />)
|
||||
|
||||
expect(screen.getByRole('heading', { name: '\u4ea7\u54c1\u5f00\u53d1\u64cd\u4f5c\u65e5\u5fd7' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('heading', { name: '产品开发日志' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('link', { name: '\u8fd4\u56de\u4ea7\u54c1\u8d44\u6599' })).toHaveAttribute('href', '/intake')
|
||||
await waitFor(() => {
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
expect.stringContaining('/api/operation-logs?'),
|
||||
{ cache: 'no-store' },
|
||||
)
|
||||
})
|
||||
expect(screen.getByRole('tab', { name: '操作日志' })).toHaveAttribute('aria-selected', 'true')
|
||||
expect(screen.getByRole('tab', { name: '通知记录' })).toHaveAttribute('aria-selected', 'false')
|
||||
expect(screen.getByRole('tabpanel', { name: '操作日志' })).toBeVisible()
|
||||
|
||||
await actor.click(screen.getByRole('tab', { name: '通知记录' }))
|
||||
|
||||
expect(screen.getByRole('tab', { name: '通知记录' })).toHaveAttribute('aria-selected', 'true')
|
||||
expect(screen.getByRole('tabpanel', { name: '通知记录' })).toBeVisible()
|
||||
expect(replaceMock).toHaveBeenCalledWith(
|
||||
'/intake/operation-logs?tab=notifications',
|
||||
{ scroll: false },
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,31 +1,110 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { ExternalLink, FileWarning } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation'
|
||||
import { BellRing, ExternalLink, FileWarning } from 'lucide-react'
|
||||
import OperationLogClient from '@/app/operation-logs/OperationLogClient'
|
||||
import NotificationLogClient from '@/app/notification-logs/NotificationLogClient'
|
||||
|
||||
type LogTab = 'operations' | 'notifications'
|
||||
|
||||
const tabs: Array<{ value: LogTab; label: string; icon: typeof FileWarning }> = [
|
||||
{ value: 'operations', label: '操作日志', icon: FileWarning },
|
||||
{ value: 'notifications', label: '通知记录', icon: BellRing },
|
||||
]
|
||||
|
||||
export default function ProductDevelopmentOperationLogClient() {
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
const searchParams = useSearchParams()
|
||||
const requestedTab = searchParams.get('tab')
|
||||
const [activeTab, setActiveTab] = useState<LogTab>(
|
||||
requestedTab === 'notifications' ? 'notifications' : 'operations',
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
const syncTabFromHistory = () => {
|
||||
const tab = new URLSearchParams(window.location.search).get('tab')
|
||||
setActiveTab(tab === 'notifications' ? 'notifications' : 'operations')
|
||||
}
|
||||
window.addEventListener('popstate', syncTabFromHistory)
|
||||
return () => window.removeEventListener('popstate', syncTabFromHistory)
|
||||
}, [])
|
||||
|
||||
const selectTab = (tab: LogTab) => {
|
||||
setActiveTab(tab)
|
||||
const params = new URLSearchParams(searchParams.toString())
|
||||
params.set('tab', tab)
|
||||
router.replace(`${pathname}?${params.toString()}`, { scroll: false })
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<header className="border-b border-zinc-200 bg-white">
|
||||
<div className="mx-auto flex max-w-[1680px] items-center justify-between gap-4 px-5 py-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-zinc-500">
|
||||
<FileWarning className="h-4 w-4" />
|
||||
产品开发
|
||||
<div className="mx-auto max-w-[1680px] px-5">
|
||||
<div className="flex items-center justify-between gap-4 py-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-sm font-semibold text-zinc-500">
|
||||
<FileWarning className="h-4 w-4" />
|
||||
产品开发
|
||||
</div>
|
||||
<h1 className="mt-1 text-xl font-bold">产品开发日志</h1>
|
||||
</div>
|
||||
<h1 className="mt-1 text-xl font-bold">产品开发操作日志</h1>
|
||||
<Link
|
||||
href="/intake"
|
||||
className="inline-flex items-center gap-2 rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm font-semibold text-zinc-700 transition hover:bg-zinc-50"
|
||||
>
|
||||
返回产品资料
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</Link>
|
||||
</div>
|
||||
<div role="tablist" aria-label="产品开发日志类型" className="flex items-center gap-1">
|
||||
{tabs.map((tab) => {
|
||||
const Icon = tab.icon
|
||||
const selected = activeTab === tab.value
|
||||
return (
|
||||
<button
|
||||
key={tab.value}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={selected}
|
||||
aria-controls={`${tab.value}-panel`}
|
||||
id={`${tab.value}-tab`}
|
||||
onClick={() => selectTab(tab.value)}
|
||||
className={`inline-flex h-10 items-center gap-2 border-b-2 px-4 text-sm font-semibold transition ${
|
||||
selected
|
||||
? 'border-zinc-900 text-zinc-900'
|
||||
: 'border-transparent text-zinc-500 hover:border-zinc-300 hover:text-zinc-800'
|
||||
}`}
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
{tab.label}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<Link
|
||||
href="/intake"
|
||||
className="inline-flex items-center gap-2 rounded-md border border-zinc-300 bg-white px-3 py-2 text-sm font-semibold text-zinc-700 transition hover:bg-zinc-50"
|
||||
>
|
||||
返回产品资料
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</header>
|
||||
<OperationLogClient showHeader={false} />
|
||||
<div
|
||||
id="operations-panel"
|
||||
role="tabpanel"
|
||||
aria-labelledby="operations-tab"
|
||||
hidden={activeTab !== 'operations'}
|
||||
>
|
||||
<OperationLogClient showHeader={false} />
|
||||
</div>
|
||||
<div
|
||||
id="notifications-panel"
|
||||
role="tabpanel"
|
||||
aria-labelledby="notifications-tab"
|
||||
hidden={activeTab !== 'notifications'}
|
||||
className="min-h-screen bg-[#f5f7f7] p-5"
|
||||
>
|
||||
<div className="mx-auto h-[calc(100vh-190px)] min-h-[620px] max-w-[1680px]">
|
||||
<NotificationLogClient embedded />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -4,7 +4,7 @@ import { verifyToken } from '@/lib/jwt'
|
||||
import ProductDevelopmentOperationLogClient from './ProductDevelopmentOperationLogClient'
|
||||
|
||||
export const metadata = {
|
||||
title: 'Product Development Operation Logs | JianSun Product Development',
|
||||
title: '产品开发日志 | 家具业务系统',
|
||||
}
|
||||
|
||||
export default async function ProductDevelopmentOperationLogPage() {
|
||||
|
||||
@ -0,0 +1,36 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import NotificationLogClient from './NotificationLogClient'
|
||||
|
||||
describe('NotificationLogClient', () => {
|
||||
afterEach(() => {
|
||||
Reflect.deleteProperty(global, 'fetch')
|
||||
})
|
||||
|
||||
it('renders the independent administrator notification log page', async () => {
|
||||
Object.defineProperty(global, 'fetch', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: jest.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
data: {
|
||||
records: [],
|
||||
integrationConfigured: true,
|
||||
syncWarning: null,
|
||||
},
|
||||
meta: {
|
||||
pagination: { total: 0, totalPages: 1 },
|
||||
},
|
||||
}),
|
||||
}),
|
||||
})
|
||||
|
||||
render(<NotificationLogClient />)
|
||||
|
||||
expect(screen.getByRole('heading', { name: '通知记录' })).toBeInTheDocument()
|
||||
expect(screen.queryByRole('button', { name: /发送/ })).not.toBeInTheDocument()
|
||||
await waitFor(() => expect(screen.getByText('暂无通知记录')).toBeInTheDocument())
|
||||
expect(screen.getByRole('navigation', { name: '通知记录分页' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,158 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { BellRing } from 'lucide-react'
|
||||
import { NotificationLogDetailDrawer } from './NotificationLogDetailDrawer'
|
||||
import { NotificationLogFilters } from './NotificationLogFilters'
|
||||
import { NotificationLogPagination } from './NotificationLogPagination'
|
||||
import { NotificationLogTable } from './NotificationLogTable'
|
||||
import type { NotificationDeliveryRecord, NotificationStatus } from './notificationLogPresentation'
|
||||
|
||||
type ApiPayload = {
|
||||
success: boolean
|
||||
data?: {
|
||||
records: NotificationDeliveryRecord[]
|
||||
integrationConfigured: boolean
|
||||
syncWarning: string | null
|
||||
}
|
||||
meta?: {
|
||||
pagination?: { total: number; totalPages: number }
|
||||
}
|
||||
error?: { message?: string }
|
||||
}
|
||||
|
||||
const ACTIVE_STATUSES = new Set<NotificationStatus>(['pending', 'sending', 'retrying', 'sent', 'unread'])
|
||||
|
||||
export default function NotificationLogClient({ embedded = false }: { embedded?: boolean }) {
|
||||
const [records, setRecords] = useState<NotificationDeliveryRecord[]>([])
|
||||
const [selected, setSelected] = useState<NotificationDeliveryRecord | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [configured, setConfigured] = useState(true)
|
||||
const [syncWarning, setSyncWarning] = useState<string | null>(null)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageInput, setPageInput] = useState('1')
|
||||
const [pageSize, setPageSize] = useState(50)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [totalPages, setTotalPages] = useState(1)
|
||||
const [keyword, setKeyword] = useState('')
|
||||
const [committedKeyword, setCommittedKeyword] = useState('')
|
||||
const [status, setStatus] = useState<NotificationStatus | ''>('')
|
||||
const [startDate, setStartDate] = useState('')
|
||||
const [endDate, setEndDate] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => {
|
||||
setCommittedKeyword(keyword.trim())
|
||||
setPage(1)
|
||||
}, 300)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [keyword])
|
||||
|
||||
const query = useMemo(() => {
|
||||
const params = new URLSearchParams({ page: String(page), pageSize: String(pageSize), refresh: 'true' })
|
||||
if (committedKeyword) params.set('keyword', committedKeyword)
|
||||
if (status) params.set('status', status)
|
||||
if (startDate) params.set('startDate', startDate)
|
||||
if (endDate) params.set('endDate', endDate)
|
||||
return params.toString()
|
||||
}, [committedKeyword, endDate, page, pageSize, startDate, status])
|
||||
|
||||
const load = useCallback(async (background = false) => {
|
||||
if (background) {
|
||||
setRefreshing(true)
|
||||
} else {
|
||||
setLoading(true)
|
||||
}
|
||||
setError(null)
|
||||
try {
|
||||
const response = await fetch(`/api/notification-deliveries?${query}`, { cache: 'no-store' })
|
||||
const payload = (await response.json()) as ApiPayload
|
||||
if (!response.ok || !payload.success || !payload.data) {
|
||||
throw new Error(payload.error?.message || '通知记录读取失败')
|
||||
}
|
||||
setRecords(payload.data.records)
|
||||
setConfigured(payload.data.integrationConfigured)
|
||||
setSyncWarning(payload.data.syncWarning)
|
||||
setTotal(payload.meta?.pagination?.total ?? 0)
|
||||
setTotalPages(payload.meta?.pagination?.totalPages ?? 1)
|
||||
} catch (loadError) {
|
||||
setError(loadError instanceof Error ? loadError.message : '通知记录读取失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
setRefreshing(false)
|
||||
}
|
||||
}, [query])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
const hasActiveDelivery = records.some((record) => ACTIVE_STATUSES.has(record.status))
|
||||
useEffect(() => {
|
||||
if (!hasActiveDelivery) return
|
||||
const timer = window.setInterval(() => void load(true), 10_000)
|
||||
return () => window.clearInterval(timer)
|
||||
}, [hasActiveDelivery, load])
|
||||
|
||||
useEffect(() => setPageInput(String(page)), [page])
|
||||
|
||||
const changeFilter = (change: () => void) => {
|
||||
setPage(1)
|
||||
change()
|
||||
}
|
||||
const commitPageInput = () => {
|
||||
const nextPage = Math.min(totalPages, Math.max(1, Number(pageInput) || 1))
|
||||
setPage(nextPage)
|
||||
setPageInput(String(nextPage))
|
||||
}
|
||||
|
||||
return (
|
||||
<main className={`relative flex min-h-[620px] flex-col bg-[#f5f7f7] text-zinc-900 ${embedded ? 'h-full' : 'h-full p-5'}`}>
|
||||
{!embedded ? (
|
||||
<header className="flex flex-wrap items-center justify-between gap-4 border border-zinc-200 bg-white px-5 py-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 text-xs font-semibold text-zinc-500"><BellRing className="h-4 w-4" />管理员后台</div>
|
||||
<h1 className="mt-1 text-xl font-bold">通知记录</h1>
|
||||
</div>
|
||||
<p className="text-sm text-zinc-500">钉钉个人通知投递审计</p>
|
||||
</header>
|
||||
) : null}
|
||||
{!configured ? (
|
||||
<div className={`${embedded ? 'border' : 'border-x border-b'} border-amber-200 bg-amber-50 px-4 py-2 text-sm text-amber-800`}>通知服务参数尚未配置,当前只能查看已有记录。</div>
|
||||
) : null}
|
||||
{syncWarning ? (
|
||||
<div className={`${embedded && configured ? 'border' : 'border-x border-b'} border-red-200 bg-red-50 px-4 py-2 text-sm text-red-700`}>{syncWarning}</div>
|
||||
) : null}
|
||||
<section className={`${embedded ? '' : 'mt-3'} flex min-h-0 flex-1 flex-col overflow-hidden border border-zinc-200 bg-white`}>
|
||||
<NotificationLogFilters
|
||||
keyword={keyword}
|
||||
status={status}
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
refreshing={refreshing}
|
||||
onKeywordChange={setKeyword}
|
||||
onStatusChange={(value) => changeFilter(() => setStatus(value))}
|
||||
onStartDateChange={(value) => changeFilter(() => setStartDate(value))}
|
||||
onEndDateChange={(value) => changeFilter(() => setEndDate(value))}
|
||||
onRefresh={() => void load(true)}
|
||||
/>
|
||||
<NotificationLogTable records={records} loading={loading} error={error} onSelect={setSelected} />
|
||||
</section>
|
||||
<NotificationLogPagination
|
||||
page={page}
|
||||
pageInput={pageInput}
|
||||
pageSize={pageSize}
|
||||
total={total}
|
||||
totalPages={totalPages}
|
||||
loading={loading}
|
||||
onPageChange={setPage}
|
||||
onPageInputChange={setPageInput}
|
||||
onCommitPageInput={commitPageInput}
|
||||
onPageSizeChange={(value) => { setPageSize(value); setPage(1) }}
|
||||
/>
|
||||
{selected ? <NotificationLogDetailDrawer record={selected} onClose={() => setSelected(null)} /> : null}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,68 @@
|
||||
import { ExternalLink, X } from 'lucide-react'
|
||||
import { NotificationStatusBadge, formatNotificationTime, type NotificationDeliveryRecord } from './notificationLogPresentation'
|
||||
|
||||
export function NotificationLogDetailDrawer({
|
||||
record,
|
||||
onClose,
|
||||
}: {
|
||||
record: NotificationDeliveryRecord
|
||||
onClose: () => void
|
||||
}) {
|
||||
const fields = [
|
||||
['创建时间', formatNotificationTime(record.createdAt)],
|
||||
['发送时间', formatNotificationTime(record.sentAt)],
|
||||
['已读时间', formatNotificationTime(record.readAt)],
|
||||
['收件人', `${record.recipientName} / ${record.recipientEmail}`],
|
||||
['钉钉用户', record.recipientDingtalkUserId],
|
||||
['触发来源', record.businessEvent],
|
||||
['操作人', record.source === 'SYSTEM' ? '系统任务' : record.initiatedByName],
|
||||
['业务记录', record.businessRecordId || '-'],
|
||||
['投递编号', record.externalDeliveryId || '-'],
|
||||
['错误码', record.lastErrorCode || '-'],
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] flex justify-end bg-black/20" onMouseDown={onClose}>
|
||||
<aside
|
||||
className="h-full w-full max-w-[600px] overflow-y-auto border-l border-zinc-200 bg-white p-5 shadow-2xl"
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-zinc-500">通知详情</p>
|
||||
<h2 className="mt-1 text-lg font-bold text-zinc-900">{record.title}</h2>
|
||||
</div>
|
||||
<button type="button" onClick={onClose} className="inline-flex h-8 w-8 items-center justify-center rounded-[5px] text-zinc-500 hover:bg-zinc-100" aria-label="关闭详情">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-5"><NotificationStatusBadge status={record.status} /></div>
|
||||
<dl className="mt-6 divide-y divide-zinc-100 border-y border-zinc-100 text-sm">
|
||||
{fields.map(([label, value]) => (
|
||||
<div key={label} className="grid grid-cols-[92px_minmax(0,1fr)] gap-3 py-3">
|
||||
<dt className="text-zinc-500">{label}</dt>
|
||||
<dd className="break-words font-medium text-zinc-800">{value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
<section className="mt-6">
|
||||
<h3 className="text-sm font-semibold text-zinc-800">消息正文</h3>
|
||||
<p className="mt-2 whitespace-pre-wrap border border-zinc-200 bg-zinc-50 p-4 text-sm leading-7 text-zinc-700">{record.content}</p>
|
||||
</section>
|
||||
<section className="mt-6 border-t border-zinc-100 pt-5">
|
||||
<div className="text-xs text-zinc-500">按钮文字</div>
|
||||
<div className="mt-1 text-sm font-semibold text-zinc-800">{record.actionTitle}</div>
|
||||
<a href={record.actionUrl} target="_blank" rel="noreferrer" className="mt-3 inline-flex items-center gap-2 text-sm font-semibold text-zinc-700 underline decoration-zinc-300 underline-offset-4 hover:text-zinc-950">
|
||||
打开消息链接
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</a>
|
||||
</section>
|
||||
{record.lastErrorMessage ? (
|
||||
<section className="mt-6 border border-red-200 bg-red-50 p-4 text-sm text-red-700">
|
||||
{record.lastErrorMessage}
|
||||
</section>
|
||||
) : null}
|
||||
</aside>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,65 @@
|
||||
import { RefreshCw, Search } from 'lucide-react'
|
||||
import { statusLabels, type NotificationStatus } from './notificationLogPresentation'
|
||||
|
||||
type Props = {
|
||||
keyword: string
|
||||
status: NotificationStatus | ''
|
||||
startDate: string
|
||||
endDate: string
|
||||
refreshing: boolean
|
||||
onKeywordChange: (value: string) => void
|
||||
onStatusChange: (value: NotificationStatus | '') => void
|
||||
onStartDateChange: (value: string) => void
|
||||
onEndDateChange: (value: string) => void
|
||||
onRefresh: () => void
|
||||
}
|
||||
|
||||
export function NotificationLogFilters(props: Props) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-3 border-b border-zinc-200 bg-white px-4 py-3">
|
||||
<label className="relative min-w-[260px] flex-1">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-zinc-400" />
|
||||
<input
|
||||
value={props.keyword}
|
||||
onChange={(event) => props.onKeywordChange(event.target.value)}
|
||||
placeholder="搜索收件人、标题、正文或业务事件"
|
||||
className="h-9 w-full rounded-[5px] border border-zinc-300 bg-white pl-9 pr-3 text-sm text-zinc-900 outline-none transition focus:border-zinc-500"
|
||||
/>
|
||||
</label>
|
||||
<select
|
||||
value={props.status}
|
||||
onChange={(event) => props.onStatusChange(event.target.value as NotificationStatus | '')}
|
||||
aria-label="投递状态"
|
||||
className="h-9 rounded-[5px] border border-zinc-300 bg-white px-3 text-sm text-zinc-800 outline-none"
|
||||
>
|
||||
<option value="">全部状态</option>
|
||||
{Object.entries(statusLabels).map(([value, label]) => (
|
||||
<option key={value} value={value}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
<input
|
||||
type="date"
|
||||
value={props.startDate}
|
||||
onChange={(event) => props.onStartDateChange(event.target.value)}
|
||||
aria-label="开始日期"
|
||||
className="h-9 rounded-[5px] border border-zinc-300 bg-white px-3 text-sm text-zinc-800 outline-none"
|
||||
/>
|
||||
<input
|
||||
type="date"
|
||||
value={props.endDate}
|
||||
onChange={(event) => props.onEndDateChange(event.target.value)}
|
||||
aria-label="结束日期"
|
||||
className="h-9 rounded-[5px] border border-zinc-300 bg-white px-3 text-sm text-zinc-800 outline-none"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={props.onRefresh}
|
||||
className="inline-flex h-9 w-9 items-center justify-center rounded-[5px] border border-zinc-300 bg-white text-zinc-600 transition hover:bg-zinc-50"
|
||||
title="刷新通知状态"
|
||||
aria-label="刷新通知状态"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${props.refreshing ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,69 @@
|
||||
import { ArrowLeft, ArrowRight } from 'lucide-react'
|
||||
|
||||
type Props = {
|
||||
page: number
|
||||
pageInput: string
|
||||
pageSize: number
|
||||
total: number
|
||||
totalPages: number
|
||||
loading: boolean
|
||||
onPageChange: (page: number) => void
|
||||
onPageInputChange: (value: string) => void
|
||||
onCommitPageInput: () => void
|
||||
onPageSizeChange: (pageSize: number) => void
|
||||
}
|
||||
|
||||
export function NotificationLogPagination(props: Props) {
|
||||
return (
|
||||
<nav aria-label="通知记录分页" className="absolute bottom-6 left-1/2 z-30 flex max-w-[calc(100%-32px)] -translate-x-1/2 items-center gap-1 whitespace-nowrap rounded-full border border-zinc-200 bg-white/95 px-2 py-2 text-xs shadow-[0_4px_12px_rgba(0,0,0,0.06),0_2px_4px_rgba(0,0,0,0.04)] backdrop-blur-xl sm:gap-2.5 sm:px-4 sm:text-[13px]">
|
||||
<span className="text-zinc-600 sm:mr-2">{props.loading ? '统计中...' : `共 ${props.total} 条`}</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={props.loading || props.page <= 1}
|
||||
onClick={() => props.onPageChange(Math.max(1, props.page - 1))}
|
||||
className="inline-flex h-[30px] w-[30px] items-center justify-center gap-1 rounded-[4px] border border-zinc-200 bg-white text-zinc-800 disabled:cursor-not-allowed disabled:opacity-40 sm:w-auto sm:px-3"
|
||||
aria-label="上一页"
|
||||
>
|
||||
<ArrowLeft className="h-3 w-3" /><span className="hidden sm:inline">上一页</span>
|
||||
</button>
|
||||
<span className="inline-flex items-center gap-1 text-zinc-600">
|
||||
<input
|
||||
value={props.pageInput}
|
||||
onChange={(event) => props.onPageInputChange(event.target.value.replace(/\D/g, ''))}
|
||||
onBlur={props.onCommitPageInput}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') props.onCommitPageInput()
|
||||
}}
|
||||
inputMode="numeric"
|
||||
aria-label="跳转页码"
|
||||
className="h-7 w-10 rounded-[4px] border border-zinc-200 bg-white px-1 text-center font-semibold text-zinc-800 outline-none focus:border-zinc-500 sm:w-[52px]"
|
||||
/>
|
||||
<span className="text-zinc-400">/</span>
|
||||
<span>{props.totalPages} 页</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={props.loading || props.page >= props.totalPages}
|
||||
onClick={() => props.onPageChange(Math.min(props.totalPages, props.page + 1))}
|
||||
className="inline-flex h-[30px] w-[30px] items-center justify-center gap-1 rounded-[4px] border border-zinc-200 bg-white text-zinc-800 disabled:cursor-not-allowed disabled:opacity-40 sm:w-auto sm:px-3"
|
||||
aria-label="下一页"
|
||||
>
|
||||
<span className="hidden sm:inline">下一页</span><ArrowRight className="h-3 w-3" />
|
||||
</button>
|
||||
<span className="inline-flex items-center gap-1.5 text-zinc-600 sm:ml-2">
|
||||
<span className="hidden sm:inline">每页</span>
|
||||
<select
|
||||
value={props.pageSize}
|
||||
onChange={(event) => props.onPageSizeChange(Number(event.target.value))}
|
||||
aria-label="每页条数"
|
||||
className="h-7 rounded-[4px] border border-zinc-200 bg-white px-2 text-zinc-800 outline-none"
|
||||
>
|
||||
<option value={50}>50</option>
|
||||
<option value={100}>100</option>
|
||||
<option value={200}>200</option>
|
||||
</select>
|
||||
<span className="hidden sm:inline">条</span>
|
||||
</span>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,60 @@
|
||||
import { NotificationStatusBadge, formatNotificationTime, type NotificationDeliveryRecord } from './notificationLogPresentation'
|
||||
|
||||
type Props = {
|
||||
records: NotificationDeliveryRecord[]
|
||||
loading: boolean
|
||||
error: string | null
|
||||
onSelect: (record: NotificationDeliveryRecord) => void
|
||||
}
|
||||
|
||||
export function NotificationLogTable({ records, loading, error, onSelect }: Props) {
|
||||
return (
|
||||
<div className="min-h-0 flex-1 overflow-auto bg-white pb-24">
|
||||
<table className="w-full min-w-[1120px] border-collapse text-sm">
|
||||
<thead className="sticky top-0 z-10 bg-zinc-100 text-left text-xs font-semibold text-zinc-600">
|
||||
<tr>
|
||||
<th className="border-b border-zinc-200 px-4 py-3">创建时间</th>
|
||||
<th className="border-b border-zinc-200 px-4 py-3">状态</th>
|
||||
<th className="border-b border-zinc-200 px-4 py-3">收件人</th>
|
||||
<th className="border-b border-zinc-200 px-4 py-3">消息标题</th>
|
||||
<th className="border-b border-zinc-200 px-4 py-3">触发来源</th>
|
||||
<th className="border-b border-zinc-200 px-4 py-3">发送结果</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-100">
|
||||
{loading ? (
|
||||
<tr><td colSpan={6} className="px-4 py-16 text-center text-zinc-400">正在读取通知记录</td></tr>
|
||||
) : error ? (
|
||||
<tr><td colSpan={6} className="px-4 py-16 text-center font-medium text-red-600">{error}</td></tr>
|
||||
) : records.length === 0 ? (
|
||||
<tr><td colSpan={6} className="px-4 py-16 text-center text-zinc-400">暂无通知记录</td></tr>
|
||||
) : records.map((record) => (
|
||||
<tr
|
||||
key={record.id}
|
||||
onClick={() => onSelect(record)}
|
||||
className="cursor-pointer bg-white transition hover:bg-zinc-50"
|
||||
>
|
||||
<td className="whitespace-nowrap px-4 py-3 text-zinc-500">{formatNotificationTime(record.createdAt)}</td>
|
||||
<td className="px-4 py-3"><NotificationStatusBadge status={record.status} /></td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="font-semibold text-zinc-800">{record.recipientName}</div>
|
||||
<div className="mt-0.5 text-xs text-zinc-400">{record.recipientEmail}</div>
|
||||
</td>
|
||||
<td className="max-w-[420px] px-4 py-3">
|
||||
<p className="line-clamp-1 font-semibold text-zinc-800">{record.title}</p>
|
||||
<p className="mt-1 line-clamp-1 text-xs text-zinc-500">{record.content}</p>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="text-zinc-700">{record.businessEvent}</div>
|
||||
<div className="mt-0.5 text-xs text-zinc-400">{record.source === 'SYSTEM' ? '系统任务' : record.initiatedByName}</div>
|
||||
</td>
|
||||
<td className="max-w-[320px] px-4 py-3 text-zinc-600">
|
||||
{record.lastErrorMessage || (record.sentAt ? `发送于 ${formatNotificationTime(record.sentAt)}` : '已提交至通知中心')}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,80 @@
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
Clock3,
|
||||
Eye,
|
||||
LoaderCircle,
|
||||
MailCheck,
|
||||
} from 'lucide-react'
|
||||
|
||||
export type NotificationStatus =
|
||||
| 'pending'
|
||||
| 'sending'
|
||||
| 'retrying'
|
||||
| 'sent'
|
||||
| 'unread'
|
||||
| 'read'
|
||||
| 'expired'
|
||||
| 'failed'
|
||||
|
||||
export type NotificationDeliveryRecord = {
|
||||
id: string
|
||||
externalDeliveryId: string | null
|
||||
businessEvent: string
|
||||
businessRecordId: string | null
|
||||
source: string
|
||||
initiatedByName: string
|
||||
recipientUserId: string
|
||||
recipientName: string
|
||||
recipientEmail: string
|
||||
recipientDingtalkUserId: string
|
||||
title: string
|
||||
content: string
|
||||
actionTitle: string
|
||||
actionUrl: string
|
||||
status: NotificationStatus
|
||||
lastErrorCode: string | null
|
||||
lastErrorMessage: string | null
|
||||
sentAt: string | null
|
||||
readAt: string | null
|
||||
lastSyncedAt: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export const statusLabels: Record<NotificationStatus, string> = {
|
||||
pending: '等待发送',
|
||||
sending: '发送中',
|
||||
retrying: '等待重试',
|
||||
sent: '已发送',
|
||||
unread: '未读',
|
||||
read: '已读',
|
||||
expired: '已过期',
|
||||
failed: '失败',
|
||||
}
|
||||
|
||||
export function formatNotificationTime(value: string | null) {
|
||||
if (!value) return '-'
|
||||
return new Date(value).toLocaleString('zh-CN', { hour12: false })
|
||||
}
|
||||
|
||||
export function NotificationStatusBadge({ status }: { status: NotificationStatus }) {
|
||||
const config = {
|
||||
pending: { Icon: Clock3, classes: 'border-zinc-200 bg-zinc-50 text-zinc-600' },
|
||||
sending: { Icon: LoaderCircle, classes: 'border-sky-200 bg-sky-50 text-sky-700' },
|
||||
retrying: { Icon: Clock3, classes: 'border-amber-200 bg-amber-50 text-amber-700' },
|
||||
sent: { Icon: MailCheck, classes: 'border-emerald-200 bg-emerald-50 text-emerald-700' },
|
||||
unread: { Icon: Eye, classes: 'border-cyan-200 bg-cyan-50 text-cyan-700' },
|
||||
read: { Icon: CheckCircle2, classes: 'border-emerald-200 bg-emerald-50 text-emerald-700' },
|
||||
expired: { Icon: Clock3, classes: 'border-zinc-200 bg-zinc-100 text-zinc-500' },
|
||||
failed: { Icon: AlertTriangle, classes: 'border-red-200 bg-red-50 text-red-700' },
|
||||
}[status]
|
||||
const Icon = config.Icon
|
||||
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1 rounded-[4px] border px-2 py-1 text-xs font-semibold ${config.classes}`}>
|
||||
<Icon className={`h-3.5 w-3.5 ${status === 'sending' ? 'animate-spin' : ''}`} />
|
||||
{statusLabels[status]}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
9
furniture-3d-system/src/app/notification-logs/page.tsx
Normal file
9
furniture-3d-system/src/app/notification-logs/page.tsx
Normal file
@ -0,0 +1,9 @@
|
||||
import { redirect } from 'next/navigation'
|
||||
|
||||
export const metadata = {
|
||||
title: '通知记录 | 家具业务系统',
|
||||
}
|
||||
|
||||
export default async function NotificationLogsPage() {
|
||||
redirect('/intake/operation-logs?tab=notifications')
|
||||
}
|
||||
@ -0,0 +1,45 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import UserMenu from '../UserMenu'
|
||||
|
||||
const mockPush = jest.fn()
|
||||
|
||||
jest.mock('next/navigation', () => ({
|
||||
usePathname: () => '/intake',
|
||||
useRouter: () => ({ push: mockPush, refresh: jest.fn() }),
|
||||
}))
|
||||
|
||||
function user(role: string) {
|
||||
return {
|
||||
id: `${role.toLowerCase()}-1`,
|
||||
name: role,
|
||||
email: `${role.toLowerCase()}@example.com`,
|
||||
role,
|
||||
avatar: null,
|
||||
department: null,
|
||||
}
|
||||
}
|
||||
|
||||
describe('UserMenu operation log entry', () => {
|
||||
beforeEach(() => mockPush.mockReset())
|
||||
|
||||
it('keeps one operation log entry for administrators', async () => {
|
||||
const actor = userEvent.setup()
|
||||
render(<UserMenu user={user('ADMIN')} />)
|
||||
|
||||
await actor.click(screen.getByRole('button', { name: /ADMIN/ }))
|
||||
await actor.click(screen.getByRole('button', { name: '操作日志' }))
|
||||
|
||||
expect(mockPush).toHaveBeenCalledWith('/intake/operation-logs')
|
||||
expect(screen.queryByRole('button', { name: '通知记录' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides operation logs from regular users', async () => {
|
||||
const actor = userEvent.setup()
|
||||
render(<UserMenu user={user('USER')} />)
|
||||
|
||||
await actor.click(screen.getByRole('button', { name: /USER/ }))
|
||||
|
||||
expect(screen.queryByRole('button', { name: '操作日志' })).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,89 @@
|
||||
/** @jest-environment node */
|
||||
|
||||
import { createHash, createHmac } from 'node:crypto'
|
||||
import { NotificationCenterClient, NotificationCenterRequestError } from './client'
|
||||
|
||||
const PROJECT_SECRET = 's'.repeat(64)
|
||||
|
||||
describe('NotificationCenterClient', () => {
|
||||
it('signs the exact request body and sends only server credentials in headers', async () => {
|
||||
const fetchImpl = jest.fn(async () => new Response(JSON.stringify({
|
||||
id: 'delivery-1',
|
||||
projectCode: 'furniture_platform',
|
||||
status: 'pending',
|
||||
recipientUserId: 'ding-1',
|
||||
title: '【家具业务系统】测试消息',
|
||||
processQueryKey: null,
|
||||
lastErrorCode: null,
|
||||
lastErrorMessage: null,
|
||||
sentAt: null,
|
||||
readAt: null,
|
||||
createdAt: '2026-08-26T07:00:00.000Z',
|
||||
updatedAt: '2026-08-26T07:00:00.000Z',
|
||||
}), { status: 202, headers: { 'content-type': 'application/json' } })) as jest.MockedFunction<typeof fetch>
|
||||
const client = new NotificationCenterClient({
|
||||
baseUrl: 'https://sc.jiansun.vip/notification-center',
|
||||
projectCode: 'furniture_platform',
|
||||
projectSecret: PROJECT_SECRET,
|
||||
fetchImpl,
|
||||
now: () => 1_700_000_000_000,
|
||||
createNonce: () => 'fixed-notification-nonce',
|
||||
})
|
||||
const input = {
|
||||
idempotencyKey: 'test:record-1:ding-1:v1',
|
||||
recipientUserId: 'ding-1',
|
||||
title: '测试消息',
|
||||
content: '测试正文',
|
||||
actionTitle: '打开产品开发',
|
||||
actionUrl: 'https://cp.jiansun.vip/intake',
|
||||
}
|
||||
|
||||
await expect(client.send(input)).resolves.toEqual(expect.objectContaining({ id: 'delivery-1' }))
|
||||
|
||||
const rawBody = JSON.stringify(input)
|
||||
const canonical = [
|
||||
'1700000000',
|
||||
'fixed-notification-nonce',
|
||||
'POST',
|
||||
'/v1/notifications',
|
||||
createHash('sha256').update(rawBody).digest('hex'),
|
||||
].join('\n')
|
||||
const expectedSignature = createHmac('sha256', PROJECT_SECRET).update(canonical).digest('hex')
|
||||
expect(fetchImpl).toHaveBeenCalledWith(
|
||||
'https://sc.jiansun.vip/notification-center/v1/notifications',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
body: rawBody,
|
||||
headers: expect.objectContaining({
|
||||
'x-yyd-project-code': 'furniture_platform',
|
||||
'x-yyd-signature': expectedSignature,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects insecure non-local notification center addresses', () => {
|
||||
expect(() => new NotificationCenterClient({
|
||||
baseUrl: 'http://example.com/notification-center',
|
||||
projectCode: 'furniture_platform',
|
||||
projectSecret: PROJECT_SECRET,
|
||||
})).toThrow('通知中心地址必须使用 HTTPS')
|
||||
})
|
||||
|
||||
it('returns the notification center error code without exposing credentials', async () => {
|
||||
const client = new NotificationCenterClient({
|
||||
baseUrl: 'https://sc.jiansun.vip/notification-center',
|
||||
projectCode: 'furniture_platform',
|
||||
projectSecret: PROJECT_SECRET,
|
||||
fetchImpl: jest.fn(async () => new Response(
|
||||
JSON.stringify({ error: 'SIGNATURE_INVALID' }),
|
||||
{ status: 401, headers: { 'content-type': 'application/json' } },
|
||||
)) as jest.MockedFunction<typeof fetch>,
|
||||
})
|
||||
|
||||
await expect(client.getDelivery('delivery-1')).rejects.toMatchObject<NotificationCenterRequestError>({
|
||||
status: 401,
|
||||
code: 'SIGNATURE_INVALID',
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,153 @@
|
||||
import 'server-only'
|
||||
|
||||
import { createHash, createHmac, randomBytes } from 'node:crypto'
|
||||
import type {
|
||||
NotificationCenterDelivery,
|
||||
NotificationCenterSendInput,
|
||||
} from './types'
|
||||
|
||||
type NotificationCenterClientConfig = {
|
||||
baseUrl: string
|
||||
projectCode: string
|
||||
projectSecret: string
|
||||
timeoutMs?: number
|
||||
fetchImpl?: typeof fetch
|
||||
now?: () => number
|
||||
createNonce?: () => string
|
||||
}
|
||||
|
||||
type NotificationCenterErrorPayload = {
|
||||
error?: string
|
||||
message?: string
|
||||
}
|
||||
|
||||
const sha256 = (value: string) => createHash('sha256').update(value).digest('hex')
|
||||
|
||||
function normalizeBaseUrl(value: string) {
|
||||
const url = new URL(value.trim())
|
||||
const isLocal = url.hostname === '127.0.0.1' || url.hostname === 'localhost'
|
||||
if (url.protocol !== 'https:' && !(isLocal && url.protocol === 'http:')) {
|
||||
throw new Error('通知中心地址必须使用 HTTPS')
|
||||
}
|
||||
if (url.username || url.password || url.search || url.hash) {
|
||||
throw new Error('通知中心地址不能包含凭据、查询参数或片段')
|
||||
}
|
||||
return url.toString().replace(/\/$/, '')
|
||||
}
|
||||
|
||||
export class NotificationCenterRequestError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status?: number,
|
||||
readonly code?: string,
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'NotificationCenterRequestError'
|
||||
}
|
||||
}
|
||||
|
||||
export class NotificationCenterClient {
|
||||
private readonly baseUrl: string
|
||||
private readonly projectCode: string
|
||||
private readonly projectSecret: string
|
||||
private readonly timeoutMs: number
|
||||
private readonly fetchImpl: typeof fetch
|
||||
private readonly now: () => number
|
||||
private readonly createNonce: () => string
|
||||
|
||||
constructor(config: NotificationCenterClientConfig) {
|
||||
if (!/^[a-z0-9_-]{2,40}$/.test(config.projectCode)) {
|
||||
throw new Error('通知中心项目代码格式不正确')
|
||||
}
|
||||
if (config.projectSecret.length < 24) {
|
||||
throw new Error('通知中心项目密钥未配置或长度不足')
|
||||
}
|
||||
|
||||
this.baseUrl = normalizeBaseUrl(config.baseUrl)
|
||||
this.projectCode = config.projectCode
|
||||
this.projectSecret = config.projectSecret
|
||||
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
|
||||
this.createNonce = config.createNonce ?? (() => randomBytes(18).toString('base64url'))
|
||||
}
|
||||
|
||||
private createHeaders(method: string, path: string, rawBody: string) {
|
||||
const timestamp = String(Math.floor(this.now() / 1000))
|
||||
const nonce = this.createNonce()
|
||||
const canonical = [timestamp, nonce, method.toUpperCase(), path, sha256(rawBody)].join('\n')
|
||||
const signature = createHmac('sha256', this.projectSecret)
|
||||
.update(canonical)
|
||||
.digest('hex')
|
||||
|
||||
return {
|
||||
'content-type': 'application/json',
|
||||
'x-yyd-project-code': this.projectCode,
|
||||
'x-yyd-timestamp': timestamp,
|
||||
'x-yyd-nonce': nonce,
|
||||
'x-yyd-signature': signature,
|
||||
}
|
||||
}
|
||||
|
||||
private async request<T>(method: 'GET' | 'POST', path: string, body?: unknown): Promise<T> {
|
||||
const rawBody = body === undefined ? '' : JSON.stringify(body)
|
||||
let response: Response
|
||||
|
||||
try {
|
||||
response = await this.fetchImpl(`${this.baseUrl}${path}`, {
|
||||
method,
|
||||
headers: this.createHeaders(method, path, rawBody),
|
||||
body: rawBody || undefined,
|
||||
cache: 'no-store',
|
||||
signal: AbortSignal.timeout(this.timeoutMs),
|
||||
})
|
||||
} catch (error) {
|
||||
throw new NotificationCenterRequestError(
|
||||
error instanceof Error ? error.message : '通知中心连接失败',
|
||||
)
|
||||
}
|
||||
|
||||
const payload = (await response.json().catch(() => null)) as T | NotificationCenterErrorPayload | null
|
||||
if (!response.ok) {
|
||||
const errorPayload = payload as NotificationCenterErrorPayload | null
|
||||
throw new NotificationCenterRequestError(
|
||||
errorPayload?.message || errorPayload?.error || `通知中心返回 HTTP ${response.status}`,
|
||||
response.status,
|
||||
errorPayload?.error,
|
||||
)
|
||||
}
|
||||
if (!payload) {
|
||||
throw new NotificationCenterRequestError('通知中心返回了无效响应', response.status)
|
||||
}
|
||||
return payload as T
|
||||
}
|
||||
|
||||
send(input: NotificationCenterSendInput) {
|
||||
return this.request<NotificationCenterDelivery>('POST', '/v1/notifications', input)
|
||||
}
|
||||
|
||||
getDelivery(deliveryId: string) {
|
||||
if (!deliveryId.trim()) throw new Error('通知投递编号不能为空')
|
||||
return this.request<NotificationCenterDelivery>(
|
||||
'GET',
|
||||
`/v1/notifications/${encodeURIComponent(deliveryId.trim())}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export function isNotificationCenterConfigured(env: NodeJS.ProcessEnv = process.env) {
|
||||
return Boolean(
|
||||
env.YYD_NOTIFICATION_CENTER_URL?.trim()
|
||||
&& env.YYD_NOTIFICATION_PROJECT_CODE?.trim()
|
||||
&& env.YYD_NOTIFICATION_PROJECT_SECRET?.trim(),
|
||||
)
|
||||
}
|
||||
|
||||
export function createNotificationCenterClientFromEnv(env: NodeJS.ProcessEnv = process.env) {
|
||||
return new NotificationCenterClient({
|
||||
baseUrl: env.YYD_NOTIFICATION_CENTER_URL?.trim() ?? '',
|
||||
projectCode: env.YYD_NOTIFICATION_PROJECT_CODE?.trim() ?? '',
|
||||
projectSecret: env.YYD_NOTIFICATION_PROJECT_SECRET?.trim() ?? '',
|
||||
timeoutMs: Number(env.YYD_NOTIFICATION_TIMEOUT_MS) || 10_000,
|
||||
})
|
||||
}
|
||||
@ -0,0 +1,36 @@
|
||||
export const NOTIFICATION_DELIVERY_STATUSES = [
|
||||
'pending',
|
||||
'sending',
|
||||
'retrying',
|
||||
'sent',
|
||||
'unread',
|
||||
'read',
|
||||
'expired',
|
||||
'failed',
|
||||
] as const
|
||||
|
||||
export type NotificationDeliveryStatus = (typeof NOTIFICATION_DELIVERY_STATUSES)[number]
|
||||
|
||||
export type NotificationCenterDelivery = {
|
||||
id: string
|
||||
projectCode: string
|
||||
status: NotificationDeliveryStatus
|
||||
recipientUserId: string
|
||||
title: string
|
||||
processQueryKey: string | null
|
||||
lastErrorCode: string | null
|
||||
lastErrorMessage: string | null
|
||||
sentAt: string | null
|
||||
readAt: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export type NotificationCenterSendInput = {
|
||||
idempotencyKey: string
|
||||
recipientUserId: string
|
||||
title: string
|
||||
content: string
|
||||
actionTitle: string
|
||||
actionUrl: string
|
||||
}
|
||||
@ -0,0 +1,105 @@
|
||||
import 'server-only'
|
||||
|
||||
import type {
|
||||
BusinessAccountType,
|
||||
NotificationDeliveryLog,
|
||||
Prisma,
|
||||
UserStatus,
|
||||
} from '@prisma/client'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
export type NotificationRecipient = {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
status: UserStatus
|
||||
businessRoles: BusinessAccountType[]
|
||||
dingtalkUserId: string | null
|
||||
}
|
||||
|
||||
export type NotificationDeliveryAuditRecipient = {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
dingtalkUserId: string
|
||||
}
|
||||
|
||||
export type NotificationDeliveryFilter = {
|
||||
keyword?: string
|
||||
status?: string
|
||||
startDate?: Date
|
||||
endDate?: Date
|
||||
}
|
||||
|
||||
function buildWhere(filter: NotificationDeliveryFilter): Prisma.NotificationDeliveryLogWhereInput {
|
||||
const where: Prisma.NotificationDeliveryLogWhereInput = {}
|
||||
if (filter.status) where.status = filter.status
|
||||
if (filter.startDate || filter.endDate) {
|
||||
where.createdAt = {
|
||||
...(filter.startDate ? { gte: filter.startDate } : {}),
|
||||
...(filter.endDate ? { lte: filter.endDate } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
const keyword = filter.keyword?.trim()
|
||||
if (keyword) {
|
||||
where.OR = [
|
||||
{ recipientName: { contains: keyword, mode: 'insensitive' } },
|
||||
{ recipientEmail: { contains: keyword, mode: 'insensitive' } },
|
||||
{ title: { contains: keyword, mode: 'insensitive' } },
|
||||
{ renderedTitle: { contains: keyword, mode: 'insensitive' } },
|
||||
{ content: { contains: keyword, mode: 'insensitive' } },
|
||||
{ businessEvent: { contains: keyword, mode: 'insensitive' } },
|
||||
]
|
||||
}
|
||||
return where
|
||||
}
|
||||
|
||||
class NotificationDeliveryRepository {
|
||||
async findRecipient(userId: string): Promise<NotificationRecipient | null> {
|
||||
return prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
status: true,
|
||||
businessRoles: true,
|
||||
dingtalkUserId: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
findByIdempotencyKey(idempotencyKey: string) {
|
||||
return prisma.notificationDeliveryLog.findUnique({ where: { idempotencyKey } })
|
||||
}
|
||||
|
||||
create(data: Prisma.NotificationDeliveryLogCreateInput) {
|
||||
return prisma.notificationDeliveryLog.create({ data })
|
||||
}
|
||||
|
||||
update(id: string, data: Prisma.NotificationDeliveryLogUpdateInput) {
|
||||
return prisma.notificationDeliveryLog.update({ where: { id }, data })
|
||||
}
|
||||
|
||||
async findMany(options: {
|
||||
filter: NotificationDeliveryFilter
|
||||
page: number
|
||||
pageSize: number
|
||||
}): Promise<{ items: NotificationDeliveryLog[]; total: number }> {
|
||||
const where = buildWhere(options.filter)
|
||||
const [items, total] = await Promise.all([
|
||||
prisma.notificationDeliveryLog.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (options.page - 1) * options.pageSize,
|
||||
take: options.pageSize,
|
||||
}),
|
||||
prisma.notificationDeliveryLog.count({ where }),
|
||||
])
|
||||
return { items, total }
|
||||
}
|
||||
}
|
||||
|
||||
export const notificationDeliveryRepository = new NotificationDeliveryRepository()
|
||||
export { NotificationDeliveryRepository }
|
||||
@ -0,0 +1,117 @@
|
||||
import 'server-only'
|
||||
|
||||
import type { BusinessAccountType, Prisma, UserStatus } from '@prisma/client'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import {
|
||||
buildDevelopmentKindWhere,
|
||||
buildStockProductWhere,
|
||||
} from '@/server/repositories/intake-product-list-filters'
|
||||
|
||||
export type ProductDevelopmentTodoUser = {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
status: UserStatus
|
||||
businessRoles: BusinessAccountType[]
|
||||
dingtalkUserId: string | null
|
||||
}
|
||||
|
||||
export type ProductDevelopmentTodoSourceRecord = {
|
||||
id: string
|
||||
spu: string
|
||||
designer: string
|
||||
purchaser: string
|
||||
createdAt: Date
|
||||
payload: Prisma.JsonValue
|
||||
profile: {
|
||||
purchaseOwner: string
|
||||
structureDesigner: string
|
||||
} | null
|
||||
pricingRows: Array<{
|
||||
costPrice: Prisma.Decimal | null
|
||||
}>
|
||||
}
|
||||
|
||||
class ProductDevelopmentTodoRepository {
|
||||
findEligibleProducts(): Promise<ProductDevelopmentTodoSourceRecord[]> {
|
||||
const developmentKindWhere = buildDevelopmentKindWhere('product')
|
||||
return prisma.intakeNonIntegratedProduct.findMany({
|
||||
where: {
|
||||
deletedAt: null,
|
||||
AND: [
|
||||
...(developmentKindWhere ? [developmentKindWhere] : []),
|
||||
{ NOT: buildStockProductWhere() },
|
||||
],
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
spu: true,
|
||||
designer: true,
|
||||
purchaser: true,
|
||||
createdAt: true,
|
||||
payload: true,
|
||||
profile: {
|
||||
select: {
|
||||
purchaseOwner: true,
|
||||
structureDesigner: true,
|
||||
},
|
||||
},
|
||||
pricingRows: {
|
||||
select: {
|
||||
costPrice: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [
|
||||
{ createdAt: 'asc' },
|
||||
{ id: 'asc' },
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
findUsersByNames(names: string[]): Promise<ProductDevelopmentTodoUser[]> {
|
||||
if (names.length === 0) return Promise.resolve([])
|
||||
return prisma.user.findMany({
|
||||
where: { name: { in: names } },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
status: true,
|
||||
businessRoles: true,
|
||||
dingtalkUserId: true,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
findAttemptKeys(keys: string[]) {
|
||||
if (keys.length === 0) return Promise.resolve([])
|
||||
return prisma.productDevelopmentTodoAttempt.findMany({
|
||||
where: { attemptKey: { in: keys } },
|
||||
select: { attemptKey: true },
|
||||
})
|
||||
}
|
||||
|
||||
createAttempts(data: Prisma.ProductDevelopmentTodoAttemptCreateManyInput[]) {
|
||||
if (data.length === 0) return Promise.resolve({ count: 0 })
|
||||
return prisma.productDevelopmentTodoAttempt.createMany({
|
||||
data,
|
||||
skipDuplicates: true,
|
||||
})
|
||||
}
|
||||
|
||||
updateAttempts(keys: string[], data: {
|
||||
status: string
|
||||
lastErrorCode?: string | null
|
||||
lastErrorMessage?: string | null
|
||||
}) {
|
||||
if (keys.length === 0) return Promise.resolve({ count: 0 })
|
||||
return prisma.productDevelopmentTodoAttempt.updateMany({
|
||||
where: { attemptKey: { in: keys } },
|
||||
data,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const productDevelopmentTodoRepository = new ProductDevelopmentTodoRepository()
|
||||
export { ProductDevelopmentTodoRepository }
|
||||
@ -0,0 +1,182 @@
|
||||
/** @jest-environment node */
|
||||
|
||||
const mockFindRecipient = jest.fn()
|
||||
const mockFindByIdempotencyKey = jest.fn()
|
||||
const mockCreate = jest.fn()
|
||||
const mockUpdate = jest.fn()
|
||||
const mockFindMany = jest.fn()
|
||||
const mockSend = jest.fn()
|
||||
|
||||
jest.mock('@/server/repositories/notification-delivery.repository', () => ({
|
||||
notificationDeliveryRepository: {
|
||||
findRecipient: (...args: unknown[]) => mockFindRecipient(...args),
|
||||
findByIdempotencyKey: (...args: unknown[]) => mockFindByIdempotencyKey(...args),
|
||||
create: (...args: unknown[]) => mockCreate(...args),
|
||||
update: (...args: unknown[]) => mockUpdate(...args),
|
||||
findMany: (...args: unknown[]) => mockFindMany(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
jest.mock('@/server/integrations/notification-center/client', () => ({
|
||||
createNotificationCenterClientFromEnv: () => ({ send: (...args: unknown[]) => mockSend(...args) }),
|
||||
isNotificationCenterConfigured: () => true,
|
||||
NotificationCenterRequestError: class NotificationCenterRequestError extends Error {},
|
||||
}))
|
||||
|
||||
import { NotificationDeliveryError, NotificationDeliveryService } from '../notification-delivery.service'
|
||||
|
||||
const now = new Date('2026-08-26T07:00:00.000Z')
|
||||
const recipient = {
|
||||
id: 'user-1',
|
||||
name: '徐霞客',
|
||||
email: 'xuxiake@example.com',
|
||||
status: 'ACTIVE',
|
||||
businessRoles: ['MANAGEMENT'],
|
||||
dingtalkUserId: 'ding-user-1',
|
||||
}
|
||||
|
||||
const input = {
|
||||
idempotencyKey: 'notification-test:record-1:user-1:v1',
|
||||
businessEvent: 'notification_test',
|
||||
businessRecordId: 'record-1',
|
||||
source: 'USER' as const,
|
||||
initiatedById: 'admin-1',
|
||||
initiatedByName: '徐霞客',
|
||||
recipientUserId: 'user-1',
|
||||
title: '通知客户端测试',
|
||||
content: '这是一条测试消息。',
|
||||
actionTitle: '打开产品开发',
|
||||
actionUrl: 'https://cp.jiansun.vip/intake',
|
||||
}
|
||||
|
||||
function storedRecord(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 'local-log-1',
|
||||
idempotencyKey: input.idempotencyKey,
|
||||
payloadHash: 'payload-hash',
|
||||
externalDeliveryId: null,
|
||||
businessEvent: input.businessEvent,
|
||||
businessRecordId: input.businessRecordId,
|
||||
source: input.source,
|
||||
initiatedById: input.initiatedById,
|
||||
initiatedByName: input.initiatedByName,
|
||||
recipientUserId: recipient.id,
|
||||
recipientName: recipient.name,
|
||||
recipientEmail: recipient.email,
|
||||
recipientDingtalkUserId: recipient.dingtalkUserId,
|
||||
title: input.title,
|
||||
renderedTitle: null,
|
||||
content: input.content,
|
||||
actionTitle: input.actionTitle,
|
||||
actionUrl: input.actionUrl,
|
||||
status: 'pending',
|
||||
lastErrorCode: null,
|
||||
lastErrorMessage: null,
|
||||
sentAt: null,
|
||||
readAt: null,
|
||||
lastSyncedAt: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('NotificationDeliveryService', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
mockFindRecipient.mockResolvedValue(recipient)
|
||||
mockFindByIdempotencyKey.mockResolvedValue(null)
|
||||
mockCreate.mockImplementation(async (data) => storedRecord({ ...data, payloadHash: data.payloadHash }))
|
||||
mockSend.mockResolvedValue({
|
||||
id: 'delivery-1',
|
||||
projectCode: 'furniture_platform',
|
||||
status: 'pending',
|
||||
recipientUserId: recipient.dingtalkUserId,
|
||||
title: '【家具业务系统】通知客户端测试',
|
||||
processQueryKey: null,
|
||||
lastErrorCode: null,
|
||||
lastErrorMessage: null,
|
||||
sentAt: null,
|
||||
readAt: null,
|
||||
createdAt: now.toISOString(),
|
||||
updatedAt: now.toISOString(),
|
||||
})
|
||||
mockUpdate.mockImplementation(async (_id, data) => storedRecord({
|
||||
...data,
|
||||
externalDeliveryId: 'delivery-1',
|
||||
renderedTitle: '【家具业务系统】通知客户端测试',
|
||||
status: 'pending',
|
||||
}))
|
||||
})
|
||||
|
||||
it('sends to the bound DingTalk user and stores the full audit payload', async () => {
|
||||
const service = new NotificationDeliveryService()
|
||||
const result = await service.sendToEmployee(input)
|
||||
|
||||
expect(mockCreate).toHaveBeenCalledWith(expect.objectContaining({
|
||||
recipientUserId: 'user-1',
|
||||
recipientName: '徐霞客',
|
||||
recipientDingtalkUserId: 'ding-user-1',
|
||||
title: input.title,
|
||||
content: input.content,
|
||||
actionUrl: input.actionUrl,
|
||||
}))
|
||||
expect(mockSend).toHaveBeenCalledWith(expect.objectContaining({
|
||||
recipientUserId: 'ding-user-1',
|
||||
idempotencyKey: input.idempotencyKey,
|
||||
}))
|
||||
expect(result.externalDeliveryId).toBe('delivery-1')
|
||||
})
|
||||
|
||||
it.each(['SUPPLIER', 'EXTERNAL_INTAKE'])('rejects %s accounts before creating a delivery', async (businessRole) => {
|
||||
mockFindRecipient.mockResolvedValue({ ...recipient, businessRoles: [businessRole] })
|
||||
const service = new NotificationDeliveryService()
|
||||
|
||||
await expect(service.sendToEmployee(input)).rejects.toMatchObject<NotificationDeliveryError>({
|
||||
code: 'RECIPIENT_EXTERNAL',
|
||||
})
|
||||
expect(mockCreate).not.toHaveBeenCalled()
|
||||
expect(mockSend).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects a reused idempotency key when the payload changed', async () => {
|
||||
mockFindByIdempotencyKey.mockResolvedValue(storedRecord({ payloadHash: 'different-hash' }))
|
||||
const service = new NotificationDeliveryService()
|
||||
|
||||
await expect(service.sendToEmployee(input)).rejects.toMatchObject<NotificationDeliveryError>({
|
||||
code: 'IDEMPOTENCY_CONFLICT',
|
||||
})
|
||||
expect(mockSend).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('records an undeliverable recipient without calling notification center', async () => {
|
||||
const service = new NotificationDeliveryService()
|
||||
const result = await service.recordUndeliverable({
|
||||
idempotencyKey: 'product-todo:daily:2026-08-27:missing-purchaser',
|
||||
businessEvent: 'product_development_daily_todo',
|
||||
businessRecordId: '2026-08-27',
|
||||
source: 'SYSTEM',
|
||||
initiatedByName: '产品开发待办定时任务',
|
||||
recipient: {
|
||||
id: 'missing:purchaser',
|
||||
name: '未配置采购',
|
||||
email: '',
|
||||
dingtalkUserId: '',
|
||||
},
|
||||
title: '待办(1)',
|
||||
content: 'XJ0100 · 产品资料已完成,需要打样 · 已用1天0小时',
|
||||
actionTitle: '查看待办',
|
||||
actionUrl: 'https://cp.jiansun.vip/intake/non-integrated/product-1?workspace=product-data',
|
||||
errorCode: 'RECIPIENT_OWNER_MISSING',
|
||||
errorMessage: 'XJ0100 的流程没有配置采购',
|
||||
})
|
||||
|
||||
expect(mockCreate).toHaveBeenCalledWith(expect.objectContaining({
|
||||
status: 'failed',
|
||||
recipientName: '未配置采购',
|
||||
lastErrorCode: 'RECIPIENT_OWNER_MISSING',
|
||||
}))
|
||||
expect(mockSend).not.toHaveBeenCalled()
|
||||
expect(result.status).toBe('failed')
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,176 @@
|
||||
/** @jest-environment node */
|
||||
|
||||
import {
|
||||
evaluateProductDevelopmentTodos,
|
||||
formatProductDevelopmentTodoElapsed,
|
||||
getBeijingDateKey,
|
||||
} from '../product-development-todo-rules'
|
||||
import type { ProductDevelopmentTodoProduct } from '../product-development-todo.types'
|
||||
|
||||
const now = new Date('2026-08-27T02:00:00.000Z')
|
||||
const day = 24 * 60 * 60 * 1_000
|
||||
|
||||
function product(overrides: Partial<ProductDevelopmentTodoProduct> = {}): ProductDevelopmentTodoProduct {
|
||||
return {
|
||||
id: 'product-1',
|
||||
spu: 'XJ0100',
|
||||
createdAt: new Date(now.getTime() - 2 * day),
|
||||
projectStatus: 'approved',
|
||||
owners: {
|
||||
appearanceDesigner: '徐霞客',
|
||||
structureDesigner: '徐霞客',
|
||||
purchaser: '徐霞客',
|
||||
},
|
||||
confirmations: {},
|
||||
approvedAt: {},
|
||||
factoryRows: [],
|
||||
sampleMeasurementRows: [],
|
||||
pricingRows: [],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function approval(nodes: string[]) {
|
||||
return {
|
||||
confirmations: Object.fromEntries(nodes.map((node) => [node, true])),
|
||||
approvedAt: Object.fromEntries(nodes.map((node, index) => [
|
||||
node,
|
||||
new Date(now.getTime() - (nodes.length - index) * day).toISOString(),
|
||||
])),
|
||||
}
|
||||
}
|
||||
|
||||
function completeFactoryRow() {
|
||||
return {
|
||||
confirmed: true,
|
||||
skuId: 'sku-1',
|
||||
model: 'A',
|
||||
skuCode: 'XJ0100-A',
|
||||
setCount: '1',
|
||||
barcode: '10001',
|
||||
specName: '规格',
|
||||
size: '10x10',
|
||||
color: '黑',
|
||||
material: '木',
|
||||
installMethod: '组装',
|
||||
supplierName: '供应商',
|
||||
supplierCode: 'S1',
|
||||
warehouse: '总仓',
|
||||
shippingMethod: '物流',
|
||||
minimumOrderQuantity: '1',
|
||||
taxIncludedUnitPrice: '100',
|
||||
leadTime: '7',
|
||||
assemblyMode: '拆装',
|
||||
productionType: '自产',
|
||||
installationService: '无',
|
||||
supplierCategory: 'A',
|
||||
purchaseOrderMethod: '采购单',
|
||||
executeStandard: '国标',
|
||||
}
|
||||
}
|
||||
|
||||
describe('evaluateProductDevelopmentTodos', () => {
|
||||
it('keeps an earlier missing node and also activates a later node whose predecessor completed', () => {
|
||||
const input = product({
|
||||
...approval(['设计卖点']),
|
||||
})
|
||||
|
||||
expect(evaluateProductDevelopmentTodos(input, now).map((item) => item.ruleId)).toEqual([
|
||||
'opening_reference_unconfirmed',
|
||||
'product_information_unfinished',
|
||||
])
|
||||
})
|
||||
|
||||
it('requires a valid timestamp as well as the confirmation boolean', () => {
|
||||
const input = product({
|
||||
confirmations: { 开品参考: true },
|
||||
approvedAt: { 开品参考: 'not-a-date' },
|
||||
})
|
||||
|
||||
expect(evaluateProductDevelopmentTodos(input, now)[0]?.ruleId)
|
||||
.toBe('opening_reference_unconfirmed')
|
||||
})
|
||||
|
||||
it('waits seven full days before activating the basic information reminder', () => {
|
||||
const approvedAt = new Date(now.getTime() - 7 * day).toISOString()
|
||||
const input = product({
|
||||
confirmations: { 产品模型: true },
|
||||
approvedAt: { 产品模型: approvedAt },
|
||||
})
|
||||
|
||||
expect(evaluateProductDevelopmentTodos(input, new Date(now.getTime() - 1)).map((item) => item.ruleId))
|
||||
.not.toContain('basic_information_unconfirmed')
|
||||
expect(evaluateProductDevelopmentTodos(input, now).map((item) => item.ruleId))
|
||||
.toContain('basic_information_unconfirmed')
|
||||
})
|
||||
|
||||
it('does not backfill the one-time sampling handoff after basic information completed', () => {
|
||||
const input = product({
|
||||
...approval(['产品模型', '基础信息']),
|
||||
})
|
||||
|
||||
expect(evaluateProductDevelopmentTodos(input, now).map((item) => item.ruleId))
|
||||
.not.toContain('product_information_sampling_handoff')
|
||||
})
|
||||
|
||||
it('does not accept product factory confirmation without complete confirmed rows', () => {
|
||||
const input = product({
|
||||
...approval(['基础信息', '产品定厂']),
|
||||
factoryRows: [{ ...completeFactoryRow(), warehouse: '' }],
|
||||
})
|
||||
|
||||
expect(evaluateProductDevelopmentTodos(input, now).map((item) => item.ruleId))
|
||||
.toContain('product_factory_unfinished')
|
||||
})
|
||||
|
||||
it('routes pricing work to purchaser and Yiyi after sample measurement completes', () => {
|
||||
const input = product({
|
||||
...approval(['产品定厂', '样包测量']),
|
||||
owners: {
|
||||
appearanceDesigner: '外观',
|
||||
structureDesigner: '结构',
|
||||
purchaser: '徐霞客',
|
||||
},
|
||||
factoryRows: [completeFactoryRow()],
|
||||
sampleMeasurementRows: [{
|
||||
skuId: 'sku-1',
|
||||
lengthCm: '10',
|
||||
widthCm: '20',
|
||||
heightCm: '30',
|
||||
weightKg: '4',
|
||||
}],
|
||||
})
|
||||
|
||||
const pricing = evaluateProductDevelopmentTodos(input, now)
|
||||
.find((item) => item.ruleId === 'product_pricing_unfinished')
|
||||
expect(pricing?.recipientNames).toEqual(['徐霞客', '依依'])
|
||||
})
|
||||
|
||||
it.each(['宁珂', '松柏'])('adds both merchandisers for purchaser %s', (purchaser) => {
|
||||
const input = product({
|
||||
...approval(['基础信息']),
|
||||
owners: {
|
||||
appearanceDesigner: '外观',
|
||||
structureDesigner: '结构',
|
||||
purchaser,
|
||||
},
|
||||
})
|
||||
|
||||
const factory = evaluateProductDevelopmentTodos(input, now)
|
||||
.find((item) => item.ruleId === 'product_factory_unfinished')
|
||||
expect(factory?.recipientNames).toEqual([purchaser, '雁兰', '槐米'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('product development todo presentation helpers', () => {
|
||||
it('formats elapsed time without unstable spacing', () => {
|
||||
expect(formatProductDevelopmentTodoElapsed(
|
||||
new Date(now.getTime() - (2 * day + 2 * 60 * 60 * 1_000)),
|
||||
now,
|
||||
)).toBe('2天2小时')
|
||||
})
|
||||
|
||||
it('uses the Beijing calendar date for idempotency', () => {
|
||||
expect(getBeijingDateKey(new Date('2026-08-26T16:01:00.000Z'))).toBe('2026-08-27')
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,220 @@
|
||||
/** @jest-environment node */
|
||||
|
||||
const mockFindEligibleProducts = jest.fn()
|
||||
const mockFindUsersByNames = jest.fn()
|
||||
const mockFindAttemptKeys = jest.fn()
|
||||
const mockCreateAttempts = jest.fn()
|
||||
const mockUpdateAttempts = jest.fn()
|
||||
const mockSendToEmployee = jest.fn()
|
||||
const mockRecordUndeliverable = jest.fn()
|
||||
|
||||
jest.mock('@/server/repositories/product-development-todo.repository', () => ({
|
||||
productDevelopmentTodoRepository: {
|
||||
findEligibleProducts: (...args: unknown[]) => mockFindEligibleProducts(...args),
|
||||
findUsersByNames: (...args: unknown[]) => mockFindUsersByNames(...args),
|
||||
findAttemptKeys: (...args: unknown[]) => mockFindAttemptKeys(...args),
|
||||
createAttempts: (...args: unknown[]) => mockCreateAttempts(...args),
|
||||
updateAttempts: (...args: unknown[]) => mockUpdateAttempts(...args),
|
||||
},
|
||||
}))
|
||||
|
||||
jest.mock('@/server/services/notification-delivery.service', () => ({
|
||||
notificationDeliveryService: {
|
||||
sendToEmployee: (...args: unknown[]) => mockSendToEmployee(...args),
|
||||
recordUndeliverable: (...args: unknown[]) => mockRecordUndeliverable(...args),
|
||||
},
|
||||
NotificationDeliveryError: class NotificationDeliveryError extends Error {
|
||||
constructor(message: string, readonly code: string) {
|
||||
super(message)
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
import { ProductDevelopmentTodoService } from '../product-development-todo.service'
|
||||
|
||||
const now = new Date('2026-08-27T02:00:00.000Z')
|
||||
|
||||
function record(payload: Record<string, unknown>) {
|
||||
return {
|
||||
id: 'product-1',
|
||||
spu: 'XJ0100',
|
||||
designer: '徐霞客',
|
||||
purchaser: '徐霞客',
|
||||
createdAt: new Date('2026-08-25T02:00:00.000Z'),
|
||||
payload,
|
||||
profile: {
|
||||
purchaseOwner: '徐霞客',
|
||||
structureDesigner: '徐霞客',
|
||||
},
|
||||
pricingRows: [],
|
||||
}
|
||||
}
|
||||
|
||||
const user = {
|
||||
id: 'user-1',
|
||||
name: '徐霞客',
|
||||
email: 'xuxiake@example.com',
|
||||
status: 'ACTIVE',
|
||||
businessRoles: ['MANAGEMENT'],
|
||||
dingtalkUserId: 'ding-user-1',
|
||||
}
|
||||
|
||||
describe('ProductDevelopmentTodoService', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks()
|
||||
mockFindEligibleProducts.mockResolvedValue([])
|
||||
mockFindUsersByNames.mockResolvedValue([user])
|
||||
mockFindAttemptKeys.mockResolvedValue([])
|
||||
mockCreateAttempts.mockResolvedValue({ count: 0 })
|
||||
mockUpdateAttempts.mockResolvedValue({ count: 0 })
|
||||
mockSendToEmployee.mockResolvedValue({ status: 'pending' })
|
||||
mockRecordUndeliverable.mockResolvedValue({ status: 'failed' })
|
||||
})
|
||||
|
||||
it('aggregates abnormal earlier and active later nodes into one recipient message', async () => {
|
||||
mockFindEligibleProducts.mockResolvedValue([
|
||||
record({
|
||||
projectStatus: 'approved',
|
||||
openingReference: {
|
||||
designer: '徐霞客',
|
||||
structureDesigner: '徐霞客',
|
||||
},
|
||||
documentWorkspaceConfirmations: { 设计卖点: true },
|
||||
documentWorkspaceApprovedAt: { 设计卖点: '2026-08-26T02:00:00.000Z' },
|
||||
}),
|
||||
])
|
||||
|
||||
const result = await new ProductDevelopmentTodoService().preview({ now })
|
||||
|
||||
expect(result.groups).toHaveLength(1)
|
||||
expect(result.groups[0]).toMatchObject({
|
||||
recipientName: '徐霞客',
|
||||
itemCount: 2,
|
||||
deliverable: true,
|
||||
})
|
||||
expect(result.groups[0]?.content).toContain('XJ0100 · 开品参考未确认')
|
||||
expect(result.groups[0]?.content).toContain('XJ0100 · 产品资料未完成')
|
||||
expect(result.groups[0]?.items.map((item) => item.actionUrl)).toEqual([
|
||||
expect.stringContaining('workspace=opening-reference'),
|
||||
expect.stringContaining('workspace=product-data'),
|
||||
])
|
||||
})
|
||||
|
||||
it('shows a visible preview issue when the purchaser is missing', async () => {
|
||||
mockFindEligibleProducts.mockResolvedValue([
|
||||
{
|
||||
...record({
|
||||
projectStatus: 'approved',
|
||||
openingReference: { designer: '外观', structureDesigner: '结构' },
|
||||
documentWorkspaceConfirmations: { 开品参考: true, 设计卖点: true, 产品模型: true },
|
||||
documentWorkspaceApprovedAt: {
|
||||
开品参考: '2026-08-24T02:00:00.000Z',
|
||||
设计卖点: '2026-08-25T02:00:00.000Z',
|
||||
产品模型: '2026-08-26T02:00:00.000Z',
|
||||
},
|
||||
}),
|
||||
purchaser: '',
|
||||
profile: { purchaseOwner: '', structureDesigner: '结构' },
|
||||
},
|
||||
])
|
||||
mockFindUsersByNames.mockResolvedValue([])
|
||||
|
||||
const result = await new ProductDevelopmentTodoService().preview({ now })
|
||||
const issue = result.groups.find((group) => group.recipientName === '未配置采购')
|
||||
|
||||
expect(issue).toMatchObject({
|
||||
deliverable: false,
|
||||
issue: 'XJ0100 的流程没有配置采购',
|
||||
})
|
||||
})
|
||||
|
||||
it('aggregates all SPUs into the missing purchaser issue', async () => {
|
||||
mockFindEligibleProducts.mockResolvedValue([
|
||||
{
|
||||
...record({
|
||||
projectStatus: 'approved',
|
||||
documentWorkspaceConfirmations: { 基础信息: true },
|
||||
documentWorkspaceApprovedAt: { 基础信息: '2026-08-26T02:00:00.000Z' },
|
||||
}),
|
||||
purchaser: '',
|
||||
profile: { purchaseOwner: '', structureDesigner: '结构' },
|
||||
},
|
||||
{
|
||||
...record({
|
||||
projectStatus: 'approved',
|
||||
documentWorkspaceConfirmations: { 基础信息: true },
|
||||
documentWorkspaceApprovedAt: { 基础信息: '2026-08-26T02:00:00.000Z' },
|
||||
}),
|
||||
id: 'product-2',
|
||||
spu: 'XJ0101',
|
||||
purchaser: '',
|
||||
profile: { purchaseOwner: '', structureDesigner: '结构' },
|
||||
},
|
||||
])
|
||||
mockFindUsersByNames.mockResolvedValue([])
|
||||
|
||||
const result = await new ProductDevelopmentTodoService().preview({ now })
|
||||
const issue = result.groups.find((group) => group.recipientName === '未配置采购')
|
||||
|
||||
expect(issue?.issue).toBe('2 个流程没有配置采购:XJ0100、XJ0101')
|
||||
})
|
||||
|
||||
it('claims one-time handoffs before sending and records a failed undeliverable group', async () => {
|
||||
mockFindEligibleProducts.mockResolvedValue([
|
||||
{
|
||||
...record({
|
||||
projectStatus: 'approved',
|
||||
openingReference: { designer: '外观', structureDesigner: '结构' },
|
||||
documentWorkspaceConfirmations: { 开品参考: true, 设计卖点: true, 产品模型: true },
|
||||
documentWorkspaceApprovedAt: {
|
||||
开品参考: '2026-08-24T02:00:00.000Z',
|
||||
设计卖点: '2026-08-25T02:00:00.000Z',
|
||||
产品模型: '2026-08-26T02:00:00.000Z',
|
||||
},
|
||||
}),
|
||||
purchaser: '',
|
||||
profile: { purchaseOwner: '', structureDesigner: '结构' },
|
||||
},
|
||||
])
|
||||
mockFindUsersByNames.mockResolvedValue([])
|
||||
|
||||
const result = await new ProductDevelopmentTodoService().send({ now })
|
||||
|
||||
expect(mockCreateAttempts).toHaveBeenCalledWith([
|
||||
expect.objectContaining({
|
||||
ruleId: 'product_information_sampling_handoff',
|
||||
productId: 'product-1',
|
||||
}),
|
||||
])
|
||||
expect(mockRecordUndeliverable).toHaveBeenCalledWith(expect.objectContaining({
|
||||
errorCode: 'RECIPIENT_OWNER_MISSING',
|
||||
title: '待办(1)',
|
||||
}))
|
||||
expect(mockSendToEmployee).not.toHaveBeenCalled()
|
||||
expect(result.failedCount).toBe(1)
|
||||
})
|
||||
|
||||
it('does not retry a one-time handoff that already has an attempt', async () => {
|
||||
mockFindEligibleProducts.mockResolvedValue([
|
||||
record({
|
||||
projectStatus: 'approved',
|
||||
openingReference: { designer: '徐霞客', structureDesigner: '徐霞客' },
|
||||
documentWorkspaceConfirmations: { 开品参考: true, 设计卖点: true, 产品模型: true },
|
||||
documentWorkspaceApprovedAt: {
|
||||
开品参考: '2026-08-24T02:00:00.000Z',
|
||||
设计卖点: '2026-08-25T02:00:00.000Z',
|
||||
产品模型: '2026-08-26T02:00:00.000Z',
|
||||
},
|
||||
}),
|
||||
])
|
||||
mockFindAttemptKeys.mockImplementation(async (keys: string[]) => (
|
||||
keys.map((attemptKey) => ({ attemptKey }))
|
||||
))
|
||||
|
||||
const result = await new ProductDevelopmentTodoService().send({ now })
|
||||
|
||||
expect(result.recipientCount).toBe(0)
|
||||
expect(mockCreateAttempts).toHaveBeenCalledWith([])
|
||||
expect(mockSendToEmployee).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@ -0,0 +1,380 @@
|
||||
import 'server-only'
|
||||
|
||||
import { createHash } from 'node:crypto'
|
||||
import type { NotificationDeliveryLog } from '@prisma/client'
|
||||
import {
|
||||
createNotificationCenterClientFromEnv,
|
||||
isNotificationCenterConfigured,
|
||||
NotificationCenterRequestError,
|
||||
} from '@/server/integrations/notification-center/client'
|
||||
import {
|
||||
NOTIFICATION_DELIVERY_STATUSES,
|
||||
type NotificationCenterDelivery,
|
||||
type NotificationDeliveryStatus,
|
||||
} from '@/server/integrations/notification-center/types'
|
||||
import {
|
||||
notificationDeliveryRepository,
|
||||
type NotificationDeliveryAuditRecipient,
|
||||
type NotificationDeliveryFilter,
|
||||
type NotificationRecipient,
|
||||
} from '@/server/repositories/notification-delivery.repository'
|
||||
|
||||
const EXTERNAL_ACCOUNT_ROLES = new Set(['SUPPLIER', 'EXTERNAL_INTAKE'])
|
||||
const ACTIVE_DELIVERY_STATUSES = new Set<NotificationDeliveryStatus>([
|
||||
'pending',
|
||||
'sending',
|
||||
'retrying',
|
||||
'sent',
|
||||
'unread',
|
||||
])
|
||||
|
||||
export type SendEmployeeNotificationInput = {
|
||||
idempotencyKey: string
|
||||
businessEvent: string
|
||||
businessRecordId?: string
|
||||
source?: 'USER' | 'SYSTEM'
|
||||
initiatedById?: string
|
||||
initiatedByName?: string
|
||||
recipientUserId: string
|
||||
title: string
|
||||
content: string
|
||||
actionTitle: string
|
||||
actionUrl: string
|
||||
retryExisting?: boolean
|
||||
}
|
||||
|
||||
export type RecordUndeliverableNotificationInput = Omit<
|
||||
SendEmployeeNotificationInput,
|
||||
'recipientUserId' | 'retryExisting'
|
||||
> & {
|
||||
recipient: NotificationDeliveryAuditRecipient
|
||||
errorCode: string
|
||||
errorMessage: string
|
||||
}
|
||||
|
||||
export type NotificationDeliveryDto = {
|
||||
id: string
|
||||
externalDeliveryId: string | null
|
||||
businessEvent: string
|
||||
businessRecordId: string | null
|
||||
source: string
|
||||
initiatedByName: string
|
||||
recipientUserId: string
|
||||
recipientName: string
|
||||
recipientEmail: string
|
||||
recipientDingtalkUserId: string
|
||||
title: string
|
||||
content: string
|
||||
actionTitle: string
|
||||
actionUrl: string
|
||||
status: string
|
||||
lastErrorCode: string | null
|
||||
lastErrorMessage: string | null
|
||||
sentAt: string | null
|
||||
readAt: string | null
|
||||
lastSyncedAt: string | null
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export class NotificationDeliveryError extends Error {
|
||||
constructor(message: string, readonly code: string) {
|
||||
super(message)
|
||||
this.name = 'NotificationDeliveryError'
|
||||
}
|
||||
}
|
||||
|
||||
function requiredText(value: string, label: string, maxLength: number) {
|
||||
const normalized = value.trim()
|
||||
if (!normalized) throw new NotificationDeliveryError(`${label}不能为空`, 'INVALID_INPUT')
|
||||
if (normalized.length > maxLength) {
|
||||
throw new NotificationDeliveryError(`${label}不能超过 ${maxLength} 个字符`, 'INVALID_INPUT')
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
function validateActionUrl(value: string) {
|
||||
let url: URL
|
||||
try {
|
||||
url = new URL(value)
|
||||
} catch {
|
||||
throw new NotificationDeliveryError('通知跳转地址格式不正确', 'ACTION_URL_INVALID')
|
||||
}
|
||||
if (
|
||||
url.protocol !== 'https:'
|
||||
|| url.hostname.toLowerCase() !== 'cp.jiansun.vip'
|
||||
|| url.username
|
||||
|| url.password
|
||||
|| url.port
|
||||
) {
|
||||
throw new NotificationDeliveryError(
|
||||
'通知跳转地址必须是 cp.jiansun.vip 的标准 HTTPS 地址',
|
||||
'ACTION_URL_INVALID',
|
||||
)
|
||||
}
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
function assertInternalRecipient(recipient: NotificationRecipient) {
|
||||
if (recipient.status !== 'ACTIVE') {
|
||||
throw new NotificationDeliveryError('收件人账号不是在职启用状态', 'RECIPIENT_INACTIVE')
|
||||
}
|
||||
if (recipient.businessRoles.some((role) => EXTERNAL_ACCOUNT_ROLES.has(role))) {
|
||||
throw new NotificationDeliveryError('供应商和外部录入人员不能接收系统通知', 'RECIPIENT_EXTERNAL')
|
||||
}
|
||||
if (!recipient.dingtalkUserId?.trim()) {
|
||||
throw new NotificationDeliveryError('收件人尚未绑定钉钉账号', 'RECIPIENT_DINGTALK_UNBOUND')
|
||||
}
|
||||
}
|
||||
|
||||
function createPayloadHash(input: {
|
||||
recipientDingtalkUserId: string
|
||||
title: string
|
||||
content: string
|
||||
actionTitle: string
|
||||
actionUrl: string
|
||||
}) {
|
||||
return createHash('sha256').update(JSON.stringify(input)).digest('hex')
|
||||
}
|
||||
|
||||
function parseDate(value: string | null) {
|
||||
if (!value) return null
|
||||
const date = new Date(value)
|
||||
return Number.isNaN(date.getTime()) ? null : date
|
||||
}
|
||||
|
||||
function normalizeStatus(status: string): NotificationDeliveryStatus {
|
||||
return NOTIFICATION_DELIVERY_STATUSES.includes(status as NotificationDeliveryStatus)
|
||||
? status as NotificationDeliveryStatus
|
||||
: 'failed'
|
||||
}
|
||||
|
||||
function maskDingtalkUserId(value: string) {
|
||||
if (value.length <= 6) return '***'
|
||||
return `${value.slice(0, 3)}***${value.slice(-3)}`
|
||||
}
|
||||
|
||||
function toDto(record: NotificationDeliveryLog): NotificationDeliveryDto {
|
||||
return {
|
||||
id: record.id,
|
||||
externalDeliveryId: record.externalDeliveryId,
|
||||
businessEvent: record.businessEvent,
|
||||
businessRecordId: record.businessRecordId,
|
||||
source: record.source,
|
||||
initiatedByName: record.initiatedByName,
|
||||
recipientUserId: record.recipientUserId,
|
||||
recipientName: record.recipientName,
|
||||
recipientEmail: record.recipientEmail,
|
||||
recipientDingtalkUserId: maskDingtalkUserId(record.recipientDingtalkUserId),
|
||||
title: record.renderedTitle || record.title,
|
||||
content: record.content,
|
||||
actionTitle: record.actionTitle,
|
||||
actionUrl: record.actionUrl,
|
||||
status: record.status,
|
||||
lastErrorCode: record.lastErrorCode,
|
||||
lastErrorMessage: record.lastErrorMessage,
|
||||
sentAt: record.sentAt?.toISOString() ?? null,
|
||||
readAt: record.readAt?.toISOString() ?? null,
|
||||
lastSyncedAt: record.lastSyncedAt?.toISOString() ?? null,
|
||||
createdAt: record.createdAt.toISOString(),
|
||||
updatedAt: record.updatedAt.toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
class NotificationDeliveryService {
|
||||
async sendToEmployee(input: SendEmployeeNotificationInput) {
|
||||
const recipient = await notificationDeliveryRepository.findRecipient(input.recipientUserId)
|
||||
if (!recipient) {
|
||||
throw new NotificationDeliveryError('收件人账号不存在', 'RECIPIENT_NOT_FOUND')
|
||||
}
|
||||
assertInternalRecipient(recipient)
|
||||
|
||||
const idempotencyKey = requiredText(input.idempotencyKey, '幂等键', 160)
|
||||
const businessEvent = requiredText(input.businessEvent, '业务事件', 100)
|
||||
const title = requiredText(input.title, '通知标题', 100)
|
||||
const content = requiredText(input.content, '通知正文', 4_600)
|
||||
const actionTitle = requiredText(input.actionTitle, '按钮文字', 20)
|
||||
const actionUrl = validateActionUrl(input.actionUrl)
|
||||
if (/^【[^】]+】/.test(title)) {
|
||||
throw new NotificationDeliveryError('通知标题不要重复添加项目名称前缀', 'INVALID_INPUT')
|
||||
}
|
||||
|
||||
const recipientDingtalkUserId = recipient.dingtalkUserId!.trim()
|
||||
const payloadHash = createPayloadHash({
|
||||
recipientDingtalkUserId,
|
||||
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?.externalDeliveryId) 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: recipient.id,
|
||||
recipientName: recipient.name,
|
||||
recipientEmail: recipient.email,
|
||||
recipientDingtalkUserId,
|
||||
title,
|
||||
content,
|
||||
actionTitle,
|
||||
actionUrl,
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
const delivery = await createNotificationCenterClientFromEnv().send({
|
||||
idempotencyKey,
|
||||
recipientUserId: recipientDingtalkUserId,
|
||||
title,
|
||||
content,
|
||||
actionTitle,
|
||||
actionUrl,
|
||||
})
|
||||
const updated = await this.applyDeliveryState(record.id, delivery)
|
||||
return toDto(updated)
|
||||
} catch (error) {
|
||||
const requestError = error instanceof NotificationCenterRequestError ? error : null
|
||||
await notificationDeliveryRepository.update(record.id, {
|
||||
status: 'failed',
|
||||
lastErrorCode: requestError?.code || 'NOTIFICATION_CENTER_REQUEST_FAILED',
|
||||
lastErrorMessage: error instanceof Error ? error.message : '通知中心调用失败',
|
||||
lastSyncedAt: new Date(),
|
||||
})
|
||||
throw new NotificationDeliveryError(
|
||||
'通知中心调用失败,请在通知记录中查看原因',
|
||||
requestError?.code || 'NOTIFICATION_CENTER_REQUEST_FAILED',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async recordUndeliverable(input: RecordUndeliverableNotificationInput) {
|
||||
const idempotencyKey = requiredText(input.idempotencyKey, '幂等键', 160)
|
||||
const existing = await notificationDeliveryRepository.findByIdempotencyKey(idempotencyKey)
|
||||
if (existing) return toDto(existing)
|
||||
|
||||
const businessEvent = requiredText(input.businessEvent, '业务事件', 100)
|
||||
const title = requiredText(input.title, '通知标题', 100)
|
||||
const content = requiredText(input.content, '通知正文', 4_600)
|
||||
const actionTitle = requiredText(input.actionTitle, '按钮文字', 20)
|
||||
const actionUrl = validateActionUrl(input.actionUrl)
|
||||
const recipient = input.recipient
|
||||
const payloadHash = createPayloadHash({
|
||||
recipientDingtalkUserId: recipient.dingtalkUserId,
|
||||
title,
|
||||
content,
|
||||
actionTitle,
|
||||
actionUrl,
|
||||
})
|
||||
const 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: recipient.id,
|
||||
recipientName: recipient.name,
|
||||
recipientEmail: recipient.email,
|
||||
recipientDingtalkUserId: recipient.dingtalkUserId,
|
||||
title,
|
||||
content,
|
||||
actionTitle,
|
||||
actionUrl,
|
||||
status: 'failed',
|
||||
lastErrorCode: requiredText(input.errorCode, '失败代码', 100),
|
||||
lastErrorMessage: requiredText(input.errorMessage, '失败原因', 2_000),
|
||||
lastSyncedAt: new Date(),
|
||||
})
|
||||
return toDto(record)
|
||||
}
|
||||
|
||||
private applyDeliveryState(recordId: string, delivery: NotificationCenterDelivery) {
|
||||
return notificationDeliveryRepository.update(recordId, {
|
||||
externalDeliveryId: delivery.id,
|
||||
renderedTitle: delivery.title,
|
||||
status: normalizeStatus(delivery.status),
|
||||
lastErrorCode: delivery.lastErrorCode,
|
||||
lastErrorMessage: delivery.lastErrorMessage,
|
||||
sentAt: parseDate(delivery.sentAt),
|
||||
readAt: parseDate(delivery.readAt),
|
||||
lastSyncedAt: new Date(),
|
||||
})
|
||||
}
|
||||
|
||||
private async refreshRecords(records: NotificationDeliveryLog[]) {
|
||||
if (!isNotificationCenterConfigured()) return { failed: 0, configured: false }
|
||||
const candidates = records.filter((record) => (
|
||||
record.externalDeliveryId
|
||||
&& ACTIVE_DELIVERY_STATUSES.has(record.status as NotificationDeliveryStatus)
|
||||
&& (!record.lastSyncedAt || Date.now() - record.lastSyncedAt.getTime() >= 5_000)
|
||||
))
|
||||
if (candidates.length === 0) return { failed: 0, configured: true }
|
||||
|
||||
const client = createNotificationCenterClientFromEnv()
|
||||
let failed = 0
|
||||
for (let index = 0; index < candidates.length; index += 5) {
|
||||
const batch = candidates.slice(index, index + 5)
|
||||
await Promise.all(batch.map(async (record) => {
|
||||
try {
|
||||
const delivery = await client.getDelivery(record.externalDeliveryId!)
|
||||
await this.applyDeliveryState(record.id, delivery)
|
||||
} catch {
|
||||
failed += 1
|
||||
}
|
||||
}))
|
||||
}
|
||||
return { failed, configured: true }
|
||||
}
|
||||
|
||||
async list(options: NotificationDeliveryFilter & {
|
||||
page?: number
|
||||
pageSize?: number
|
||||
refresh?: boolean
|
||||
}) {
|
||||
const page = Math.max(1, options.page ?? 1)
|
||||
const pageSize = Math.min(200, Math.max(1, options.pageSize ?? 50))
|
||||
let result = await notificationDeliveryRepository.findMany({
|
||||
page,
|
||||
pageSize,
|
||||
filter: options,
|
||||
})
|
||||
let syncResult = { failed: 0, configured: isNotificationCenterConfigured() }
|
||||
|
||||
if (options.refresh) {
|
||||
syncResult = await this.refreshRecords(result.items)
|
||||
if (result.items.length > 0) {
|
||||
result = await notificationDeliveryRepository.findMany({ page, pageSize, filter: options })
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
records: result.items.map(toDto),
|
||||
total: result.total,
|
||||
page,
|
||||
pageSize,
|
||||
totalPages: Math.max(1, Math.ceil(result.total / pageSize)),
|
||||
integrationConfigured: syncResult.configured,
|
||||
syncWarning: syncResult.failed > 0 ? `${syncResult.failed} 条投递状态刷新失败` : null,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const notificationDeliveryService = new NotificationDeliveryService()
|
||||
export { NotificationDeliveryService }
|
||||
@ -0,0 +1,287 @@
|
||||
import 'server-only'
|
||||
|
||||
import { createHash } from 'node:crypto'
|
||||
import type { BusinessAccountType } from '@prisma/client'
|
||||
import {
|
||||
productDevelopmentTodoRepository,
|
||||
type ProductDevelopmentTodoSourceRecord,
|
||||
type ProductDevelopmentTodoUser,
|
||||
} from '@/server/repositories/product-development-todo.repository'
|
||||
import {
|
||||
evaluateProductDevelopmentTodos,
|
||||
formatProductDevelopmentTodoElapsed,
|
||||
getBeijingDateKey,
|
||||
} from '@/server/services/product-development-todo-rules'
|
||||
import type {
|
||||
ProductDevelopmentTodoCandidate,
|
||||
ProductDevelopmentTodoProduct,
|
||||
} from '@/server/services/product-development-todo.types'
|
||||
|
||||
const EXTERNAL_ACCOUNT_ROLES = new Set<BusinessAccountType>(['SUPPLIER', 'EXTERNAL_INTAKE'])
|
||||
const PRODUCT_DETAIL_ORIGIN = 'https://cp.jiansun.vip'
|
||||
|
||||
export type PreparedTodoItem = ProductDevelopmentTodoCandidate & {
|
||||
actionUrl: string
|
||||
elapsed: string
|
||||
onceAttemptKey: string | null
|
||||
}
|
||||
|
||||
export type RecipientResolution = {
|
||||
key: string
|
||||
name: string
|
||||
user: ProductDevelopmentTodoUser | null
|
||||
errorCode: string | null
|
||||
errorMessage: string | null
|
||||
}
|
||||
|
||||
export type PreparedRecipientGroup = {
|
||||
recipient: RecipientResolution
|
||||
items: PreparedTodoItem[]
|
||||
title: string
|
||||
content: string
|
||||
actionTitle: string
|
||||
actionUrl: string
|
||||
idempotencyKey: string
|
||||
onceAttemptKeys: string[]
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: {}
|
||||
}
|
||||
|
||||
function asArray(value: unknown) {
|
||||
return Array.isArray(value) ? value : []
|
||||
}
|
||||
|
||||
function text(value: unknown) {
|
||||
return typeof value === 'string' ? value.trim() : ''
|
||||
}
|
||||
|
||||
function toRuleProduct(record: ProductDevelopmentTodoSourceRecord): ProductDevelopmentTodoProduct {
|
||||
const payload = asRecord(record.payload)
|
||||
const openingReference = asRecord(payload.openingReference)
|
||||
const structuredPricingRows = record.pricingRows.map((row) => ({
|
||||
costPrice: row.costPrice?.toString() ?? '',
|
||||
}))
|
||||
|
||||
return {
|
||||
id: record.id,
|
||||
spu: record.spu,
|
||||
createdAt: record.createdAt,
|
||||
projectStatus: text(payload.projectStatus) || 'approved',
|
||||
owners: {
|
||||
appearanceDesigner: text(openingReference.designer) || record.designer.trim(),
|
||||
structureDesigner:
|
||||
record.profile?.structureDesigner.trim()
|
||||
|| text(openingReference.structureDesigner),
|
||||
purchaser:
|
||||
record.profile?.purchaseOwner.trim()
|
||||
|| text(payload.purchaseOwner)
|
||||
|| record.purchaser.trim(),
|
||||
},
|
||||
confirmations: asRecord(payload.documentWorkspaceConfirmations),
|
||||
approvedAt: asRecord(payload.documentWorkspaceApprovedAt),
|
||||
factoryRows: asArray(payload.factoryRows),
|
||||
sampleMeasurementRows: asArray(payload.sampleMeasurementRows),
|
||||
pricingRows: structuredPricingRows.length > 0
|
||||
? structuredPricingRows
|
||||
: asArray(payload.pricingRows),
|
||||
}
|
||||
}
|
||||
|
||||
function buildActionUrl(item: ProductDevelopmentTodoCandidate) {
|
||||
const path = `/intake/non-integrated/${encodeURIComponent(item.productId)}`
|
||||
const query = new URLSearchParams({ workspace: item.workspace, mode: 'edit' })
|
||||
return `${PRODUCT_DETAIL_ORIGIN}${path}?${query.toString()}`
|
||||
}
|
||||
|
||||
function recipientKey(value: string) {
|
||||
return createHash('sha256').update(value).digest('hex').slice(0, 24)
|
||||
}
|
||||
|
||||
function buildOnceAttemptKey(item: ProductDevelopmentTodoCandidate, recipient: string) {
|
||||
return `product-todo:once:${item.ruleId}:${item.productId}:${recipientKey(recipient)}`
|
||||
}
|
||||
|
||||
function classifyUser(name: string, user: ProductDevelopmentTodoUser | undefined): RecipientResolution {
|
||||
if (!user) {
|
||||
return {
|
||||
key: `name:${name}`,
|
||||
name,
|
||||
user: null,
|
||||
errorCode: 'RECIPIENT_NOT_FOUND',
|
||||
errorMessage: `流程负责人“${name}”没有对应的系统账号`,
|
||||
}
|
||||
}
|
||||
if (user.status !== 'ACTIVE') {
|
||||
return {
|
||||
key: `user:${user.id}`,
|
||||
name,
|
||||
user,
|
||||
errorCode: 'RECIPIENT_INACTIVE',
|
||||
errorMessage: `流程负责人“${name}”的系统账号不是在职启用状态`,
|
||||
}
|
||||
}
|
||||
if (user.businessRoles.some((role) => EXTERNAL_ACCOUNT_ROLES.has(role))) {
|
||||
return {
|
||||
key: `user:${user.id}`,
|
||||
name,
|
||||
user,
|
||||
errorCode: 'RECIPIENT_EXTERNAL',
|
||||
errorMessage: `流程负责人“${name}”是供应商或外部录入账号,不能接收系统通知`,
|
||||
}
|
||||
}
|
||||
if (!user.dingtalkUserId?.trim()) {
|
||||
return {
|
||||
key: `user:${user.id}`,
|
||||
name,
|
||||
user,
|
||||
errorCode: 'RECIPIENT_DINGTALK_UNBOUND',
|
||||
errorMessage: `流程负责人“${name}”尚未绑定钉钉账号`,
|
||||
}
|
||||
}
|
||||
return {
|
||||
key: `user:${user.id}`,
|
||||
name,
|
||||
user,
|
||||
errorCode: null,
|
||||
errorMessage: null,
|
||||
}
|
||||
}
|
||||
|
||||
function missingOwnerResolution(item: ProductDevelopmentTodoCandidate): RecipientResolution {
|
||||
const roleLabel = item.recipientRole === 'appearanceDesigner'
|
||||
? '外观设计师'
|
||||
: item.recipientRole === 'structureDesigner'
|
||||
? '结构设计师'
|
||||
: item.recipientRole === 'purchaser'
|
||||
? '采购'
|
||||
: '固定收件人'
|
||||
return {
|
||||
key: `missing:${item.recipientRole}`,
|
||||
name: `未配置${roleLabel}`,
|
||||
user: null,
|
||||
errorCode: 'RECIPIENT_OWNER_MISSING',
|
||||
errorMessage: `${item.spu} 的流程没有配置${roleLabel}`,
|
||||
}
|
||||
}
|
||||
|
||||
function buildMessage(recipientName: string, items: PreparedTodoItem[]) {
|
||||
const title = `待办(${items.length})`
|
||||
const lines = items.map((item) => (
|
||||
`${item.spu} · ${item.nodeLabel} · 已用${item.elapsed} · [查看 >](${item.actionUrl})`
|
||||
))
|
||||
return {
|
||||
title,
|
||||
content: `${recipientName},今日有 ${items.length} 项待处理。\n\n${lines.join('\n')}`,
|
||||
}
|
||||
}
|
||||
|
||||
export function toProductDevelopmentTodoPreviewGroup(
|
||||
group: PreparedRecipientGroup,
|
||||
) {
|
||||
return {
|
||||
recipientName: group.recipient.name,
|
||||
deliverable: !group.recipient.errorCode,
|
||||
issue: group.recipient.errorMessage,
|
||||
itemCount: group.items.length,
|
||||
title: group.title,
|
||||
content: group.content,
|
||||
actionUrl: group.actionUrl,
|
||||
items: group.items.map((item) => ({
|
||||
productId: item.productId,
|
||||
spu: item.spu,
|
||||
nodeLabel: item.nodeLabel,
|
||||
elapsed: item.elapsed,
|
||||
actionUrl: item.actionUrl,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
export async function prepareProductDevelopmentTodos(input: {
|
||||
now: Date
|
||||
ignoreAttemptHistory?: boolean
|
||||
}) {
|
||||
const records = await productDevelopmentTodoRepository.findEligibleProducts()
|
||||
const candidates = records.flatMap((record) => (
|
||||
evaluateProductDevelopmentTodos(toRuleProduct(record), input.now)
|
||||
))
|
||||
const ownerNames = Array.from(new Set(candidates.flatMap((item) => item.recipientNames)))
|
||||
const users = await productDevelopmentTodoRepository.findUsersByNames(ownerNames)
|
||||
const userByName = new Map(users.map((user) => [user.name, user]))
|
||||
const expanded = candidates.flatMap((item) => {
|
||||
if (item.recipientNames.length === 0) {
|
||||
return [{ item, recipient: missingOwnerResolution(item) }]
|
||||
}
|
||||
return item.recipientNames.map((name) => ({
|
||||
item,
|
||||
recipient: classifyUser(name, userByName.get(name)),
|
||||
}))
|
||||
})
|
||||
const withAttemptKeys = expanded.map(({ item, recipient }) => ({
|
||||
item,
|
||||
recipient,
|
||||
onceAttemptKey: item.deliveryMode === 'once'
|
||||
? buildOnceAttemptKey(item, recipient.key)
|
||||
: null,
|
||||
}))
|
||||
const attemptKeys = withAttemptKeys.flatMap((entry) => (
|
||||
entry.onceAttemptKey ? [entry.onceAttemptKey] : []
|
||||
))
|
||||
const existingAttemptKeys = input.ignoreAttemptHistory
|
||||
? new Set<string>()
|
||||
: new Set((await productDevelopmentTodoRepository.findAttemptKeys(attemptKeys))
|
||||
.map((entry) => entry.attemptKey))
|
||||
const preparedItems = withAttemptKeys
|
||||
.filter((entry) => !entry.onceAttemptKey || !existingAttemptKeys.has(entry.onceAttemptKey))
|
||||
.map(({ item, recipient, onceAttemptKey }) => ({
|
||||
recipient,
|
||||
item: {
|
||||
...item,
|
||||
actionUrl: buildActionUrl(item),
|
||||
elapsed: formatProductDevelopmentTodoElapsed(item.activatedAt, input.now),
|
||||
onceAttemptKey,
|
||||
} satisfies PreparedTodoItem,
|
||||
}))
|
||||
const groupsByRecipient = new Map<string, {
|
||||
recipient: RecipientResolution
|
||||
items: PreparedTodoItem[]
|
||||
}>()
|
||||
preparedItems.forEach(({ recipient, item }) => {
|
||||
const current = groupsByRecipient.get(recipient.key) ?? { recipient, items: [] }
|
||||
current.items.push(item)
|
||||
groupsByRecipient.set(recipient.key, current)
|
||||
})
|
||||
|
||||
const dateKey = getBeijingDateKey(input.now)
|
||||
const groups = Array.from(groupsByRecipient.values()).map(({ recipient, items }) => {
|
||||
items.sort((left, right) => (
|
||||
left.activatedAt.getTime() - right.activatedAt.getTime()
|
||||
|| left.spu.localeCompare(right.spu, 'zh-CN')
|
||||
|| left.ruleId.localeCompare(right.ruleId)
|
||||
))
|
||||
const resolvedRecipient = recipient.errorCode === 'RECIPIENT_OWNER_MISSING'
|
||||
? {
|
||||
...recipient,
|
||||
errorMessage: items.length === 1
|
||||
? recipient.errorMessage
|
||||
: `${items.length} 个流程没有配置${recipient.name.replace(/^未配置/, '')}:${Array.from(new Set(items.map((item) => item.spu))).join('、')}`,
|
||||
}
|
||||
: recipient
|
||||
return {
|
||||
recipient: resolvedRecipient,
|
||||
items,
|
||||
...buildMessage(recipient.name, items),
|
||||
actionTitle: '查看待办',
|
||||
actionUrl: items[0]!.actionUrl,
|
||||
idempotencyKey: `product-todo:daily:${dateKey}:${recipientKey(recipient.key)}`,
|
||||
onceAttemptKeys: items.flatMap((item) => (
|
||||
item.onceAttemptKey ? [item.onceAttemptKey] : []
|
||||
)),
|
||||
} satisfies PreparedRecipientGroup
|
||||
})
|
||||
groups.sort((left, right) => left.recipient.name.localeCompare(right.recipient.name, 'zh-CN'))
|
||||
return { records, candidates, groups }
|
||||
}
|
||||
@ -0,0 +1,281 @@
|
||||
import type {
|
||||
ProductDevelopmentTodoCandidate,
|
||||
ProductDevelopmentTodoProduct,
|
||||
} from '@/server/services/product-development-todo.types'
|
||||
|
||||
export const PRODUCT_DEVELOPMENT_TODO_EVENT = 'product_development_daily_todo'
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1_000
|
||||
const PRODUCT_INFORMATION_WAIT_MS = 7 * DAY_MS
|
||||
|
||||
type NodeKey =
|
||||
| '开品参考'
|
||||
| '设计卖点'
|
||||
| '产品模型'
|
||||
| '基础信息'
|
||||
| '产品定厂'
|
||||
| '样包测量'
|
||||
| '产品核价'
|
||||
|
||||
const PURCHASER_MERCHANDISER_NAMES = new Set(['宁珂', '松柏'])
|
||||
const MERCHANDISER_NAMES = ['雁兰', '槐米']
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: {}
|
||||
}
|
||||
|
||||
function text(value: unknown) {
|
||||
return typeof value === 'string' ? value.trim() : ''
|
||||
}
|
||||
|
||||
function parseDate(value: unknown): Date | null {
|
||||
const normalized = text(value)
|
||||
if (!normalized) return null
|
||||
const parsed = new Date(normalized)
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed
|
||||
}
|
||||
|
||||
function splitNames(value: string) {
|
||||
return Array.from(new Set(
|
||||
value
|
||||
.split(/[、,,;;/]/)
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean),
|
||||
))
|
||||
}
|
||||
|
||||
function nodeApproved(product: ProductDevelopmentTodoProduct, node: NodeKey) {
|
||||
return product.confirmations[node] === true && parseDate(product.approvedAt[node]) !== null
|
||||
}
|
||||
|
||||
function approvedAt(product: ProductDevelopmentTodoProduct, node: NodeKey) {
|
||||
return parseDate(product.approvedAt[node])
|
||||
}
|
||||
|
||||
const FACTORY_REQUIRED_KEYS = [
|
||||
'model',
|
||||
'skuCode',
|
||||
'setCount',
|
||||
'barcode',
|
||||
'specName',
|
||||
'material',
|
||||
'installMethod',
|
||||
'supplierName',
|
||||
'warehouse',
|
||||
'shippingMethod',
|
||||
'minimumOrderQuantity',
|
||||
'leadTime',
|
||||
'productionType',
|
||||
'installationService',
|
||||
'supplierCategory',
|
||||
'purchaseOrderMethod',
|
||||
'executeStandard',
|
||||
] as const
|
||||
|
||||
function factoryContentComplete(rows: unknown[]) {
|
||||
return rows.length > 0 && rows.every((entry) => {
|
||||
const row = asRecord(entry)
|
||||
return row.confirmed === true && FACTORY_REQUIRED_KEYS.every((key) => text(row[key]))
|
||||
})
|
||||
}
|
||||
|
||||
function sampleMeasurementContentComplete(rows: unknown[]) {
|
||||
return rows.length > 0 && rows.every((entry) => {
|
||||
const row = asRecord(entry)
|
||||
return ['lengthCm', 'widthCm', 'heightCm', 'weightKg']
|
||||
.every((key) => text(row[key]))
|
||||
})
|
||||
}
|
||||
|
||||
function pricingContentComplete(rows: unknown[]) {
|
||||
return rows.length > 0 && rows.every((entry) => text(asRecord(entry).costPrice))
|
||||
}
|
||||
|
||||
function productFactoryApproved(product: ProductDevelopmentTodoProduct) {
|
||||
return nodeApproved(product, '产品定厂') && factoryContentComplete(product.factoryRows)
|
||||
}
|
||||
|
||||
function sampleMeasurementApproved(product: ProductDevelopmentTodoProduct) {
|
||||
return nodeApproved(product, '样包测量')
|
||||
&& productFactoryApproved(product)
|
||||
&& sampleMeasurementContentComplete(product.sampleMeasurementRows)
|
||||
}
|
||||
|
||||
function productPricingApproved(product: ProductDevelopmentTodoProduct) {
|
||||
return nodeApproved(product, '产品核价') && pricingContentComplete(product.pricingRows)
|
||||
}
|
||||
|
||||
function purchaserRecipients(purchaser: string, extraNames: string[] = []) {
|
||||
const purchaserNames = splitNames(purchaser)
|
||||
const needsMerchandisers = purchaserNames.some((name) => (
|
||||
PURCHASER_MERCHANDISER_NAMES.has(name)
|
||||
))
|
||||
return Array.from(new Set([
|
||||
...purchaserNames,
|
||||
...(needsMerchandisers ? MERCHANDISER_NAMES : []),
|
||||
...extraNames,
|
||||
]))
|
||||
}
|
||||
|
||||
function candidate(input: Omit<ProductDevelopmentTodoCandidate, 'productId' | 'spu'> & {
|
||||
product: ProductDevelopmentTodoProduct
|
||||
}): ProductDevelopmentTodoCandidate {
|
||||
return {
|
||||
ruleId: input.ruleId,
|
||||
productId: input.product.id,
|
||||
spu: input.product.spu,
|
||||
nodeLabel: input.nodeLabel,
|
||||
workspace: input.workspace,
|
||||
recipientRole: input.recipientRole,
|
||||
recipientNames: input.recipientNames,
|
||||
activatedAt: input.activatedAt,
|
||||
deliveryMode: input.deliveryMode,
|
||||
}
|
||||
}
|
||||
|
||||
export function evaluateProductDevelopmentTodos(
|
||||
product: ProductDevelopmentTodoProduct,
|
||||
now: Date,
|
||||
): ProductDevelopmentTodoCandidate[] {
|
||||
if (product.projectStatus === 'rejected') return []
|
||||
|
||||
const items: ProductDevelopmentTodoCandidate[] = []
|
||||
const openingApprovedAt = approvedAt(product, '开品参考')
|
||||
const sellingPointApprovedAt = approvedAt(product, '设计卖点')
|
||||
const productInformationApprovedAt = approvedAt(product, '产品模型')
|
||||
const basicInformationApprovedAt = approvedAt(product, '基础信息')
|
||||
const productFactoryApprovedAt = approvedAt(product, '产品定厂')
|
||||
const sampleMeasurementApprovedAt = approvedAt(product, '样包测量')
|
||||
|
||||
if (!nodeApproved(product, '开品参考')) {
|
||||
items.push(candidate({
|
||||
product,
|
||||
ruleId: 'opening_reference_unconfirmed',
|
||||
nodeLabel: '开品参考未确认',
|
||||
workspace: 'opening-reference',
|
||||
recipientRole: 'appearanceDesigner',
|
||||
recipientNames: splitNames(product.owners.appearanceDesigner),
|
||||
activatedAt: product.createdAt,
|
||||
deliveryMode: 'daily',
|
||||
}))
|
||||
}
|
||||
|
||||
if (nodeApproved(product, '开品参考') && !nodeApproved(product, '设计卖点')) {
|
||||
items.push(candidate({
|
||||
product,
|
||||
ruleId: 'selling_point_unconfirmed',
|
||||
nodeLabel: '设计卖点未确认',
|
||||
workspace: 'design-selling-point',
|
||||
recipientRole: 'appearanceDesigner',
|
||||
recipientNames: splitNames(product.owners.appearanceDesigner),
|
||||
activatedAt: openingApprovedAt!,
|
||||
deliveryMode: 'daily',
|
||||
}))
|
||||
}
|
||||
|
||||
if (nodeApproved(product, '设计卖点') && !nodeApproved(product, '产品模型')) {
|
||||
items.push(candidate({
|
||||
product,
|
||||
ruleId: 'product_information_unfinished',
|
||||
nodeLabel: '产品资料未完成',
|
||||
workspace: 'product-data',
|
||||
recipientRole: 'structureDesigner',
|
||||
recipientNames: splitNames(product.owners.structureDesigner),
|
||||
activatedAt: sellingPointApprovedAt!,
|
||||
deliveryMode: 'daily',
|
||||
}))
|
||||
}
|
||||
|
||||
if (nodeApproved(product, '产品模型') && !nodeApproved(product, '基础信息')) {
|
||||
items.push(candidate({
|
||||
product,
|
||||
ruleId: 'product_information_sampling_handoff',
|
||||
nodeLabel: '产品资料已完成,需要打样',
|
||||
workspace: 'product-data',
|
||||
recipientRole: 'purchaser',
|
||||
recipientNames: purchaserRecipients(product.owners.purchaser),
|
||||
activatedAt: productInformationApprovedAt!,
|
||||
deliveryMode: 'once',
|
||||
}))
|
||||
}
|
||||
|
||||
if (
|
||||
nodeApproved(product, '产品模型')
|
||||
&& !nodeApproved(product, '基础信息')
|
||||
&& now.getTime() - productInformationApprovedAt!.getTime() >= PRODUCT_INFORMATION_WAIT_MS
|
||||
) {
|
||||
items.push(candidate({
|
||||
product,
|
||||
ruleId: 'basic_information_unconfirmed',
|
||||
nodeLabel: '基础信息未确认',
|
||||
workspace: 'basic-info',
|
||||
recipientRole: 'structureDesigner',
|
||||
recipientNames: splitNames(product.owners.structureDesigner),
|
||||
activatedAt: productInformationApprovedAt!,
|
||||
deliveryMode: 'daily',
|
||||
}))
|
||||
}
|
||||
|
||||
if (nodeApproved(product, '基础信息') && !productFactoryApproved(product)) {
|
||||
items.push(candidate({
|
||||
product,
|
||||
ruleId: 'product_factory_unfinished',
|
||||
nodeLabel: '产品定厂未完成',
|
||||
workspace: 'product-factory',
|
||||
recipientRole: 'purchaser',
|
||||
recipientNames: purchaserRecipients(product.owners.purchaser),
|
||||
activatedAt: basicInformationApprovedAt!,
|
||||
deliveryMode: 'daily',
|
||||
}))
|
||||
}
|
||||
|
||||
if (productFactoryApproved(product) && !sampleMeasurementApproved(product)) {
|
||||
items.push(candidate({
|
||||
product,
|
||||
ruleId: 'sample_measurement_unfinished',
|
||||
nodeLabel: '样包测量未完成',
|
||||
workspace: 'sample-measurement',
|
||||
recipientRole: 'purchaser',
|
||||
recipientNames: purchaserRecipients(product.owners.purchaser),
|
||||
activatedAt: productFactoryApprovedAt!,
|
||||
deliveryMode: 'daily',
|
||||
}))
|
||||
}
|
||||
|
||||
if (sampleMeasurementApproved(product) && !productPricingApproved(product)) {
|
||||
items.push(candidate({
|
||||
product,
|
||||
ruleId: 'product_pricing_unfinished',
|
||||
nodeLabel: '产品核价未完成',
|
||||
workspace: 'product-pricing',
|
||||
recipientRole: 'purchaser',
|
||||
recipientNames: purchaserRecipients(product.owners.purchaser, ['依依']),
|
||||
activatedAt: sampleMeasurementApprovedAt!,
|
||||
deliveryMode: 'daily',
|
||||
}))
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
export function formatProductDevelopmentTodoElapsed(activatedAt: Date, now: Date) {
|
||||
const totalMinutes = Math.max(0, Math.floor((now.getTime() - activatedAt.getTime()) / 60_000))
|
||||
const days = Math.floor(totalMinutes / (24 * 60))
|
||||
const hours = Math.floor((totalMinutes % (24 * 60)) / 60)
|
||||
const minutes = totalMinutes % 60
|
||||
|
||||
if (days > 0) return `${days}天${hours}小时`
|
||||
if (hours > 0) return `${hours}小时${minutes}分钟`
|
||||
return `${minutes}分钟`
|
||||
}
|
||||
|
||||
export function getBeijingDateKey(now: Date) {
|
||||
return new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'Asia/Shanghai',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).format(now)
|
||||
}
|
||||
@ -0,0 +1,150 @@
|
||||
import 'server-only'
|
||||
|
||||
import { productDevelopmentTodoRepository } from '@/server/repositories/product-development-todo.repository'
|
||||
import {
|
||||
notificationDeliveryService,
|
||||
NotificationDeliveryError,
|
||||
} from '@/server/services/notification-delivery.service'
|
||||
import {
|
||||
prepareProductDevelopmentTodos,
|
||||
toProductDevelopmentTodoPreviewGroup,
|
||||
} from '@/server/services/product-development-todo-preparation.service'
|
||||
import {
|
||||
getBeijingDateKey,
|
||||
PRODUCT_DEVELOPMENT_TODO_EVENT,
|
||||
} from '@/server/services/product-development-todo-rules'
|
||||
|
||||
class ProductDevelopmentTodoService {
|
||||
async preview(input: { now?: Date; ignoreAttemptHistory?: boolean } = {}) {
|
||||
const now = input.now ?? new Date()
|
||||
const prepared = await prepareProductDevelopmentTodos({
|
||||
now,
|
||||
ignoreAttemptHistory: input.ignoreAttemptHistory,
|
||||
})
|
||||
return {
|
||||
mode: 'preview' as const,
|
||||
now: now.toISOString(),
|
||||
productCount: prepared.records.length,
|
||||
candidateCount: prepared.candidates.length,
|
||||
recipientCount: prepared.groups.length,
|
||||
deliverableRecipientCount: prepared.groups.filter((group) => !group.recipient.errorCode).length,
|
||||
issueCount: prepared.groups.filter((group) => group.recipient.errorCode).length,
|
||||
groups: prepared.groups.map(toProductDevelopmentTodoPreviewGroup),
|
||||
}
|
||||
}
|
||||
|
||||
async send(input: { now?: Date } = {}) {
|
||||
const now = input.now ?? new Date()
|
||||
const prepared = await prepareProductDevelopmentTodos({ now })
|
||||
const onceAttempts = prepared.groups.flatMap((group) => (
|
||||
group.items.flatMap((item) => item.onceAttemptKey ? [{
|
||||
attemptKey: item.onceAttemptKey,
|
||||
ruleId: item.ruleId,
|
||||
productId: item.productId,
|
||||
recipientKey: group.recipient.key,
|
||||
attemptedAt: now,
|
||||
}] : [])
|
||||
))
|
||||
await productDevelopmentTodoRepository.createAttempts(onceAttempts)
|
||||
|
||||
const results: Array<{
|
||||
recipientName: string
|
||||
itemCount: number
|
||||
status: 'attempted' | 'failed'
|
||||
errorCode: string | null
|
||||
}> = []
|
||||
|
||||
for (const group of prepared.groups) {
|
||||
try {
|
||||
if (group.recipient.errorCode) {
|
||||
await notificationDeliveryService.recordUndeliverable({
|
||||
idempotencyKey: group.idempotencyKey,
|
||||
businessEvent: PRODUCT_DEVELOPMENT_TODO_EVENT,
|
||||
businessRecordId: getBeijingDateKey(now),
|
||||
source: 'SYSTEM',
|
||||
initiatedByName: '产品开发待办定时任务',
|
||||
recipient: {
|
||||
id: group.recipient.user?.id ?? group.recipient.key,
|
||||
name: group.recipient.name,
|
||||
email: group.recipient.user?.email ?? '',
|
||||
dingtalkUserId: group.recipient.user?.dingtalkUserId?.trim() ?? '',
|
||||
},
|
||||
title: group.title,
|
||||
content: group.content,
|
||||
actionTitle: group.actionTitle,
|
||||
actionUrl: group.actionUrl,
|
||||
errorCode: group.recipient.errorCode,
|
||||
errorMessage: group.recipient.errorMessage ?? '收件人不可投递',
|
||||
})
|
||||
await productDevelopmentTodoRepository.updateAttempts(group.onceAttemptKeys, {
|
||||
status: 'failed',
|
||||
lastErrorCode: group.recipient.errorCode,
|
||||
lastErrorMessage: group.recipient.errorMessage,
|
||||
})
|
||||
results.push({
|
||||
recipientName: group.recipient.name,
|
||||
itemCount: group.items.length,
|
||||
status: 'failed',
|
||||
errorCode: group.recipient.errorCode,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
await notificationDeliveryService.sendToEmployee({
|
||||
idempotencyKey: group.idempotencyKey,
|
||||
businessEvent: PRODUCT_DEVELOPMENT_TODO_EVENT,
|
||||
businessRecordId: getBeijingDateKey(now),
|
||||
source: 'SYSTEM',
|
||||
initiatedByName: '产品开发待办定时任务',
|
||||
recipientUserId: group.recipient.user!.id,
|
||||
title: group.title,
|
||||
content: group.content,
|
||||
actionTitle: group.actionTitle,
|
||||
actionUrl: group.actionUrl,
|
||||
retryExisting: false,
|
||||
})
|
||||
await productDevelopmentTodoRepository.updateAttempts(group.onceAttemptKeys, {
|
||||
status: 'attempted',
|
||||
lastErrorCode: null,
|
||||
lastErrorMessage: null,
|
||||
})
|
||||
results.push({
|
||||
recipientName: group.recipient.name,
|
||||
itemCount: group.items.length,
|
||||
status: 'attempted',
|
||||
errorCode: null,
|
||||
})
|
||||
} catch (error) {
|
||||
const errorCode = error instanceof NotificationDeliveryError
|
||||
? error.code
|
||||
: 'PRODUCT_TODO_DELIVERY_FAILED'
|
||||
const errorMessage = error instanceof Error ? error.message : '产品开发待办发送失败'
|
||||
await productDevelopmentTodoRepository.updateAttempts(group.onceAttemptKeys, {
|
||||
status: 'failed',
|
||||
lastErrorCode: errorCode,
|
||||
lastErrorMessage: errorMessage,
|
||||
})
|
||||
results.push({
|
||||
recipientName: group.recipient.name,
|
||||
itemCount: group.items.length,
|
||||
status: 'failed',
|
||||
errorCode,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
mode: 'send' as const,
|
||||
now: now.toISOString(),
|
||||
productCount: prepared.records.length,
|
||||
candidateCount: prepared.candidates.length,
|
||||
recipientCount: prepared.groups.length,
|
||||
attemptedCount: results.filter((result) => result.status === 'attempted').length,
|
||||
failedCount: results.filter((result) => result.status === 'failed').length,
|
||||
results,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const productDevelopmentTodoService = new ProductDevelopmentTodoService()
|
||||
export { ProductDevelopmentTodoService }
|
||||
@ -0,0 +1,53 @@
|
||||
export type ProductDevelopmentTodoWorkspace =
|
||||
| 'opening-reference'
|
||||
| 'design-selling-point'
|
||||
| 'product-data'
|
||||
| 'basic-info'
|
||||
| 'product-factory'
|
||||
| 'sample-measurement'
|
||||
| 'product-pricing'
|
||||
|
||||
export type ProductDevelopmentTodoRecipientRole =
|
||||
| 'appearanceDesigner'
|
||||
| 'structureDesigner'
|
||||
| 'purchaser'
|
||||
| 'fixed'
|
||||
|
||||
export type ProductDevelopmentTodoRuleId =
|
||||
| 'opening_reference_unconfirmed'
|
||||
| 'selling_point_unconfirmed'
|
||||
| 'product_information_unfinished'
|
||||
| 'product_information_sampling_handoff'
|
||||
| 'basic_information_unconfirmed'
|
||||
| 'product_factory_unfinished'
|
||||
| 'sample_measurement_unfinished'
|
||||
| 'product_pricing_unfinished'
|
||||
|
||||
export type ProductDevelopmentTodoProduct = {
|
||||
id: string
|
||||
spu: string
|
||||
createdAt: Date
|
||||
projectStatus: string
|
||||
owners: {
|
||||
appearanceDesigner: string
|
||||
structureDesigner: string
|
||||
purchaser: string
|
||||
}
|
||||
confirmations: Record<string, unknown>
|
||||
approvedAt: Record<string, unknown>
|
||||
factoryRows: unknown[]
|
||||
sampleMeasurementRows: unknown[]
|
||||
pricingRows: unknown[]
|
||||
}
|
||||
|
||||
export type ProductDevelopmentTodoCandidate = {
|
||||
ruleId: ProductDevelopmentTodoRuleId
|
||||
productId: string
|
||||
spu: string
|
||||
nodeLabel: string
|
||||
workspace: ProductDevelopmentTodoWorkspace
|
||||
recipientRole: ProductDevelopmentTodoRecipientRole
|
||||
recipientNames: string[]
|
||||
activatedAt: Date
|
||||
deliveryMode: 'daily' | 'once'
|
||||
}
|
||||
@ -333,6 +333,23 @@ ensure_bom_costing_worker_secret() {
|
||||
chmod 600 "$SHARED/.env" || true
|
||||
}
|
||||
|
||||
ensure_product_development_todo_secret() {
|
||||
if grep -qE '^PRODUCT_DEVELOPMENT_TODO_CRON_SECRET=.+$' "$SHARED/.env"; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
local secret
|
||||
secret="$(node -e "process.stdout.write(require('node:crypto').randomBytes(32).toString('hex'))")"
|
||||
if [ -z "$secret" ]; then
|
||||
echo "Unable to generate product development todo cron secret" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
umask 077
|
||||
printf '\nPRODUCT_DEVELOPMENT_TODO_CRON_SECRET=%s\n' "$secret" >> "$SHARED/.env"
|
||||
chmod 600 "$SHARED/.env" || true
|
||||
}
|
||||
|
||||
ensure_image_search_model() {
|
||||
local model_dir="$SHARED/models/dinov2-small"
|
||||
local source_base="${IMAGE_SEARCH_MODEL_SOURCE_BASE_URL:-https://www.modelscope.cn/models/Xenova/dinov2-small/resolve/master}"
|
||||
@ -388,12 +405,16 @@ install_virtual_inventory_push_cron() {
|
||||
local cron_file
|
||||
local cron_line
|
||||
local fixed_cron_line
|
||||
local todo_cron_line
|
||||
local lock_file="/tmp/furniture-3d-system-virtual-inventory-push.lock"
|
||||
local fixed_lock_file="/tmp/furniture-3d-system-fixed-virtual-inventory-push.lock"
|
||||
local todo_lock_file="/tmp/furniture-3d-system-product-development-todos.lock"
|
||||
local endpoint="http://127.0.0.1:$APP_PORT/api/intake/non-integrated/jushuitan-virtual-inventory-push"
|
||||
local fixed_endpoint="http://127.0.0.1:$APP_PORT/api/intake/non-integrated/jushuitan-fixed-virtual-inventory-push"
|
||||
local todo_endpoint="http://127.0.0.1:$APP_PORT/api/internal/product-development-todos/run?mode=send"
|
||||
local log_file="$SHARED/logs/jushuitan-virtual-inventory-push.log"
|
||||
local fixed_log_file="$SHARED/logs/jushuitan-fixed-virtual-inventory-push.log"
|
||||
local todo_log_file="$SHARED/logs/product-development-todos.log"
|
||||
|
||||
mkdir -p "$SHARED/logs"
|
||||
cron_file="$(mktemp)"
|
||||
@ -402,12 +423,18 @@ install_virtual_inventory_push_cron() {
|
||||
sed '\|/api/intake/non-integrated/jushuitan-virtual-inventory-push|d' |
|
||||
sed '\|furniture-3d-system-virtual-inventory-push|d' |
|
||||
sed '\|/api/intake/non-integrated/jushuitan-fixed-virtual-inventory-push|d' |
|
||||
sed '\|furniture-3d-system-fixed-virtual-inventory-push|d' > "$cron_file"
|
||||
sed '\|furniture-3d-system-fixed-virtual-inventory-push|d' |
|
||||
sed '\|/api/internal/product-development-todos/run|d' |
|
||||
sed '\|furniture-3d-system-product-development-todos|d' |
|
||||
sed '\|^CRON_TZ=Asia/Shanghai$|d' > "$cron_file"
|
||||
|
||||
cron_line="* * * * * /usr/bin/flock -n \"$lock_file\" /bin/bash -lc 'set -euo pipefail; set -a; source \"$SHARED/.env\"; set +a; : \"\${JST_VIRTUAL_INVENTORY_CRON_SECRET:?JST_VIRTUAL_INVENTORY_CRON_SECRET is required}\"; curl --fail --silent --show-error --max-time 55 --request POST \"$endpoint\" --header \"x-jst-virtual-inventory-secret: \${JST_VIRTUAL_INVENTORY_CRON_SECRET}\"' >> \"$log_file\" 2>&1 # furniture-3d-system-virtual-inventory-push"
|
||||
fixed_cron_line="0 20 * * * /usr/bin/flock -n \"$fixed_lock_file\" /bin/bash -lc 'set -euo pipefail; set -a; source \"$SHARED/.env\"; set +a; : \"\${JST_VIRTUAL_INVENTORY_CRON_SECRET:?JST_VIRTUAL_INVENTORY_CRON_SECRET is required}\"; curl --fail --silent --show-error --max-time 1800 --request POST \"$fixed_endpoint\" --header \"x-jst-virtual-inventory-secret: \${JST_VIRTUAL_INVENTORY_CRON_SECRET}\"' >> \"$fixed_log_file\" 2>&1 # furniture-3d-system-fixed-virtual-inventory-push"
|
||||
todo_cron_line="0 10 * * * /usr/bin/flock -n \"$todo_lock_file\" /bin/bash -lc 'set -euo pipefail; set -a; source \"$SHARED/.env\"; set +a; : \"\${PRODUCT_DEVELOPMENT_TODO_CRON_SECRET:?PRODUCT_DEVELOPMENT_TODO_CRON_SECRET is required}\"; curl --fail --silent --show-error --max-time 300 --request POST \"$todo_endpoint\" --header \"x-product-development-todo-secret: \${PRODUCT_DEVELOPMENT_TODO_CRON_SECRET}\"' >> \"$todo_log_file\" 2>&1 # furniture-3d-system-product-development-todos"
|
||||
printf '%s\n' "$cron_line" >> "$cron_file"
|
||||
printf '%s\n' "$fixed_cron_line" >> "$cron_file"
|
||||
printf '%s\n' 'CRON_TZ=Asia/Shanghai' >> "$cron_file"
|
||||
printf '%s\n' "$todo_cron_line" >> "$cron_file"
|
||||
crontab "$cron_file"
|
||||
rm -f -- "$cron_file"
|
||||
}
|
||||
@ -520,6 +547,7 @@ ensure_shared_non_integrated_assets() {
|
||||
mkdir -p "$BASE" "$RELEASES" "$SHARED"
|
||||
copy_legacy_env_if_needed
|
||||
ensure_bom_costing_worker_secret
|
||||
ensure_product_development_todo_secret
|
||||
ensure_repo
|
||||
fetch_target_commit "$(resolve_fetch_ref)"
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user