feat: integrate notification admin and recoverable API keys

This commit is contained in:
chenpei 2026-08-29 10:25:32 +08:00
parent 92a2326699
commit 6ac36e73af
37 changed files with 3544 additions and 163 deletions

View File

@ -135,6 +135,13 @@ DATABASE_URL=postgresql://...
NEXTAUTH_SECRET=replace-this-with-a-strong-secret
```
启用素材平台 API Key 创建和后续查看时,还必须配置独立且稳定的加密密钥:
```bash
# 至少 32 个字符;生产环境必须独立生成并稳定保存
API_KEY_ENCRYPTION_KEY=replace-this-with-a-separate-strong-secret
```
常用配置:
```bash
@ -167,6 +174,11 @@ DINGTALK_PERFORMANCE_SURVEY_NOTIFICATION_CHANNEL=robot_oto
DINGTALK_ROBOT_CODE=your-dingtalk-robot-code
# 灰度阶段填写允许走机器人单聊的钉钉 userId,多个用逗号分隔;留空表示全量启用
DINGTALK_ROBOT_OTO_TEST_USER_IDS=user-id-1,user-id-2
# 通知中心管理后台代理(仅服务端使用,绝不能下发到浏览器)
YYD_NOTIFICATION_CENTER_ADMIN_URL=https://notify-admin.example.com/notification-center/admin
YYD_NOTIFICATION_CENTER_ADMIN_API_SECRET=replace-with-server-side-admin-secret
# 可选,请求超时毫秒;默认 8000
YYD_NOTIFICATION_CENTER_ADMIN_TIMEOUT_MS=8000
# 下载与外部接口
DOWNLOAD_TOKEN_SECRET=replace-this

View File

