538 lines
15 KiB
TypeScript
538 lines
15 KiB
TypeScript
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,
|
||
})
|
||
}
|