import { prisma } from '@/lib/prisma' type NotificationCenterAdminMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' export type NotificationCenterAdminActor = { userId: string username?: string | null realName?: string | null email?: string | null } export type NotificationCenterAdminResponse = { 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 } export async function resolveNotificationCenterRecipientAlias(alias: string): Promise<{ recipientUserId: string } | null> { const normalizedAlias = clean(alias) if (!normalizedAlias) { throw new NotificationCenterAdminError('花名不能为空', { statusCode: 422, code: 'NOTIFICATION_CENTER_ADMIN_VALIDATION', }) } const rows = await prisma.performanceSurveyEmployeeProfile.findMany({ where: { flowerName: normalizedAlias }, select: { dingtalkUserId: true }, take: 5, }) const candidateUserIds = Array.from( new Set( rows .map((row) => clean(row.dingtalkUserId)) .filter((value): value is string => Boolean(value)), ), ) if (candidateUserIds.length !== 1) { return null } return { recipientUserId: candidateUserIds[0], } } function getConfig() { const timeoutCandidate = Number(process.env.YYD_NOTIFICATION_CENTER_ADMIN_TIMEOUT_MS || '') return { 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) const sanitized: Record = {} 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) .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 | null)?.error ?? (sanitizedPayload as Record | null)?.message ?? (sanitizedPayload as Record | null)?.detail, ) const details = stringifyDetails( extracted ? null : (sanitizedPayload as Record | null)?.details ?? (sanitizedPayload as Record | 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 = { 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 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 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 { const text = await response.text() if (!text) return null try { return JSON.parse(text) } catch { return { message: text } } } async function requestNotificationCenterAdmin(input: { method: NotificationCenterAdminMethod path: string actor: NotificationCenterAdminActor query?: URLSearchParams body?: unknown allowProjectSecrets?: boolean }): Promise> { 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 deleteNotificationCenterProject( actor: NotificationCenterAdminActor, code: string, ) { return requestNotificationCenterAdmin({ method: 'DELETE', path: buildProjectScopedPath(code), actor, }) } 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 revealNotificationCenterProjectSecret( actor: NotificationCenterAdminActor, code: string, ) { const projectCode = requireProjectCode(code) const result = await requestNotificationCenterAdmin>({ method: 'POST', path: buildProjectScopedPath(projectCode, '/reveal-secret'), actor, allowProjectSecrets: true, }) const sourceEnv = result.data.env && typeof result.data.env === 'object' && !Array.isArray(result.data.env) ? result.data.env as Record : {} const env = Object.fromEntries( [ 'YYD_NOTIFICATION_CENTER_URL', 'YYD_NOTIFICATION_CENTER_PROJECT_CODE', 'YYD_NOTIFICATION_CENTER_PROJECT_SECRET', ] .filter((key) => sourceEnv[key] != null) .map((key) => [key, String(sourceEnv[key])]), ) return { ...result, data: { projectCode: clean(result.data.projectCode) || projectCode, secret: clean(result.data.secret ?? result.data.projectSecret) || '', env, }, } } export async function listNotificationCenterProjectRecipients( actor: NotificationCenterAdminActor, code: string, 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, }) }