@ -36,9 +36,8 @@ export default function ApiKeysPage() {
if (status === 'unauthenticated') {
router.push('/login');
} else if (status === 'authenticated' && session?.user) {
const userRole = (session.user as any).role;
if (userRole !== 'ADMIN' && userRole !== 'SYSTEM_ADMIN') {
router.push('/');
if (!(session.user as any).isSuperAdmin) {
router.push('/admin');
}
}
}, [status, session, router]);
@ -207,7 +206,7 @@ export default function ApiKeysPage() {
<div className="bg-gray-100 p-3 rounded font-mono text-sm break-all mb-4">
{createdKey}
</div>
<p className="text-sm text-red-600 mb-4">注意:此密钥只显示一次,关闭后将无法再次查看</p>
<p className="text-sm text-gray-600 mb-4">关闭后仍可在超级管理员工作台的“权限与凭证”中按需查看。</p>
<button
onClick={() => {
setCreatedKey(null);

View File

@ -0,0 +1,70 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { isSuperAdmin } from '@/lib/department-permission';
import {
ApiKeyEncryptionConfigError,
ApiKeyNotRecoverableError,
revealApiKey,
} from '@/lib/external-api';
const NO_STORE_HEADERS = {
'Cache-Control': 'no-store, max-age=0',
Pragma: 'no-cache',
};
export async function POST(
_request: NextRequest,
{ params }: { params: { id: string } },
) {
try {
const session = await getServerSession(authOptions);
const userId = (session?.user as { id?: string } | undefined)?.id;
if (!userId) {
return NextResponse.json(
{ success: false, error: '未登录' },
{ status: 401, headers: NO_STORE_HEADERS },
);
}
if (!(await isSuperAdmin(userId))) {
return NextResponse.json(
{ success: false, error: '仅超级管理员可查看' },
{ status: 403, headers: NO_STORE_HEADERS },
);
}
const key = await revealApiKey(params.id, userId);
if (!key) {
return NextResponse.json(
{ success: false, error: 'API Key 不存在' },
{ status: 404, headers: NO_STORE_HEADERS },
);
}
return NextResponse.json(
{ success: true, data: { key } },
{ headers: NO_STORE_HEADERS },
);
} catch (error) {
if (error instanceof ApiKeyNotRecoverableError) {
return NextResponse.json(
{ success: false, error: error.message, code: 'API_KEY_NOT_RECOVERABLE' },
{ status: 409, headers: NO_STORE_HEADERS },
);
}
if (error instanceof ApiKeyEncryptionConfigError) {
return NextResponse.json(
{ success: false, error: 'API Key 加密配置缺失,请联系运维配置后重试' },
{ status: 503, headers: NO_STORE_HEADERS },
);
}
console.error('[admin/api-keys/[id]/reveal] POST error:', error);
return NextResponse.json(
{ success: false, error: '读取 API Key 失败' },
{ status: 500, headers: NO_STORE_HEADERS },
);
}
}

View File

@ -7,7 +7,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { isSuperAdmin, hasSystemAdminPermission } from '@/lib/department-permission';
import { isSuperAdmin } from '@/lib/department-permission';
import { enableApiKey, disableApiKey } from '@/lib/external-api';
import { prisma } from '@/lib/prisma';
@ -29,13 +29,9 @@ export async function PUT(
// 检查权限
const userId = (session.user as any).id;
const userRole = (session.user as any).role;
const isSuperAdminUser = await isSuperAdmin(userId);
const hasPermission = hasSystemAdminPermission(userRole, isSuperAdminUser);
if (!hasPermission) {
if (!(await isSuperAdmin(userId))) {
return NextResponse.json(
{ success: false, error: '无权限操作' },
{ success: false, error: '仅超级管理员可操作' },
{ status: 403 }
);
}
@ -102,13 +98,9 @@ export async function DELETE(
// 检查权限
const userId = (session.user as any).id;
const userRole = (session.user as any).role;
const isSuperAdminUser = await isSuperAdmin(userId);
const hasPermission = hasSystemAdminPermission(userRole, isSuperAdminUser);
if (!hasPermission) {
if (!(await isSuperAdmin(userId))) {
return NextResponse.json(
{ success: false, error: '无权限操作' },
{ success: false, error: '仅超级管理员可操作' },
{ status: 403 }
);
}

View File

@ -0,0 +1,159 @@
/** @jest-environment node */
import { getServerSession } from 'next-auth'
import { isSuperAdmin } from '@/lib/department-permission'
import {
ApiKeyNotRecoverableError,
createApiKey,
getApiKeys,
getApiKeyPreview,
revealApiKey,
} from '@/lib/external-api'
import { prisma } from '@/lib/prisma'
import { GET as getApiKeysRoute, POST as createApiKeyRoute } from '@/app/api/admin/api-keys/route'
import { PUT as updateApiKeyRoute, DELETE as deleteApiKeyRoute } from '@/app/api/admin/api-keys/[id]/route'
import { POST as revealApiKeyRoute } from '@/app/api/admin/api-keys/[id]/reveal/route'
import { enableApiKey, disableApiKey } from '@/lib/external-api'
jest.mock('next-auth', () => ({
getServerSession: jest.fn(),
}))
jest.mock('@/lib/auth', () => ({
authOptions: {},
}))
jest.mock('@/lib/department-permission', () => ({
isSuperAdmin: jest.fn(),
}))
jest.mock('@/lib/external-api', () => ({
ApiKeyEncryptionConfigError: class ApiKeyEncryptionConfigError extends Error {},
ApiKeyNotRecoverableError: class ApiKeyNotRecoverableError extends Error {},
getApiKeys: jest.fn(),
createApiKey: jest.fn(),
getApiKeyPreview: jest.fn((key: string) => `${String(key).slice(0, 8)}...`),
enableApiKey: jest.fn(),
disableApiKey: jest.fn(),
revealApiKey: jest.fn(),
}))
jest.mock('@/lib/prisma', () => ({
prisma: {
apiKey: {
findUnique: jest.fn(),
delete: jest.fn(),
},
},
}))
describe('admin api-keys routes', () => {
beforeEach(() => {
jest.clearAllMocks()
;(getServerSession as jest.Mock).mockResolvedValue({
user: {
id: 'admin-1',
role: 'SYSTEM_ADMIN',
isSuperAdmin: false,
},
})
;(isSuperAdmin as jest.Mock).mockResolvedValue(true)
})
test('GET 会继续返回 masked key 预览', async () => {
;(getApiKeys as jest.Mock).mockResolvedValue([
{ id: 'key-1', key: 'abcdefgh12345678', name: '任务系统', isActive: true, canReveal: true },
])
const response = await getApiKeysRoute(new Request('http://localhost/api/admin/api-keys') as any)
const body = await response.json()
expect(response.status).toBe(200)
expect(body.data[0].key).toBe('abcdefgh...')
expect(body.data[0].canReveal).toBe(true)
expect(getApiKeyPreview).toHaveBeenCalledWith('abcdefgh12345678')
})
test('非真正超管时 GET/POST/PUT/DELETE/reveal 都返回 403', async () => {
;(isSuperAdmin as jest.Mock).mockResolvedValue(false)
const getResponse = await getApiKeysRoute(new Request('http://localhost/api/admin/api-keys') as any)
const postResponse = await createApiKeyRoute({ json: jest.fn().mockResolvedValue({ name: '任务系统' }) } as any)
const putResponse = await updateApiKeyRoute(
{ json: jest.fn().mockResolvedValue({ isActive: true }) } as any,
{ params: { id: 'key-1' } } as any,
)
const deleteResponse = await deleteApiKeyRoute(
new Request('http://localhost/api/admin/api-keys/key-1') as any,
{ params: { id: 'key-1' } } as any,
)
const revealResponse = await revealApiKeyRoute(
new Request('http://localhost/api/admin/api-keys/key-1/reveal', { method: 'POST' }) as any,
{ params: { id: 'key-1' } } as any,
)
expect(getResponse.status).toBe(403)
expect(postResponse.status).toBe(403)
expect(putResponse.status).toBe(403)
expect(deleteResponse.status).toBe(403)
expect(revealResponse.status).toBe(403)
expect(createApiKey).not.toHaveBeenCalled()
expect(enableApiKey).not.toHaveBeenCalled()
expect(disableApiKey).not.toHaveBeenCalled()
expect(prisma.apiKey.delete).not.toHaveBeenCalled()
expect(revealApiKey).not.toHaveBeenCalled()
})
test('超管可创建并管理 API Key', async () => {
;(createApiKey as jest.Mock).mockResolvedValue({ id: 'key-1', key: 'plain-key' })
;(prisma.apiKey.findUnique as jest.Mock).mockResolvedValue({ id: 'key-1' })
;(prisma.apiKey.delete as jest.Mock).mockResolvedValue({ id: 'key-1' })
const postResponse = await createApiKeyRoute({ json: jest.fn().mockResolvedValue({ name: '任务系统' }) } as any)
const putResponse = await updateApiKeyRoute(
{ json: jest.fn().mockResolvedValue({ isActive: false }) } as any,
{ params: { id: 'key-1' } } as any,
)
const deleteResponse = await deleteApiKeyRoute(
new Request('http://localhost/api/admin/api-keys/key-1') as any,
{ params: { id: 'key-1' } } as any,
)
expect(postResponse.status).toBe(200)
expect((await postResponse.json()).data.key).toBe('plain-key')
expect(postResponse.headers.get('cache-control')).toContain('no-store')
expect(putResponse.status).toBe(200)
expect(disableApiKey).toHaveBeenCalledWith('key-1')
expect(deleteResponse.status).toBe(200)
expect(prisma.apiKey.delete).toHaveBeenCalledWith({ where: { id: 'key-1' } })
})
test('超管可按需查看完整 Key,响应禁止缓存', async () => {
;(revealApiKey as jest.Mock).mockResolvedValue('plain-key')
const response = await revealApiKeyRoute(
new Request('http://localhost/api/admin/api-keys/key-1/reveal', { method: 'POST' }) as any,
{ params: { id: 'key-1' } } as any,
)
const body = await response.json()
expect(response.status).toBe(200)
expect(body.data.key).toBe('plain-key')
expect(response.headers.get('cache-control')).toContain('no-store')
expect(revealApiKey).toHaveBeenCalledWith('key-1', 'admin-1')
})
test('历史纯哈希 Key 返回可操作的 409 提示', async () => {
;(revealApiKey as jest.Mock).mockRejectedValue(new ApiKeyNotRecoverableError())
const response = await revealApiKeyRoute(
new Request('http://localhost/api/admin/api-keys/key-old/reveal', { method: 'POST' }) as any,
{ params: { id: 'key-old' } } as any,
)
const body = await response.json()
expect(response.status).toBe(409)
expect(body.code).toBe('API_KEY_NOT_RECOVERABLE')
})
})

View File

@ -7,8 +7,13 @@
import { NextRequest, NextResponse } from 'next/server';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/lib/auth';
import { isSuperAdmin, hasSystemAdminPermission } from '@/lib/department-permission';
import { getApiKeys, createApiKey, getApiKeyPreview } from '@/lib/external-api';
import { isSuperAdmin } from '@/lib/department-permission';
import {
ApiKeyEncryptionConfigError,
createApiKey,
getApiKeyPreview,
getApiKeys,
} from '@/lib/external-api';
/**
* GET - 获取 API Key 列表
@ -23,15 +28,11 @@ export async function GET(request: NextRequest) {
);
}
// 检查权限(仅超级管理员和系统管理员可访问)
// 检查权限(仅真正超级管理员可访问)
const userId = (session.user as any).id;
const userRole = (session.user as any).role;
const isSuperAdminUser = await isSuperAdmin(userId);
const hasPermission = hasSystemAdminPermission(userRole, isSuperAdminUser);
if (!hasPermission) {
if (!(await isSuperAdmin(userId))) {
return NextResponse.json(
{ success: false, error: '无权限访问' },
{ success: false, error: '仅超级管理员可访问' },
{ status: 403 }
);
}
@ -72,13 +73,9 @@ export async function POST(request: NextRequest) {
// 检查权限
const userId = (session.user as any).id;
const userRole = (session.user as any).role;
const isSuperAdminUser = await isSuperAdmin(userId);
const hasPermission = hasSystemAdminPermission(userRole, isSuperAdminUser);
if (!hasPermission) {
if (!(await isSuperAdmin(userId))) {
return NextResponse.json(
{ success: false, error: '无权限操作' },
{ success: false, error: '仅超级管理员可操作' },
{ status: 403 }
);
}
@ -95,12 +92,21 @@ export async function POST(request: NextRequest) {
const apiKey = await createApiKey(name.trim(), userId);
return NextResponse.json({
success: true,
data: apiKey,
message: 'API Key 创建成功,请妥善保管密钥,此密钥仅显示一次',
});
return NextResponse.json(
{
success: true,
data: apiKey,
message: 'API Key 创建成功,之后仍可由超级管理员按需查看',
},
{ headers: { 'Cache-Control': 'no-store, max-age=0', Pragma: 'no-cache' } },
);
} catch (error) {
if (error instanceof ApiKeyEncryptionConfigError) {
return NextResponse.json(
{ success: false, error: 'API Key 加密配置缺失,请联系运维配置后重试' },
{ status: 503 },
);
}
console.error('[admin/api-keys] POST error:', error);
return NextResponse.json(
{ success: false, error: '服务器错误' },

View File

@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { hasSystemAdminPermission, isSuperAdmin } from '@/lib/department-permission'
import { isSuperAdmin } from '@/lib/department-permission'
import { retryExternalNotificationDelivery } from '@/lib/external-auto-listing'
async function assertAdminPermission() {
@ -11,12 +11,8 @@ async function assertAdminPermission() {
}
const userId = (session.user as any).id
const userRole = (session.user as any).role
const isSuperAdminUser = await isSuperAdmin(userId)
const hasPermission = hasSystemAdminPermission(userRole, isSuperAdminUser)
if (!hasPermission) {
return NextResponse.json({ success: false, error: '无权限操作' }, { status: 403 })
if (!(await isSuperAdmin(userId))) {
return NextResponse.json({ success: false, error: '仅超级管理员可操作' }, { status: 403 })
}
return null

View File

@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { hasSystemAdminPermission, isSuperAdmin } from '@/lib/department-permission'
import { isSuperAdmin } from '@/lib/department-permission'
import { listExternalNotificationDeliveries } from '@/lib/external-auto-listing'
async function assertAdminPermission() {
@ -11,12 +11,8 @@ async function assertAdminPermission() {
}
const userId = (session.user as any).id
const userRole = (session.user as any).role
const isSuperAdminUser = await isSuperAdmin(userId)
const hasPermission = hasSystemAdminPermission(userRole, isSuperAdminUser)
if (!hasPermission) {
return NextResponse.json({ success: false, error: '无权限访问' }, { status: 403 })
if (!(await isSuperAdmin(userId))) {
return NextResponse.json({ success: false, error: '仅超级管理员可访问' }, { status: 403 })
}
return null

View File

@ -1,8 +1,13 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { hasSystemAdminPermission, isSuperAdmin } from '@/lib/department-permission'
import { updateExternalNotificationTarget } from '@/lib/external-auto-listing'
import { isSuperAdmin } from '@/lib/department-permission'
import {
buildExternalNotificationTargetAdminView,
getExternalNotificationTargetById,
updateExternalNotificationTarget,
type ExternalNotificationTarget,
} from '@/lib/external-auto-listing'
async function assertAdminPermission() {
const session = await getServerSession(authOptions)
@ -11,17 +16,42 @@ async function assertAdminPermission() {
}
const userId = (session.user as any).id
const userRole = (session.user as any).role
const isSuperAdminUser = await isSuperAdmin(userId)
const hasPermission = hasSystemAdminPermission(userRole, isSuperAdminUser)
if (!hasPermission) {
return NextResponse.json({ success: false, error: '无权限操作' }, { status: 403 })
if (!(await isSuperAdmin(userId))) {
return NextResponse.json({ success: false, error: '仅超级管理员可操作' }, { status: 403 })
}
return null
}
function normalizeAuthConfigInput(
value: unknown,
existing: ExternalNotificationTarget,
nextAuthType: string,
): Record<string, string> | undefined {
const record =
value && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown>
: null
if (!record) return undefined
if (nextAuthType === 'bearer') {
const token = String(record.token || '').trim()
return { token: token || String(existing.authConfig.token || '').trim() }
}
if (nextAuthType === 'header') {
const headerName = String(record.headerName || existing.authConfig.headerName || '').trim()
const headerValue = String(record.headerValue || '').trim()
|| String(existing.authConfig.headerValue || '').trim()
const nextConfig: Record<string, string> = {}
if (headerName) nextConfig.headerName = headerName
if (headerValue) nextConfig.headerValue = headerValue
return nextConfig
}
return {}
}
function toErrorResponse(error: unknown) {
const message = error instanceof Error ? error.message : '服务器错误'
const status =
@ -53,21 +83,24 @@ export async function PUT(
}
const body = await request.json()
const existing = await getExternalNotificationTargetById(targetId)
if (!existing) {
return NextResponse.json({ success: false, error: '通知目标不存在' }, { status: 404 })
}
const nextAuthType =
typeof body?.authType === 'string' ? body.authType : existing.authType
const target = await updateExternalNotificationTarget(targetId, {
name: typeof body?.name === 'string' ? body.name : undefined,
callbackUrl: typeof body?.callbackUrl === 'string' ? body.callbackUrl : undefined,
isActive: typeof body?.isActive === 'boolean' ? body.isActive : undefined,
authType: typeof body?.authType === 'string' ? body.authType : undefined,
authConfig:
body?.authConfig && typeof body.authConfig === 'object' && !Array.isArray(body.authConfig)
? body.authConfig
: undefined,
authConfig: normalizeAuthConfigInput(body?.authConfig, existing, nextAuthType),
eventTypes: Array.isArray(body?.eventTypes) ? body.eventTypes : undefined,
})
return NextResponse.json({
success: true,
data: target,
data: buildExternalNotificationTargetAdminView(target),
message: '通知目标更新成功',
})
} catch (error) {

View File

@ -1,8 +1,9 @@
import { NextRequest, NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { hasSystemAdminPermission, isSuperAdmin } from '@/lib/department-permission'
import { isSuperAdmin } from '@/lib/department-permission'
import {
buildExternalNotificationTargetAdminView,
createExternalNotificationTarget,
listExternalNotificationTargets,
} from '@/lib/external-auto-listing'
@ -14,12 +15,8 @@ async function assertAdminPermission() {
}
const userId = (session.user as any).id
const userRole = (session.user as any).role
const isSuperAdminUser = await isSuperAdmin(userId)
const hasPermission = hasSystemAdminPermission(userRole, isSuperAdminUser)
if (!hasPermission) {
return NextResponse.json({ success: false, error: '无权限访问' }, { status: 403 })
if (!(await isSuperAdmin(userId))) {
return NextResponse.json({ success: false, error: '仅超级管理员可访问' }, { status: 403 })
}
return null
@ -48,7 +45,7 @@ export async function GET(request: NextRequest) {
const targets = await listExternalNotificationTargets()
return NextResponse.json({
success: true,
data: targets,
data: targets.map(buildExternalNotificationTargetAdminView),
})
} catch (error) {
console.error('[admin/external-notification-targets] GET error:', error)
@ -78,7 +75,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json({
success: true,
data: target,
data: buildExternalNotificationTargetAdminView(target),
message: '通知目标创建成功',
})
} catch (error) {

View File

@ -0,0 +1,61 @@
import { NextResponse } from 'next/server'
import { getServerSession } from 'next-auth'
import { authOptions } from '@/lib/auth'
import { isSuperAdmin } from '@/lib/department-permission'
import {
type NotificationCenterAdminActor,
NotificationCenterAdminError,
} from '@/lib/notification-center-admin'
export async function requireNotificationCenterSuperAdmin(): Promise<
{ actor: NotificationCenterAdminActor; session: unknown } | { response: NextResponse }
> {
const session = await getServerSession(authOptions)
const user = (session as {
user?: { id?: string; username?: string; realName?: string | null; email?: string | null }
} | null)?.user
if (!user?.id) {
return {
response: NextResponse.json({ success: false, error: '未登录' }, { status: 401 }),
}
}
if (!(await isSuperAdmin(user.id))) {
return {
response: NextResponse.json({ success: false, error: '仅超级管理员可访问通知中心管理接口' }, { status: 403 }),
}
}
return {
actor: {
userId: user.id,
username: user.username || null,
realName: user.realName || null,
email: user.email || null,
},
session,
}
}
export function toNotificationCenterAdminErrorResponse(error: unknown) {
if (error instanceof NotificationCenterAdminError) {
return NextResponse.json(
{
success: false,
error: error.message,
...(error.details ? { details: error.details } : {}),
code: error.code,
},
{ status: error.statusCode },
)
}
const message = error instanceof Error ? error.message : '通知中心请求失败'
console.error('[notification-center-admin] route error:', message)
return NextResponse.json(
{ success: false, error: '通知中心请求失败,请稍后重试', code: 'NOTIFICATION_CENTER_ADMIN_ROUTE_ERROR' },
{ status: 500 },
)
}

View File

@ -0,0 +1,22 @@
import { NextRequest, NextResponse } from 'next/server'
import { listNotificationCenterAudits } from '@/lib/notification-center-admin'
import {
requireNotificationCenterSuperAdmin,
toNotificationCenterAdminErrorResponse,
} from '../_shared'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
export async function GET(request: NextRequest) {
try {
const auth = await requireNotificationCenterSuperAdmin()
if ('response' in auth) return auth.response
const result = await listNotificationCenterAudits(auth.actor, new URL(request.url).searchParams)
return NextResponse.json({ success: true, data: result.data }, { status: result.status })
} catch (error) {
return toNotificationCenterAdminErrorResponse(error)
}
}

View File

@ -0,0 +1,22 @@
import { NextRequest, NextResponse } from 'next/server'
import { listNotificationCenterDeliveries } from '@/lib/notification-center-admin'
import {
requireNotificationCenterSuperAdmin,
toNotificationCenterAdminErrorResponse,
} from '../_shared'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
export async function GET(request: NextRequest) {
try {
const auth = await requireNotificationCenterSuperAdmin()
if ('response' in auth) return auth.response
const result = await listNotificationCenterDeliveries(auth.actor, new URL(request.url).searchParams)
return NextResponse.json({ success: true, data: result.data }, { status: result.status })
} catch (error) {
return toNotificationCenterAdminErrorResponse(error)
}
}

View File

@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from 'next/server'
import { listNotificationCenterProjectRecipients } from '@/lib/notification-center-admin'
import {
requireNotificationCenterSuperAdmin,
toNotificationCenterAdminErrorResponse,
} from '../../../_shared'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
function readProjectCode(params: { code?: string }) {
return String(params?.code || '').trim()
}
export async function GET(
request: NextRequest,
{ params }: { params: { code: string } },
) {
try {
const auth = await requireNotificationCenterSuperAdmin()
if ('response' in auth) return auth.response
const code = readProjectCode(params)
if (!code) {
return NextResponse.json({ success: false, error: '通知中心项目不存在' }, { status: 404 })
}
const result = await listNotificationCenterProjectRecipients(
auth.actor,
code,
new URL(request.url).searchParams,
)
return NextResponse.json({ success: true, data: result.data }, { status: result.status })
} catch (error) {
return toNotificationCenterAdminErrorResponse(error)
}
}

View File

@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from 'next/server'
import { rotateNotificationCenterProjectSecret } from '@/lib/notification-center-admin'
import {
requireNotificationCenterSuperAdmin,
toNotificationCenterAdminErrorResponse,
} from '../../../_shared'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
export async function POST(
request: NextRequest,
{ params }: { params: { code: string } },
) {
try {
const auth = await requireNotificationCenterSuperAdmin()
if ('response' in auth) return auth.response
const code = String(params?.code || '').trim()
if (!code) {
return NextResponse.json({ success: false, error: '通知中心项目不存在' }, { status: 404 })
}
const body = await request.json().catch(() => ({}))
const result = await rotateNotificationCenterProjectSecret(auth.actor, code, body)
return NextResponse.json({ success: true, data: result.data }, { status: result.status })
} catch (error) {
return toNotificationCenterAdminErrorResponse(error)
}
}

View File

@ -0,0 +1,72 @@
import { NextRequest, NextResponse } from 'next/server'
import {
getNotificationCenterProject,
updateNotificationCenterProject,
} from '@/lib/notification-center-admin'
import {
requireNotificationCenterSuperAdmin,
toNotificationCenterAdminErrorResponse,
} from '../../_shared'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
function readProjectCode(params: { code?: string }) {
return String(params?.code || '').trim()
}
export async function GET(
_request: NextRequest,
{ params }: { params: { code: string } },
) {
try {
const auth = await requireNotificationCenterSuperAdmin()
if ('response' in auth) return auth.response
const code = readProjectCode(params)
if (!code) {
return NextResponse.json({ success: false, error: '通知中心项目不存在' }, { status: 404 })
}
const result = await getNotificationCenterProject(auth.actor, code)
return NextResponse.json({ success: true, data: result.data }, { status: result.status })
} catch (error) {
return toNotificationCenterAdminErrorResponse(error)
}
}
async function handlePatch(
request: NextRequest,
{ params }: { params: { code: string } },
) {
try {
const auth = await requireNotificationCenterSuperAdmin()
if ('response' in auth) return auth.response
const code = readProjectCode(params)
if (!code) {
return NextResponse.json({ success: false, error: '通知中心项目不存在' }, { status: 404 })
}
const body = await request.json().catch(() => ({}))
const result = await updateNotificationCenterProject(auth.actor, code, body)
return NextResponse.json({ success: true, data: result.data }, { status: result.status })
} catch (error) {
return toNotificationCenterAdminErrorResponse(error)
}
}
export async function PATCH(
request: NextRequest,
context: { params: { code: string } },
) {
return handlePatch(request, context)
}
export async function PUT(
request: NextRequest,
context: { params: { code: string } },
) {
return handlePatch(request, context)
}

View File

@ -0,0 +1,35 @@
import { NextRequest, NextResponse } from 'next/server'
import { sendNotificationCenterProjectTest } from '@/lib/notification-center-admin'
import {
requireNotificationCenterSuperAdmin,
toNotificationCenterAdminErrorResponse,
} from '../../../_shared'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
function readProjectCode(params: { code?: string }) {
return String(params?.code || '').trim()
}
export async function POST(
request: NextRequest,
{ params }: { params: { code: string } },
) {
try {
const auth = await requireNotificationCenterSuperAdmin()
if ('response' in auth) return auth.response
const code = readProjectCode(params)
if (!code) {
return NextResponse.json({ success: false, error: '通知中心项目不存在' }, { status: 404 })
}
const body = await request.json().catch(() => ({}))
const result = await sendNotificationCenterProjectTest(auth.actor, code, body)
return NextResponse.json({ success: true, data: result.data }, { status: result.status })
} catch (error) {
return toNotificationCenterAdminErrorResponse(error)
}
}

View File

@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from 'next/server'
import {
createNotificationCenterProject,
listNotificationCenterProjects,
} from '@/lib/notification-center-admin'
import {
requireNotificationCenterSuperAdmin,
toNotificationCenterAdminErrorResponse,
} from '../_shared'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
export async function GET(request: NextRequest) {
try {
const auth = await requireNotificationCenterSuperAdmin()
if ('response' in auth) return auth.response
const result = await listNotificationCenterProjects(auth.actor, new URL(request.url).searchParams)
return NextResponse.json({ success: true, data: result.data }, { status: result.status })
} catch (error) {
return toNotificationCenterAdminErrorResponse(error)
}
}
export async function POST(request: NextRequest) {
try {
const auth = await requireNotificationCenterSuperAdmin()
if ('response' in auth) return auth.response
const body = await request.json().catch(() => ({}))
const result = await createNotificationCenterProject(auth.actor, body)
return NextResponse.json({ success: true, data: result.data }, { status: result.status })
} catch (error) {
return toNotificationCenterAdminErrorResponse(error)
}
}

View File

@ -0,0 +1,22 @@
import { NextRequest, NextResponse } from 'next/server'
import { listNotificationCenterRecipients } from '@/lib/notification-center-admin'
import {
requireNotificationCenterSuperAdmin,
toNotificationCenterAdminErrorResponse,
} from '../_shared'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
export async function GET(request: NextRequest) {
try {
const auth = await requireNotificationCenterSuperAdmin()
if ('response' in auth) return auth.response
const result = await listNotificationCenterRecipients(auth.actor, new URL(request.url).searchParams)
return NextResponse.json({ success: true, data: result.data }, { status: result.status })
} catch (error) {
return toNotificationCenterAdminErrorResponse(error)
}
}

View File

@ -0,0 +1,23 @@
import { NextRequest, NextResponse } from 'next/server'
import { sendNotificationCenterTest } from '@/lib/notification-center-admin'
import {
requireNotificationCenterSuperAdmin,
toNotificationCenterAdminErrorResponse,
} from '../_shared'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
export async function POST(request: NextRequest) {
try {
const auth = await requireNotificationCenterSuperAdmin()
if ('response' in auth) return auth.response
const body = await request.json().catch(() => ({}))
const result = await sendNotificationCenterTest(auth.actor, body)
return NextResponse.json({ success: true, data: result.data }, { status: result.status })
} catch (error) {
return toNotificationCenterAdminErrorResponse(error)
}
}

View File

@ -0,0 +1,92 @@
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import SuperAdminsTab from './SuperAdminsTab'
jest.mock('@/components/admin/NotificationCenterPanel', () => ({
__esModule: true,
default: () => <div>统一通知中心管理工作面</div>,
}))
describe('SuperAdminsTab workbench navigation', () => {
beforeEach(() => {
global.fetch = jest.fn((input: RequestInfo | URL) => {
const url = String(input)
if (url === '/api/admin/super-admins') {
return Promise.resolve({
ok: true,
json: async () => ({ superAdmins: [], count: 0, canAdd: true }),
} as Response)
}
return Promise.resolve({
ok: true,
json: async () => ({ data: [] }),
} as Response)
}) as jest.Mock
})
test('默认进入统一通知中心且三类能力互不混排', async () => {
const user = userEvent.setup()
render(<SuperAdminsTab showToast={jest.fn()} />)
expect(await screen.findByText('统一通知中心管理工作面')).toBeInTheDocument()
expect(screen.queryByText('素材平台 API Key')).not.toBeInTheDocument()
expect(screen.queryByText('素材外发目标')).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: /权限与凭证/ }))
expect(screen.getByText('素材平台 API Key')).toBeInTheDocument()
expect(screen.queryByText('统一通知中心管理工作面')).not.toBeInTheDocument()
await user.click(screen.getByRole('button', { name: /素材外发/ }))
expect(screen.getByText('素材外发目标')).toBeInTheDocument()
expect(screen.queryByText('素材平台 API Key')).not.toBeInTheDocument()
await waitFor(() => expect(global.fetch).toHaveBeenCalledWith('/api/admin/super-admins'))
})
test('超级管理员可按需查看并再次隐藏完整 API Key', async () => {
const user = userEvent.setup()
;(global.fetch as jest.Mock).mockImplementation((input: RequestInfo | URL) => {
const url = String(input)
if (url === '/api/admin/super-admins') {
return Promise.resolve({
ok: true,
json: async () => ({ superAdmins: [], count: 0, canAdd: true }),
} as Response)
}
if (url === '/api/admin/api-keys/key-1/reveal') {
return Promise.resolve({
ok: true,
json: async () => ({ data: { key: '1234567890abcdef1234567890abcdef' } }),
} as Response)
}
if (url.includes('/api/admin/api-keys')) {
return Promise.resolve({
ok: true,
json: async () => ({
data: [{
id: 'key-1',
name: '外部任务系统',
key: '12345678...',
canReveal: true,
isActive: true,
createdBy: 'admin-1',
lastUsedAt: null,
createdAt: '2026-08-29T00:00:00.000Z',
}],
}),
} as Response)
}
return Promise.resolve({ ok: true, json: async () => ({ data: [] }) } as Response)
})
render(<SuperAdminsTab showToast={jest.fn()} />)
await user.click(await screen.findByRole('button', { name: /权限与凭证/ }))
await screen.findByText('外部任务系统')
const revealButton = screen.getByTitle('查看完整 Key')
await user.click(revealButton)
expect(await screen.findByText('1234567890abcdef1234567890abcdef')).toBeInTheDocument()
await user.click(screen.getByTitle('隐藏完整 Key'))
expect(screen.queryByText('1234567890abcdef1234567890abcdef')).not.toBeInTheDocument()
})
})

View File

@ -1,7 +1,9 @@
'use client'
import { useEffect, useState } from 'react'
import { useCallback, useEffect, useState } from 'react'
import { parseFetchError } from '@/lib/error-handler'
import { BellRing, Copy, Eye, EyeOff, KeyRound, ShieldCheck, Webhook } from 'lucide-react'
import NotificationCenterPanel from '@/components/admin/NotificationCenterPanel'
interface SuperAdmin {
id: string
@ -17,6 +19,7 @@ interface ApiKey {
id: string
name: string
key: string
canReveal: boolean
isActive: boolean
createdBy: string
lastUsedAt: string | null
@ -32,6 +35,9 @@ interface ExternalNotificationTarget {
isActive: boolean
authType: NotificationAuthType
authConfig: Record<string, string>
authConfigured?: boolean
authMasked?: string
authSummary?: string
eventTypes: string[]
createdAt: string
updatedAt: string
@ -109,27 +115,18 @@ function formatDateTime(value: string | null) {
return new Date(value).toLocaleString('zh-CN')
}
function maskSecret(value: string | undefined) {
const normalized = String(value || '').trim()
if (!normalized) {
return '未配置'
}
if (normalized.length <= 8) {
return '已配置'
}
return `${normalized.slice(0, 4)}****${normalized.slice(-4)}`
}
function getNotificationTargetAuthSummary(target: ExternalNotificationTarget) {
if (target.authSummary || target.authMasked) {
return target.authSummary || target.authMasked || '已安全配置'
}
if (target.authType === 'bearer') {
return `Bearer · ${maskSecret(target.authConfig.token)}`
return target.authConfigured === false ? 'Bearer · 未配置' : 'Bearer · 已安全配置'
}
if (target.authType === 'header') {
const headerName = target.authConfig.headerName || '自定义请求头'
return `Header · ${headerName}`
return `${headerName} · ${target.authConfigured === false ? '未配置' : '已安全配置'}`
}
return '无鉴权'
@ -170,7 +167,21 @@ interface SuperAdminsTabProps {
showToast: (message: string, type?: 'success' | 'error') => void
}
type SuperAdminWorkspace = 'access' | 'notificationCenter' | 'outbound'
const SUPER_ADMIN_WORKSPACES: Array<{
id: SuperAdminWorkspace
label: string
description: string
icon: typeof ShieldCheck
}> = [
{ id: 'access', label: '权限与凭证', description: '超级管理员与素材平台 API Key', icon: ShieldCheck },
{ id: 'notificationCenter', label: '统一通知中心', description: '项目、花名、测试与员工单聊', icon: BellRing },
{ id: 'outbound', label: '素材外发', description: 'Webhook 目标与自动上架投递', icon: Webhook },
]
export default function SuperAdminsTab({ showToast }: SuperAdminsTabProps) {
const [activeWorkspace, setActiveWorkspace] = useState<SuperAdminWorkspace>('notificationCenter')
const [superAdmins, setSuperAdmins] = useState<SuperAdmin[]>([])
const [loading, setLoading] = useState(true)
const [count, setCount] = useState(0)
@ -185,6 +196,8 @@ export default function SuperAdminsTab({ showToast }: SuperAdminsTabProps) {
const [showCreateModal, setShowCreateModal] = useState(false)
const [newKeyName, setNewKeyName] = useState('')
const [createdKey, setCreatedKey] = useState<string | null>(null)
const [revealedApiKeys, setRevealedApiKeys] = useState<Record<string, string>>({})
const [revealingApiKeyId, setRevealingApiKeyId] = useState<string | null>(null)
const [notificationTargets, setNotificationTargets] = useState<ExternalNotificationTarget[]>([])
const [notificationTargetsLoading, setNotificationTargetsLoading] = useState(true)
const [notificationTargetForm, setNotificationTargetForm] = useState<NotificationTargetFormState>(
@ -199,7 +212,7 @@ export default function SuperAdminsTab({ showToast }: SuperAdminsTabProps) {
const [retryingNotificationDeliveryId, setRetryingNotificationDeliveryId] = useState<string | null>(null)
// 加载超级管理员列表
const loadSuperAdmins = async () => {
const loadSuperAdmins = useCallback(async () => {
try {
const response = await fetch('/api/admin/super-admins')
if (response.ok) {
@ -216,17 +229,7 @@ export default function SuperAdminsTab({ showToast }: SuperAdminsTabProps) {
} finally {
setLoading(false)
}
}
useEffect(() => {
loadSuperAdmins()
fetchApiKeys()
fetchNotificationTargets()
}, [])
useEffect(() => {
fetchNotificationDeliveries(notificationDeliveryStatusFilter)
}, [notificationDeliveryStatusFilter])
}, [showToast])
// 添加超级管理员
const handleAddSuperAdmin = async () => {
@ -287,7 +290,7 @@ export default function SuperAdminsTab({ showToast }: SuperAdminsTabProps) {
}
// 加载 API Keys
const fetchApiKeys = async () => {
const fetchApiKeys = useCallback(async () => {
try {
setApiKeysLoading(true)
const response = await fetch('/api/admin/api-keys')
@ -305,14 +308,14 @@ export default function SuperAdminsTab({ showToast }: SuperAdminsTabProps) {
} finally {
setApiKeysLoading(false)
}
}
}, [showToast])
const resetNotificationTargetForm = () => {
setEditingNotificationTargetId(null)
setNotificationTargetForm(EMPTY_NOTIFICATION_TARGET_FORM)
}
const fetchNotificationTargets = async () => {
const fetchNotificationTargets = useCallback(async () => {
try {
setNotificationTargetsLoading(true)
const response = await fetch('/api/admin/external-notification-targets')
@ -330,9 +333,9 @@ export default function SuperAdminsTab({ showToast }: SuperAdminsTabProps) {
} finally {
setNotificationTargetsLoading(false)
}
}
}, [showToast])
const fetchNotificationDeliveries = async (status: string) => {
const fetchNotificationDeliveries = useCallback(async (status: string) => {
try {
setNotificationDeliveriesLoading(true)
const searchParams = new URLSearchParams()
@ -360,7 +363,7 @@ export default function SuperAdminsTab({ showToast }: SuperAdminsTabProps) {
} finally {
setNotificationDeliveriesLoading(false)
}
}
}, [showToast])
const handleSaveNotificationTarget = async () => {
if (!notificationTargetForm.name.trim()) {
@ -373,14 +376,19 @@ export default function SuperAdminsTab({ showToast }: SuperAdminsTabProps) {
return
}
if (notificationTargetForm.authType === 'bearer' && !notificationTargetForm.token.trim()) {
if (
notificationTargetForm.authType === 'bearer' &&
!notificationTargetForm.token.trim() &&
!editingNotificationTargetId
) {
showToast('请选择 Bearer 鉴权时必须填写 token', 'error')
return
}
if (
notificationTargetForm.authType === 'header' &&
(!notificationTargetForm.headerName.trim() || !notificationTargetForm.headerValue.trim())
(!notificationTargetForm.headerName.trim() ||
(!notificationTargetForm.headerValue.trim() && !editingNotificationTargetId))
) {
showToast('自定义 Header 鉴权需要填写请求头名称和值', 'error')
return
@ -395,12 +403,16 @@ export default function SuperAdminsTab({ showToast }: SuperAdminsTabProps) {
try {
const authConfig =
notificationTargetForm.authType === 'bearer'
? { token: notificationTargetForm.token.trim() }
? notificationTargetForm.token.trim()
? { token: notificationTargetForm.token.trim() }
: undefined
: notificationTargetForm.authType === 'header'
? {
? notificationTargetForm.headerValue.trim()
? {
headerName: notificationTargetForm.headerName.trim(),
headerValue: notificationTargetForm.headerValue.trim(),
}
}
: undefined
: {}
const payload = {
@ -408,7 +420,7 @@ export default function SuperAdminsTab({ showToast }: SuperAdminsTabProps) {
callbackUrl: notificationTargetForm.callbackUrl.trim(),
isActive: notificationTargetForm.isActive,
authType: notificationTargetForm.authType,
authConfig,
...(authConfig === undefined ? {} : { authConfig }),
eventTypes: notificationTargetForm.eventTypes,
}
@ -446,9 +458,9 @@ export default function SuperAdminsTab({ showToast }: SuperAdminsTabProps) {
name: target.name,
callbackUrl: target.callbackUrl,
authType: target.authType,
token: target.authConfig.token || '',
token: '',
headerName: target.authConfig.headerName || '',
headerValue: target.authConfig.headerValue || '',
headerValue: '',
eventTypes: target.eventTypes?.length > 0 ? target.eventTypes : ['task-detail.full-set.ready'],
isActive: target.isActive,
})
@ -558,6 +570,54 @@ export default function SuperAdminsTab({ showToast }: SuperAdminsTabProps) {
}
}
const handleRevealApiKey = async (apiKey: ApiKey) => {
if (revealedApiKeys[apiKey.id]) {
setRevealedApiKeys((current) => {
const next = { ...current }
delete next[apiKey.id]
return next
})
return
}
if (!apiKey.canReveal) {
showToast('该历史 API Key 只保存了哈希,请创建新 Key 后逐步替换', 'error')
return
}
try {
setRevealingApiKeyId(apiKey.id)
const response = await fetch(`/api/admin/api-keys/${apiKey.id}/reveal`, {
method: 'POST',
cache: 'no-store',
})
if (!response.ok) {
const errorData = await parseFetchError(response)
showToast(errorData.message, 'error')
return
}
const data = await response.json()
setRevealedApiKeys((current) => ({ ...current, [apiKey.id]: data.data.key }))
} catch (error) {
console.error('查看 API Key 失败:', error)
const errorMessage = error instanceof Error ? error.message : '查看失败'
showToast(errorMessage, 'error')
} finally {
setRevealingApiKeyId(null)
}
}
const handleCopyApiKey = async (apiKey: string) => {
try {
await navigator.clipboard.writeText(apiKey)
showToast('API Key 已复制', 'success')
} catch (error) {
console.error('复制 API Key 失败:', error)
showToast('复制失败,请手动选择密钥文本', 'error')
}
}
// 删除 API Key
const handleDeleteApiKey = async (id: string) => {
if (!confirm('确定要删除此 API Key 吗?删除后无法恢复。')) return
@ -569,6 +629,11 @@ export default function SuperAdminsTab({ showToast }: SuperAdminsTabProps) {
if (response.ok) {
showToast('API Key 已删除', 'success')
setRevealedApiKeys((current) => {
const next = { ...current }
delete next[id]
return next
})
fetchApiKeys()
} else {
const errorData = await parseFetchError(response)
@ -581,29 +646,97 @@ export default function SuperAdminsTab({ showToast }: SuperAdminsTabProps) {
}
}
useEffect(() => {
void loadSuperAdmins()
void fetchApiKeys()
void fetchNotificationTargets()
}, [fetchApiKeys, fetchNotificationTargets, loadSuperAdmins])
useEffect(() => {
if (activeWorkspace !== 'access') {
setRevealedApiKeys({})
setCreatedKey(null)
setShowCreateModal(false)
}
}, [activeWorkspace])
useEffect(() => {
void fetchNotificationDeliveries(notificationDeliveryStatusFilter)
}, [fetchNotificationDeliveries, notificationDeliveryStatusFilter])
if (loading) {
return (
<div className="card-apple">
<div className="p-8 text-center text-gray-500 dark:text-gray-400">
加载中...
<div className="overflow-hidden rounded-[12px] border border-[#e2e8f0] bg-white" aria-label="正在加载超级管理员工作台">
<div className="border-b border-[#e2e8f0] px-6 py-5">
<div className="h-6 w-44 animate-pulse rounded-[6px] bg-[#e8edf3]" />
<div className="mt-3 h-4 w-full max-w-xl animate-pulse rounded-[6px] bg-[#eef2f6]" />
</div>
<div className="grid gap-4 p-6 lg:grid-cols-[220px_minmax(0,1fr)]">
<div className="h-44 animate-pulse rounded-[8px] bg-[#eef2f6]" />
<div className="space-y-3">
<div className="h-20 animate-pulse rounded-[8px] bg-[#eef2f6]" />
<div className="h-20 animate-pulse rounded-[8px] bg-[#eef2f6]" />
<div className="h-20 animate-pulse rounded-[8px] bg-[#eef2f6]" />
</div>
</div>
</div>
)
}
return (
<div className="card-apple">
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">
超级管理员管理
<div className="overflow-hidden rounded-[12px] border border-[#e2e8f0] bg-white">
<div className="border-b border-[#e2e8f0] px-5 py-5 sm:px-6">
<h2 className="text-[20px] font-semibold tracking-[-0.02em] text-[#101828]">
超级管理员工作台
</h2>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
当前:{count} / 4 个超级管理员
{!canAdd && <span className="ml-2 text-orange-600 dark:text-orange-400">(已达上限)</span>}
<p className="mt-1 max-w-[72ch] text-[14px] leading-6 text-[#66758b]">
集中管理高权限账号、平台凭证、统一钉钉通知和素材外发。各分区权限均绑定当前项目的超级管理员身份。
</p>
</div>
<div className="p-6 space-y-6">
<div className="grid min-w-0 lg:grid-cols-[232px_minmax(0,1fr)]">
<nav aria-label="超级管理员工作台菜单" className="border-b border-[#e2e8f0] bg-[#f8fafc] p-2 lg:border-b-0 lg:border-r lg:p-3">
<div className="grid grid-cols-3 gap-1 lg:grid-cols-1">
{SUPER_ADMIN_WORKSPACES.map((workspace) => {
const Icon = workspace.icon
const active = activeWorkspace === workspace.id
return (
<button
key={workspace.id}
type="button"
aria-current={active ? 'page' : undefined}
onClick={() => setActiveWorkspace(workspace.id)}
className={`min-h-[58px] rounded-[8px] px-3 py-2.5 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#5a9ce6] ${
active
? 'bg-[#e6f4ff] text-[#0050cb]'
: 'text-[#4f6077] hover:bg-[#eef2f6] hover:text-[#101828]'
}`}
>
<span className="flex items-center gap-2 text-[14px] font-semibold">
<Icon className={`h-4 w-4 ${active ? 'text-[#1677ff]' : 'text-[#66758b]'}`} aria-hidden="true" />
{workspace.label}
</span>
<span className={`mt-1 hidden text-[12px] leading-5 lg:block ${active ? 'text-[#315f91]' : 'text-[#66758b]'}`}>
{workspace.description}
</span>
</button>
)
})}
</div>
</nav>
<div className="min-w-0">
{activeWorkspace === 'access' && (
<div className="space-y-6 p-4 sm:p-6">
<div className="border-b border-[#e2e8f0] pb-4">
<div className="flex items-center gap-2">
<KeyRound className="h-4 w-4 text-[#1677ff]" aria-hidden="true" />
<h3 className="text-[16px] font-semibold text-[#101828]">权限与凭证</h3>
</div>
<p className="mt-1 text-[13px] text-[#66758b]">
当前 {count} / 4 个超级管理员{!canAdd ? ',已达到上限' : ''}。本区 API Key 仅属于素材平台外部接口。
</p>
</div>
{/* 添加超级管理员 */}
{canAdd && (
<div className="p-4 bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
@ -680,17 +813,17 @@ export default function SuperAdminsTab({ showToast }: SuperAdminsTabProps) {
)}
</div>
{/* API Key 管理卡片 */}
{/* 素材平台 API Key 管理 */}
<div className="mt-8">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-medium text-gray-900 dark:text-white">
API Key 管理
素材平台 API Key
</h3>
<button
onClick={() => setShowCreateModal(true)}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm"
>
创建 API Key
创建素材平台 API Key
</button>
</div>
@ -700,7 +833,7 @@ export default function SuperAdminsTab({ showToast }: SuperAdminsTabProps) {
</div>
) : apiKeys.length === 0 ? (
<div className="p-8 text-center text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
<p>暂无 API Key</p>
<p>暂无素材平台 API Key</p>
<p className="mt-2 text-sm text-gray-400 dark:text-gray-500">
点击上方按钮创建第一个 API Key
</p>
@ -736,8 +869,28 @@ export default function SuperAdminsTab({ showToast }: SuperAdminsTabProps) {
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900 dark:text-white">
{key.name}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-mono text-gray-500 dark:text-gray-400">
{key.key.substring(0, 16)}...
<td className="px-6 py-4 text-sm text-gray-500 dark:text-gray-400">
<div className="flex min-w-56 items-center gap-2">
<code className="break-all font-mono text-xs text-gray-700 dark:text-gray-300">
{revealedApiKeys[key.id] || key.key}
</code>
{revealedApiKeys[key.id] && (
<button
type="button"
onClick={() => handleCopyApiKey(revealedApiKeys[key.id])}
className="shrink-0 rounded p-1.5 text-gray-500 transition-colors hover:bg-gray-100 hover:text-gray-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-500 dark:hover:bg-gray-800 dark:hover:text-white"
aria-label={`复制 ${key.name} 的 API Key`}
title="复制完整 Key"
>
<Copy className="h-4 w-4" aria-hidden="true" />
</button>
)}
</div>
{!key.canReveal && (
<p className="mt-1 text-xs text-amber-700 dark:text-amber-300">
历史 Key 无法恢复,需创建新 Key 替换
</p>
)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span
@ -759,6 +912,21 @@ export default function SuperAdminsTab({ showToast }: SuperAdminsTabProps) {
{new Date(key.createdAt).toLocaleString('zh-CN')}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm space-x-2">
<button
type="button"
onClick={() => handleRevealApiKey(key)}
disabled={!key.canReveal || revealingApiKeyId === key.id}
className="inline-flex items-center gap-1 text-gray-600 transition-colors hover:text-gray-950 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-500 disabled:cursor-not-allowed disabled:opacity-40 dark:text-gray-300 dark:hover:text-white"
aria-label={`${revealedApiKeys[key.id] ? '隐藏' : '查看'} ${key.name} 的完整 API Key`}
title={key.canReveal ? (revealedApiKeys[key.id] ? '隐藏完整 Key' : '查看完整 Key') : '历史 Key 无法恢复'}
>
{revealedApiKeys[key.id] ? (
<EyeOff className="h-4 w-4" aria-hidden="true" />
) : (
<Eye className="h-4 w-4" aria-hidden="true" />
)}
{revealingApiKeyId === key.id ? '读取中' : revealedApiKeys[key.id] ? '隐藏' : '查看'}
</button>
<button
onClick={() => handleToggleApiKeyActive(key.id, key.isActive)}
className="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300"
@ -778,16 +946,24 @@ export default function SuperAdminsTab({ showToast }: SuperAdminsTabProps) {
</table>
</div>
)}
</div>
</div>
</div>
)}
<div className="mt-8">
{activeWorkspace === 'notificationCenter' && (
<NotificationCenterPanel showToast={showToast} />
)}
{activeWorkspace === 'outbound' && (
<div className="space-y-8 p-4 sm:p-6">
<div>
<div className="flex flex-col gap-3 mb-4 md:flex-row md:items-center md:justify-between">
<div>
<h3 className="text-lg font-medium text-gray-900 dark:text-white">
外部系统通知目标
素材外发目标
</h3>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
这里配置素材库主动通知的目标系统。`task-detail.full-set.ready` 主要给自动上架平台,`task-detail.barcode-images.updated` 主要给产品平台。
配置素材库向其他业务系统发送 Webhook 的目标,与统一通知中心向员工发送钉钉单聊不同。
</p>
</div>
<button
@ -894,7 +1070,7 @@ export default function SuperAdminsTab({ showToast }: SuperAdminsTabProps) {
<div className="bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
<div className="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
<h4 className="text-sm font-medium text-gray-900 dark:text-white">
{editingNotificationTargetId ? '编辑通知目标' : '新增通知目标'}
{editingNotificationTargetId ? '编辑素材外发目标' : '新增素材回调'}
</h4>
</div>
<div className="p-4 space-y-4">
@ -1071,7 +1247,7 @@ export default function SuperAdminsTab({ showToast }: SuperAdminsTabProps) {
? '保存中...'
: editingNotificationTargetId
? '保存修改'
: '创建目标'}
: '新增素材回调'}
</button>
<button
onClick={resetNotificationTargetForm}
@ -1219,27 +1395,38 @@ export default function SuperAdminsTab({ showToast }: SuperAdminsTabProps) {
</table>
</div>
)}
</div>
</div>
)}
</div>
</div>
{/* 创建 API Key 模态框 */}
{showCreateModal && (
{activeWorkspace === 'access' && showCreateModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-md w-full mx-4">
<h2 className="text-xl font-bold mb-4 text-gray-900 dark:text-white">
创建 API Key
创建素材平台 API Key
</h2>
{createdKey ? (
<div>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-2">
API Key 已创建,请妥善保存:
API Key 已创建,可复制给对应的外部系统:
</p>
<div className="bg-gray-100 dark:bg-gray-700 p-3 rounded font-mono text-sm break-all mb-4 text-gray-900 dark:text-white">
{createdKey}
</div>
<p className="text-sm text-red-600 dark:text-red-400 mb-4">
注意:此密钥只显示一次,关闭后将无法再次查看
<button
type="button"
onClick={() => handleCopyApiKey(createdKey)}
className="mb-4 inline-flex items-center gap-2 text-sm text-blue-600 transition-colors hover:text-blue-800 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-violet-500 dark:text-blue-400 dark:hover:text-blue-300"
>
<Copy className="h-4 w-4" aria-hidden="true" />
复制 API Key
</button>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-4">
关闭后仍可在列表中由超级管理员按需查看,请只通过安全渠道发送给外部系统负责人。
</p>
<button
onClick={() => {

View File

@ -0,0 +1,172 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import NotificationCenterPanel from './NotificationCenterPanel'
jest.mock('@gsap/react', () => ({
useGSAP: jest.fn(),
}))
jest.mock('gsap', () => ({
registerPlugin: jest.fn(),
matchMedia: () => ({
add: jest.fn(),
revert: jest.fn(),
}),
fromTo: jest.fn(),
}))
const project = {
name: '素材平台',
projectCode: 'material_library',
isActive: true,
allowedHosts: ['https://sc.jiansun.vip'],
recipientMode: 'alias',
recipientCount: 1,
recipients: [{ alias: '测试员工', userId: 'ding-user-1' }],
updatedAt: '2026-08-28T08:00:00.000Z',
deliverySummary: { lastStatus: 'sent', lastSentAt: '2026-08-28T08:00:00.000Z', recentFailedCount: 0 },
}
const jsonResponse = (body: unknown, status = 200) =>
Promise.resolve({
ok: status >= 200 && status < 300,
status,
json: async () => body,
} as Response)
describe('NotificationCenterPanel', () => {
const showToast = jest.fn()
beforeEach(() => {
jest.clearAllMocks()
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText: jest.fn().mockResolvedValue(undefined) },
})
global.fetch = jest.fn((input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input)
if (url.includes('/api/admin/notification-center/projects?includeRecipients=1') && (!init?.method || init.method === 'GET')) {
return jsonResponse({ success: true, data: { projects: [project] } })
}
if (url.endsWith('/api/admin/notification-center/projects') && init?.method === 'POST') {
return jsonResponse({
success: true,
data: {
project,
secret: 'one-time-project-secret',
env: {
YYD_NOTIFICATION_CENTER_URL: 'https://sc.jiansun.vip/notification-center',
YYD_NOTIFICATION_CENTER_PROJECT_CODE: 'fixed_assets',
YYD_NOTIFICATION_CENTER_PROJECT_SECRET: 'one-time-project-secret',
},
},
}, 201)
}
if (url.includes('/recipients') && init?.method === 'PUT') {
return jsonResponse({ project })
}
if (url.includes('/rotate-secret') && init?.method === 'POST') {
return jsonResponse({
success: true,
data: {
secret: 'rotated-one-time-secret',
env: { YYD_NOTIFICATION_CENTER_PROJECT_SECRET: 'rotated-one-time-secret' },
},
})
}
if (url.endsWith('/test') && init?.method === 'POST') {
return jsonResponse({ deliveryId: 'delivery-1', status: 'pending' }, 202)
}
if (url.includes('/deliveries')) {
return jsonResponse({ deliveries: [] })
}
return jsonResponse({ error: { message: 'unexpected request' } }, 500)
}) as jest.Mock
})
test('默认展示项目总览并明确区分素材外发', async () => {
render(<NotificationCenterPanel showToast={showToast} />)
expect(await screen.findByText('素材平台')).toBeInTheDocument()
expect(screen.getByText(/素材平台向外部系统发送 Webhook 的能力位于“素材外发”/)).toBeInTheDocument()
expect(screen.getAllByText('1', { selector: 'span' })).toHaveLength(3)
expect(screen.getByRole('button', { name: '新增接入项目' })).toBeInTheDocument()
})
test('新建项目后只展示一次项目密钥和环境变量', async () => {
const user = userEvent.setup()
render(<NotificationCenterPanel showToast={showToast} />)
await screen.findByText('素材平台')
await user.click(screen.getByRole('button', { name: '新增接入项目' }))
await user.type(screen.getByLabelText('项目中文名称'), '固定资产系统')
await user.type(screen.getByLabelText('projectCode'), 'fixed_assets')
await user.type(screen.getByLabelText('允许跳转域名 allowedHosts'), 'https://assets.example.com')
await user.click(screen.getByRole('button', { name: '创建并生成密钥' }))
expect(await screen.findByText('one-time-project-secret')).toBeInTheDocument()
expect(screen.getByText(/YYD_NOTIFICATION_CENTER_PROJECT_CODE=fixed_assets/)).toBeInTheDocument()
expect(global.fetch).toHaveBeenCalledWith(
'/api/admin/notification-center/projects',
expect.objectContaining({ method: 'POST' }),
)
})
test('保存花名使用项目专属 recipients 代理接口', async () => {
const user = userEvent.setup()
render(<NotificationCenterPanel showToast={showToast} />)
await screen.findByText('素材平台')
await user.click(screen.getByRole('button', { name: /收件人花名/ }))
expect(await screen.findByDisplayValue('测试员工')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: '保存花名清单' }))
await waitFor(() => {
expect(global.fetch).toHaveBeenCalledWith(
'/api/admin/notification-center/projects/material_library/recipients',
expect.objectContaining({ method: 'PUT' }),
)
})
})
test('密钥轮换要求二次确认且新密钥只在确认后展示', async () => {
const user = userEvent.setup()
render(<NotificationCenterPanel showToast={showToast} />)
await screen.findByText('素材平台')
await user.click(screen.getByRole('button', { name: '管理项目 素材平台' }))
await user.click(screen.getByRole('button', { name: '轮换项目密钥' }))
expect(screen.getByText(/旧密钥的调用会立即鉴权失败/)).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: '确认轮换,旧密钥立即失效' }))
expect(await screen.findByText('rotated-one-time-secret')).toBeInTheDocument()
})
test('测试通知在确认前不发送,确认后才调用 mock 接口', async () => {
const user = userEvent.setup()
render(<NotificationCenterPanel showToast={showToast} />)
await screen.findByText('素材平台')
await user.click(screen.getByRole('button', { name: /测试通知/ }))
fireEvent.change(screen.getByLabelText('真实收件人'), { target: { value: '测试员工' } })
await user.click(screen.getByRole('button', { name: '向指定员工发送测试通知' }))
expect(global.fetch).not.toHaveBeenCalledWith(expect.stringContaining('/test'), expect.anything())
expect(screen.getByText(/确认后会真实调用钉钉/)).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: '确认发送给该员工' }))
await waitFor(() => {
expect(global.fetch).toHaveBeenCalledWith(
'/api/admin/notification-center/projects/material_library/test',
expect.objectContaining({ method: 'POST' }),
)
})
})
test('通知中心离线时显示可恢复错误且不伪装成空数据', async () => {
global.fetch = jest.fn().mockRejectedValue(new Error('连接超时'))
render(<NotificationCenterPanel showToast={showToast} />)
expect(await screen.findByRole('alert')).toHaveTextContent('连接超时')
expect(screen.getByRole('alert')).toHaveTextContent('项目配置没有被修改')
})
})

View File

@ -0,0 +1,963 @@
'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useGSAP } from '@gsap/react'
import gsap from 'gsap'
import {
Activity,
AlertTriangle,
Check,
ChevronRight,
Clipboard,
KeyRound,
Plus,
RefreshCw,
Send,
Settings2,
Users,
X,
} from 'lucide-react'
gsap.registerPlugin(useGSAP)
type PanelView = 'projects' | 'recipients' | 'test' | 'deliveries'
type RecipientMode = 'userId' | 'alias'
interface NotificationRecipient {
alias: string
userId: string
}
interface DeliverySummary {
lastStatus: string | null
lastSentAt: string | null
recentFailedCount: number
}
export interface NotificationProject {
name: string
projectCode: string
isActive: boolean
allowedHosts: string[]
recipientMode: RecipientMode
recipientCount: number
recipients: NotificationRecipient[]
updatedAt: string | null
deliverySummary: DeliverySummary
}
interface NotificationDelivery {
id: string
projectCode: string
status: string
recipientAlias: string | null
recipientUserIdMasked: string | null
title: string | null
lastErrorCode: string | null
lastErrorMessage: string | null
createdAt: string
updatedAt: string
}
interface ProjectFormState {
name: string
projectCode: string
isActive: boolean
allowedHostsText: string
recipientMode: RecipientMode
recipients: NotificationRecipient[]
}
interface OneTimeSecret {
projectCode: string
secret: string
env: Record<string, string>
reason: 'created' | 'rotated'
}
interface NotificationCenterPanelProps {
showToast: (message: string, type?: 'success' | 'error') => void
}
const EMPTY_PROJECT_FORM: ProjectFormState = {
name: '',
projectCode: '',
isActive: true,
allowedHostsText: '',
recipientMode: 'userId',
recipients: [],
}
const DELIVERY_STATUSES = [
['all', '全部状态'],
['pending', '等待发送'],
['sending', '发送中'],
['retrying', '等待重试'],
['sent', '已送达钉钉'],
['unread', '员工未读'],
['read', '员工已读'],
['failed', '发送失败'],
['expired', '已过期'],
] as const
const VIEW_ITEMS: Array<{
id: PanelView
label: string
description: string
icon: typeof Activity
}> = [
{ id: 'projects', label: '接入项目', description: '项目、域名与密钥', icon: Settings2 },
{ id: 'recipients', label: '收件人花名', description: '花名与钉钉账号', icon: Users },
{ id: 'test', label: '测试通知', description: '向指定员工验证链路', icon: Send },
{ id: 'deliveries', label: '投递记录', description: '状态与故障处理', icon: Activity },
]
function normalizeRecipient(input: unknown): NotificationRecipient | null {
if (!input || typeof input !== 'object') return null
const item = input as Record<string, unknown>
const alias = String(item.alias || item.recipientAlias || '').trim()
const userId = String(item.userId || item.recipientUserId || '').trim()
if (!alias || !userId) return null
return { alias, userId }
}
function normalizeProject(input: unknown): NotificationProject {
const item = (input || {}) as Record<string, unknown>
const recipients = Array.isArray(item.recipients)
? item.recipients.map(normalizeRecipient).filter((value): value is NotificationRecipient => Boolean(value))
: []
const summary = (item.deliverySummary || {}) as Record<string, unknown>
return {
name: String(item.name || item.displayName || item.projectName || item.projectCode || '未命名项目'),
projectCode: String(item.projectCode || item.code || ''),
isActive: item.isActive !== false && item.enabled !== false,
allowedHosts: Array.isArray(item.allowedHosts)
? item.allowedHosts.map((value) => String(value)).filter(Boolean)
: [],
recipientMode:
item.recipientMode === 'alias' || item.recipientMode === 'userId_or_alias'
? 'alias'
: 'userId',
recipientCount: Number(item.recipientCount ?? recipients.length ?? 0),
recipients,
updatedAt: item.updatedAt ? String(item.updatedAt) : null,
deliverySummary: {
lastStatus: summary.lastStatus ? String(summary.lastStatus) : null,
lastSentAt: summary.lastSentAt ? String(summary.lastSentAt) : null,
recentFailedCount: Number(summary.recentFailedCount || 0),
},
}
}
function normalizeDelivery(input: unknown): NotificationDelivery {
const item = (input || {}) as Record<string, unknown>
const rawRecipientUserId = String(item.recipientUserId || '').trim()
const recipientUserIdMasked = item.recipientUserIdMasked
? String(item.recipientUserIdMasked)
: rawRecipientUserId
? rawRecipientUserId.length <= 6
? '***'
: `${rawRecipientUserId.slice(0, 3)}***${rawRecipientUserId.slice(-3)}`
: null
return {
id: String(item.id || ''),
projectCode: String(item.projectCode || ''),
status: String(item.status || 'pending'),
recipientAlias: item.recipientAlias ? String(item.recipientAlias) : null,
recipientUserIdMasked,
title: item.businessTitle ? String(item.businessTitle) : item.title ? String(item.title) : null,
lastErrorCode: item.lastErrorCode ? String(item.lastErrorCode) : null,
lastErrorMessage: item.lastErrorMessage ? String(item.lastErrorMessage) : null,
createdAt: String(item.createdAt || ''),
updatedAt: String(item.updatedAt || item.createdAt || ''),
}
}
function formatDateTime(value: string | null) {
if (!value) return '暂无'
const date = new Date(value)
return Number.isNaN(date.getTime()) ? '暂无' : date.toLocaleString('zh-CN')
}
function parseAllowedHosts(value: string) {
return Array.from(
new Set(
value
.split(/[\n,,]/)
.map((host) => {
const normalized = host.trim()
if (!normalized) return ''
try {
return new URL(normalized.includes('://') ? normalized : `https://${normalized}`).hostname.toLowerCase()
} catch {
return normalized.toLowerCase()
}
})
.filter(Boolean),
),
)
}
function getStatusLabel(status: string | null) {
return DELIVERY_STATUSES.find(([value]) => value === status)?.[1] || status || '暂无投递'
}
function getStatusClassName(status: string | null) {
if (status === 'sent' || status === 'read') return 'bg-[#e8f7f2] text-[#087052]'
if (status === 'failed' || status === 'expired') return 'bg-[#fef0ee] text-[#b42318]'
if (status === 'sending' || status === 'retrying') return 'bg-[#fff7e8] text-[#9a4d08]'
return 'bg-[#eef2f6] text-[#4f6077]'
}
function getDeliveryAdvice(delivery: NotificationDelivery) {
if (delivery.lastErrorCode === 'DELIVERY_OUTCOME_UNKNOWN') {
return '钉钉是否收到尚不确定。请先联系收件人或在钉钉侧人工核验,不要直接重复发送。'
}
if (delivery.lastErrorCode === 'RECIPIENT_NOT_FOUND') {
return '未找到已登记的收件人。请核对花名或钉钉 userId 后重新测试。'
}
if (delivery.lastErrorCode === 'ACTION_URL_NOT_ALLOWED') {
return '按钮地址不在该项目允许的域名内。请先更新 allowedHosts。'
}
if (delivery.status === 'failed') {
return '通知发送失败。请根据下方原因修正配置,再重新发起测试。'
}
if (delivery.status === 'expired') {
return '该通知已经超过有效期,不会继续重试。请确认业务是否仍需要通知。'
}
return null
}
async function readJsonResponse(response: Response) {
const data = await response.json().catch(() => null)
if (!response.ok) {
const message =
data?.error?.message ||
data?.message ||
(typeof data?.error === 'string' ? data.error : null) ||
'统一通知中心暂时不可用,请稍后重试'
throw new Error(message)
}
return data || {}
}
function unwrapProxyData(input: unknown): Record<string, any> {
const value = input && typeof input === 'object' ? input as Record<string, any> : {}
return value.data && typeof value.data === 'object' ? value.data : value
}
function useDialogKeyboard(open: boolean, close: () => void) {
useEffect(() => {
if (!open) return
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') close()
}
window.addEventListener('keydown', onKeyDown)
return () => window.removeEventListener('keydown', onKeyDown)
}, [close, open])
}
export default function NotificationCenterPanel({ showToast }: NotificationCenterPanelProps) {
const rootRef = useRef<HTMLDivElement>(null)
const [activeView, setActiveView] = useState<PanelView>('projects')
const [projects, setProjects] = useState<NotificationProject[]>([])
const [projectsLoading, setProjectsLoading] = useState(true)
const [projectsError, setProjectsError] = useState<string | null>(null)
const [drawerOpen, setDrawerOpen] = useState(false)
const [editingCode, setEditingCode] = useState<string | null>(null)
const [projectForm, setProjectForm] = useState<ProjectFormState>(EMPTY_PROJECT_FORM)
const [savingProject, setSavingProject] = useState(false)
const [lastCreatedCode, setLastCreatedCode] = useState<string | null>(null)
const [oneTimeSecret, setOneTimeSecret] = useState<OneTimeSecret | null>(null)
const [rotateProject, setRotateProject] = useState<NotificationProject | null>(null)
const [rotatingSecret, setRotatingSecret] = useState(false)
const [recipientProjectCode, setRecipientProjectCode] = useState('')
const [recipientRows, setRecipientRows] = useState<NotificationRecipient[]>([])
const [savingRecipients, setSavingRecipients] = useState(false)
const [testProjectCode, setTestProjectCode] = useState('')
const [testRecipientMode, setTestRecipientMode] = useState<RecipientMode>('userId')
const [testRecipient, setTestRecipient] = useState('')
const [testTitle, setTestTitle] = useState('统一通知中心测试')
const [testBody, setTestBody] = useState('这是一条由超级管理员发起的链路测试通知。')
const [testButtonLabel, setTestButtonLabel] = useState('查看详情')
const [testActionUrl, setTestActionUrl] = useState('')
const [confirmTestOpen, setConfirmTestOpen] = useState(false)
const [sendingTest, setSendingTest] = useState(false)
const [deliveryProjectFilter, setDeliveryProjectFilter] = useState('all')
const [deliveryStatusFilter, setDeliveryStatusFilter] = useState('all')
const [deliveries, setDeliveries] = useState<NotificationDelivery[]>([])
const [deliveriesLoading, setDeliveriesLoading] = useState(false)
const [deliveriesError, setDeliveriesError] = useState<string | null>(null)
const loadProjects = useCallback(async () => {
setProjectsLoading(true)
setProjectsError(null)
try {
const data = await readJsonResponse(await fetch('/api/admin/notification-center/projects?includeRecipients=1'))
const payload = unwrapProxyData(data)
const rawProjects = payload.projects || payload.data || []
const nextProjects = Array.isArray(rawProjects) ? rawProjects.map(normalizeProject) : []
setProjects(nextProjects)
setRecipientProjectCode((current) => current || nextProjects[0]?.projectCode || '')
setTestProjectCode((current) => current || nextProjects[0]?.projectCode || '')
} catch (error) {
const message = error instanceof Error ? error.message : '统一通知中心暂时不可用,请稍后重试'
setProjectsError(message)
} finally {
setProjectsLoading(false)
}
}, [])
const loadDeliveries = useCallback(async () => {
setDeliveriesLoading(true)
setDeliveriesError(null)
try {
const params = new URLSearchParams({ limit: '50' })
if (deliveryProjectFilter !== 'all') params.set('projectCode', deliveryProjectFilter)
if (deliveryStatusFilter !== 'all') params.set('status', deliveryStatusFilter)
const data = await readJsonResponse(
await fetch(`/api/admin/notification-center/deliveries?${params.toString()}`),
)
const payload = unwrapProxyData(data)
const rawDeliveries = payload.deliveries || payload.data || []
setDeliveries(Array.isArray(rawDeliveries) ? rawDeliveries.map(normalizeDelivery) : [])
} catch (error) {
setDeliveriesError(error instanceof Error ? error.message : '投递记录加载失败,请稍后重试')
} finally {
setDeliveriesLoading(false)
}
}, [deliveryProjectFilter, deliveryStatusFilter])
useEffect(() => {
void loadProjects()
}, [loadProjects])
useEffect(() => {
if (activeView === 'deliveries') void loadDeliveries()
}, [activeView, loadDeliveries])
const selectedRecipientProject = useMemo(
() => projects.find((project) => project.projectCode === recipientProjectCode) || null,
[projects, recipientProjectCode],
)
const selectedTestProject = useMemo(
() => projects.find((project) => project.projectCode === testProjectCode) || null,
[projects, testProjectCode],
)
useEffect(() => {
setRecipientRows(selectedRecipientProject?.recipients || [])
}, [selectedRecipientProject])
useEffect(() => {
if (!selectedTestProject) return
setTestRecipientMode(selectedTestProject.recipientMode)
setTestRecipient('')
}, [selectedTestProject])
useGSAP(
() => {
const mm = gsap.matchMedia()
mm.add(
{ reduceMotion: '(prefers-reduced-motion: reduce)' },
(context) => {
const reduceMotion = Boolean(context.conditions?.reduceMotion)
gsap.fromTo(
'[data-nc-view]',
{ autoAlpha: 0, y: reduceMotion ? 0 : 8 },
{ autoAlpha: 1, y: 0, duration: reduceMotion ? 0 : 0.2, ease: 'power2.out' },
)
},
)
return () => mm.revert()
},
{ scope: rootRef, dependencies: [activeView], revertOnUpdate: true },
)
useGSAP(
() => {
if (!drawerOpen && !oneTimeSecret) return
const mm = gsap.matchMedia()
mm.add(
{ reduceMotion: '(prefers-reduced-motion: reduce)' },
(context) => {
const reduceMotion = Boolean(context.conditions?.reduceMotion)
if (drawerOpen) {
gsap.fromTo(
'[data-nc-drawer]',
{ autoAlpha: 0, x: reduceMotion ? 0 : 24 },
{ autoAlpha: 1, x: 0, duration: reduceMotion ? 0 : 0.22, ease: 'power2.out' },
)
}
if (oneTimeSecret) {
gsap.fromTo(
'[data-secret-reveal]',
{ autoAlpha: 0, y: reduceMotion ? 0 : 10 },
{ autoAlpha: 1, y: 0, duration: reduceMotion ? 0 : 0.2, ease: 'power2.out' },
)
}
},
)
return () => mm.revert()
},
{ scope: rootRef, dependencies: [drawerOpen, oneTimeSecret], revertOnUpdate: true },
)
useGSAP(
() => {
if (!lastCreatedCode) return
const mm = gsap.matchMedia()
mm.add(
{ reduceMotion: '(prefers-reduced-motion: reduce)' },
(context) => {
if (context.conditions?.reduceMotion) return
gsap.fromTo(
`[data-project-code="${lastCreatedCode}"]`,
{ autoAlpha: 0, y: 8 },
{ autoAlpha: 1, y: 0, duration: 0.22, ease: 'power2.out', clearProps: 'opacity,visibility,transform' },
)
},
)
return () => mm.revert()
},
{ scope: rootRef, dependencies: [lastCreatedCode, projects], revertOnUpdate: true },
)
const closeDrawer = () => {
setDrawerOpen(false)
setEditingCode(null)
setProjectForm(EMPTY_PROJECT_FORM)
}
useDialogKeyboard(drawerOpen, closeDrawer)
useDialogKeyboard(Boolean(rotateProject), () => setRotateProject(null))
useDialogKeyboard(Boolean(oneTimeSecret), () => setOneTimeSecret(null))
useDialogKeyboard(confirmTestOpen, () => setConfirmTestOpen(false))
const openCreateProject = () => {
setEditingCode(null)
setProjectForm(EMPTY_PROJECT_FORM)
setDrawerOpen(true)
}
const openEditProject = (project: NotificationProject) => {
setEditingCode(project.projectCode)
setProjectForm({
name: project.name,
projectCode: project.projectCode,
isActive: project.isActive,
allowedHostsText: project.allowedHosts.join('\n'),
recipientMode: project.recipientMode,
recipients: project.recipients,
})
setDrawerOpen(true)
}
const addProjectRecipient = () => {
setProjectForm((current) => ({
...current,
recipients: [...current.recipients, { alias: '', userId: '' }],
}))
}
const updateProjectRecipient = (index: number, field: keyof NotificationRecipient, value: string) => {
setProjectForm((current) => ({
...current,
recipients: current.recipients.map((recipient, recipientIndex) =>
recipientIndex === index ? { ...recipient, [field]: value } : recipient,
),
}))
}
const saveProject = async () => {
if (!projectForm.name.trim()) {
showToast('请填写项目中文名称', 'error')
return
}
if (!projectForm.projectCode.trim()) {
showToast('请填写 projectCode', 'error')
return
}
const normalizedCode = projectForm.projectCode.trim()
if (!/^[a-z][a-z0-9_-]{2,63}$/.test(normalizedCode)) {
showToast('projectCode 需以小写字母开头,只能包含小写字母、数字、下划线和短横线', 'error')
return
}
const recipients = projectForm.recipients
.map((recipient) => ({ alias: recipient.alias.trim(), userId: recipient.userId.trim() }))
.filter((recipient) => recipient.alias || recipient.userId)
if (recipients.some((recipient) => !recipient.alias || !recipient.userId)) {
showToast('每条花名都必须同时填写花名和钉钉 userId', 'error')
return
}
const normalizedAliases = recipients.map((recipient) => recipient.alias.replace(/\s+/g, '').toLocaleLowerCase('zh-CN'))
if (new Set(normalizedAliases).size !== normalizedAliases.length) {
showToast('同一项目内不能登记重复花名', 'error')
return
}
setSavingProject(true)
try {
const payload = {
name: projectForm.name.trim(),
projectCode: normalizedCode,
isActive: projectForm.isActive,
allowedHosts: parseAllowedHosts(projectForm.allowedHostsText),
recipientMode: projectForm.recipientMode,
recipients,
}
const response = await fetch(
editingCode
? `/api/admin/notification-center/projects/${encodeURIComponent(editingCode)}`
: '/api/admin/notification-center/projects',
{
method: editingCode ? 'PATCH' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
},
)
const data = await readJsonResponse(response)
closeDrawer()
await loadProjects()
if (!editingCode) {
const responsePayload = unwrapProxyData(data)
setLastCreatedCode(normalizedCode)
if (responsePayload.secret) {
setOneTimeSecret({
projectCode: normalizedCode,
secret: String(responsePayload.secret),
env: responsePayload.env && typeof responsePayload.env === 'object' ? responsePayload.env : {},
reason: 'created',
})
}
}
showToast(editingCode ? '接入项目已更新' : '接入项目已创建', 'success')
} catch (error) {
showToast(error instanceof Error ? error.message : '接入项目保存失败', 'error')
} finally {
setSavingProject(false)
}
}
const confirmRotateSecret = async () => {
if (!rotateProject) return
setRotatingSecret(true)
try {
const data = await readJsonResponse(
await fetch(
`/api/admin/notification-center/projects/${encodeURIComponent(rotateProject.projectCode)}/rotate-secret`,
{ method: 'POST' },
),
)
const responsePayload = unwrapProxyData(data)
setRotateProject(null)
setOneTimeSecret({
projectCode: rotateProject.projectCode,
secret: String(responsePayload.secret || ''),
env: responsePayload.env && typeof responsePayload.env === 'object' ? responsePayload.env : {},
reason: 'rotated',
})
showToast('项目密钥已轮换,请立即更新调用方环境变量', 'success')
} catch (error) {
showToast(error instanceof Error ? error.message : '项目密钥轮换失败', 'error')
} finally {
setRotatingSecret(false)
}
}
const saveRecipients = async () => {
if (!recipientProjectCode) return
const rows = recipientRows
.map((recipient) => ({ alias: recipient.alias.trim(), userId: recipient.userId.trim() }))
.filter((recipient) => recipient.alias || recipient.userId)
if (rows.some((row) => !row.alias || !row.userId)) {
showToast('每条花名都必须同时填写花名和钉钉 userId', 'error')
return
}
const aliases = rows.map((row) => row.alias.replace(/\s+/g, '').toLocaleLowerCase('zh-CN'))
if (new Set(aliases).size !== aliases.length) {
showToast('同一项目内不能登记重复花名', 'error')
return
}
setSavingRecipients(true)
try {
await readJsonResponse(
await fetch(
`/api/admin/notification-center/projects/${encodeURIComponent(recipientProjectCode)}/recipients`,
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ recipients: rows }),
},
),
)
await loadProjects()
showToast('收件人花名已保存', 'success')
} catch (error) {
showToast(error instanceof Error ? error.message : '花名保存失败', 'error')
} finally {
setSavingRecipients(false)
}
}
const resolvedTestRecipient = useMemo(() => {
if (!selectedTestProject || !testRecipient.trim()) return null
if (testRecipientMode === 'userId') {
return { label: `钉钉 userId:${testRecipient.trim()}`, userId: testRecipient.trim() }
}
const recipient = selectedTestProject.recipients.find(
(item) => item.alias === testRecipient,
)
return recipient ? { label: `${recipient.alias}(${recipient.userId})`, userId: recipient.userId } : null
}, [selectedTestProject, testRecipient, testRecipientMode])
const sendTestNotification = async () => {
if (!selectedTestProject || !resolvedTestRecipient) return
setSendingTest(true)
try {
const payload = {
recipientAlias: testRecipientMode === 'alias' ? testRecipient : undefined,
recipientUserId: testRecipientMode === 'userId' ? testRecipient.trim() : undefined,
title: testTitle.trim(),
content: testBody.trim(),
actionTitle: testButtonLabel.trim() || undefined,
actionUrl: testActionUrl.trim() || undefined,
}
await readJsonResponse(
await fetch(
`/api/admin/notification-center/projects/${encodeURIComponent(selectedTestProject.projectCode)}/test`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
},
),
)
setConfirmTestOpen(false)
showToast('测试通知已进入发送队列', 'success')
} catch (error) {
setConfirmTestOpen(false)
const message = error instanceof Error ? error.message : '测试通知发送失败'
showToast(`${message}。页面没有自动重发,请先到投递记录核验是否已经发送。`, 'error')
} finally {
setSendingTest(false)
}
}
const copyText = async (value: string, label: string) => {
try {
await navigator.clipboard.writeText(value)
showToast(`${label}已复制`, 'success')
} catch {
showToast('复制失败,请手动选择文本复制', 'error')
}
}
const enabledProjectCount = projects.filter((project) => project.isActive).length
const aliasCount = projects.reduce((total, project) => total + project.recipientCount, 0)
const failedCount = projects.reduce(
(total, project) => total + project.deliverySummary.recentFailedCount,
0,
)
return (
<div ref={rootRef} className="min-w-0">
<div className="border-b border-[#e2e8f0] px-5 py-5 sm:px-6">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div>
<h2 className="text-[20px] font-semibold tracking-[-0.02em] text-[#101828]">
统一通知中心
</h2>
<p className="mt-1 max-w-[72ch] text-[14px] leading-6 text-[#66758b]">
管理由统一钉钉机器人发送员工单聊的接入项目、花名和投递状态。素材平台向外部系统发送 Webhook 的能力位于“素材外发”。
</p>
</div>
<button
type="button"
onClick={() => void loadProjects()}
disabled={projectsLoading}
className="inline-flex min-h-10 items-center justify-center gap-2 rounded-[8px] border border-[#d9e0e7] bg-white px-3 text-[14px] font-medium text-[#4f6077] transition-colors hover:bg-[#f8fafc] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
>
<RefreshCw className={`h-4 w-4 ${projectsLoading ? 'animate-spin' : ''}`} aria-hidden="true" />
刷新项目
</button>
</div>
<dl className="mt-5 grid grid-cols-2 divide-x divide-y divide-[#e2e8f0] overflow-hidden rounded-[10px] border border-[#e2e8f0] bg-[#f8fafc] lg:grid-cols-4 lg:divide-y-0">
{[
['接入项目', String(projects.length), '当前已登记'],
['正在使用', String(enabledProjectCount), '已启用项目'],
['收件人花名', String(aliasCount), '已登记映射'],
['近期失败', String(failedCount), failedCount > 0 ? '需要处理' : '未发现异常'],
].map(([label, value, hint]) => (
<div key={label} className="min-w-0 px-4 py-3.5">
<dt className="text-[12px] font-medium text-[#66758b]">{label}</dt>
<dd className="mt-1 flex items-baseline gap-2">
<span className="text-[20px] font-semibold tabular-nums text-[#101828]">{value}</span>
<span className={`truncate text-[12px] ${label === '近期失败' && failedCount > 0 ? 'text-[#b42318]' : 'text-[#66758b]'}`}>
{hint}
</span>
</dd>
</div>
))}
</dl>
</div>
<div className="grid min-w-0 lg:grid-cols-[220px_minmax(0,1fr)]">
<nav
aria-label="统一通知中心管理菜单"
className="border-b border-[#e2e8f0] bg-[#f8fafc] p-2 lg:border-b-0 lg:border-r lg:p-3"
>
<div className="grid grid-cols-2 gap-1 sm:grid-cols-4 lg:grid-cols-1">
{VIEW_ITEMS.map((item) => {
const Icon = item.icon
const active = activeView === item.id
return (
<button
key={item.id}
type="button"
aria-current={active ? 'page' : undefined}
onClick={() => setActiveView(item.id)}
className={`min-h-[58px] rounded-[8px] px-3 py-2.5 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#5a9ce6] ${
active
? 'bg-[#e6f4ff] text-[#0050cb]'
: 'text-[#4f6077] hover:bg-[#eef2f6] hover:text-[#101828]'
}`}
>
<span className="flex items-center gap-2 text-[14px] font-semibold">
<Icon className={`h-4 w-4 ${active ? 'text-[#1677ff]' : 'text-[#66758b]'}`} aria-hidden="true" />
{item.label}
</span>
<span className={`mt-1 hidden text-[12px] leading-5 lg:block ${active ? 'text-[#315f91]' : 'text-[#66758b]'}`}>
{item.description}
</span>
</button>
)
})}
</div>
</nav>
<div data-nc-view className="min-w-0 p-4 sm:p-5 lg:p-6">
{projectsError && (
<div role="alert" className="mb-5 rounded-[8px] border border-[#f3c7c1] bg-[#fef6f5] px-4 py-3 text-[14px] text-[#8f2119]">
<div className="flex items-start gap-2">
<AlertTriangle className="mt-0.5 h-4 w-4 flex-none" aria-hidden="true" />
<div>
<p className="font-semibold">统一通知中心暂时无法连接</p>
<p className="mt-1 leading-6">{projectsError}。项目配置没有被修改,可以稍后刷新重试。</p>
</div>
</div>
</div>
)}
{activeView === 'projects' && (
<section aria-labelledby="notification-projects-heading">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h3 id="notification-projects-heading" className="text-[16px] font-semibold text-[#101828]">接入项目</h3>
<p className="mt-1 text-[13px] leading-5 text-[#66758b]">新增项目后只会展示一次项目密钥,请立即保存到调用方环境变量。</p>
</div>
<button
type="button"
onClick={openCreateProject}
className="inline-flex min-h-10 items-center justify-center gap-2 rounded-[8px] bg-[#1677ff] px-4 text-[14px] font-semibold text-white transition-colors hover:bg-[#0f65dc] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] focus-visible:ring-offset-2"
>
<Plus className="h-4 w-4" aria-hidden="true" />
新增接入项目
</button>
</div>
{projectsLoading ? (
<div className="mt-5 space-y-2" aria-label="正在加载接入项目">
{[0, 1, 2].map((item) => <div key={item} className="h-[74px] animate-pulse rounded-[8px] bg-[#eef2f6]" />)}
</div>
) : projects.length === 0 ? (
<div className="mt-5 rounded-[10px] border border-dashed border-[#cfd8e3] px-5 py-10 text-center">
<Settings2 className="mx-auto h-6 w-6 text-[#66758b]" aria-hidden="true" />
<p className="mt-3 text-[14px] font-semibold text-[#101828]">还没有接入项目</p>
<p className="mt-1 text-[13px] text-[#66758b]">新增项目后即可生成项目密钥、登记花名并发送测试通知。</p>
<button type="button" onClick={openCreateProject} className="mt-4 min-h-10 rounded-[8px] bg-[#1677ff] px-4 text-[14px] font-semibold text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] focus-visible:ring-offset-2">
新增第一个接入项目
</button>
</div>
) : (
<div className="mt-5 overflow-hidden rounded-[10px] border border-[#e2e8f0]">
<div className="hidden grid-cols-[minmax(180px,1.2fr)_minmax(150px,1fr)_minmax(170px,1fr)_110px_130px_44px] gap-3 border-b border-[#e2e8f0] bg-[#f8fafc] px-4 py-2.5 text-[12px] font-semibold text-[#66758b] md:grid">
<span>项目</span><span>允许跳转域名</span><span>最近投递</span><span>花名</span><span>更新时间</span><span className="sr-only">操作</span>
</div>
<div className="divide-y divide-[#e2e8f0] bg-white">
{projects.map((project) => (
<div
key={project.projectCode}
data-project-code={project.projectCode}
className="grid min-w-0 gap-3 px-4 py-4 md:grid-cols-[minmax(180px,1.2fr)_minmax(150px,1fr)_minmax(170px,1fr)_110px_130px_44px] md:items-center"
>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="truncate text-[14px] font-semibold text-[#101828]">{project.name}</span>
<span className={`rounded-[6px] px-2 py-0.5 text-[11px] font-medium ${project.isActive ? 'bg-[#e8f7f2] text-[#087052]' : 'bg-[#eef2f6] text-[#66758b]'}`}>
{project.isActive ? '使用中' : '已停用'}
</span>
</div>
<code className="mt-1 block truncate text-[12px] text-[#66758b]">{project.projectCode}</code>
</div>
<div className="min-w-0 text-[12px] leading-5 text-[#4f6077]">
<span className="font-medium text-[#66758b] md:hidden">允许域名:</span>
{project.allowedHosts.length > 0 ? project.allowedHosts.slice(0, 2).join('、') : '未配置'}
{project.allowedHosts.length > 2 ? ` 等 ${project.allowedHosts.length} 个` : ''}
</div>
<div>
<span className={`inline-flex rounded-[6px] px-2 py-1 text-[11px] font-medium ${getStatusClassName(project.deliverySummary.lastStatus)}`}>
{getStatusLabel(project.deliverySummary.lastStatus)}
</span>
{project.deliverySummary.recentFailedCount > 0 && (
<p className="mt-1 text-[11px] text-[#b42318]">近期失败 {project.deliverySummary.recentFailedCount} 条</p>
)}
</div>
<div className="text-[13px] text-[#4f6077]"><span className="font-medium text-[#66758b] md:hidden">花名:</span>{project.recipientCount}</div>
<div className="text-[12px] leading-5 text-[#66758b]">{formatDateTime(project.updatedAt)}</div>
<button
type="button"
onClick={() => openEditProject(project)}
aria-label={`管理项目 ${project.name}`}
className="inline-flex h-10 w-10 items-center justify-center justify-self-end rounded-[8px] border border-[#d9e0e7] text-[#4f6077] transition-colors hover:bg-[#f8fafc] hover:text-[#101828] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] focus-visible:ring-offset-2"
>
<ChevronRight className="h-4 w-4" aria-hidden="true" />
</button>
</div>
))}
</div>
</div>
)}
</section>
)}
{activeView === 'recipients' && (
<section aria-labelledby="notification-recipients-heading">
<h3 id="notification-recipients-heading" className="text-[16px] font-semibold text-[#101828]">收件人花名</h3>
<p className="mt-1 text-[13px] leading-5 text-[#66758b]">花名必须由管理员明确登记,系统不会模糊猜测收件人。</p>
{projects.length === 0 ? (
<div className="mt-5 rounded-[8px] border border-dashed border-[#cfd8e3] p-6 text-[13px] text-[#66758b]">请先在“接入项目”中新建项目,再登记收件人花名。</div>
) : (
<div className="mt-5 max-w-3xl">
<label htmlFor="recipient-project" className="block text-[13px] font-semibold text-[#344054]">所属项目</label>
<select id="recipient-project" value={recipientProjectCode} onChange={(event) => setRecipientProjectCode(event.target.value)} className="mt-2 min-h-10 w-full rounded-[8px] border border-[#cfd8e3] bg-white px-3 text-[14px] text-[#101828] focus:border-[#1677ff] focus:outline-none focus:ring-2 focus:ring-[#1677ff]/20">
{projects.map((project) => <option key={project.projectCode} value={project.projectCode}>{project.name} · {project.projectCode}</option>)}
</select>
<div className="mt-5 overflow-hidden rounded-[10px] border border-[#e2e8f0]">
<div className="grid grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)_40px] gap-2 bg-[#f8fafc] px-3 py-2 text-[12px] font-semibold text-[#66758b]"><span>花名</span><span>钉钉 userId</span><span className="sr-only">删除</span></div>
<div className="divide-y divide-[#e2e8f0]">
{recipientRows.length === 0 && <p className="px-4 py-6 text-center text-[13px] text-[#66758b]">暂无花名。点击下方按钮登记明确的花名与钉钉 userId。</p>}
{recipientRows.map((recipient, index) => (
<div key={`${index}-${recipient.alias}`} className="grid grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)_40px] gap-2 px-3 py-2">
<label className="sr-only" htmlFor={`recipient-alias-${index}`}>第 {index + 1} 条花名</label>
<input id={`recipient-alias-${index}`} value={recipient.alias} onChange={(event) => setRecipientRows((current) => current.map((row, rowIndex) => rowIndex === index ? { ...row, alias: event.target.value } : row))} placeholder="例如:小王" className="min-h-10 min-w-0 rounded-[8px] border border-[#cfd8e3] px-3 text-[14px] focus:border-[#1677ff] focus:outline-none focus:ring-2 focus:ring-[#1677ff]/20" />
<label className="sr-only" htmlFor={`recipient-user-${index}`}>第 {index + 1} 条钉钉 userId</label>
<input id={`recipient-user-${index}`} value={recipient.userId} onChange={(event) => setRecipientRows((current) => current.map((row, rowIndex) => rowIndex === index ? { ...row, userId: event.target.value } : row))} placeholder="精确钉钉 userId" className="min-h-10 min-w-0 rounded-[8px] border border-[#cfd8e3] px-3 text-[14px] focus:border-[#1677ff] focus:outline-none focus:ring-2 focus:ring-[#1677ff]/20" />
<button type="button" onClick={() => setRecipientRows((current) => current.filter((_, rowIndex) => rowIndex !== index))} aria-label={`删除花名 ${recipient.alias || index + 1}`} className="inline-flex h-10 w-10 items-center justify-center rounded-[8px] text-[#b42318] hover:bg-[#fef0ee] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]"><X className="h-4 w-4" aria-hidden="true" /></button>
</div>
))}
</div>
</div>
<div className="mt-4 flex flex-wrap gap-2">
<button type="button" onClick={() => setRecipientRows((current) => [...current, { alias: '', userId: '' }])} className="inline-flex min-h-10 items-center gap-2 rounded-[8px] border border-[#cfd8e3] px-3 text-[14px] font-medium text-[#344054] hover:bg-[#f8fafc] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]"><Plus className="h-4 w-4" aria-hidden="true" />登记花名</button>
<button type="button" onClick={() => void saveRecipients()} disabled={savingRecipients} className="min-h-10 rounded-[8px] bg-[#1677ff] px-4 text-[14px] font-semibold text-white hover:bg-[#0f65dc] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] focus-visible:ring-offset-2 disabled:opacity-50">{savingRecipients ? '保存中…' : '保存花名清单'}</button>
</div>
</div>
)}
</section>
)}
{activeView === 'test' && (
<section aria-labelledby="notification-test-heading" className="max-w-3xl">
<h3 id="notification-test-heading" className="text-[16px] font-semibold text-[#101828]">向指定员工发送测试通知</h3>
<p className="mt-1 text-[13px] leading-5 text-[#66758b]">这是一次真实钉钉单聊。发送前会再次展示项目、收件人与通知内容。</p>
{projects.length === 0 ? (
<div className="mt-5 rounded-[8px] border border-dashed border-[#cfd8e3] p-6 text-[13px] text-[#66758b]">请先新增并启用接入项目,再发送测试通知。</div>
) : (
<div className="mt-5 grid gap-4 sm:grid-cols-2">
<div className="sm:col-span-2">
<label htmlFor="test-project" className="block text-[13px] font-semibold text-[#344054]">接入项目</label>
<select id="test-project" value={testProjectCode} onChange={(event) => setTestProjectCode(event.target.value)} className="mt-2 min-h-10 w-full rounded-[8px] border border-[#cfd8e3] bg-white px-3 text-[14px] focus:border-[#1677ff] focus:outline-none focus:ring-2 focus:ring-[#1677ff]/20">
{projects.filter((project) => project.isActive).map((project) => <option key={project.projectCode} value={project.projectCode}>{project.name} · {project.projectCode}</option>)}
</select>
</div>
<div>
<label htmlFor="test-recipient-mode" className="block text-[13px] font-semibold text-[#344054]">收件人方式</label>
<select id="test-recipient-mode" value={testRecipientMode} onChange={(event) => { setTestRecipientMode(event.target.value as RecipientMode); setTestRecipient('') }} className="mt-2 min-h-10 w-full rounded-[8px] border border-[#cfd8e3] bg-white px-3 text-[14px] focus:border-[#1677ff] focus:outline-none focus:ring-2 focus:ring-[#1677ff]/20">
<option value="userId">精确钉钉 userId</option><option value="alias">已登记花名</option>
</select>
</div>
<div>
<label htmlFor="test-recipient" className="block text-[13px] font-semibold text-[#344054]">真实收件人</label>
{testRecipientMode === 'alias' ? (
<select id="test-recipient" value={testRecipient} onChange={(event) => setTestRecipient(event.target.value)} className="mt-2 min-h-10 w-full rounded-[8px] border border-[#cfd8e3] bg-white px-3 text-[14px] focus:border-[#1677ff] focus:outline-none focus:ring-2 focus:ring-[#1677ff]/20">
<option value="">请选择已登记花名</option>
{(selectedTestProject?.recipients || []).map((recipient) => <option key={recipient.alias} value={recipient.alias}>{recipient.alias} · {recipient.userId}</option>)}
</select>
) : (
<input id="test-recipient" value={testRecipient} onChange={(event) => setTestRecipient(event.target.value)} placeholder="输入精确钉钉 userId" className="mt-2 min-h-10 w-full rounded-[8px] border border-[#cfd8e3] px-3 text-[14px] focus:border-[#1677ff] focus:outline-none focus:ring-2 focus:ring-[#1677ff]/20" />
)}
</div>
<div className="sm:col-span-2"><label htmlFor="test-title" className="block text-[13px] font-semibold text-[#344054]">通知标题</label><input id="test-title" value={testTitle} onChange={(event) => setTestTitle(event.target.value)} className="mt-2 min-h-10 w-full rounded-[8px] border border-[#cfd8e3] px-3 text-[14px] focus:border-[#1677ff] focus:outline-none focus:ring-2 focus:ring-[#1677ff]/20" /></div>
<div className="sm:col-span-2"><label htmlFor="test-body" className="block text-[13px] font-semibold text-[#344054]">通知正文</label><textarea id="test-body" value={testBody} onChange={(event) => setTestBody(event.target.value)} rows={4} className="mt-2 w-full resize-y rounded-[8px] border border-[#cfd8e3] px-3 py-2 text-[14px] leading-6 focus:border-[#1677ff] focus:outline-none focus:ring-2 focus:ring-[#1677ff]/20" /></div>
<div><label htmlFor="test-button" className="block text-[13px] font-semibold text-[#344054]">按钮文字</label><input id="test-button" value={testButtonLabel} onChange={(event) => setTestButtonLabel(event.target.value)} placeholder="可选" className="mt-2 min-h-10 w-full rounded-[8px] border border-[#cfd8e3] px-3 text-[14px] focus:border-[#1677ff] focus:outline-none focus:ring-2 focus:ring-[#1677ff]/20" /></div>
<div><label htmlFor="test-url" className="block text-[13px] font-semibold text-[#344054]">按钮地址</label><input id="test-url" value={testActionUrl} onChange={(event) => setTestActionUrl(event.target.value)} placeholder="需属于 allowedHosts" className="mt-2 min-h-10 w-full rounded-[8px] border border-[#cfd8e3] px-3 text-[14px] focus:border-[#1677ff] focus:outline-none focus:ring-2 focus:ring-[#1677ff]/20" /></div>
<div className="sm:col-span-2 rounded-[8px] border border-[#d7e8fb] bg-[#f5f9ff] px-4 py-3 text-[13px] leading-6 text-[#315f91]">
{resolvedTestRecipient ? `将通过“${selectedTestProject?.name}”向 ${resolvedTestRecipient.label} 发送一条真实钉钉单聊。` : '请选择或填写明确收件人。系统不会根据姓名模糊猜人。'}
</div>
<div className="sm:col-span-2"><button type="button" onClick={() => setConfirmTestOpen(true)} disabled={!resolvedTestRecipient || !testTitle.trim() || !testBody.trim()} className="inline-flex min-h-10 items-center gap-2 rounded-[8px] bg-[#1677ff] px-4 text-[14px] font-semibold text-white hover:bg-[#0f65dc] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"><Send className="h-4 w-4" aria-hidden="true" />向指定员工发送测试通知</button></div>
</div>
)}
</section>
)}
{activeView === 'deliveries' && (
<section aria-labelledby="notification-deliveries-heading">
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
<div><h3 id="notification-deliveries-heading" className="text-[16px] font-semibold text-[#101828]">投递记录</h3><p className="mt-1 text-[13px] leading-5 text-[#66758b]">优先展示可理解的状态与处理建议。发送结果不确定时必须先人工核验。</p></div>
<div className="grid gap-2 sm:grid-cols-2">
<label className="sr-only" htmlFor="delivery-project-filter">按项目筛选</label><select id="delivery-project-filter" value={deliveryProjectFilter} onChange={(event) => setDeliveryProjectFilter(event.target.value)} className="min-h-10 rounded-[8px] border border-[#cfd8e3] bg-white px-3 text-[13px] focus:border-[#1677ff] focus:outline-none focus:ring-2 focus:ring-[#1677ff]/20"><option value="all">全部项目</option>{projects.map((project) => <option key={project.projectCode} value={project.projectCode}>{project.name}</option>)}</select>
<label className="sr-only" htmlFor="delivery-status-filter">按状态筛选</label><select id="delivery-status-filter" value={deliveryStatusFilter} onChange={(event) => setDeliveryStatusFilter(event.target.value)} className="min-h-10 rounded-[8px] border border-[#cfd8e3] bg-white px-3 text-[13px] focus:border-[#1677ff] focus:outline-none focus:ring-2 focus:ring-[#1677ff]/20">{DELIVERY_STATUSES.map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select>
</div>
</div>
{deliveriesError ? <div role="alert" className="mt-5 rounded-[8px] border border-[#f3c7c1] bg-[#fef6f5] px-4 py-3 text-[13px] leading-6 text-[#8f2119]">{deliveriesError}。没有自动重发任何通知,请稍后刷新。</div> : deliveriesLoading ? <div className="mt-5 space-y-2">{[0,1,2].map((item) => <div key={item} className="h-20 animate-pulse rounded-[8px] bg-[#eef2f6]" />)}</div> : deliveries.length === 0 ? <div className="mt-5 rounded-[8px] border border-dashed border-[#cfd8e3] p-8 text-center text-[13px] text-[#66758b]">当前筛选条件下没有投递记录。发送测试通知后可在这里查看状态。</div> : (
<div className="mt-5 divide-y divide-[#e2e8f0] overflow-hidden rounded-[10px] border border-[#e2e8f0]">
{deliveries.map((delivery) => { const advice = getDeliveryAdvice(delivery); return <div key={delivery.id} className="grid gap-3 bg-white px-4 py-4 lg:grid-cols-[minmax(150px,0.8fr)_minmax(210px,1.2fr)_120px_minmax(220px,1.2fr)] lg:items-start"><div><p className="text-[13px] font-semibold text-[#101828]">{delivery.projectCode}</p><p className="mt-1 text-[11px] text-[#66758b]">{formatDateTime(delivery.createdAt)}</p></div><div><p className="text-[13px] font-medium text-[#344054]">{delivery.title || '未命名通知'}</p><p className="mt-1 text-[12px] text-[#66758b]">收件人:{delivery.recipientAlias || delivery.recipientUserIdMasked || '未返回'}</p></div><div><span className={`inline-flex rounded-[6px] px-2 py-1 text-[11px] font-medium ${getStatusClassName(delivery.status)}`}>{getStatusLabel(delivery.status)}</span></div><div className="text-[12px] leading-5 text-[#66758b]">{advice ? <p className={delivery.lastErrorCode === 'DELIVERY_OUTCOME_UNKNOWN' ? 'font-medium text-[#9a4d08]' : 'text-[#8f2119]'}>{advice}</p> : <p>{delivery.lastErrorMessage || '暂无异常'}</p>}{delivery.lastErrorCode && <code className="mt-1 block text-[11px] text-[#8b95a5]">错误标识:{delivery.lastErrorCode}</code>}</div></div> })}
</div>
)}
</section>
)}
</div>
</div>
{drawerOpen && (
<div className="fixed inset-0 z-[70] flex justify-end bg-[#06192d]/35" onMouseDown={(event) => { if (event.currentTarget === event.target) closeDrawer() }}>
<section data-nc-drawer role="dialog" aria-modal="true" aria-labelledby="project-drawer-title" className="h-full w-full max-w-[560px] overflow-y-auto bg-white shadow-[0_18px_48px_rgba(6,25,45,0.2)]">
<div className="sticky top-0 z-10 flex items-start justify-between border-b border-[#e2e8f0] bg-white px-5 py-4">
<div><h3 id="project-drawer-title" className="text-[18px] font-semibold text-[#101828]">{editingCode ? '管理接入项目' : '新增接入项目'}</h3><p className="mt-1 text-[12px] text-[#66758b]">{editingCode ? '修改名称、域名、状态和收件人方式。' : '创建后会生成只展示一次的项目密钥。'}</p></div>
<button type="button" onClick={closeDrawer} aria-label="关闭项目编辑" className="inline-flex h-10 w-10 items-center justify-center rounded-[8px] text-[#4f6077] hover:bg-[#f8fafc] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]"><X className="h-5 w-5" aria-hidden="true" /></button>
</div>
<div className="space-y-5 px-5 py-5">
<div><label htmlFor="project-name" className="block text-[13px] font-semibold text-[#344054]">项目中文名称</label><input id="project-name" autoFocus value={projectForm.name} onChange={(event) => setProjectForm((current) => ({ ...current, name: event.target.value }))} placeholder="例如:固定资产系统" className="mt-2 min-h-10 w-full rounded-[8px] border border-[#cfd8e3] px-3 text-[14px] focus:border-[#1677ff] focus:outline-none focus:ring-2 focus:ring-[#1677ff]/20" /></div>
<div><label htmlFor="project-code" className="block text-[13px] font-semibold text-[#344054]">projectCode</label><input id="project-code" disabled={Boolean(editingCode)} value={projectForm.projectCode} onChange={(event) => setProjectForm((current) => ({ ...current, projectCode: event.target.value }))} placeholder="例如:fixed_assets" className="mt-2 min-h-10 w-full rounded-[8px] border border-[#cfd8e3] px-3 font-mono text-[13px] focus:border-[#1677ff] focus:outline-none focus:ring-2 focus:ring-[#1677ff]/20 disabled:bg-[#f2f4f7] disabled:text-[#66758b]" /><p className="mt-1 text-[12px] leading-5 text-[#66758b]">创建后不可修改,用于签名和调用方环境变量。</p></div>
<div><label htmlFor="project-hosts" className="block text-[13px] font-semibold text-[#344054]">允许跳转域名 allowedHosts</label><textarea id="project-hosts" rows={4} value={projectForm.allowedHostsText} onChange={(event) => setProjectForm((current) => ({ ...current, allowedHostsText: event.target.value }))} placeholder={'example.com\ninternal.example.com'} className="mt-2 w-full resize-y rounded-[8px] border border-[#cfd8e3] px-3 py-2 font-mono text-[13px] leading-6 focus:border-[#1677ff] focus:outline-none focus:ring-2 focus:ring-[#1677ff]/20" /><p className="mt-1 text-[12px] leading-5 text-[#66758b]">每行一个主机名,不含协议、端口或路径。通知按钮只能跳转到这些域名。</p></div>
<div><label htmlFor="project-recipient-mode" className="block text-[13px] font-semibold text-[#344054]">默认收件人方式</label><select id="project-recipient-mode" value={projectForm.recipientMode} onChange={(event) => setProjectForm((current) => ({ ...current, recipientMode: event.target.value as RecipientMode }))} className="mt-2 min-h-10 w-full rounded-[8px] border border-[#cfd8e3] bg-white px-3 text-[14px] focus:border-[#1677ff] focus:outline-none focus:ring-2 focus:ring-[#1677ff]/20"><option value="userId">精确钉钉 userId</option><option value="alias">已登记花名</option></select></div>
<label className="flex items-center gap-3 rounded-[8px] border border-[#e2e8f0] px-3 py-3 text-[13px] font-medium text-[#344054]"><input type="checkbox" checked={projectForm.isActive} onChange={(event) => setProjectForm((current) => ({ ...current, isActive: event.target.checked }))} className="h-4 w-4 rounded border-[#b8c2ce] text-[#1677ff] focus:ring-[#1677ff]" />保存后启用该项目</label>
{!editingCode && projectForm.recipientMode === 'alias' && <div><div className="flex items-center justify-between"><span className="text-[13px] font-semibold text-[#344054]">初始花名清单</span><button type="button" onClick={addProjectRecipient} className="inline-flex min-h-10 items-center gap-1 rounded-[8px] px-2 text-[13px] font-medium text-[#1677ff] hover:bg-[#f5f9ff] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]"><Plus className="h-4 w-4" aria-hidden="true" />添加花名</button></div><div className="mt-2 space-y-2">{projectForm.recipients.map((recipient, index) => <div key={index} className="grid grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)_40px] gap-2"><input aria-label={`第 ${index + 1} 条花名`} value={recipient.alias} onChange={(event) => updateProjectRecipient(index, 'alias', event.target.value)} placeholder="花名" className="min-h-10 min-w-0 rounded-[8px] border border-[#cfd8e3] px-3 text-[13px] focus:border-[#1677ff] focus:outline-none" /><input aria-label={`第 ${index + 1} 条钉钉 userId`} value={recipient.userId} onChange={(event) => updateProjectRecipient(index, 'userId', event.target.value)} placeholder="钉钉 userId" className="min-h-10 min-w-0 rounded-[8px] border border-[#cfd8e3] px-3 text-[13px] focus:border-[#1677ff] focus:outline-none" /><button type="button" aria-label={`删除第 ${index + 1} 条花名`} onClick={() => setProjectForm((current) => ({ ...current, recipients: current.recipients.filter((_, rowIndex) => rowIndex !== index) }))} className="inline-flex h-10 w-10 items-center justify-center rounded-[8px] text-[#b42318] hover:bg-[#fef0ee] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]"><X className="h-4 w-4" aria-hidden="true" /></button></div>)}</div></div>}
{editingCode && <div className="rounded-[8px] border border-[#e2e8f0] p-4"><div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"><div><p className="text-[13px] font-semibold text-[#344054]">项目密钥</p><p className="mt-1 text-[12px] leading-5 text-[#66758b]">现有密钥不会再次显示。需要更换时执行轮换。</p></div><button type="button" onClick={() => { const project = projects.find((item) => item.projectCode === editingCode); if (project) setRotateProject(project) }} className="inline-flex min-h-10 items-center justify-center gap-2 rounded-[8px] border border-[#cfd8e3] px-3 text-[13px] font-medium text-[#344054] hover:bg-[#f8fafc] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]"><KeyRound className="h-4 w-4" aria-hidden="true" />轮换项目密钥</button></div></div>}
</div>
<div className="sticky bottom-0 flex justify-end gap-2 border-t border-[#e2e8f0] bg-white px-5 py-4"><button type="button" onClick={closeDrawer} className="min-h-10 rounded-[8px] border border-[#cfd8e3] px-4 text-[14px] font-medium text-[#344054] hover:bg-[#f8fafc] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff]">取消</button><button type="button" onClick={() => void saveProject()} disabled={savingProject} className="min-h-10 rounded-[8px] bg-[#1677ff] px-4 text-[14px] font-semibold text-white hover:bg-[#0f65dc] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#1677ff] focus-visible:ring-offset-2 disabled:opacity-50">{savingProject ? '保存中…' : editingCode ? '保存项目' : '创建并生成密钥'}</button></div>
</section>
</div>
)}
{rotateProject && <div className="fixed inset-0 z-[80] flex items-center justify-center bg-[#06192d]/40 px-4"><section role="alertdialog" aria-modal="true" aria-labelledby="rotate-secret-title" className="w-full max-w-md rounded-[12px] bg-white p-5 shadow-[0_18px_48px_rgba(6,25,45,0.2)]"><div className="flex h-10 w-10 items-center justify-center rounded-[8px] bg-[#fff7e8] text-[#9a4d08]"><AlertTriangle className="h-5 w-5" aria-hidden="true" /></div><h3 id="rotate-secret-title" className="mt-4 text-[17px] font-semibold text-[#101828]">确认轮换“{rotateProject.name}”的项目密钥?</h3><p className="mt-2 text-[13px] leading-6 text-[#66758b]">轮换完成后,所有仍使用旧密钥的调用会立即鉴权失败。请确认你能够马上更新调用方环境变量。</p><div className="mt-5 flex justify-end gap-2"><button type="button" onClick={() => setRotateProject(null)} className="min-h-10 rounded-[8px] border border-[#cfd8e3] px-4 text-[14px] font-medium text-[#344054]">取消</button><button type="button" onClick={() => void confirmRotateSecret()} disabled={rotatingSecret} className="min-h-10 rounded-[8px] bg-[#b42318] px-4 text-[14px] font-semibold text-white disabled:opacity-50">{rotatingSecret ? '轮换中…' : '确认轮换,旧密钥立即失效'}</button></div></section></div>}
{confirmTestOpen && resolvedTestRecipient && selectedTestProject && <div className="fixed inset-0 z-[80] flex items-center justify-center bg-[#06192d]/40 px-4"><section role="alertdialog" aria-modal="true" aria-labelledby="confirm-test-title" className="w-full max-w-lg rounded-[12px] bg-white p-5 shadow-[0_18px_48px_rgba(6,25,45,0.2)]"><div className="flex h-10 w-10 items-center justify-center rounded-[8px] bg-[#e6f4ff] text-[#1677ff]"><Send className="h-5 w-5" aria-hidden="true" /></div><h3 id="confirm-test-title" className="mt-4 text-[17px] font-semibold text-[#101828]">确认发送真实测试通知</h3><dl className="mt-4 grid gap-3 rounded-[8px] bg-[#f8fafc] p-4 text-[13px]"><div><dt className="text-[#66758b]">项目</dt><dd className="mt-1 font-medium text-[#101828]">{selectedTestProject.name} · {selectedTestProject.projectCode}</dd></div><div><dt className="text-[#66758b]">真实收件人</dt><dd className="mt-1 font-medium text-[#101828]">{resolvedTestRecipient.label}</dd></div><div><dt className="text-[#66758b]">标题</dt><dd className="mt-1 font-medium text-[#101828]">{testTitle}</dd></div></dl><p className="mt-3 text-[13px] leading-6 text-[#9a4d08]">确认后会真实调用钉钉,不是页面预览。</p><div className="mt-5 flex justify-end gap-2"><button type="button" onClick={() => setConfirmTestOpen(false)} className="min-h-10 rounded-[8px] border border-[#cfd8e3] px-4 text-[14px] font-medium text-[#344054]">取消</button><button type="button" onClick={() => void sendTestNotification()} disabled={sendingTest} className="min-h-10 rounded-[8px] bg-[#1677ff] px-4 text-[14px] font-semibold text-white disabled:opacity-50">{sendingTest ? '正在提交…' : '确认发送给该员工'}</button></div></section></div>}
{oneTimeSecret && <div className="fixed inset-0 z-[90] flex items-center justify-center bg-[#06192d]/45 px-4"><section data-secret-reveal role="dialog" aria-modal="true" aria-labelledby="one-time-secret-title" className="max-h-[90dvh] w-full max-w-2xl overflow-y-auto rounded-[12px] bg-white p-5 shadow-[0_18px_48px_rgba(6,25,45,0.22)]"><div className="flex h-10 w-10 items-center justify-center rounded-[8px] bg-[#e8f7f2] text-[#087052]"><Check className="h-5 w-5" aria-hidden="true" /></div><h3 id="one-time-secret-title" className="mt-4 text-[18px] font-semibold text-[#101828]">{oneTimeSecret.reason === 'created' ? '接入项目已创建' : '项目密钥已轮换'}</h3><p className="mt-2 text-[13px] leading-6 text-[#b42318]">下面的项目密钥只完整展示这一次。关闭后无法再次查看,请现在复制并保存到调用方安全环境变量。</p><div className="mt-5"><div className="flex items-center justify-between"><span className="text-[13px] font-semibold text-[#344054]">项目密钥</span><button type="button" onClick={() => void copyText(oneTimeSecret.secret, '项目密钥')} className="inline-flex min-h-10 items-center gap-2 rounded-[8px] px-3 text-[13px] font-medium text-[#1677ff] hover:bg-[#f5f9ff]"><Clipboard className="h-4 w-4" aria-hidden="true" />复制</button></div><code className="mt-2 block break-all rounded-[8px] border border-[#d9e0e7] bg-[#f8fafc] p-3 text-[13px] leading-6 text-[#101828]">{oneTimeSecret.secret}</code></div><div className="mt-5"><div className="flex items-center justify-between"><span className="text-[13px] font-semibold text-[#344054]">调用方环境变量</span><button type="button" onClick={() => void copyText(Object.entries(oneTimeSecret.env).map(([key, value]) => `${key}=${value}`).join('\n'), '环境变量')} className="inline-flex min-h-10 items-center gap-2 rounded-[8px] px-3 text-[13px] font-medium text-[#1677ff] hover:bg-[#f5f9ff]"><Clipboard className="h-4 w-4" aria-hidden="true" />复制全部</button></div><pre className="mt-2 overflow-x-auto rounded-[8px] border border-[#d9e0e7] bg-[#06192d] p-3 text-[12px] leading-6 text-[#eaf1f8]">{Object.entries(oneTimeSecret.env).map(([key, value]) => `${key}=${value}`).join('\n')}</pre></div><div className="mt-5 flex justify-end"><button type="button" onClick={() => setOneTimeSecret(null)} className="min-h-10 rounded-[8px] bg-[#1677ff] px-4 text-[14px] font-semibold text-white">我已安全保存,关闭</button></div></section></div>}
</div>
)
}

View File

@ -2,7 +2,7 @@
## 结论
当前应用发布工具验证十四份目标 migration。普通部署脚本**只读验证,不会自动修改数据库**。只有独立 migrator 在同时拿到精确 Git 提交、数据库身份和备份凭证后,才允许执行 `prisma migrate deploy`。
当前应用发布工具验证十六份目标 migration。普通部署脚本**只读验证,不会自动修改数据库**。只有独立 migrator 在同时拿到精确 Git 提交、数据库身份和备份凭证后,才允许执行 `prisma migrate deploy`。
目标 migration:
@ -21,8 +21,9 @@
- `20260826_add_pack_video_early_access`
- `20260828_add_pack_item_can_edit_assets`
- `20260828_add_task_detail_change_approvals`
- `20260829_add_recoverable_api_key_secret`
它们新增可空租约字段/索引、修改未来 Logo 默认值、精确把 `system_settings` 品牌两列中的退役 `s3.1080p.hk` 值改为本地品牌图,并把 `materials_uploadId_key` 重建为“`uploadId` 非空且素材未软删除”范围内的唯一 btree 索引;同时新增人员组织树、素材托管字段、素材不完整空壳标记、账号—人员绑定表、钉钉流程快照字段,以及任务详情图片变更审批单和通知 outbox。审批迁移只新增表、约束与索引,不改写现有素材和资产行。新增 `form_fields` 后会将同步游标重置一次,让下一轮同步完整补齐明细字段。索引迁移会先检测未删除素材中的重复 `uploadId`;人员绑定迁移会先检测一个钉钉身份对应多个账号的冲突。任一预检失败都会直接停止,不会猜测性合并、删除素材或留下一半回填。迁移没有自动 down;执行失败时停止发布,恢复依赖执行前的数据库备份。
它们新增可空租约字段/索引、修改未来 Logo 默认值、精确把 `system_settings` 品牌两列中的退役 `s3.1080p.hk` 值改为本地品牌图,并把 `materials_uploadId_key` 重建为“`uploadId` 非空且素材未软删除”范围内的唯一 btree 索引;同时新增人员组织树、素材托管字段、素材不完整空壳标记、账号—人员绑定表、钉钉流程快照字段,以及任务详情图片变更审批单和通知 outbox。审批迁移只新增表、约束与索引,不改写现有素材和资产行。`20260829_add_recoverable_api_key_secret` 只新增 `api_keys.secret_ciphertext` / `secret_nonce` / `secret_auth_tag` 三个可空加密列、`api_key_reveal_audits` 审计表和 `api_key_id+created_at`、`viewer_user_id+created_at` 两个非唯一 btree 索引,不回填旧 key。新增 `form_fields` 后会将同步游标重置一次,让下一轮同步完整补齐明细字段。索引迁移会先检测未删除素材中的重复 `uploadId`;人员绑定迁移会先检测一个钉钉身份对应多个账号的冲突。任一预检失败都会直接停止,不会猜测性合并、删除素材或留下一半回填。迁移没有自动 down;执行失败时停止发布,恢复依赖执行前的数据库备份。
## 强制前提
@ -37,7 +38,7 @@
1. 构建并固定同一 exact SHA/digest 的 runner 与 migrator;
2. legacy bootstrap `Audit`;
3. legacy bootstrap `Apply`,外部验收 503,确认旧容器停止且保留;
4. 在同一维护窗内运行本文的真实备份、隔离恢复、十四条目标 migration 和迁移后审计;
4. 在同一维护窗内运行本文的真实备份、隔离恢复、十六条目标 migration 和迁移后审计;
5. 仅在所有门禁通过后,运行普通同 SHA 应用部署。
顺序不得改为“先 migration,后 bootstrap”。本文备份工具的生产前后摘要若有变化,说明维护窗内仍可能存在写入,会直接失败且不生成 receipt。
@ -361,7 +362,7 @@ docker run --rm \
"$MIGRATOR_IMAGE" --apply
```
工具会在修改数据库前先验证审计目录可执行“新建→原子改名→删除”,再依次完成:核对镜像 SHA、配置端口与数据库实际端口、核对机器生成的 receipt 和完整备份产物、拒绝未知待执行/失败 migration、执行 `prisma migrate deploy`,最后核对十四条目标 migration 的数据库 checksum、完成状态、关键人员组织表、素材空壳标记、钉钉流程快照表及需求类型和表单字段、字段类型与可空性、租约与需求类型索引的精确列顺序、`materials_uploadId_key` 的唯一性/btree/单列/有效就绪状态/active-only 谓词、Logo 默认值,以及每条现存 Logo 地址均为本地品牌图或当前可信桶地址,并原子写审计 JSON。
工具会在修改数据库前先验证审计目录可执行“新建→原子改名→删除”,再依次完成:核对镜像 SHA、配置端口与数据库实际端口、核对机器生成的 receipt 和完整备份产物、拒绝未知待执行/失败 migration、执行 `prisma migrate deploy`,最后核对十六条目标 migration 的数据库 checksum、完成状态、关键人员组织表、素材空壳标记、钉钉流程快照表及需求类型和表单字段、字段类型与可空性、`api_keys` 三个可空加密列、`api_key_reveal_audits` 关键列、租约/需求类型/API Key reveal 审计索引的精确列顺序、`materials_uploadId_key` 的唯一性/btree/单列/有效就绪状态/active-only 谓词、Logo 默认值,以及每条现存 Logo 地址均为本地品牌图或当前可信桶地址,并原子写审计 JSON。
apply 成功后立即固定审计文件本身;后续“迁移批次部署”读取这一个文件,不能手工重写:
@ -377,7 +378,7 @@ test -n "$MIGRATION_AUDIT_SHA256"
只有以下全部成立,才进入应用首次安全上线步骤:
- `applied.json` 的 `verified=true`;
- 十二条 target migration 均 `completed=true`,source/database checksum 一致;
- 十六条 target migration 均 `completed=true`,source/database checksum 一致;
- `_prisma_migrations.finished_at` 非空、`rolled_back_at` 为空;
- 视频租约列/索引、任务详情作业租约列/索引、active-only `uploadId` 唯一索引、品牌默认值精确匹配;
- 再次运行 `--audit` 仍通过;

View File

@ -2,11 +2,16 @@
jest.mock('@/lib/prisma', () => ({
prisma: {
$transaction: jest.fn(async (operations: Array<Promise<unknown>>) => Promise.all(operations)),
apiKey: {
findUnique: jest.fn(),
findMany: jest.fn(),
update: jest.fn(),
create: jest.fn(),
},
apiKeyRevealAudit: {
create: jest.fn(),
},
},
}))
@ -15,6 +20,8 @@ import {
createApiKey,
getApiKeyFromRequest,
getApiKeyPreview,
ApiKeyNotRecoverableError,
revealApiKey,
validateApiKey,
} from '@/lib/external-api'
@ -24,6 +31,12 @@ const apiKeyDb = prisma.apiKey as unknown as {
create: jest.Mock
}
const revealAuditDb = prisma.apiKeyRevealAudit as unknown as {
create: jest.Mock
}
const originalEncryptionKey = process.env.API_KEY_ENCRYPTION_KEY
function createRequest(url: string, headers?: Record<string, string>) {
return {
nextUrl: new URL(url),
@ -62,9 +75,20 @@ describe('getApiKeyFromRequest', () => {
})
describe('API Key 哈希存储', () => {
beforeEach(() => jest.clearAllMocks())
beforeEach(() => {
jest.clearAllMocks()
process.env.API_KEY_ENCRYPTION_KEY = 'test-api-key-encryption-secret-32-bytes-minimum'
})
test('新建 Key 只向数据库写入哈希,原始值仅返回一次', async () => {
afterAll(() => {
if (originalEncryptionKey === undefined) {
delete process.env.API_KEY_ENCRYPTION_KEY
} else {
process.env.API_KEY_ENCRYPTION_KEY = originalEncryptionKey
}
})
test('新建 Key 同时保存鉴权哈希和 AES-GCM 密文,不把原文直接落库', async () => {
apiKeyDb.create.mockImplementation(async ({ data }: any) => ({
id: 'key-1',
name: data.name,
@ -78,6 +102,10 @@ describe('API Key 哈希存储', () => {
expect(created.key).toMatch(/^[a-f0-9]{32}$/)
expect(storedKey).not.toBe(created.key)
expect(storedKey).toMatch(/^v1:[a-f0-9]{8}:[a-f0-9]{64}$/)
expect(apiKeyDb.create.mock.calls[0][0].data.secretCiphertext).toEqual(expect.any(String))
expect(apiKeyDb.create.mock.calls[0][0].data.secretNonce).toEqual(expect.any(String))
expect(apiKeyDb.create.mock.calls[0][0].data.secretAuthTag).toEqual(expect.any(String))
expect(JSON.stringify(apiKeyDb.create.mock.calls[0][0].data)).not.toContain(created.key)
})
test('已有明文 Key 首次验证成功后迁移成哈希', async () => {
@ -90,6 +118,41 @@ describe('API Key 哈希存储', () => {
expect(result?.id).toBe('legacy-1')
expect(apiKeyDb.update.mock.calls[0][0].data.key).toMatch(/^v1:legacy-a:[a-f0-9]{64}$/)
expect(apiKeyDb.update.mock.calls[0][0].data.secretCiphertext).toEqual(expect.any(String))
})
test('超级管理员可解密新 Key,且每次查看都会写入审计', async () => {
let stored: any
apiKeyDb.create.mockImplementation(async ({ data }: any) => {
stored = data
return {
id: 'key-1',
name: data.name,
createdAt: new Date('2026-07-10T00:00:00Z'),
}
})
revealAuditDb.create.mockResolvedValue({ id: 'audit-1' })
const created = await createApiKey('产品平台', 'admin-1')
apiKeyDb.findUnique.mockResolvedValue({ id: 'key-1', ...stored })
await expect(revealApiKey('key-1', 'viewer-1')).resolves.toBe(created.key)
expect(revealAuditDb.create).toHaveBeenCalledWith({
data: { apiKeyId: 'key-1', viewerUserId: 'viewer-1' },
})
})
test('历史纯哈希 Key 无法伪造明文,明确要求重新创建', async () => {
apiKeyDb.findUnique.mockResolvedValue({
id: 'legacy-hash',
key: `v1:12345678:${'a'.repeat(64)}`,
secretCiphertext: null,
secretNonce: null,
secretAuthTag: null,
})
await expect(revealApiKey('legacy-hash', 'viewer-1')).rejects.toBeInstanceOf(ApiKeyNotRecoverableError)
expect(revealAuditDb.create).not.toHaveBeenCalled()
})
test('后台只展示原始 Key 前缀', () => {

View File

@ -6,13 +6,15 @@ import { GET as getDeliveriesRoute } from '@/app/api/admin/external-notification
import { POST as retryDeliveryRoute } from '@/app/api/admin/external-notification-deliveries/[id]/retry/route'
import { getServerSession } from 'next-auth'
import {
buildExternalNotificationTargetAdminView,
createExternalNotificationTarget,
getExternalNotificationTargetById,
listExternalNotificationDeliveries,
listExternalNotificationTargets,
retryExternalNotificationDelivery,
updateExternalNotificationTarget,
} from '@/lib/external-auto-listing'
import { hasSystemAdminPermission, isSuperAdmin } from '@/lib/department-permission'
import { isSuperAdmin } from '@/lib/department-permission'
jest.mock('next-auth', () => ({
getServerSession: jest.fn(),
@ -24,12 +26,24 @@ jest.mock('@/lib/auth', () => ({
jest.mock('@/lib/department-permission', () => ({
isSuperAdmin: jest.fn(),
hasSystemAdminPermission: jest.fn(),
}))
jest.mock('@/lib/external-auto-listing', () => ({
AUTO_LISTING_EVENT_TYPE: 'task-detail.full-set.ready',
buildExternalNotificationTargetAdminView: jest.fn((target) => ({
...target,
authConfig: target.authType === 'header'
? { headerName: target.authConfig.headerName, headerValue: '' }
: target.authType === 'bearer'
? { token: '' }
: {},
authConfigured: true,
authMasked: target.authType === 'header'
? `Header · ${target.authConfig.headerName} · conf****1234`
: 'Bearer · secr****oken',
})),
createExternalNotificationTarget: jest.fn(),
getExternalNotificationTargetById: jest.fn(),
listExternalNotificationTargets: jest.fn(),
updateExternalNotificationTarget: jest.fn(),
listExternalNotificationDeliveries: jest.fn(),
@ -37,16 +51,22 @@ jest.mock('@/lib/external-auto-listing', () => ({
}))
describe('external notification admin routes', () => {
let consoleErrorSpy: jest.SpyInstance
beforeEach(() => {
jest.clearAllMocks()
consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined)
;(getServerSession as jest.Mock).mockResolvedValue({
user: {
id: 'admin-1',
role: 'SYSTEM_ADMIN',
},
})
;(isSuperAdmin as jest.Mock).mockResolvedValue(false)
;(hasSystemAdminPermission as jest.Mock).mockReturnValue(true)
;(isSuperAdmin as jest.Mock).mockResolvedValue(true)
})
afterEach(() => {
consoleErrorSpy.mockRestore()
})
test('GET /api/admin/external-notification-targets 应返回通知目标列表', async () => {
@ -70,6 +90,10 @@ describe('external notification admin routes', () => {
expect(response.status).toBe(200)
expect(body.success).toBe(true)
expect(body.data).toHaveLength(1)
expect(body.data[0].authConfig).toEqual({ token: '' })
expect(body.data[0].authMasked).toContain('Bearer')
expect(JSON.stringify(body)).not.toContain('secret-token')
expect(buildExternalNotificationTargetAdminView).toHaveBeenCalled()
})
test('POST /api/admin/external-notification-targets 应创建通知目标', async () => {
@ -99,6 +123,8 @@ describe('external notification admin routes', () => {
expect(response.status).toBe(200)
expect(body.success).toBe(true)
expect(body.data.authConfig).toEqual({ token: '' })
expect(JSON.stringify(body)).not.toContain('secret-token')
expect(createExternalNotificationTarget).toHaveBeenCalledWith({
name: '自动上架平台',
callbackUrl: 'https://auto.example.com/callback',
@ -131,6 +157,17 @@ describe('external notification admin routes', () => {
})
test('PUT /api/admin/external-notification-targets/[id] 应更新通知目标', async () => {
;(getExternalNotificationTargetById as jest.Mock).mockResolvedValue({
id: 'target-1',
name: '自动上架平台',
callbackUrl: 'https://auto.example.com/callback',
isActive: true,
authType: 'header',
authConfig: { headerName: 'X-Signature', headerValue: 'old-secret' },
eventTypes: ['task-detail.full-set.ready'],
createdAt: '2026-03-25T10:00:00.000Z',
updatedAt: '2026-03-25T10:00:00.000Z',
})
;(updateExternalNotificationTarget as jest.Mock).mockResolvedValue({
id: 'target-1',
name: '自动上架平台-新版',
@ -149,7 +186,7 @@ describe('external notification admin routes', () => {
callbackUrl: 'https://auto.example.com/callback',
isActive: false,
authType: 'header',
authConfig: { headerName: 'X-Signature', headerValue: 'abc123' },
authConfig: { headerName: 'X-Signature', headerValue: '' },
}),
} as any
@ -160,12 +197,14 @@ describe('external notification admin routes', () => {
expect(response.status).toBe(200)
expect(body.success).toBe(true)
expect(body.data.authConfig).toEqual({ headerName: 'X-Signature', headerValue: '' })
expect(JSON.stringify(body)).not.toContain('abc123')
expect(updateExternalNotificationTarget).toHaveBeenCalledWith('target-1', {
name: '自动上架平台-新版',
callbackUrl: 'https://auto.example.com/callback',
isActive: false,
authType: 'header',
authConfig: { headerName: 'X-Signature', headerValue: 'abc123' },
authConfig: { headerName: 'X-Signature', headerValue: 'old-secret' },
eventTypes: undefined,
})
})
@ -224,6 +263,20 @@ describe('external notification admin routes', () => {
expect(retryExternalNotificationDelivery).toHaveBeenCalledWith('delivery-1')
})
test('非真正超管时即使是 SYSTEM_ADMIN 也返回 403', async () => {
;(isSuperAdmin as jest.Mock).mockResolvedValue(false)
const targetsResponse = await getTargetsRoute(new Request('http://localhost/api/admin/external-notification-targets') as any)
const retryResponse = await retryDeliveryRoute(new Request('http://localhost') as any, {
params: { id: 'delivery-1' },
} as any)
expect(targetsResponse.status).toBe(403)
expect(retryResponse.status).toBe(403)
expect(listExternalNotificationTargets).not.toHaveBeenCalled()
expect(retryExternalNotificationDelivery).not.toHaveBeenCalled()
})
test('未登录时应拒绝访问', async () => {
;(getServerSession as jest.Mock).mockResolvedValue(null)

View File

@ -0,0 +1,233 @@
/** @jest-environment node */
import { getServerSession } from 'next-auth'
import { isSuperAdmin } from '@/lib/department-permission'
import {
createNotificationCenterProject,
getNotificationCenterProject,
listNotificationCenterAudits,
listNotificationCenterDeliveries,
listNotificationCenterProjectRecipients,
listNotificationCenterProjects,
rotateNotificationCenterProjectSecret,
sendNotificationCenterProjectTest,
updateNotificationCenterProject,
NotificationCenterAdminError,
} from '@/lib/notification-center-admin'
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 { 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'
import { GET as getAuditsRoute } from '@/app/api/admin/notification-center/audits/route'
jest.mock('next-auth', () => ({
getServerSession: jest.fn(),
}))
jest.mock('@/lib/auth', () => ({
authOptions: {},
}))
jest.mock('@/lib/department-permission', () => ({
isSuperAdmin: jest.fn(),
}))
jest.mock('@/lib/notification-center-admin', () => ({
NotificationCenterAdminError: class NotificationCenterAdminError extends Error {
statusCode: number
code: string
details?: string | null
constructor(message: string, options: { statusCode?: number; code?: string; details?: string | null } = {}) {
super(message)
this.name = 'NotificationCenterAdminError'
this.statusCode = options.statusCode ?? 500
this.code = options.code ?? 'NOTIFICATION_CENTER_ADMIN_ERROR'
this.details = options.details ?? null
}
},
listNotificationCenterProjects: jest.fn(),
createNotificationCenterProject: jest.fn(),
getNotificationCenterProject: jest.fn(),
updateNotificationCenterProject: jest.fn(),
rotateNotificationCenterProjectSecret: jest.fn(),
listNotificationCenterProjectRecipients: jest.fn(),
sendNotificationCenterProjectTest: jest.fn(),
listNotificationCenterDeliveries: jest.fn(),
listNotificationCenterAudits: jest.fn(),
}))
describe('notification-center admin routes', () => {
beforeEach(() => {
jest.clearAllMocks()
;(getServerSession as jest.Mock).mockResolvedValue({
user: {
id: 'admin-1',
role: 'SYSTEM_ADMIN',
isSuperAdmin: true,
username: 'wuxie',
realName: '无邪',
email: 'wuxie@example.com',
},
})
;(isSuperAdmin as jest.Mock).mockResolvedValue(true)
})
test('非真正超管即使 session 带 SYSTEM_ADMIN / isSuperAdmin 也返回 403', async () => {
;(isSuperAdmin as jest.Mock).mockResolvedValue(false)
const response = await getProjectsRoute(new Request('http://localhost/api/admin/notification-center/projects') as any)
const body = await response.json()
expect(response.status).toBe(403)
expect(body.error).toContain('仅超级管理员可访问')
expect(listNotificationCenterProjects).not.toHaveBeenCalled()
})
test('GET /projects 调用后端代理层并传操作人身份', async () => {
;(listNotificationCenterProjects as jest.Mock).mockResolvedValue({
status: 200,
data: [{ code: 'material_library' }],
})
const response = await getProjectsRoute(new Request('http://localhost/api/admin/notification-center/projects?page=1') as any)
const body = await response.json()
expect(response.status).toBe(200)
expect(body.success).toBe(true)
expect(listNotificationCenterProjects).toHaveBeenCalledWith(
expect.objectContaining({
userId: 'admin-1',
username: 'wuxie',
realName: '无邪',
}),
expect.any(URLSearchParams),
)
})
test('POST /projects 透传一次性 project secret/env', async () => {
;(createNotificationCenterProject as jest.Mock).mockResolvedValue({
status: 201,
data: {
code: 'material_library',
projectSecret: 'project-secret-1',
env: 'YYD_NOTIFICATION_CENTER_PROJECT_SECRET=project-secret-1',
},
})
const request = {
json: jest.fn().mockResolvedValue({ code: 'material_library' }),
} as any
const response = await postProjectsRoute(request)
const body = await response.json()
expect(response.status).toBe(201)
expect(body.data.projectSecret).toBe('project-secret-1')
expect(body.data.env).toContain('YYD_NOTIFICATION_CENTER_PROJECT_SECRET')
})
test('GET /projects/[code] 与 PATCH /projects/[code] 可用', async () => {
;(getNotificationCenterProject as jest.Mock).mockResolvedValue({
status: 200,
data: { code: 'material_library', name: '素材库' },
})
;(updateNotificationCenterProject as jest.Mock).mockResolvedValue({
status: 200,
data: { code: 'material_library', name: '素材库-新版' },
})
const getResponse = await getProjectRoute(new Request('http://localhost') as any, { params: { code: 'material_library' } } as any)
const patchResponse = await patchProjectRoute(
{ json: jest.fn().mockResolvedValue({ name: '素材库-新版' }) } as any,
{ params: { code: 'material_library' } } as any,
)
expect(getResponse.status).toBe(200)
expect((await getResponse.json()).data.code).toBe('material_library')
expect(patchResponse.status).toBe(200)
expect((await patchResponse.json()).data.name).toBe('素材库-新版')
})
test('POST /projects/[code]/rotate-secret 透传一次性 secret/env', async () => {
;(rotateNotificationCenterProjectSecret as jest.Mock).mockResolvedValue({
status: 200,
data: {
projectSecret: 'rotated-secret',
envText: 'YYD_NOTIFICATION_CENTER_PROJECT_SECRET=rotated-secret',
},
})
const response = await rotateProjectSecretRoute(
{ json: jest.fn().mockResolvedValue({ reason: 'rotate' }) } as any,
{ params: { code: 'material_library' } } as any,
)
const body = await response.json()
expect(response.status).toBe(200)
expect(body.data.projectSecret).toBe('rotated-secret')
})
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' }] })
;(listNotificationCenterAudits as jest.Mock).mockResolvedValue({ status: 200, data: [{ id: 'audit-1' }] })
;(sendNotificationCenterProjectTest as jest.Mock).mockResolvedValue({ status: 200, data: { id: 'test-1' } })
const recipients = await getProjectRecipientsRoute(
new Request('http://localhost/api/admin/notification-center/projects/material_library/recipients?page=2') as any,
{ params: { code: 'material_library' } } as any,
)
const deliveries = await getDeliveriesRoute(new Request('http://localhost/api/admin/notification-center/deliveries?projectCode=material_library') as any)
const audits = await getAuditsRoute(new Request('http://localhost/api/admin/notification-center/audits?projectCode=material_library') as any)
const testResponse = await postProjectTestRoute(
{ json: jest.fn().mockResolvedValue({ title: '连通性测试' }) } as any,
{ params: { code: 'material_library' } } as any,
)
expect(recipients.status).toBe(200)
expect(deliveries.status).toBe(200)
expect(audits.status).toBe(200)
expect(testResponse.status).toBe(200)
expect(listNotificationCenterProjectRecipients).toHaveBeenCalledWith(
expect.objectContaining({ userId: 'admin-1' }),
'material_library',
expect.any(URLSearchParams),
)
expect(sendNotificationCenterProjectTest).toHaveBeenCalledWith(
expect.objectContaining({ userId: 'admin-1' }),
'material_library',
{ title: '连通性测试' },
)
})
test('下游错误会返回中文错误且不泄漏管理员密钥', async () => {
const error = new NotificationCenterAdminError('通知中心管理员鉴权失败,请检查服务端密钥配置', {
statusCode: 502,
code: 'NOTIFICATION_CENTER_ADMIN_UNAUTHORIZED',
details: 'invalid admin key',
})
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => undefined)
;(listNotificationCenterProjects as jest.Mock).mockRejectedValue(error)
const response = await getProjectsRoute(new Request('http://localhost/api/admin/notification-center/projects') as any)
const body = await response.json()
expect(response.status).toBe(502)
expect(body.error).toContain('通知中心管理员鉴权失败')
expect(JSON.stringify(body)).not.toContain('server-admin-secret')
expect(consoleSpy).not.toHaveBeenCalledWith(expect.stringContaining('server-admin-secret'))
})
test('未登录返回 401', async () => {
;(getServerSession as jest.Mock).mockResolvedValue(null)
const response = await getProjectsRoute(new Request('http://localhost/api/admin/notification-center/projects') as any)
const body = await response.json()
expect(response.status).toBe(401)
expect(body.error).toBe('未登录')
})
})

View File

@ -0,0 +1,226 @@
/** @jest-environment node */
import {
createNotificationCenterProject,
listNotificationCenterProjectRecipients,
listNotificationCenterProjects,
NotificationCenterAdminError,
rotateNotificationCenterProjectSecret,
sendNotificationCenterProjectTest,
updateNotificationCenterProject,
} from '@/lib/notification-center-admin'
describe('notification-center-admin client', () => {
const actor = {
userId: 'super-1',
username: 'wuxie',
realName: '无邪',
email: 'wuxie@example.com',
}
beforeEach(() => {
jest.restoreAllMocks()
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('列表查询会透传操作人身份并剥离 project/admin secret', async () => {
;(global.fetch as jest.Mock).mockResolvedValue({
ok: true,
status: 200,
text: async () => JSON.stringify({
items: [
{
code: 'material_library',
name: '素材库',
projectSecret: 'should-not-return',
env: 'SECRET=1',
adminSecret: 'server-admin-secret',
},
],
adminKey: 'server-admin-secret',
}),
})
const result = await listNotificationCenterProjects(actor)
expect(result.status).toBe(200)
expect(result.data).toEqual({
items: [
{
code: 'material_library',
name: '素材库',
},
],
})
const [, init] = (global.fetch as jest.Mock).mock.calls[0]
const headers = init.headers as Record<string, string>
expect(headers.Authorization).toBe('Bearer server-admin-secret')
expect(headers['x-yyd-admin-actor']).toBe('super-1')
expect(headers['x-yyd-admin-display-name']).toBe('无邪')
})
test('创建项目仅保留一次性 project secret/env,不回传 admin secret', async () => {
;(global.fetch as jest.Mock).mockResolvedValue({
ok: true,
status: 201,
text: async () => JSON.stringify({
code: 'material_library',
projectSecret: 'project-secret-1',
env: 'YYD_NOTIFICATION_CENTER_PROJECT_SECRET=project-secret-1',
adminSecret: 'server-admin-secret',
adminKey: 'server-admin-secret',
}),
})
const result = await createNotificationCenterProject(actor, { code: 'material_library' })
expect(result.status).toBe(201)
expect(result.data).toEqual({
code: 'material_library',
projectSecret: 'project-secret-1',
env: 'YYD_NOTIFICATION_CENTER_PROJECT_SECRET=project-secret-1',
})
})
test('轮换密钥同样只保留一次性 project secret/env', async () => {
;(global.fetch as jest.Mock).mockResolvedValue({
ok: true,
status: 200,
text: async () => JSON.stringify({
projectSecret: 'next-secret',
envText: 'YYD_NOTIFICATION_CENTER_PROJECT_SECRET=next-secret',
adminToken: 'server-admin-secret',
}),
})
const result = await rotateNotificationCenterProjectSecret(actor, 'material_library')
expect(result.data).toEqual({
projectSecret: 'next-secret',
envText: 'YYD_NOTIFICATION_CENTER_PROJECT_SECRET=next-secret',
})
})
test('PATCH 项目更新与项目级 recipients/test 会命中嵌套路径', async () => {
;(global.fetch as jest.Mock)
.mockResolvedValueOnce({
ok: true,
status: 200,
text: async () => JSON.stringify({ code: 'material_library', name: '素材库-新版' }),
})
.mockResolvedValueOnce({
ok: true,
status: 200,
text: async () => JSON.stringify([{ userId: 'ding-1' }]),
})
.mockResolvedValueOnce({
ok: true,
status: 200,
text: async () => JSON.stringify({ id: 'test-1' }),
})
await updateNotificationCenterProject(actor, 'material_library', { name: '素材库-新版' })
await listNotificationCenterProjectRecipients(
actor,
'material_library',
new URLSearchParams('page=2'),
)
await sendNotificationCenterProjectTest(actor, 'material_library', {
title: '连通性测试',
projectCode: 'material_library',
})
expect((global.fetch as jest.Mock).mock.calls[0][0]).toBe(
'https://notify-admin.example.com/projects/material_library',
)
expect((global.fetch as jest.Mock).mock.calls[0][1]).toMatchObject({
method: 'PATCH',
})
expect((global.fetch as jest.Mock).mock.calls[1][0]).toBe(
'https://notify-admin.example.com/projects/material_library/recipients?page=2',
)
expect((global.fetch as jest.Mock).mock.calls[2][0]).toBe(
'https://notify-admin.example.com/projects/material_library/test',
)
expect((global.fetch as jest.Mock).mock.calls[2][1]).toMatchObject({
method: 'POST',
body: JSON.stringify({ title: '连通性测试' }),
})
})
test('下游 401 会转成中文配置错误且不泄漏管理员密钥', async () => {
;(global.fetch as jest.Mock).mockResolvedValue({
ok: false,
status: 401,
text: async () => JSON.stringify({
error: 'invalid admin key',
adminKey: 'server-admin-secret',
}),
})
await expect(listNotificationCenterProjects(actor)).rejects.toMatchObject({
statusCode: 502,
code: 'NOTIFICATION_CENTER_ADMIN_UNAUTHORIZED',
})
await listNotificationCenterProjects(actor).catch((error: NotificationCenterAdminError) => {
expect(error.message).toContain('通知中心管理员鉴权失败')
expect(error.message).not.toContain('server-admin-secret')
expect(error.details || '').not.toContain('server-admin-secret')
})
})
test('409 与 422 会保留业务状态并输出中文错误', async () => {
;(global.fetch as jest.Mock)
.mockResolvedValueOnce({
ok: false,
status: 409,
text: async () => JSON.stringify({ error: 'project already exists' }),
})
.mockResolvedValueOnce({
ok: false,
status: 422,
text: async () => JSON.stringify({ message: 'code is required' }),
})
await expect(createNotificationCenterProject(actor, {})).rejects.toMatchObject({
statusCode: 409,
code: 'NOTIFICATION_CENTER_ADMIN_CONFLICT',
})
await expect(createNotificationCenterProject(actor, {})).rejects.toMatchObject({
statusCode: 422,
code: 'NOTIFICATION_CENTER_ADMIN_VALIDATION',
})
})
test('下游 500 会转成中文服务异常', async () => {
;(global.fetch as jest.Mock).mockResolvedValue({
ok: false,
status: 500,
text: async () => JSON.stringify({ error: 'internal error' }),
})
await expect(listNotificationCenterProjects(actor)).rejects.toMatchObject({
statusCode: 502,
code: 'NOTIFICATION_CENTER_ADMIN_UPSTREAM',
})
})
test('离线与超时会映射为中文错误', async () => {
;(global.fetch as jest.Mock)
.mockRejectedValueOnce(new TypeError('fetch failed'))
.mockRejectedValueOnce(Object.assign(new Error('aborted'), { name: 'AbortError' }))
await expect(listNotificationCenterProjects(actor)).rejects.toMatchObject({
statusCode: 503,
code: 'NOTIFICATION_CENTER_ADMIN_OFFLINE',
})
await expect(listNotificationCenterProjects(actor)).rejects.toMatchObject({
statusCode: 504,
code: 'NOTIFICATION_CENTER_ADMIN_TIMEOUT',
})
})
})

View File

@ -81,6 +81,10 @@ const OPTIONAL_ENV_VARS = [
'YYD_NOTIFICATION_CENTER_PROJECT_CODE',
'YYD_NOTIFICATION_CENTER_PROJECT_SECRET',
'YYD_NOTIFICATION_CENTER_TEST_USER_IDS',
'YYD_NOTIFICATION_CENTER_ADMIN_URL',
'YYD_NOTIFICATION_CENTER_ADMIN_API_SECRET',
'YYD_NOTIFICATION_CENTER_ADMIN_TIMEOUT_MS',
'API_KEY_ENCRYPTION_KEY',
'REPORT_DB_SERVER',
'REPORT_DB_DATABASE',
'REPORT_DB_USER',
@ -187,6 +191,31 @@ export function validateEnvFormats(): void {
}
}
const notificationCenterAdminUrl = process.env.YYD_NOTIFICATION_CENTER_ADMIN_URL
if (notificationCenterAdminUrl) {
try {
const url = new URL(notificationCenterAdminUrl)
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
errors.push('YYD_NOTIFICATION_CENTER_ADMIN_URL 格式错误:应该使用 http:// 或 https:// 协议')
}
} catch (e) {
errors.push(`YYD_NOTIFICATION_CENTER_ADMIN_URL 格式错误:${e}`)
}
}
const notificationCenterAdminTimeoutMs = process.env.YYD_NOTIFICATION_CENTER_ADMIN_TIMEOUT_MS
if (
notificationCenterAdminTimeoutMs &&
(!/^\d+$/.test(notificationCenterAdminTimeoutMs.trim()) || Number(notificationCenterAdminTimeoutMs) <= 0)
) {
errors.push('YYD_NOTIFICATION_CENTER_ADMIN_TIMEOUT_MS 必须是正整数毫秒值')
}
const apiKeyEncryptionKey = process.env.API_KEY_ENCRYPTION_KEY?.trim()
if (apiKeyEncryptionKey && Buffer.byteLength(apiKeyEncryptionKey, 'utf8') < 32) {
errors.push('API_KEY_ENCRYPTION_KEY 至少需要 32 个字符')
}
const singleReviewVars = [
'WORK_REVIEW_SINGLE_REVIEW_TEST_PROJECT_ID',
'WORK_REVIEW_SINGLE_REVIEW_TEST_MANAGER_EMPLOYEE_ID',

View File

@ -7,6 +7,22 @@ import { NextRequest, NextResponse } from 'next/server';
import crypto from 'crypto';
const API_KEY_HASH_PREFIX = 'v1';
const API_KEY_ENCRYPTION_ENV = 'API_KEY_ENCRYPTION_KEY';
const API_KEY_ENCRYPTION_ALGORITHM = 'aes-256-gcm';
export class ApiKeyEncryptionConfigError extends Error {
constructor() {
super(`${API_KEY_ENCRYPTION_ENV} 未配置或长度不足 32 个字符`);
this.name = 'ApiKeyEncryptionConfigError';
}
}
export class ApiKeyNotRecoverableError extends Error {
constructor() {
super('该历史 API Key 只保存了哈希,无法恢复原文,请创建新 Key 后逐步替换');
this.name = 'ApiKeyNotRecoverableError';
}
}
function hashApiKey(apiKey: string): string {
const preview = apiKey.slice(0, 8);
@ -14,6 +30,56 @@ function hashApiKey(apiKey: string): string {
return `${API_KEY_HASH_PREFIX}:${preview}:${digest}`;
}
function isHashedApiKey(storedKey: string): boolean {
return /^v1:[^:]{1,16}:[a-f0-9]{64}$/i.test(storedKey);
}
function getApiKeyEncryptionKey(): Buffer {
const secret = process.env[API_KEY_ENCRYPTION_ENV]?.trim();
if (!secret || Buffer.byteLength(secret, 'utf8') < 32) {
throw new ApiKeyEncryptionConfigError();
}
return crypto.createHash('sha256').update(secret, 'utf8').digest();
}
function isApiKeyEncryptionConfigured(): boolean {
const secret = process.env[API_KEY_ENCRYPTION_ENV]?.trim();
return Boolean(secret && Buffer.byteLength(secret, 'utf8') >= 32);
}
function encryptApiKeySecret(apiKey: string) {
const nonce = crypto.randomBytes(12);
const cipher = crypto.createCipheriv(
API_KEY_ENCRYPTION_ALGORITHM,
getApiKeyEncryptionKey(),
nonce,
);
const ciphertext = Buffer.concat([cipher.update(apiKey, 'utf8'), cipher.final()]);
return {
secretCiphertext: ciphertext.toString('base64'),
secretNonce: nonce.toString('base64'),
secretAuthTag: cipher.getAuthTag().toString('base64'),
};
}
function decryptApiKeySecret(input: {
secretCiphertext: string;
secretNonce: string;
secretAuthTag: string;
}) {
const decipher = crypto.createDecipheriv(
API_KEY_ENCRYPTION_ALGORITHM,
getApiKeyEncryptionKey(),
Buffer.from(input.secretNonce, 'base64'),
);
decipher.setAuthTag(Buffer.from(input.secretAuthTag, 'base64'));
return Buffer.concat([
decipher.update(Buffer.from(input.secretCiphertext, 'base64')),
decipher.final(),
]).toString('utf8');
}
export function getApiKeyPreview(storedKey: string): string {
const match = storedKey.match(/^v1:([^:]{1,16}):[a-f0-9]{64}$/i);
return `${match?.[1] || storedKey.slice(0, 8)}...`;
@ -104,8 +170,9 @@ export async function validateApiKey(apiKey: string | null) {
createdBy: true,
},
});
let matchedLegacyPlaintext = false;
// 已有明文 Key 在首次成功调用时原地迁移为哈希,不改变调用方使用方式。
// 已有明文 Key 在加密配置就绪后,于首次成功调用时原地迁移,不改变调用方使用方式。
if (!keyRecord) {
keyRecord = await prisma.apiKey.findUnique({
where: { key: apiKey },
@ -116,18 +183,26 @@ export async function validateApiKey(apiKey: string | null) {
createdBy: true,
},
});
matchedLegacyPlaintext = Boolean(keyRecord);
}
if (!keyRecord || !keyRecord.isActive) {
return null;
}
// 更新最后使用时间
const legacySecretUpdate = matchedLegacyPlaintext && isApiKeyEncryptionConfigured()
? {
key: hashedKey,
...encryptApiKeySecret(apiKey),
}
: {};
// 更新最后使用时间;历史明文只有在可同时加密留存时才迁移为哈希。
await prisma.apiKey.update({
where: { id: keyRecord.id },
data: {
lastUsedAt: new Date(),
key: hashedKey,
...legacySecretUpdate,
},
});
@ -235,11 +310,13 @@ export function generateApiKey(): string {
*/
export async function createApiKey(name: string, createdBy: string) {
const key = generateApiKey();
const encryptedSecret = encryptApiKeySecret(key);
const apiKey = await prisma.apiKey.create({
data: {
name,
key: hashApiKey(key),
...encryptedSecret,
createdBy,
},
});
@ -247,7 +324,7 @@ export async function createApiKey(name: string, createdBy: string) {
return {
id: apiKey.id,
name: apiKey.name,
key, // 仅在创建时返回完整 key
key,
createdAt: apiKey.createdAt,
};
}
@ -276,12 +353,15 @@ export async function enableApiKey(id: string) {
* 获取 API Key 列表
*/
export async function getApiKeys() {
return prisma.apiKey.findMany({
const apiKeys = await prisma.apiKey.findMany({
select: {
id: true,
name: true,
// 不返回完整 key,只返回前8位
key: true,
secretCiphertext: true,
secretNonce: true,
secretAuthTag: true,
isActive: true,
lastUsedAt: true,
createdAt: true,
@ -289,6 +369,68 @@ export async function getApiKeys() {
},
orderBy: { createdAt: 'desc' },
});
return apiKeys.map(({ secretCiphertext, secretNonce, secretAuthTag, ...apiKey }) => ({
...apiKey,
canReveal: Boolean(secretCiphertext && secretNonce && secretAuthTag) || !isHashedApiKey(apiKey.key),
}));
}
/**
* 按需读取完整 API Key。新 Key 从加密列解密;历史明文在首次查看时原地加密迁移。
*/
export async function revealApiKey(id: string, viewerUserId: string): Promise<string | null> {
const apiKey = await prisma.apiKey.findUnique({
where: { id },
select: {
id: true,
key: true,
secretCiphertext: true,
secretNonce: true,
secretAuthTag: true,
},
});
if (!apiKey) {
return null;
}
if (apiKey.secretCiphertext && apiKey.secretNonce && apiKey.secretAuthTag) {
const secret = decryptApiKeySecret({
secretCiphertext: apiKey.secretCiphertext,
secretNonce: apiKey.secretNonce,
secretAuthTag: apiKey.secretAuthTag,
});
if (isHashedApiKey(apiKey.key) && hashApiKey(secret) !== apiKey.key) {
throw new Error('API Key 加密原文与鉴权哈希不一致');
}
await prisma.apiKeyRevealAudit.create({
data: { apiKeyId: id, viewerUserId },
});
return secret;
}
if (isHashedApiKey(apiKey.key)) {
throw new ApiKeyNotRecoverableError();
}
const legacyPlaintext = apiKey.key;
await prisma.$transaction([
prisma.apiKey.update({
where: { id },
data: {
key: hashApiKey(legacyPlaintext),
...encryptApiKeySecret(legacyPlaintext),
},
}),
prisma.apiKeyRevealAudit.create({
data: { apiKeyId: id, viewerUserId },
}),
]);
return legacyPlaintext;
}
/**

View File

@ -31,6 +31,13 @@ export interface ExternalNotificationTarget {
updatedAt: string
}
export interface ExternalNotificationTargetAdminView
extends Omit<ExternalNotificationTarget, 'authConfig'> {
authConfig: Record<string, string>
authConfigured: boolean
authMasked: string
}
export interface ExternalNotificationDelivery {
id: string
targetId: string
@ -245,6 +252,51 @@ function normalizeTargetRow(row: any): ExternalNotificationTarget {
}
}
function maskSecretForAdminSummary(value: string | null | undefined) {
const normalized = String(value || '').trim()
if (!normalized) return '未配置'
if (normalized.length <= 8) return '已配置'
return `${normalized.slice(0, 4)}****${normalized.slice(-4)}`
}
export function buildExternalNotificationTargetAdminView(
target: ExternalNotificationTarget,
): ExternalNotificationTargetAdminView {
const authConfig = normalizeAuthConfig(target.authConfig)
if (target.authType === AUTO_LISTING_AUTH_TYPE.BEARER) {
const token = String(authConfig.token || '').trim()
return {
...target,
authConfig: { token: '' },
authConfigured: Boolean(token),
authMasked: `Bearer · ${maskSecretForAdminSummary(token)}`,
}
}
if (target.authType === AUTO_LISTING_AUTH_TYPE.HEADER) {
const headerName = String(authConfig.headerName || '').trim()
const headerValue = String(authConfig.headerValue || '').trim()
return {
...target,
authConfig: {
...(headerName ? { headerName } : {}),
headerValue: '',
},
authConfigured: Boolean(headerName && headerValue),
authMasked: headerName
? `Header · ${headerName} · ${maskSecretForAdminSummary(headerValue)}`
: 'Header · 未配置',
}
}
return {
...target,
authConfig: {},
authConfigured: false,
authMasked: '无鉴权',
}
}
function normalizeDeliveryRow(row: any): ExternalNotificationDelivery {
return {
id: String(row.id),
@ -333,7 +385,7 @@ function normalizeTargetInput(
}
}
async function getExternalNotificationTargetById(targetId: string) {
export async function getExternalNotificationTargetById(targetId: string) {
const rows = await queryRaw<any>(
`
SELECT

View File

@ -0,0 +1,457 @@
type NotificationCenterAdminMethod = 'GET' | 'POST' | 'PUT' | 'PATCH'
export type NotificationCenterAdminActor = {
userId: string
username?: string | null
realName?: string | null
email?: string | null
}
export type NotificationCenterAdminResponse<T = unknown> = {
status: number
data: T
}
export class NotificationCenterAdminError extends Error {
statusCode: number
code: string
details?: string | null
constructor(
message: string,
options: {
statusCode?: number
code?: string
details?: string | null
} = {},
) {
super(message)
this.name = 'NotificationCenterAdminError'
this.statusCode = options.statusCode ?? 500
this.code = options.code ?? 'NOTIFICATION_CENTER_ADMIN_ERROR'
this.details = options.details ?? null
}
}
const DEFAULT_TIMEOUT_MS = 8_000
const ADMIN_ACTOR_HEADER_NAME = 'x-yyd-admin-actor'
const ADMIN_DISPLAY_NAME_HEADER_NAME = 'x-yyd-admin-display-name'
const PROJECT_SECRET_KEYS = new Set([
'projectsecret',
'project_secret',
'secret',
'projectenv',
'project_env',
'env',
'envtext',
'env_text',
'envlines',
'env_lines',
'dotenv',
'environmentvariables',
'environment_variables',
])
function clean(value: unknown) {
const text = String(value ?? '').trim()
return text || null
}
function getConfig() {
const timeoutCandidate = Number(process.env.YYD_NOTIFICATION_CENTER_ADMIN_TIMEOUT_MS || '')
return {
baseUrl: String(process.env.YYD_NOTIFICATION_CENTER_ADMIN_URL || '').trim().replace(/\/$/, ''),
adminApiSecret: String(process.env.YYD_NOTIFICATION_CENTER_ADMIN_API_SECRET || '').trim(),
timeoutMs:
Number.isFinite(timeoutCandidate) && timeoutCandidate > 0
? timeoutCandidate
: DEFAULT_TIMEOUT_MS,
}
}
export function getNotificationCenterAdminConfigStatus() {
const config = getConfig()
const missing: string[] = []
if (!config.baseUrl) missing.push('YYD_NOTIFICATION_CENTER_ADMIN_URL')
if (!config.adminApiSecret) missing.push('YYD_NOTIFICATION_CENTER_ADMIN_API_SECRET')
return {
ready: missing.length === 0,
missing,
timeoutMs: config.timeoutMs,
}
}
function normalizeKey(value: string) {
return value.replace(/[^a-z0-9_]/gi, '').toLowerCase()
}
function shouldStripAdminKey(key: string) {
const normalized = normalizeKey(key)
if (normalized === 'authorization') return true
return normalized.includes('admin')
&& (normalized.includes('secret') || normalized.includes('key') || normalized.includes('token'))
}
function shouldStripProjectSecretKey(key: string) {
return PROJECT_SECRET_KEYS.has(normalizeKey(key))
}
function sanitizeValue(value: unknown, allowProjectSecrets: boolean): unknown {
if (Array.isArray(value)) {
return value.map((item) => sanitizeValue(item, allowProjectSecrets))
}
if (!value || typeof value !== 'object') {
return value
}
const entries = Object.entries(value as Record<string, unknown>)
const sanitized: Record<string, unknown> = {}
for (const [key, child] of entries) {
if (shouldStripAdminKey(key)) continue
if (!allowProjectSecrets && shouldStripProjectSecretKey(key)) continue
sanitized[key] = sanitizeValue(child, allowProjectSecrets)
}
return sanitized
}
function stringifyDetails(payload: unknown): string | null {
if (payload == null) return null
if (typeof payload === 'string') return clean(payload)
if (Array.isArray(payload)) {
const parts = payload
.map((item) => stringifyDetails(item))
.filter((item): item is string => Boolean(item))
return parts.length > 0 ? parts.join(';') : null
}
if (typeof payload === 'object') {
const values = Object.values(payload as Record<string, unknown>)
.map((item) => stringifyDetails(item))
.filter((item): item is string => Boolean(item))
return values.length > 0 ? values.join(';') : null
}
return clean(String(payload))
}
function buildHttpErrorMessage(status: number, payload: unknown) {
const sanitizedPayload = sanitizeValue(payload, false)
const extracted = clean(
(sanitizedPayload as Record<string, unknown> | null)?.error
?? (sanitizedPayload as Record<string, unknown> | null)?.message
?? (sanitizedPayload as Record<string, unknown> | null)?.detail,
)
const details = stringifyDetails(
extracted
? null
: (sanitizedPayload as Record<string, unknown> | null)?.details
?? (sanitizedPayload as Record<string, unknown> | null)?.errors
?? sanitizedPayload,
)
const suffix = extracted || details ? `:${extracted || details}` : ''
if (status === 401) {
return new NotificationCenterAdminError(
`通知中心管理员鉴权失败,请检查服务端密钥配置${suffix}`,
{ statusCode: 502, code: 'NOTIFICATION_CENTER_ADMIN_UNAUTHORIZED', details },
)
}
if (status === 409) {
return new NotificationCenterAdminError(
`通知中心数据冲突,请刷新后重试${suffix}`,
{ statusCode: 409, code: 'NOTIFICATION_CENTER_ADMIN_CONFLICT', details },
)
}
if (status === 422) {
return new NotificationCenterAdminError(
`通知中心参数校验失败${suffix}`,
{ statusCode: 422, code: 'NOTIFICATION_CENTER_ADMIN_VALIDATION', details },
)
}
if (status >= 500) {
return new NotificationCenterAdminError(
`通知中心服务异常,请稍后重试${suffix}`,
{ statusCode: 502, code: 'NOTIFICATION_CENTER_ADMIN_UPSTREAM', details },
)
}
if (status >= 400) {
return new NotificationCenterAdminError(
extracted || details || `通知中心请求失败(HTTP ${status})`,
{ statusCode: status, code: 'NOTIFICATION_CENTER_ADMIN_BAD_REQUEST', details },
)
}
return new NotificationCenterAdminError(
extracted || details || '通知中心请求失败',
{ statusCode: 500, code: 'NOTIFICATION_CENTER_ADMIN_UNKNOWN', details },
)
}
function buildOperatorDisplayName(actor: NotificationCenterAdminActor) {
return clean(actor.realName) || clean(actor.username) || clean(actor.email) || actor.userId
}
function buildHeaders(actor: NotificationCenterAdminActor, hasBody: boolean) {
const config = getConfig()
const headers: Record<string, string> = {
Accept: 'application/json',
Authorization: `Bearer ${config.adminApiSecret}`,
[ADMIN_ACTOR_HEADER_NAME]: actor.userId,
}
const displayName = buildOperatorDisplayName(actor)
if (displayName) headers[ADMIN_DISPLAY_NAME_HEADER_NAME] = displayName
if (hasBody) headers['Content-Type'] = 'application/json'
return headers
}
function buildProjectScopedPath(code: string, suffix = '') {
return `/projects/${encodeURIComponent(code)}${suffix}`
}
function requireProjectCode(code: unknown) {
const projectCode = clean(code)
if (!projectCode) {
throw new NotificationCenterAdminError('通知中心项目编码不能为空', {
statusCode: 422,
code: 'NOTIFICATION_CENTER_ADMIN_VALIDATION',
})
}
return projectCode
}
function normalizeQueryWithoutProjectCode(query?: URLSearchParams) {
if (!query) return undefined
const nextQuery = new URLSearchParams(query)
nextQuery.delete('projectCode')
nextQuery.delete('code')
return nextQuery
}
function pickProjectCodeFromQuery(query?: URLSearchParams) {
return clean(query?.get('projectCode') || query?.get('code'))
}
function pickProjectCodeFromBody(body: unknown) {
if (!body || typeof body !== 'object' || Array.isArray(body)) {
return null
}
const payload = body as Record<string, unknown>
return clean(payload.projectCode || payload.code)
}
function omitProjectCode(body: unknown) {
if (!body || typeof body !== 'object' || Array.isArray(body)) {
return body
}
const payload = body as Record<string, unknown>
const { projectCode: _projectCode, code: _code, ...rest } = payload
return rest
}
function buildUrl(path: string, query?: URLSearchParams) {
const config = getConfig()
if (!query || Array.from(query.keys()).length === 0) {
return `${config.baseUrl}${path}`
}
return `${config.baseUrl}${path}?${query.toString()}`
}
async function parseResponseBody(response: Response): Promise<unknown> {
const text = await response.text()
if (!text) return null
try {
return JSON.parse(text)
} catch {
return { message: text }
}
}
async function requestNotificationCenterAdmin<T = unknown>(input: {
method: NotificationCenterAdminMethod
path: string
actor: NotificationCenterAdminActor
query?: URLSearchParams
body?: unknown
allowProjectSecrets?: boolean
}): Promise<NotificationCenterAdminResponse<T>> {
const status = getNotificationCenterAdminConfigStatus()
if (!status.ready) {
throw new NotificationCenterAdminError(
`通知中心管理员配置不完整:${status.missing.join('、')}`,
{ statusCode: 503, code: 'NOTIFICATION_CENTER_ADMIN_CONFIG' },
)
}
const config = getConfig()
const hasBody = input.body !== undefined
const body = hasBody ? JSON.stringify(input.body) : undefined
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), config.timeoutMs)
try {
const response = await fetch(buildUrl(input.path, input.query), {
method: input.method,
headers: buildHeaders(input.actor, hasBody),
body,
signal: controller.signal,
cache: 'no-store',
})
const payload = await parseResponseBody(response)
if (!response.ok) {
throw buildHttpErrorMessage(response.status, payload)
}
return {
status: response.status,
data: sanitizeValue(payload, Boolean(input.allowProjectSecrets)) as T,
}
} catch (error) {
if (error instanceof NotificationCenterAdminError) {
throw error
}
if (error && typeof error === 'object' && (error as { name?: string }).name === 'AbortError') {
throw new NotificationCenterAdminError('通知中心请求超时,请稍后重试', {
statusCode: 504,
code: 'NOTIFICATION_CENTER_ADMIN_TIMEOUT',
})
}
throw new NotificationCenterAdminError('通知中心暂时不可用,请检查网络或服务状态', {
statusCode: 503,
code: 'NOTIFICATION_CENTER_ADMIN_OFFLINE',
})
} finally {
clearTimeout(timeout)
}
}
export async function listNotificationCenterProjects(
actor: NotificationCenterAdminActor,
query?: URLSearchParams,
) {
return requestNotificationCenterAdmin({
method: 'GET',
path: '/projects',
actor,
query,
})
}
export async function createNotificationCenterProject(
actor: NotificationCenterAdminActor,
body: unknown,
) {
return requestNotificationCenterAdmin({
method: 'POST',
path: '/projects',
actor,
body,
allowProjectSecrets: true,
})
}
export async function getNotificationCenterProject(
actor: NotificationCenterAdminActor,
code: string,
) {
return requestNotificationCenterAdmin({
method: 'GET',
path: buildProjectScopedPath(code),
actor,
})
}
export async function updateNotificationCenterProject(
actor: NotificationCenterAdminActor,
code: string,
body: unknown,
) {
return requestNotificationCenterAdmin({
method: 'PATCH',
path: buildProjectScopedPath(code),
actor,
body,
})
}
export async function rotateNotificationCenterProjectSecret(
actor: NotificationCenterAdminActor,
code: string,
body?: unknown,
) {
return requestNotificationCenterAdmin({
method: 'POST',
path: buildProjectScopedPath(code, '/rotate-secret'),
actor,
body,
allowProjectSecrets: true,
})
}
export async function listNotificationCenterProjectRecipients(
actor: NotificationCenterAdminActor,
code: string,
query?: URLSearchParams,
) {
return requestNotificationCenterAdmin({
method: 'GET',
path: buildProjectScopedPath(requireProjectCode(code), '/recipients'),
actor,
query: normalizeQueryWithoutProjectCode(query),
})
}
export async function listNotificationCenterRecipients(
actor: NotificationCenterAdminActor,
query?: URLSearchParams,
) {
return listNotificationCenterProjectRecipients(
actor,
requireProjectCode(pickProjectCodeFromQuery(query)),
query,
)
}
export async function sendNotificationCenterProjectTest(
actor: NotificationCenterAdminActor,
code: string,
body: unknown,
) {
return requestNotificationCenterAdmin({
method: 'POST',
path: buildProjectScopedPath(requireProjectCode(code), '/test'),
actor,
body: omitProjectCode(body),
})
}
export async function sendNotificationCenterTest(
actor: NotificationCenterAdminActor,
body: unknown,
) {
return sendNotificationCenterProjectTest(
actor,
requireProjectCode(pickProjectCodeFromBody(body)),
body,
)
}
export async function listNotificationCenterDeliveries(
actor: NotificationCenterAdminActor,
query?: URLSearchParams,
) {
return requestNotificationCenterAdmin({
method: 'GET',
path: '/deliveries',
actor,
query,
})
}
export async function listNotificationCenterAudits(
actor: NotificationCenterAdminActor,
query?: URLSearchParams,
) {
return requestNotificationCenterAdmin({
method: 'GET',
path: '/audits',
actor,
query,
})
}

