feat: integrate notification admin and recoverable API keys
This commit is contained in:
parent
92a2326699
commit
6ac36e73af
12
README.md
12
README.md
@ -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
|
||||
|
||||
@ -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);
|
||||
|
||||
70
app/api/admin/api-keys/[id]/reveal/route.ts
Normal file
70
app/api/admin/api-keys/[id]/reveal/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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 }
|
||||
);
|
||||
}
|
||||
|
||||
159
app/api/admin/api-keys/route.test.ts
Normal file
159
app/api/admin/api-keys/route.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
@ -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: '服务器错误' },
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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) {
|
||||
|
||||
61
app/api/admin/notification-center/_shared.ts
Normal file
61
app/api/admin/notification-center/_shared.ts
Normal 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 },
|
||||
)
|
||||
}
|
||||
22
app/api/admin/notification-center/audits/route.ts
Normal file
22
app/api/admin/notification-center/audits/route.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
22
app/api/admin/notification-center/deliveries/route.ts
Normal file
22
app/api/admin/notification-center/deliveries/route.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
72
app/api/admin/notification-center/projects/[code]/route.ts
Normal file
72
app/api/admin/notification-center/projects/[code]/route.ts
Normal 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)
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
38
app/api/admin/notification-center/projects/route.ts
Normal file
38
app/api/admin/notification-center/projects/route.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
22
app/api/admin/notification-center/recipients/route.ts
Normal file
22
app/api/admin/notification-center/recipients/route.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
23
app/api/admin/notification-center/test/route.ts
Normal file
23
app/api/admin/notification-center/test/route.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
92
components/SuperAdminsTab.test.tsx
Normal file
92
components/SuperAdminsTab.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
@ -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={() => {
|
||||
|
||||
172
components/admin/NotificationCenterPanel.test.tsx
Normal file
172
components/admin/NotificationCenterPanel.test.tsx
Normal 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('项目配置没有被修改')
|
||||
})
|
||||
})
|
||||
963
components/admin/NotificationCenterPanel.tsx
Normal file
963
components/admin/NotificationCenterPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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` 仍通过;
|
||||
|
||||
@ -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 前缀', () => {
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
233
lib/__tests__/notification-center-admin-routes.test.ts
Normal file
233
lib/__tests__/notification-center-admin-routes.test.ts
Normal 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('未登录')
|
||||
})
|
||||
})
|
||||
226
lib/__tests__/notification-center-admin.test.ts
Normal file
226
lib/__tests__/notification-center-admin.test.ts
Normal 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',
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -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',
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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
|
||||
|
||||
457
lib/notification-center-admin.ts
Normal file
457
lib/notification-center-admin.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
@ -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");
|
||||
@ -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")
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user