2026-08-29 20:51:03 +08:00

967 lines
36 KiB
TypeScript

import crypto from 'crypto'
import express, { NextFunction, Request, Response } from 'express'
import { buildH5EntryUrl, createH5EntryPage, getRedirectPathFromActionUrl, LoginInputError, validateRedirectPath } from './auth'
import { authenticateAdmin, type AdminRequest } from './admin-auth'
import { AdminApiError, AdminProjectRegistry, assertAdminRuntimeConfig, maskUserId, type ProjectRecipientInput, type ProjectUpdateInput, type ProjectUpsertInput } from './admin-project-registry'
import { assertRuntimeConfig, getDingTalkConfig, getPublicBaseUrl, loadProjectConfigs, type ProjectConfig, type ProjectLoginConfig } from './config'
import { NotificationDirectoryError, resolveRecipientAlias as resolveRecipientAliasFromDirectory } from './directory'
import { DingTalkError, exchangeH5AuthCodeForUserId, sendRobotMessage } from './dingtalk'
import prisma from './prisma'
import { createCanonicalSignature, safeEqual, sha256 } from './signature'
import { classifyDeliveryFailure } from './worker'
type SignedRequest = Request & { rawBody?: Buffer; project?: ProjectConfig }
export type ProjectSource = Map<string, ProjectConfig> | AdminProjectRegistry
const clean = (value: unknown) => String(value || '').trim()
const getTodayLabel = () => {
const parts = new Intl.DateTimeFormat('en-CA', {
timeZone: process.env.TZ || 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).formatToParts(new Date())
const year = parts.find((item) => item.type === 'year')?.value || '0000'
const month = parts.find((item) => item.type === 'month')?.value || '00'
const day = parts.find((item) => item.type === 'day')?.value || '00'
return `${year}-${month}-${day}`
}
class PublicApiError extends Error {
constructor(
public readonly status: number,
public readonly code: string,
message?: string,
) {
super(message || code)
this.name = 'PublicApiError'
}
}
export const renderNotificationTitle = (projectName: string, businessTitle: string) =>
`【${clean(projectName)}】${clean(businessTitle)}`
export const renderNotificationContent = (
_projectName: string,
_businessTitle: string,
businessContent: string,
) => clean(businessContent)
const ticketTtlSeconds = () => {
const configured = Number(process.env.DINGTALK_LOGIN_TICKET_TTL_SECONDS || 60)
return Number.isInteger(configured) && configured >= 30 && configured <= 60 ? configured : 60
}
const isRegistryProjectSource = (source: ProjectSource): source is AdminProjectRegistry =>
source instanceof AdminProjectRegistry
const publicErrorPayload = (error: PublicApiError) =>
error.message && error.message !== error.code
? { error: error.code, message: error.message }
: { error: error.code }
const adminErrorPayload = (error: AdminApiError) => ({
error: {
code: error.code,
message: error.message,
...(error.details === undefined ? {} : { details: error.details }),
},
})
const deliveryView = (row: {
id: string
projectCode: string
status: string
recipientUserId: string
renderedTitle: string
processQueryKey: string | null
lastErrorCode: string | null
lastErrorMessage: string | null
sentAt: Date | null
readAt: Date | null
createdAt: Date
updatedAt: Date
}) => ({
id: row.id,
projectCode: row.projectCode,
status: row.status,
recipientUserId: row.recipientUserId,
title: row.renderedTitle,
processQueryKey: row.processQueryKey,
lastErrorCode: row.lastErrorCode,
lastErrorMessage: row.lastErrorMessage,
sentAt: row.sentAt,
readAt: row.readAt,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
})
const adminDeliveryView = (row: {
id: string
projectCode: string
idempotencyKey: string
recipientUserId: string
renderedTitle: string
status: string
attemptCount: number
maxAttempts: number
lastErrorCode: string | null
lastErrorMessage: string | null
processQueryKey: string | null
actionTitle: string
isTest: boolean
sentAt: Date | null
readAt: Date | null
createdAt: Date
updatedAt: Date
}) => ({
id: row.id,
projectCode: row.projectCode,
idempotencyKey: row.idempotencyKey,
recipientAlias: null,
recipientUserIdMasked: maskUserId(row.recipientUserId),
title: row.renderedTitle,
status: row.status,
attemptCount: row.attemptCount,
maxAttempts: row.maxAttempts,
lastErrorCode: row.lastErrorCode,
lastErrorMessage: row.lastErrorMessage,
processQueryKey: row.processQueryKey,
actionTitle: row.actionTitle,
isTest: row.isTest,
sentAt: row.sentAt,
readAt: row.readAt,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
})
const hasControlCharacters = (value: string) => /[\u0000-\u001f\u007f]/.test(value)
const normalizeActionUrl = (value: string) => {
if (!value) return ''
if (value.length > 2048 || hasControlCharacters(value)) {
throw new Error('actionUrl 过长或包含非法字符')
}
return value
}
const canWrapActionUrlWithLogin = (value: string, project: ProjectConfig) => {
if (!value || !project.login) return false
try {
const url = new URL(value)
const callbackHost = new URL(project.login.callbackUrl).hostname.toLowerCase()
const allowedHosts = new Set(project.allowedHosts.map((host) => host.toLowerCase()))
allowedHosts.add(callbackHost)
return url.protocol === 'https:' &&
!url.username &&
!url.password &&
!url.port &&
allowedHosts.has(url.hostname.toLowerCase())
} catch {
return false
}
}
const renderActionUrl = (value: string, project: ProjectConfig) => {
if (!canWrapActionUrlWithLogin(value, project)) return value
try {
return buildH5EntryUrl({
publicBaseUrl: getPublicBaseUrl(),
project,
redirectPath: getRedirectPathFromActionUrl(value, project),
})
} catch {
return value
}
}
type CreateServerDependencies = {
exchangeH5Code?: (code: string) => Promise<string>
sendTestMessage?: typeof sendRobotMessage
resolveRecipientAlias?: (recipientAlias: string) => Promise<string>
}
type NormalizedNotificationInput = {
project: ProjectConfig
idempotencyKey: string
recipientUserId: string
title: string
content: string
actionTitle: string
destinationActionUrl: string
renderedActionUrl: string
hasSelfBrandedTitle: boolean
}
const getProjectFromSource = async (source: ProjectSource, projectCode: string) => {
if (isRegistryProjectSource(source)) return source.getActiveProject(projectCode)
return source.get(projectCode)
}
const parseRecipientsInput = (value: unknown): ProjectRecipientInput[] => {
if (value === undefined) return []
if (!Array.isArray(value)) throw new AdminApiError(422, 'RECIPIENTS_INVALID', 'recipients 必须是数组')
return value.map((item, index) => {
if (!item || typeof item !== 'object' || Array.isArray(item)) {
throw new AdminApiError(422, 'RECIPIENTS_INVALID', `recipients[${index}] 必须是对象`)
}
const row = item as Record<string, unknown>
return {
alias: clean(row.alias),
userId: clean(row.userId),
}
})
}
const parseAllowedHosts = (value: unknown) => {
if (value === undefined) return []
if (!Array.isArray(value)) throw new AdminApiError(422, 'ALLOWED_HOSTS_INVALID', 'allowedHosts 必须是数组')
return value.map((item) => clean(item)).filter(Boolean)
}
const parseOptionalBoolean = (value: unknown, field: string) => {
if (value === undefined) return undefined
if (typeof value !== 'boolean') {
throw new AdminApiError(422, 'BOOLEAN_FIELD_INVALID', `${field} 必须是布尔值`)
}
return value
}
const parseLoginPayload = (value: unknown): ProjectLoginConfig | { enabled: false } | null | undefined => {
if (value === undefined) return undefined
if (value === null) return null
if (typeof value !== 'object' || Array.isArray(value)) {
throw new AdminApiError(422, 'LOGIN_INVALID', 'login 必须是对象、null 或省略')
}
const row = value as Record<string, unknown>
if (row.enabled === false) return { enabled: false }
return {
enabled: true,
callbackUrl: clean(row.callbackUrl),
allowedRedirectPathPrefixes: Array.isArray(row.allowedRedirectPathPrefixes)
? row.allowedRedirectPathPrefixes.map((item) => clean(item)).filter(Boolean)
: [],
}
}
const buildPublicNotificationInput = async (input: {
project: ProjectConfig
body: Record<string, unknown> | undefined
requireIdempotency: boolean
resolveRecipientAlias: (recipientAlias: string) => Promise<string>
}) => {
const body = input.body || {}
const idempotencyKey = clean(body.idempotencyKey)
const requestedRecipientUserId = clean(body.recipientUserId)
const recipientAlias = clean(body.recipientAlias)
const title = clean(body.title)
const content = clean(body.content)
const actionTitle = clean(body.actionTitle) || '查看详情'
if (requestedRecipientUserId && recipientAlias) {
throw new PublicApiError(422, 'RECIPIENT_SELECTOR_CONFLICT')
}
if (recipientAlias.length > 50 || /[\u0000-\u001f\u007f]/.test(recipientAlias)) {
throw new PublicApiError(422, 'RECIPIENT_ALIAS_INVALID')
}
let recipientUserId = requestedRecipientUserId
if (!recipientUserId && recipientAlias) {
try {
recipientUserId = clean(await input.resolveRecipientAlias(recipientAlias))
} catch (error) {
if (error instanceof NotificationDirectoryError) {
throw new PublicApiError(error.status, error.code, error.message)
}
throw error
}
}
let destinationActionUrl = ''
try {
destinationActionUrl = normalizeActionUrl(clean(body.actionUrl))
} catch (error: any) {
throw new PublicApiError(422, 'ACTION_URL_INVALID', clean(error?.message))
}
const renderedActionUrl = renderActionUrl(destinationActionUrl, input.project)
if (input.requireIdempotency && (!idempotencyKey || idempotencyKey.length > 160)) {
throw new PublicApiError(422, 'IDEMPOTENCY_KEY_INVALID')
}
if (!recipientUserId || !title || !content) throw new PublicApiError(422, 'MESSAGE_FIELDS_REQUIRED')
if (title.length > 100 || content.length > 4800 || actionTitle.length > 20) {
throw new PublicApiError(422, 'MESSAGE_TOO_LONG')
}
if (title.startsWith('【由壹点')) {
throw new PublicApiError(422, 'TITLE_BRAND_RESERVED')
}
return {
project: input.project,
idempotencyKey,
recipientUserId,
title,
content,
actionTitle,
destinationActionUrl,
renderedActionUrl,
hasSelfBrandedTitle: title.startsWith(`【${input.project.name}】`),
}
}
const createOrReuseDelivery = async (input: {
payload: NormalizedNotificationInput
isTest?: boolean
enforceIdempotency?: boolean
maxAttempts?: number
}) => {
const enforceIdempotency = input.enforceIdempotency !== false
const normalized = {
recipientUserId: input.payload.recipientUserId,
title: input.payload.title,
content: input.payload.content,
actionTitle: input.payload.actionTitle,
actionUrl: input.payload.destinationActionUrl,
}
const requestHash = sha256(JSON.stringify(normalized))
if (enforceIdempotency) {
const existing = await prisma.notificationDelivery.findUnique({
where: {
projectCode_idempotencyKey: {
projectCode: input.payload.project.code,
idempotencyKey: input.payload.idempotencyKey,
},
},
})
if (existing) {
if (existing.requestHash !== requestHash) throw new PublicApiError(409, 'IDEMPOTENCY_CONFLICT')
return { row: existing, reused: true }
}
}
if (input.payload.hasSelfBrandedTitle) throw new PublicApiError(422, 'TITLE_BRAND_RESERVED')
const renderedTitle = renderNotificationTitle(input.payload.project.name, input.payload.title)
const renderedContent = renderNotificationContent(input.payload.project.name, input.payload.title, input.payload.content)
if (renderedTitle.length > 120 || renderedContent.length > 4800) {
throw new PublicApiError(422, 'MESSAGE_TOO_LONG')
}
const created = await prisma.notificationDelivery.create({
data: {
id: crypto.randomUUID(),
projectCode: input.payload.project.code,
idempotencyKey: input.payload.idempotencyKey,
requestHash,
recipientUserId: input.payload.recipientUserId,
businessTitle: input.payload.title,
renderedTitle,
content: renderedContent,
actionTitle: input.payload.actionTitle,
actionUrl: input.payload.renderedActionUrl,
maxAttempts: input.maxAttempts ?? 5,
isTest: Boolean(input.isTest),
},
})
return { row: created, reused: false }
}
const buildAdminNotificationInput = async (
project: ProjectConfig,
body: Record<string, unknown> | undefined,
resolveRecipientAlias: (recipientAlias: string) => Promise<string>,
) => {
const parsed = await buildPublicNotificationInput({
project,
body: {
...body,
idempotencyKey: body?.idempotencyKey || `admin-test:${project.code}:${Date.now()}:${crypto.randomUUID()}`,
},
requireIdempotency: true,
resolveRecipientAlias,
})
return {
...parsed,
idempotencyKey: `admin-test:${project.code}:${Date.now()}:${crypto.randomUUID()}`,
}
}
const parseAdminProjectCreateBody = (body: Record<string, unknown> | undefined): ProjectUpsertInput => {
const input = body || {}
return {
projectCode: clean(input.projectCode || input.code),
name: clean(input.name),
allowedHosts: parseAllowedHosts(input.allowedHosts),
isActive: parseOptionalBoolean(input.isActive, 'isActive'),
recipients: parseRecipientsInput(input.recipients),
login: parseLoginPayload(input.login),
}
}
const parseAdminProjectUpdateBody = (body: Record<string, unknown> | undefined): ProjectUpdateInput => {
const input = body || {}
const update: ProjectUpdateInput = {}
if (Object.prototype.hasOwnProperty.call(input, 'name')) update.name = clean(input.name)
if (Object.prototype.hasOwnProperty.call(input, 'allowedHosts')) update.allowedHosts = parseAllowedHosts(input.allowedHosts)
if (Object.prototype.hasOwnProperty.call(input, 'isActive')) update.isActive = parseOptionalBoolean(input.isActive, 'isActive')
if (Object.prototype.hasOwnProperty.call(input, 'login')) update.login = parseLoginPayload(input.login)
return update
}
const writePublicError = (res: Response, error: PublicApiError) =>
res.status(error.status).json(publicErrorPayload(error))
const writeAdminError = (res: Response, error: AdminApiError) =>
res.status(error.status).json(adminErrorPayload(error))
export const createServer = (projectSource: ProjectSource, dependencies: CreateServerDependencies = {}) => {
const exchangeH5Code = dependencies.exchangeH5Code || exchangeH5AuthCodeForUserId
const sendTestMessage = dependencies.sendTestMessage || sendRobotMessage
const resolveRecipientAlias = dependencies.resolveRecipientAlias || resolveRecipientAliasFromDirectory
const app = express()
app.disable('x-powered-by')
app.use('/v1', express.json({
limit: '64kb',
verify: (req, _res, buffer) => { (req as SignedRequest).rawBody = Buffer.from(buffer) },
}))
app.use('/admin', express.json({ limit: '256kb' }))
app.get('/health', async (_req, res) => {
try {
await prisma.$queryRaw`SELECT 1`
const groups = await prisma.notificationDelivery.groupBy({ by: ['status'], _count: { _all: true } })
const counts: Record<string, number> = {}
for (const group of groups) counts[group.status] = group._count._all
const outcomeUnknown = await prisma.notificationDelivery.count({
where: { status: 'failed', lastErrorCode: 'DELIVERY_OUTCOME_UNKNOWN' },
})
res.json({
ok: true,
service: 'youyidian-notification-center',
database: 'ready',
deliveries: { counts, failed: counts.failed || 0, outcomeUnknown },
})
} catch {
res.status(503).json({ ok: false, service: 'youyidian-notification-center', database: 'unavailable' })
}
})
app.get('/v1/auth/h5-entry', async (req, res) => {
const project = await getProjectFromSource(projectSource, clean(req.query.projectCode))
if (!project?.login) return res.status(404).send('Not Found')
try {
const redirectPath = validateRedirectPath(req.query.redirectPath, project)
const publicBaseUrl = getPublicBaseUrl()
if (!publicBaseUrl) throw new Error('通知中心公网地址未配置')
const { appKey, corpId } = getDingTalkConfig()
if (!appKey || !corpId) throw new Error('钉钉免登客户端标识或 CorpId 未配置')
const page = createH5EntryPage({
clientId: appKey,
corpId,
projectCode: project.code,
projectName: project.name,
redirectPath,
h5CodeUrl: `${publicBaseUrl}/v1/auth/h5-code`,
})
res.set({
'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0',
'Content-Security-Policy': `default-src 'none'; script-src 'nonce-${page.cspNonce}' https://g.alicdn.com; style-src 'nonce-${page.cspNonce}'; connect-src 'self' ${new URL(publicBaseUrl).origin}; form-action ${new URL(project.login.callbackUrl).origin}; base-uri 'none'; frame-ancestors 'none'`,
'Referrer-Policy': 'no-referrer',
'X-Content-Type-Options': 'nosniff',
'X-Frame-Options': 'DENY',
})
res.type('html').send(page.html)
} catch (error) {
if (error instanceof LoginInputError) return res.status(422).send('Invalid redirect path')
return res.status(503).send('Login broker unavailable')
}
})
app.post('/v1/auth/h5-code', async (req, res, next) => {
try {
const project = await getProjectFromSource(projectSource, clean(req.body?.projectCode))
if (!project?.login) return res.status(404).json({ error: 'PROJECT_LOGIN_NOT_FOUND' })
const code = clean(req.body?.code)
if (code.length < 8 || code.length > 2048 || /[\u0000-\u001f\u007f]/.test(code)) {
return res.status(422).json({ error: 'H5_CODE_INVALID' })
}
let redirectPath: string
try {
redirectPath = validateRedirectPath(req.body?.redirectPath, project)
} catch {
return res.status(422).json({ error: 'REDIRECT_PATH_INVALID' })
}
let dingtalkUserId: string
try {
dingtalkUserId = clean(await exchangeH5Code(code))
if (!dingtalkUserId) throw new Error('EMPTY_USER_ID')
} catch (error) {
const status = error instanceof DingTalkError && error.deterministic ? 401 : 502
return res.status(status).json({ error: 'H5_AUTH_FAILED' })
}
const ticket = crypto.randomBytes(32).toString('base64url')
const ticketNonce = crypto.randomBytes(24).toString('base64url')
const now = new Date()
try {
await prisma.dingtalkLoginTicket.deleteMany({
where: { expiresAt: { lt: new Date(now.getTime() - 24 * 60 * 60_000) } },
})
await prisma.dingtalkLoginTicket.create({
data: {
id: crypto.randomUUID(),
projectCode: project.code,
audience: new URL(project.login.callbackUrl).origin,
ticketHash: sha256(ticket),
ticketNonce,
authCodeHash: sha256(code),
dingtalkUserId,
redirectPath,
expiresAt: new Date(now.getTime() + ticketTtlSeconds() * 1000),
},
})
} catch (error: any) {
if (clean(error?.code) === 'P2002') return res.status(401).json({ error: 'H5_AUTH_FAILED' })
return next(error)
}
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0')
res.status(201).json({
ticket,
formPostUrl: project.login.callbackUrl,
expiresIn: ticketTtlSeconds(),
})
} catch (error) {
next(error)
}
})
const authenticate = async (req: SignedRequest, res: Response, next: NextFunction) => {
try {
const projectCode = clean(req.header('x-yyd-project-code'))
const timestamp = clean(req.header('x-yyd-timestamp'))
const nonce = clean(req.header('x-yyd-nonce'))
const signature = clean(req.header('x-yyd-signature'))
const project = await getProjectFromSource(projectSource, projectCode)
if (!project || !timestamp || !nonce || !signature) return res.status(401).json({ error: 'AUTH_REQUIRED' })
const timestampMs = Number(timestamp) * 1000
if (!Number.isFinite(timestampMs) || Math.abs(Date.now() - timestampMs) > 5 * 60_000) {
return res.status(401).json({ error: 'TIMESTAMP_EXPIRED' })
}
if (!/^[a-zA-Z0-9_-]{16,100}$/.test(nonce)) return res.status(401).json({ error: 'NONCE_INVALID' })
const expected = createCanonicalSignature({
secret: project.secret,
timestamp,
nonce,
method: req.method,
path: req.path,
body: req.rawBody || Buffer.alloc(0),
})
if (!safeEqual(expected, signature)) return res.status(401).json({ error: 'SIGNATURE_INVALID' })
await prisma.requestNonce.deleteMany({ where: { expiresAt: { lt: new Date() } } })
try {
await prisma.requestNonce.create({ data: { projectCode, nonce, expiresAt: new Date(timestampMs + 5 * 60_000) } })
} catch {
return res.status(409).json({ error: 'NONCE_REPLAYED' })
}
req.project = project
next()
} catch (error) {
next(error)
}
}
app.post('/v1/notifications', authenticate, async (req: SignedRequest, res, next) => {
try {
const payload = await buildPublicNotificationInput({
project: req.project!,
body: req.body as Record<string, unknown> | undefined,
requireIdempotency: true,
resolveRecipientAlias,
})
const created = await createOrReuseDelivery({ payload })
res.status(created.reused ? 200 : 202).json(deliveryView(created.row))
} catch (error) {
if (error instanceof PublicApiError) return writePublicError(res, error)
next(error)
}
})
app.get('/v1/notifications/:id', authenticate, async (req: SignedRequest, res, next) => {
try {
const delivery = await prisma.notificationDelivery.findFirst({
where: { id: String(req.params.id), projectCode: req.project!.code },
})
if (!delivery) return res.status(404).json({ error: 'NOT_FOUND' })
res.json(deliveryView(delivery))
} catch (error) {
next(error)
}
})
app.get('/v1/notifications', authenticate, async (req: SignedRequest, res, next) => {
try {
const status = clean(req.query.status)
const limit = Math.min(50, Math.max(1, Number(req.query.limit) || 20))
const rows = await prisma.notificationDelivery.findMany({
where: {
projectCode: req.project!.code,
...(status ? { status } : {}),
},
orderBy: { createdAt: 'desc' },
take: limit,
})
res.set('Cache-Control', 'no-store')
res.json({ items: rows.map(deliveryView) })
} catch (error) {
next(error)
}
})
app.post('/v1/auth/tickets/redeem', authenticate, async (req: SignedRequest, res, next) => {
try {
const project = req.project!
const ticket = clean(req.body?.ticket)
if (!/^[a-zA-Z0-9_-]{43}$/.test(ticket)) {
return res.status(401).json({ error: 'TICKET_INVALID_OR_EXPIRED' })
}
const ticketHash = sha256(ticket)
const audience = project.login ? new URL(project.login.callbackUrl).origin : ''
const now = new Date()
const redeemed = await prisma.$transaction(async (tx) => {
const consumed = await tx.dingtalkLoginTicket.updateMany({
where: {
ticketHash,
projectCode: project.code,
audience,
consumedAt: null,
expiresAt: { gt: now },
},
data: { consumedAt: now },
})
if (consumed.count !== 1) return null
const row = await tx.dingtalkLoginTicket.findUnique({
where: { ticketHash },
select: { dingtalkUserId: true, redirectPath: true },
})
if (!row) throw new Error('票据消费后读取失败')
return row
})
if (!redeemed) return res.status(401).json({ error: 'TICKET_INVALID_OR_EXPIRED' })
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, max-age=0')
res.json(redeemed)
} catch (error) {
next(error)
}
})
if (isRegistryProjectSource(projectSource)) {
app.post('/admin/runtime/import-env', authenticateAdmin, async (req: AdminRequest, res, next) => {
try {
const result = await projectSource.importFromEnvironment(req.adminActor!)
res.status(200).json(result)
} catch (error: any) {
if (!(error instanceof AdminApiError) && /YYD_NOTIFICATION_PROJECTS_JSON|项目/.test(clean(error?.message))) {
return writeAdminError(res, new AdminApiError(422, 'ENV_IMPORT_INVALID', clean(error?.message)))
}
next(error)
}
})
app.post('/admin/runtime/refresh', authenticateAdmin, async (req: AdminRequest, res, next) => {
try {
const result = await projectSource.recordRuntimeRefresh(req.adminActor!)
res.json(result)
} catch (error) {
next(error)
}
})
app.get('/admin/projects', authenticateAdmin, async (req: AdminRequest, res, next) => {
try {
const includeRecipientsQuery = clean(req.query.includeRecipients)
const includeRecipients = includeRecipientsQuery ? includeRecipientsQuery !== '0' : true
res.json(await projectSource.listProjects(includeRecipients))
} catch (error) {
next(error)
}
})
app.get('/admin/projects/:projectCode', authenticateAdmin, async (req: AdminRequest, res, next) => {
try {
const includeRecipientsQuery = clean(req.query.includeRecipients)
const includeRecipients = includeRecipientsQuery ? includeRecipientsQuery !== '0' : true
res.json(await projectSource.getProjectView(clean(req.params.projectCode), includeRecipients))
} catch (error) {
next(error)
}
})
app.post('/admin/projects', authenticateAdmin, async (req: AdminRequest, res, next) => {
try {
const body = parseAdminProjectCreateBody(req.body as Record<string, unknown> | undefined)
res.status(201).json(await projectSource.createProject(body, req.adminActor!))
} catch (error: any) {
if (error instanceof AdminApiError) return writeAdminError(res, error)
if (error instanceof Error) return writeAdminError(res, new AdminApiError(422, 'PROJECT_INPUT_INVALID', error.message))
next(error)
}
})
app.patch('/admin/projects/:projectCode', authenticateAdmin, async (req: AdminRequest, res, next) => {
try {
const projectCode = clean(req.params.projectCode)
const body = parseAdminProjectUpdateBody(req.body as Record<string, unknown> | undefined)
res.json(await projectSource.updateProject(projectCode, body, req.adminActor!))
} catch (error: any) {
if (error instanceof AdminApiError) return writeAdminError(res, error)
if (error instanceof Error) return writeAdminError(res, new AdminApiError(422, 'PROJECT_INPUT_INVALID', error.message))
next(error)
}
})
app.delete('/admin/projects/:projectCode', authenticateAdmin, async (req: AdminRequest, res, next) => {
try {
res.json(await projectSource.deleteProject(clean(req.params.projectCode), req.adminActor!))
} catch (error) {
next(error)
}
})
app.get('/admin/projects/:projectCode/recipients', authenticateAdmin, async (req: AdminRequest, res, next) => {
try {
res.json(await projectSource.listRecipients(clean(req.params.projectCode)))
} catch (error) {
next(error)
}
})
app.put('/admin/projects/:projectCode/recipients', authenticateAdmin, async (req: AdminRequest, res, next) => {
try {
const recipients = parseRecipientsInput((req.body as Record<string, unknown> | undefined)?.recipients)
res.json(await projectSource.replaceRecipients(clean(req.params.projectCode), recipients, req.adminActor!))
} catch (error: any) {
if (error instanceof AdminApiError) return writeAdminError(res, error)
if (error instanceof Error) return writeAdminError(res, new AdminApiError(422, 'RECIPIENTS_INVALID', error.message))
next(error)
}
})
app.post('/admin/projects/:projectCode/rotate-secret', authenticateAdmin, async (req: AdminRequest, res, next) => {
try {
res.json(await projectSource.rotateSecret(clean(req.params.projectCode), req.adminActor!))
} catch (error) {
next(error)
}
})
app.post(
'/admin/projects/:projectCode/reveal-secret',
(_req, res, next) => {
res.set({
'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0',
Pragma: 'no-cache',
Expires: '0',
})
next()
},
authenticateAdmin,
async (req: AdminRequest, res, next) => {
try {
res.json(await projectSource.revealSecret(clean(req.params.projectCode), req.adminActor!))
} catch (error) {
next(error)
}
},
)
app.post('/admin/projects/:projectCode/test', authenticateAdmin, async (req: AdminRequest, res, next) => {
try {
const project = await projectSource.resolveProjectConfigForCreate(clean(req.params.projectCode))
const payload = await buildAdminNotificationInput(
project,
req.body as Record<string, unknown> | undefined,
resolveRecipientAlias,
)
const created = await createOrReuseDelivery({
payload,
isTest: true,
maxAttempts: 1,
enforceIdempotency: false,
})
try {
const processQueryKey = await sendTestMessage({
recipientUserId: created.row.recipientUserId,
title: created.row.renderedTitle,
content: created.row.content,
actionTitle: created.row.actionTitle,
actionUrl: created.row.actionUrl,
})
const sent = await prisma.notificationDelivery.update({
where: { id: created.row.id },
data: {
status: 'sent',
attemptCount: 1,
processQueryKey,
sentAt: new Date(),
nextAttemptAt: new Date(Date.now() + 60_000),
lastErrorCode: null,
lastErrorMessage: null,
},
})
await prisma.notificationAudit.create({
data: {
id: crypto.randomUUID(),
actorType: req.adminActor!.actorType,
actorId: req.adminActor!.actorId,
action: 'project.test',
targetProjectCode: project.code,
requestId: req.adminActor!.requestId || null,
ipAddress: req.adminActor!.ipAddress || null,
summary: `发送项目 ${project.code} 测试通知`,
detailsJson: JSON.stringify({
recipientUserIdMasked: maskUserId(created.row.recipientUserId),
title: created.row.businessTitle,
actionUrlHost: (() => {
try {
return payload.destinationActionUrl ? new URL(payload.destinationActionUrl).host : null
} catch {
return null
}
})(),
sentDate: getTodayLabel(),
}),
},
})
return res.status(201).json({ delivery: adminDeliveryView(sent) })
} catch (error) {
const decision = classifyDeliveryFailure({
error,
attemptCount: 1,
maxAttempts: 1,
})
const failed = await prisma.notificationDelivery.update({
where: { id: created.row.id },
data: {
status: decision.failed ? 'failed' : 'retrying',
attemptCount: 1,
lastErrorCode: decision.errorCode,
lastErrorMessage: decision.errorMessage,
},
})
await prisma.notificationAudit.create({
data: {
id: crypto.randomUUID(),
actorType: req.adminActor!.actorType,
actorId: req.adminActor!.actorId,
action: 'project.test',
targetProjectCode: project.code,
requestId: req.adminActor!.requestId || null,
ipAddress: req.adminActor!.ipAddress || null,
summary: `发送项目 ${project.code} 测试通知失败`,
detailsJson: JSON.stringify({
recipientUserIdMasked: maskUserId(created.row.recipientUserId),
title: created.row.businessTitle,
errorCode: decision.errorCode,
errorMessage: decision.errorMessage,
sentDate: getTodayLabel(),
}),
},
})
return res.status(201).json({ delivery: adminDeliveryView(failed) })
}
} catch (error) {
if (error instanceof PublicApiError) {
return writeAdminError(res, new AdminApiError(error.status, error.code, error.message))
}
next(error)
}
})
app.get('/admin/deliveries', authenticateAdmin, async (req: AdminRequest, res, next) => {
try {
const isTestRaw = clean(req.query.isTest).toLowerCase()
const isTest = isTestRaw ? isTestRaw === 'true' || isTestRaw === '1' : undefined
res.json(await projectSource.listDeliveries({
projectCode: clean(req.query.projectCode) || undefined,
status: clean(req.query.status) || undefined,
isTest,
take: Number(req.query.limit),
skip: Number(req.query.offset),
}))
} catch (error) {
next(error)
}
})
app.get('/admin/audits', authenticateAdmin, async (req: AdminRequest, res, next) => {
try {
res.json(await projectSource.listAudits({
projectCode: clean(req.query.projectCode) || undefined,
action: clean(req.query.action) || undefined,
take: Number(req.query.limit),
skip: Number(req.query.offset),
}))
} catch (error) {
next(error)
}
})
}
app.use((error: any, req: Request, res: Response, _next: NextFunction) => {
const path = clean(req.path)
if (clean(error?.type) === 'entity.too.large') {
if (path.startsWith('/admin')) {
return res.status(413).json({ error: { code: 'REQUEST_TOO_LARGE', message: '请求体超过大小限制' } })
}
return res.status(413).json({ error: 'REQUEST_TOO_LARGE' })
}
if (error instanceof AdminApiError) return writeAdminError(res, error)
if (error instanceof PublicApiError) return writePublicError(res, error)
console.error('[NotificationCenter]', {
name: clean(error?.name) || 'Error',
code: clean(error?.code) || undefined,
message: clean(error?.message).slice(0, 500) || '服务异常',
})
if (path.startsWith('/admin')) {
return res.status(500).json({ error: { code: 'INTERNAL_ERROR', message: '服务异常' } })
}
res.status(500).json({ error: 'INTERNAL_ERROR', message: '服务异常' })
})
return app
}
export const resolveRuntimeProjectSource = async () => {
try {
assertAdminRuntimeConfig()
} catch (error: any) {
const projects = loadProjectConfigs()
assertRuntimeConfig(projects)
return {
projectSource: projects as ProjectSource,
mode: 'legacy' as const,
adminAvailable: false,
activeProjectCount: projects.size,
importedProjectCount: 0,
refreshedAt: null,
adminConfigError: clean(error?.message) || '管理员配置缺失',
}
}
const projectRegistry = new AdminProjectRegistry()
const imported = await projectRegistry.importFromEnvironment({
actorType: 'system',
actorId: 'startup-import',
})
const refreshed = await projectRegistry.refresh()
const projects = projectRegistry.getRuntimeProjectsSnapshot()
assertRuntimeConfig(projects)
return {
projectSource: projectRegistry as ProjectSource,
mode: 'registry' as const,
adminAvailable: true,
activeProjectCount: projects.size,
importedProjectCount: imported.importedProjectCodes.length,
refreshedAt: refreshed.refreshedAt,
adminConfigError: null,
}
}