@@ -820,9 +891,10 @@ export default function NotificationCenterPanel({ showToast }: NotificationCente
@@ -936,7 +1008,7 @@ export default function NotificationCenterPanel({ showToast }: NotificationCente
{ if (event.currentTarget === event.target) closeDrawer() }}>
-
{editingCode ? '管理接入项目' : '新增接入项目'}
{editingCode ? '修改名称、域名、状态和收件人方式。' : '创建后会生成只展示一次的项目密钥。'}
+
{editingCode ? '查看配置 / 编辑资料' : '新增接入项目'}
{editingCode ? '查看或修改名称、域名、状态和收件人方式。' : '创建后生成长期有效、默认隐藏的项目密钥。'}
@@ -946,7 +1018,82 @@ export default function NotificationCenterPanel({ showToast }: NotificationCente
{!editingCode && projectForm.recipientMode === 'alias' &&
}
- {editingCode &&
项目密钥
现有密钥不会再次显示。需要更换时执行轮换。
}
+ {editingCode && (
+
+
+
+
当前项目密钥
+
+ 默认隐藏。查看只会读取当前密钥,不会轮换,也不会影响调用方继续使用。
+
+
+ {revealedSecret?.projectCode === editingCode ? (
+
+ ) : (
+
+ )}
+
+
+
+ {revealedSecret?.projectCode === editingCode
+ ? revealedSecret.secret
+ : '••••••••••••••••••••••••••••••••'}
+
+ {revealedSecret?.projectCode === editingCode && (
+
+ )}
+
+ {revealedSecret?.projectCode === editingCode && Object.keys(revealedSecret.env).length > 0 && (
+
+ )}
+
+
+
轮换会使旧密钥立即失效,仅在确实需要更换时使用。
+
+
+ )}
@@ -957,7 +1104,7 @@ export default function NotificationCenterPanel({ showToast }: NotificationCente
{confirmTestOpen && resolvedTestRecipient && selectedTestProject &&
确认发送真实测试通知
- 项目
- {selectedTestProject.name} · {selectedTestProject.projectCode}
- 真实收件人
- {resolvedTestRecipient.label}
- 标题
- {testTitle}
确认后会真实调用钉钉,不是页面预览。
}
- {oneTimeSecret &&
{oneTimeSecret.reason === 'created' ? '接入项目已创建' : '项目密钥已轮换'}
下面的项目密钥只完整展示这一次。关闭后无法再次查看,请现在复制并保存到调用方安全环境变量。
项目密钥
{oneTimeSecret.secret}调用方环境变量
{Object.entries(oneTimeSecret.env).map(([key, value]) => `${key}=${value}`).join('\n')}}
+ {oneTimeSecret &&
{oneTimeSecret.reason === 'created' ? '接入项目已创建' : '项目密钥已轮换'}
这是当前有效的项目密钥。关闭后仍可在“查看配置 / 编辑资料”中按需查看;请只复制到调用方后端的安全环境变量。
项目密钥
{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 4eccee39..53fb8b83 100644
--- a/lib/__tests__/notification-center-admin-routes.test.ts
+++ b/lib/__tests__/notification-center-admin-routes.test.ts
@@ -10,6 +10,7 @@ import {
listNotificationCenterDeliveries,
listNotificationCenterProjectRecipients,
listNotificationCenterProjects,
+ revealNotificationCenterProjectSecret,
rotateNotificationCenterProjectSecret,
sendNotificationCenterProjectTest,
updateNotificationCenterProject,
@@ -18,6 +19,7 @@ import {
import { GET as getProjectsRoute, POST as postProjectsRoute } from '@/app/api/admin/notification-center/projects/route'
import { GET as getProjectRoute, PATCH as patchProjectRoute } from '@/app/api/admin/notification-center/projects/[code]/route'
import { POST as rotateProjectSecretRoute } from '@/app/api/admin/notification-center/projects/[code]/rotate-secret/route'
+import { POST as revealProjectSecretRoute } from '@/app/api/admin/notification-center/projects/[code]/reveal-secret/route'
import { GET as getProjectRecipientsRoute } from '@/app/api/admin/notification-center/projects/[code]/recipients/route'
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'
@@ -49,6 +51,7 @@ jest.mock('@/lib/notification-center-admin', () => ({
}
},
listNotificationCenterProjects: jest.fn(),
+ revealNotificationCenterProjectSecret: jest.fn(),
createNotificationCenterProject: jest.fn(),
getNotificationCenterProject: jest.fn(),
updateNotificationCenterProject: jest.fn(),
@@ -170,6 +173,40 @@ describe('notification-center admin routes', () => {
expect(body.data.projectSecret).toBe('rotated-secret')
})
+ test('POST /projects/[code]/reveal-secret 仅超管可读取当前密钥且禁止缓存', async () => {
+ ;(revealNotificationCenterProjectSecret as jest.Mock).mockResolvedValue({
+ status: 200,
+ data: {
+ projectCode: 'script_monitor',
+ secret: 'current-project-secret',
+ },
+ })
+
+ const response = await revealProjectSecretRoute(
+ {} as any,
+ { params: { code: 'script_monitor' } } as any,
+ )
+ const body = await response.json()
+
+ expect(response.status).toBe(200)
+ expect(response.headers.get('cache-control')).toContain('no-store')
+ expect(response.headers.get('pragma')).toBe('no-cache')
+ expect(body.data.secret).toBe('current-project-secret')
+ expect(revealNotificationCenterProjectSecret).toHaveBeenCalledWith(
+ expect.objectContaining({ userId: 'admin-1' }),
+ 'script_monitor',
+ )
+
+ ;(isSuperAdmin as jest.Mock).mockResolvedValue(false)
+ const forbidden = await revealProjectSecretRoute(
+ {} as any,
+ { params: { code: 'script_monitor' } } as any,
+ )
+ expect(forbidden.status).toBe(403)
+ expect(forbidden.headers.get('cache-control')).toContain('no-store')
+ expect(revealNotificationCenterProjectSecret).toHaveBeenCalledTimes(1)
+ })
+
test('GET /projects/[code]/recipients、GET /deliveries、GET /audits、POST /projects/[code]/test 可用', async () => {
;(listNotificationCenterProjectRecipients as jest.Mock).mockResolvedValue({ status: 200, data: [{ userId: 'ding-1' }] })
;(listNotificationCenterDeliveries as jest.Mock).mockResolvedValue({ status: 200, data: [{ id: 'delivery-1' }] })
diff --git a/lib/__tests__/notification-center-admin.test.ts b/lib/__tests__/notification-center-admin.test.ts
index 8e24db8b..67201a0c 100644
--- a/lib/__tests__/notification-center-admin.test.ts
+++ b/lib/__tests__/notification-center-admin.test.ts
@@ -5,6 +5,7 @@ import {
listNotificationCenterProjectRecipients,
listNotificationCenterProjects,
NotificationCenterAdminError,
+ revealNotificationCenterProjectSecret,
rotateNotificationCenterProjectSecret,
sendNotificationCenterProjectTest,
updateNotificationCenterProject,
@@ -104,6 +105,44 @@ describe('notification-center-admin client', () => {
})
})
+ test('查看当前密钥命中专属接口,保留 project secret 且剥离 admin secret', async () => {
+ ;(global.fetch as jest.Mock).mockResolvedValue({
+ ok: true,
+ status: 200,
+ text: async () => JSON.stringify({
+ projectCode: 'script_monitor',
+ secret: 'current-project-secret',
+ env: {
+ YYD_NOTIFICATION_CENTER_PROJECT_CODE: 'script_monitor',
+ YYD_NOTIFICATION_CENTER_PROJECT_SECRET: 'current-project-secret',
+ YYD_NOTIFICATION_CENTER_ADMIN_API_SECRET: 'server-admin-secret',
+ },
+ project: { recipients: [{ userId: 'ding-private-user' }] },
+ adminSecret: 'server-admin-secret',
+ }),
+ })
+
+ const result = await revealNotificationCenterProjectSecret(actor, 'script_monitor')
+
+ expect((global.fetch as jest.Mock).mock.calls[0][0]).toBe(
+ 'https://notify-admin.example.com/projects/script_monitor/reveal-secret',
+ )
+ expect((global.fetch as jest.Mock).mock.calls[0][1]).toMatchObject({
+ method: 'POST',
+ cache: 'no-store',
+ })
+ expect(result.data).toEqual({
+ projectCode: 'script_monitor',
+ secret: 'current-project-secret',
+ env: {
+ YYD_NOTIFICATION_CENTER_PROJECT_CODE: 'script_monitor',
+ YYD_NOTIFICATION_CENTER_PROJECT_SECRET: 'current-project-secret',
+ },
+ })
+ expect(JSON.stringify(result.data)).not.toContain('server-admin-secret')
+ expect(JSON.stringify(result.data)).not.toContain('ding-private-user')
+ })
+
test('PATCH 项目更新与项目级 recipients/test 会命中嵌套路径', async () => {
;(global.fetch as jest.Mock)
.mockResolvedValueOnce({
diff --git a/lib/notification-center-admin.ts b/lib/notification-center-admin.ts
index 2c1683af..23d40c65 100644
--- a/lib/notification-center-admin.ts
+++ b/lib/notification-center-admin.ts
@@ -384,6 +384,39 @@ export async function rotateNotificationCenterProjectSecret(
})
}
+export async function revealNotificationCenterProjectSecret(
+ actor: NotificationCenterAdminActor,
+ code: string,
+) {
+ const projectCode = requireProjectCode(code)
+ const result = await requestNotificationCenterAdmin
>({
+ method: 'POST',
+ path: buildProjectScopedPath(projectCode, '/reveal-secret'),
+ actor,
+ allowProjectSecrets: true,
+ })
+ const sourceEnv = result.data.env && typeof result.data.env === 'object' && !Array.isArray(result.data.env)
+ ? result.data.env as Record
+ : {}
+ const env = Object.fromEntries(
+ [
+ 'YYD_NOTIFICATION_CENTER_URL',
+ 'YYD_NOTIFICATION_CENTER_PROJECT_CODE',
+ 'YYD_NOTIFICATION_CENTER_PROJECT_SECRET',
+ ]
+ .filter((key) => sourceEnv[key] != null)
+ .map((key) => [key, String(sourceEnv[key])]),
+ )
+ return {
+ ...result,
+ data: {
+ projectCode: clean(result.data.projectCode) || projectCode,
+ secret: clean(result.data.secret ?? result.data.projectSecret) || '',
+ env,
+ },
+ }
+}
+
export async function listNotificationCenterProjectRecipients(
actor: NotificationCenterAdminActor,
code: string,