967 lines
36 KiB
TypeScript
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,
|
|
}
|
|
}
|