gongxiangp2/lib/notification-center-admin.ts
2026-08-29 21:39:17 +08:00

538 lines
15 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<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
}
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<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 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<Record<string, unknown>>({
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<string, unknown>
: {}
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,
})
}