View File

@ -0,0 +1,18 @@
ALTER TABLE "api_keys"
ADD COLUMN "secret_ciphertext" TEXT,
ADD COLUMN "secret_nonce" TEXT,
ADD COLUMN "secret_auth_tag" TEXT;
CREATE TABLE "api_key_reveal_audits" (
"id" TEXT NOT NULL,
"api_key_id" TEXT NOT NULL,
"viewer_user_id" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "api_key_reveal_audits_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "api_key_reveal_audits_api_key_id_created_at_idx"
ON "api_key_reveal_audits"("api_key_id", "created_at");
CREATE INDEX "api_key_reveal_audits_viewer_user_id_created_at_idx"
ON "api_key_reveal_audits"("viewer_user_id", "created_at");

View File

@ -521,6 +521,9 @@ model ApiKey {
id String @id @default(uuid())
name String
key String @unique
secretCiphertext String? @map("secret_ciphertext")
secretNonce String? @map("secret_nonce")
secretAuthTag String? @map("secret_auth_tag")
isActive Boolean @default(true) @map("is_active")
createdBy String @map("created_by")
lastUsedAt DateTime? @map("last_used_at")
@ -535,6 +538,17 @@ model ApiKey {
@@map("api_keys")
}
model ApiKeyRevealAudit {
id String @id @default(uuid())
apiKeyId String @map("api_key_id")
viewerUserId String @map("viewer_user_id")
createdAt DateTime @default(now()) @map("created_at")
@@index([apiKeyId, createdAt])
@@index([viewerUserId, createdAt])
@@map("api_key_reveal_audits")
}
model ExternalApiIdempotencyRecord {
id String @id @default(uuid())
apiKeyId String @map("api_key_id")

View File

@ -24,6 +24,7 @@ const TARGET_MIGRATIONS = Object.freeze([
'20260826_add_pack_video_early_access',
'20260828_add_pack_item_can_edit_assets',
'20260828_add_task_detail_change_approvals',
'20260829_add_recoverable_api_key_secret',
])
const VERIFIED_BACKUP_GENERATOR = 'scripts/create-verified-production-backup.mjs'
const VERIFIED_BACKUP_MAINTENANCE_CONFIRMATION = 'NO_WRITES_DURING_SNAPSHOT'
@ -261,6 +262,8 @@ async function queryEvidence(prisma, localMigrations) {
AND ((table_name = 'materials' AND column_name IN ('uploadId', 'transcode_lease_token', 'transcode_lease_expires_at'))
OR (table_name = 'materials' AND column_name IN ('custody_department_id', 'custody_at', 'custody_reason', 'is_incomplete_shell'))
OR (table_name = 'product_package_upload_jobs' AND column_name IN ('worker_id', 'lease_token', 'lease_expires_at'))
OR (table_name = 'api_keys' AND column_name IN ('secret_ciphertext', 'secret_nonce', 'secret_auth_tag'))
OR (table_name = 'api_key_reveal_audits' AND column_name IN ('id', 'api_key_id', 'viewer_user_id', 'created_at'))
OR (table_name = 'system_settings' AND column_name IN ('headerLogoUrl', 'loginLogoUrl', 'logoUrl'))
OR (table_name = 'organization_units' AND column_name IN ('id', 'source_dept_id', 'parent_id', 'path', 'level', 'is_active'))
OR (table_name = 'performance_survey_employee_profiles' AND column_name IN ('employment_state', 'resigned_at', 'handover_dingtalk_user_id'))
@ -304,7 +307,9 @@ async function queryEvidence(prisma, localMigrations) {
'ding_workflow_sync_states_lease_idx',
'task_detail_change_requests_status_expires_idx',
'task_detail_change_requests_status_updated_idx',
'task_detail_change_notifications_status_retry_idx'
'task_detail_change_notifications_status_retry_idx',
'api_key_reveal_audits_api_key_id_created_at_idx',
'api_key_reveal_audits_viewer_user_id_created_at_idx'
)
ORDER BY idx.relname`,
)
@ -350,6 +355,13 @@ export function assertTargetMigrationEvidence(localMigrations, evidence) {
requireColumn('materials', 'transcode_lease_token', 'text', 'YES')
requireColumn('materials', 'transcode_lease_expires_at', 'timestamp without time zone', 'YES')
requireColumn('materials', 'uploadId', 'text', 'YES')
requireColumn('api_keys', 'secret_ciphertext', 'text', 'YES')
requireColumn('api_keys', 'secret_nonce', 'text', 'YES')
requireColumn('api_keys', 'secret_auth_tag', 'text', 'YES')
requireColumn('api_key_reveal_audits', 'id', 'text', 'NO')
requireColumn('api_key_reveal_audits', 'api_key_id', 'text', 'NO')
requireColumn('api_key_reveal_audits', 'viewer_user_id', 'text', 'NO')
requireColumn('api_key_reveal_audits', 'created_at', 'timestamp without time zone', 'NO')
requireColumn('task_detail_change_requests', 'id', 'text', 'NO')
requireColumn('task_detail_change_requests', 'material_id', 'text', 'NO')
requireColumn('task_detail_change_requests', 'requester_user_id', 'text', 'NO')
@ -485,6 +497,16 @@ export function assertTargetMigrationEvidence(localMigrations, evidence) {
'task_detail_change_notifications',
['status', 'next_retry_at'],
)
assertExactBtreeIndex(
indexByName.get('api_key_reveal_audits_api_key_id_created_at_idx'),
'api_key_reveal_audits',
['api_key_id', 'created_at'],
)
assertExactBtreeIndex(
indexByName.get('api_key_reveal_audits_viewer_user_id_created_at_idx'),
'api_key_reveal_audits',
['viewer_user_id', 'created_at'],
)
const isTrustedBrandValue = (value, localDefault) => {
const normalized = String(value || '').trim()

View File

@ -340,6 +340,7 @@ test('target evidence rejects nullable logo columns and structurally wrong index
['20260826_add_pack_video_early_access', { checksum: 'm' }],
['20260828_add_pack_item_can_edit_assets', { checksum: 'n' }],
['20260828_add_task_detail_change_approvals', { checksum: 'o' }],
['20260829_add_recoverable_api_key_secret', { checksum: 'p' }],
])
const evidence = {
migrationRows: [
@ -358,11 +359,19 @@ test('target evidence rejects nullable logo columns and structurally wrong index
{ migration_name: '20260826_add_pack_video_early_access', checksum: 'm', finished_at: 'now', rolled_back_at: null },
{ migration_name: '20260828_add_pack_item_can_edit_assets', checksum: 'n', finished_at: 'now', rolled_back_at: null },
{ migration_name: '20260828_add_task_detail_change_approvals', checksum: 'o', finished_at: 'now', rolled_back_at: null },
{ migration_name: '20260829_add_recoverable_api_key_secret', checksum: 'p', finished_at: 'now', rolled_back_at: null },
],
columns: [
{ table_name: 'materials', column_name: 'transcode_lease_token', data_type: 'text', is_nullable: 'YES' },
{ table_name: 'materials', column_name: 'transcode_lease_expires_at', data_type: 'timestamp without time zone', is_nullable: 'YES' },
{ table_name: 'materials', column_name: 'uploadId', data_type: 'text', is_nullable: 'YES' },
{ table_name: 'api_keys', column_name: 'secret_ciphertext', data_type: 'text', is_nullable: 'YES' },
{ table_name: 'api_keys', column_name: 'secret_nonce', data_type: 'text', is_nullable: 'YES' },
{ table_name: 'api_keys', column_name: 'secret_auth_tag', data_type: 'text', is_nullable: 'YES' },
{ table_name: 'api_key_reveal_audits', column_name: 'id', data_type: 'text', is_nullable: 'NO' },
{ table_name: 'api_key_reveal_audits', column_name: 'api_key_id', data_type: 'text', is_nullable: 'NO' },
{ table_name: 'api_key_reveal_audits', column_name: 'viewer_user_id', data_type: 'text', is_nullable: 'NO' },
{ table_name: 'api_key_reveal_audits', column_name: 'created_at', data_type: 'timestamp without time zone', is_nullable: 'NO' },
{ table_name: 'materials', column_name: 'custody_department_id', data_type: 'text', is_nullable: 'YES' },
{ table_name: 'materials', column_name: 'custody_at', data_type: 'timestamp without time zone', is_nullable: 'YES' },
{ table_name: 'materials', column_name: 'custody_reason', data_type: 'text', is_nullable: 'YES' },
@ -448,6 +457,8 @@ test('target evidence rejects nullable logo columns and structurally wrong index
{ indexname: 'task_detail_change_requests_status_expires_idx', indexdef: 'CREATE INDEX task_detail_change_requests_status_expires_idx ON public.task_detail_change_requests USING btree (status, expires_at)' },
{ indexname: 'task_detail_change_requests_status_updated_idx', indexdef: 'CREATE INDEX task_detail_change_requests_status_updated_idx ON public.task_detail_change_requests USING btree (status, updated_at)' },
{ indexname: 'task_detail_change_notifications_status_retry_idx', indexdef: 'CREATE INDEX task_detail_change_notifications_status_retry_idx ON public.task_detail_change_notifications USING btree (status, next_retry_at)' },
{ indexname: 'api_key_reveal_audits_api_key_id_created_at_idx', indexdef: 'CREATE INDEX api_key_reveal_audits_api_key_id_created_at_idx ON public.api_key_reveal_audits USING btree (api_key_id, created_at)' },
{ indexname: 'api_key_reveal_audits_viewer_user_id_created_at_idx', indexdef: 'CREATE INDEX api_key_reveal_audits_viewer_user_id_created_at_idx ON public.api_key_reveal_audits USING btree (viewer_user_id, created_at)' },
],
brandRows: [
{ id: 'local', headerLogoUrl: '/brand/header-logo.png', loginLogoUrl: '/brand/login-logo.png' },
@ -509,6 +520,16 @@ test('target evidence rejects nullable logo columns and structurally wrong index
() => assertTargetMigrationEvidence(migrations, missingDemandTypeColumn),
/ding_workflow_snapshots\.demand_type/,
)
const missingRecoverableApiKeyColumn = {
...evidence,
columns: evidence.columns.filter((column) => !(
column.table_name === 'api_keys' && column.column_name === 'secret_ciphertext'
)),
}
assert.throws(
() => assertTargetMigrationEvidence(migrations, missingRecoverableApiKeyColumn),
/api_keys\.secret_ciphertext/,
)
const missingFormFieldsColumn = {
...evidence,
columns: evidence.columns.filter((column) => !(
@ -519,6 +540,14 @@ test('target evidence rejects nullable logo columns and structurally wrong index
() => assertTargetMigrationEvidence(migrations, missingFormFieldsColumn),
/ding_workflow_snapshots\.form_fields/,
)
const missingRevealAuditIndex = {
...evidence,
indexes: evidence.indexes.filter((index) => index.indexname !== 'api_key_reveal_audits_api_key_id_created_at_idx'),
}
assert.throws(
() => assertTargetMigrationEvidence(migrations, missingRevealAuditIndex),
/api_key_reveal_audits/,
)
const legacyStoredLogo = {
...evidence,
brandRows: [{
@ -599,6 +628,8 @@ test('container supply chain and migrator image are immutable while SOP keeps co
assert.match(migratorSource, new RegExp(`'${migrationName}'`))
assert.ok(sop.includes('`' + migrationName + '`'))
}
assert.match(migratorSource, /'20260829_add_recoverable_api_key_secret'/)
assert.ok(sop.includes('`20260829_add_recoverable_api_key_secret`'))
assert.match(backupSource, /'--format=custom'/)
assert.match(backupSource, /'--serializable-deferrable'/)
assert.match(backupSource, /\.dump\.partial-/)
@ -663,6 +694,8 @@ test('container supply chain and migrator image are immutable while SOP keeps co
assert.match(sop, /-v "\$HOST_RESULT_DIR:\/audit"/)
assert.match(sop, /test -f "\$PREFLIGHT_AUDIT_FILE" && test ! -L "\$PREFLIGHT_AUDIT_FILE"/)
assert.doesNotMatch(sop, /-v \/root\/gongxiangp2-migration-audit:\/audit/)
assert.match(sop, /十六份目标 migration/)
assert.match(sop, /十六条目标 migration/)
const applyMigration = migratorSource.indexOf("['migrate', 'deploy'")
const postEvidence = migratorSource.indexOf('after = await queryEvidence(verifyClient, localMigrations)', applyMigration)