@@ -1102,9 +952,9 @@ export default function NotificationCenterPanel({ showToast }: NotificationCente
{rotateProject &&
确认轮换“{rotateProject.name}”的项目密钥?
轮换完成后,所有仍使用旧密钥的调用会立即鉴权失败。请确认你能够马上更新调用方环境变量。
}
- {confirmTestOpen && resolvedTestRecipient && selectedTestProject &&
确认发送真实测试通知
- 项目
- {selectedTestProject.name} · {selectedTestProject.projectCode}
- 真实收件人
- {resolvedTestRecipient.label}
- 标题
- {testTitle}
确认后会真实调用钉钉,不是页面预览。
}
+ {confirmTestOpen && preparedTestRecipient && selectedTestProject &&
确认发送真实测试通知
- 项目
- {selectedTestProject.name} · {selectedTestProject.projectCode}
- 真实收件人
- {preparedTestRecipient.label}
- 标题
- {testTitle}
通知中心会统一在标题前加上项目名称;确认后会真实调用钉钉,不是页面预览。
}
- {oneTimeSecret &&
{oneTimeSecret.reason === 'created' ? '接入项目已创建' : '项目密钥已轮换'}
这是当前有效的项目密钥。关闭后仍可在“查看配置 / 编辑资料”中按需查看;请只复制到调用方后端的安全环境变量。
项目密钥
{oneTimeSecret.secret}调用方环境变量
{Object.entries(oneTimeSecret.env).map(([key, value]) => `${key}=${value}`).join('\n')}}
+ {oneTimeSecret &&
{oneTimeSecret.reason === 'created' ? '接入项目已创建' : '项目密钥已轮换'}
请把下面的 URL、projectCode 和项目密钥保存到调用方后端的安全环境变量中。关闭后仍可在“查看配置 / 编辑资料”里按需查看当前密钥。
统一通知中心 URL{oneTimeSecret.env.YYD_NOTIFICATION_CENTER_URL || '后端未返回'}
projectCode{oneTimeSecret.projectCode || oneTimeSecret.env.YYD_NOTIFICATION_CENTER_PROJECT_CODE || '后端未返回'}
项目密钥
{oneTimeSecret.secret}调用方环境变量
{Object.entries(oneTimeSecret.env).map(([key, value]) => `${key}=${value}`).join('\n')}}
)
}
diff --git a/lib/__tests__/notification-center-admin-routes.test.ts b/lib/__tests__/notification-center-admin-routes.test.ts
index 53fb8b8..79fdba3 100644
--- a/lib/__tests__/notification-center-admin-routes.test.ts
+++ b/lib/__tests__/notification-center-admin-routes.test.ts
@@ -11,6 +11,7 @@ import {
listNotificationCenterProjectRecipients,
listNotificationCenterProjects,
revealNotificationCenterProjectSecret,
+ resolveNotificationCenterRecipientAlias,
rotateNotificationCenterProjectSecret,
sendNotificationCenterProjectTest,
updateNotificationCenterProject,
@@ -24,6 +25,7 @@ import { GET as getProjectRecipientsRoute } from '@/app/api/admin/notification-c
import { POST as postProjectTestRoute } from '@/app/api/admin/notification-center/projects/[code]/test/route'
import { GET as getDeliveriesRoute } from '@/app/api/admin/notification-center/deliveries/route'
import { GET as getAuditsRoute } from '@/app/api/admin/notification-center/audits/route'
+import { POST as postRecipientResolveRoute } from '@/app/api/internal/notification-center/recipients/resolve/route'
jest.mock('next-auth', () => ({
getServerSession: jest.fn(),
@@ -60,11 +62,13 @@ jest.mock('@/lib/notification-center-admin', () => ({
sendNotificationCenterProjectTest: jest.fn(),
listNotificationCenterDeliveries: jest.fn(),
listNotificationCenterAudits: jest.fn(),
+ resolveNotificationCenterRecipientAlias: jest.fn(),
}))
describe('notification-center admin routes', () => {
beforeEach(() => {
jest.clearAllMocks()
+ process.env.YYD_NOTIFICATION_CENTER_ADMIN_API_SECRET = 'server-admin-secret'
;(getServerSession as jest.Mock).mockResolvedValue({
user: {
id: 'admin-1',
@@ -240,6 +244,68 @@ describe('notification-center admin routes', () => {
)
})
+ test('POST /internal/notification-center/recipients/resolve 仅允许服务端 Bearer 调用并返回最小结果', async () => {
+ ;(resolveNotificationCenterRecipientAlias as jest.Mock).mockResolvedValue({
+ recipientUserId: 'ding-user-1',
+ })
+
+ const response = await postRecipientResolveRoute(
+ new Request('http://localhost/api/internal/notification-center/recipients/resolve', {
+ method: 'POST',
+ headers: {
+ Authorization: 'Bearer server-admin-secret',
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ recipientAlias: '测试员工' }),
+ }) as any,
+ )
+ const body = await response.json()
+
+ expect(response.status).toBe(200)
+ expect(response.headers.get('cache-control')).toContain('no-store')
+ expect(body).toEqual({ recipientUserId: 'ding-user-1' })
+ expect(resolveNotificationCenterRecipientAlias).toHaveBeenCalledWith('测试员工')
+ })
+
+ test('POST /internal/notification-center/recipients/resolve 对未授权和未命中返回明确定义错误', async () => {
+ const unauthorized = await postRecipientResolveRoute(
+ new Request('http://localhost/api/internal/notification-center/recipients/resolve', {
+ method: 'POST',
+ headers: {
+ Authorization: 'Bearer wrong-secret',
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ recipientAlias: '测试员工' }),
+ }) as any,
+ )
+ const unauthorizedBody = await unauthorized.json()
+
+ expect(unauthorized.status).toBe(401)
+ expect(unauthorizedBody).toEqual({
+ error: { code: 'UNAUTHORIZED', message: '未授权' },
+ })
+ expect(resolveNotificationCenterRecipientAlias).not.toHaveBeenCalled()
+
+ ;(resolveNotificationCenterRecipientAlias as jest.Mock).mockResolvedValue(null)
+
+ const notFound = await postRecipientResolveRoute(
+ new Request('http://localhost/api/internal/notification-center/recipients/resolve', {
+ method: 'POST',
+ headers: {
+ Authorization: 'Bearer server-admin-secret',
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ recipientAlias: '查无此人' }),
+ }) as any,
+ )
+ const notFoundBody = await notFound.json()
+
+ expect(notFound.status).toBe(404)
+ expect(notFoundBody).toEqual({
+ error: { code: 'RECIPIENT_ALIAS_NOT_FOUND', message: '花名未找到对应钉钉 userId' },
+ })
+ })
+
test('下游错误会返回中文错误且不泄漏管理员密钥', async () => {
const error = new NotificationCenterAdminError('通知中心管理员鉴权失败,请检查服务端密钥配置', {
statusCode: 502,
diff --git a/lib/__tests__/notification-center-admin.test.ts b/lib/__tests__/notification-center-admin.test.ts
index 67201a0..877896d 100644
--- a/lib/__tests__/notification-center-admin.test.ts
+++ b/lib/__tests__/notification-center-admin.test.ts
@@ -1,15 +1,25 @@
/** @jest-environment node */
+jest.mock('@/lib/prisma', () => ({
+ prisma: {
+ performanceSurveyEmployeeProfile: {
+ findMany: jest.fn(),
+ },
+ },
+}))
+
import {
createNotificationCenterProject,
listNotificationCenterProjectRecipients,
listNotificationCenterProjects,
NotificationCenterAdminError,
revealNotificationCenterProjectSecret,
+ resolveNotificationCenterRecipientAlias,
rotateNotificationCenterProjectSecret,
sendNotificationCenterProjectTest,
updateNotificationCenterProject,
} from '@/lib/notification-center-admin'
+import { prisma } from '@/lib/prisma'
describe('notification-center-admin client', () => {
const actor = {
@@ -21,12 +31,40 @@ describe('notification-center-admin client', () => {
beforeEach(() => {
jest.restoreAllMocks()
+ ;(prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock).mockReset()
process.env.YYD_NOTIFICATION_CENTER_ADMIN_URL = 'https://notify-admin.example.com'
process.env.YYD_NOTIFICATION_CENTER_ADMIN_API_SECRET = 'server-admin-secret'
delete process.env.YYD_NOTIFICATION_CENTER_ADMIN_TIMEOUT_MS
global.fetch = jest.fn()
})
+ test('精确花名解析只返回唯一有效的钉钉 userId', async () => {
+ ;(prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock).mockResolvedValue([
+ { dingtalkUserId: ' ding-user-1 ' },
+ { dingtalkUserId: 'ding-user-1' },
+ { dingtalkUserId: null },
+ ])
+
+ await expect(resolveNotificationCenterRecipientAlias(' 测试员工 ')).resolves.toEqual({
+ recipientUserId: 'ding-user-1',
+ })
+
+ expect(prisma.performanceSurveyEmployeeProfile.findMany).toHaveBeenCalledWith({
+ where: { flowerName: '测试员工' },
+ select: { dingtalkUserId: true },
+ take: 5,
+ })
+ })
+
+ test('花名对应多个不同钉钉 userId 时不返回模糊结果', async () => {
+ ;(prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock).mockResolvedValue([
+ { dingtalkUserId: 'ding-user-1' },
+ { dingtalkUserId: 'ding-user-2' },
+ ])
+
+ await expect(resolveNotificationCenterRecipientAlias('重名员工')).resolves.toBeNull()
+ })
+
test('列表查询会透传操作人身份并剥离 project/admin secret', async () => {
;(global.fetch as jest.Mock).mockResolvedValue({
ok: true,
diff --git a/lib/notification-center-admin.ts b/lib/notification-center-admin.ts
index 23d40c6..1b5d7a6 100644
--- a/lib/notification-center-admin.ts
+++ b/lib/notification-center-admin.ts
@@ -1,3 +1,5 @@
+import { prisma } from '@/lib/prisma'
+
type NotificationCenterAdminMethod = 'GET' | 'POST' | 'PUT' | 'PATCH'
export type NotificationCenterAdminActor = {
@@ -58,6 +60,40 @@ function clean(value: unknown) {
return text || null
}
+export async function resolveNotificationCenterRecipientAlias(alias: string): Promise<{
+ recipientUserId: string
+} | null> {
+ const normalizedAlias = clean(alias)
+ if (!normalizedAlias) {
+ throw new NotificationCenterAdminError('花名不能为空', {
+ statusCode: 422,
+ code: 'NOTIFICATION_CENTER_ADMIN_VALIDATION',
+ })
+ }
+
+ const rows = await prisma.performanceSurveyEmployeeProfile.findMany({
+ where: { flowerName: normalizedAlias },
+ select: { dingtalkUserId: true },
+ take: 5,
+ })
+
+ const candidateUserIds = Array.from(
+ new Set(
+ rows
+ .map((row) => clean(row.dingtalkUserId))
+ .filter((value): value is string => Boolean(value)),
+ ),
+ )
+
+ if (candidateUserIds.length !== 1) {
+ return null
+ }
+
+ return {
+ recipientUserId: candidateUserIds[0],
+ }
+}
+
function getConfig() {
const timeoutCandidate = Number(process.env.YYD_NOTIFICATION_CENTER_ADMIN_TIMEOUT_MS || '')
return {