1044 lines
35 KiB
TypeScript
1044 lines
35 KiB
TypeScript
import crypto from 'crypto'
|
||
import prisma from './prisma'
|
||
import { getPublicBaseUrl, loadProjectConfigs, normalizeRecipientAlias, type ProjectConfig, type ProjectLoginConfig } from './config'
|
||
|
||
const clean = (value: unknown) => String(value || '').trim()
|
||
const RECENT_FAILURE_WINDOW_DAYS = 7
|
||
|
||
export class AdminApiError extends Error {
|
||
constructor(
|
||
public readonly status: number,
|
||
public readonly code: string,
|
||
message: string,
|
||
public readonly details?: unknown,
|
||
) {
|
||
super(message)
|
||
this.name = 'AdminApiError'
|
||
}
|
||
}
|
||
|
||
export type AdminAuditActor = {
|
||
actorType: 'admin' | 'system'
|
||
actorId: string
|
||
requestId?: string
|
||
ipAddress?: string
|
||
}
|
||
|
||
export type ProjectRecipientInput = {
|
||
alias: string
|
||
userId: string
|
||
}
|
||
|
||
export type ProjectUpsertInput = {
|
||
projectCode?: string
|
||
name: string
|
||
allowedHosts?: string[]
|
||
isActive?: boolean
|
||
recipients?: ProjectRecipientInput[]
|
||
login?: ProjectLoginConfig | { enabled: false } | null
|
||
}
|
||
|
||
export type ProjectUpdateInput = {
|
||
name?: string
|
||
allowedHosts?: string[]
|
||
isActive?: boolean
|
||
login?: ProjectLoginConfig | { enabled: false } | null
|
||
}
|
||
|
||
export type ProjectRecipientView = {
|
||
alias: string
|
||
userId: string
|
||
}
|
||
|
||
export type ProjectListItem = {
|
||
name: string
|
||
projectCode: string
|
||
isActive: boolean
|
||
allowedHosts: string[]
|
||
recipientMode: 'userId_or_alias'
|
||
recipientCount: number
|
||
recipients?: ProjectRecipientView[]
|
||
updatedAt: Date
|
||
deliverySummary: {
|
||
lastStatus: string | null
|
||
lastSentAt: Date | null
|
||
recentFailedCount: number
|
||
}
|
||
login?: {
|
||
enabled: true
|
||
callbackUrl: string
|
||
allowedRedirectPathPrefixes: string[]
|
||
}
|
||
}
|
||
|
||
type ProjectCacheEntry = {
|
||
config: ProjectConfig
|
||
isActive: boolean
|
||
}
|
||
|
||
type ProjectDbRow = Awaited<ReturnType<typeof prisma.notificationProject.findFirstOrThrow>>
|
||
type EncryptedStringPayload = {
|
||
ciphertext: string
|
||
nonce: string
|
||
authTag: string
|
||
}
|
||
|
||
const looksLikeHexKey = (value: string) => /^[0-9a-f]{64}$/i.test(value)
|
||
|
||
const decodeEncryptionKey = (raw: string) => {
|
||
const value = clean(raw)
|
||
if (!value) {
|
||
throw new Error('YYD_NOTIFICATION_CONFIG_ENCRYPTION_KEY 未配置')
|
||
}
|
||
if (looksLikeHexKey(value)) {
|
||
return Buffer.from(value, 'hex')
|
||
}
|
||
try {
|
||
const normalized = value.replace(/-/g, '+').replace(/_/g, '/')
|
||
const decoded = Buffer.from(normalized, 'base64')
|
||
if (decoded.length === 32) return decoded
|
||
} catch {
|
||
// ignore
|
||
}
|
||
throw new Error('YYD_NOTIFICATION_CONFIG_ENCRYPTION_KEY 必须是 32 字节的 hex 或 base64/base64url 密钥')
|
||
}
|
||
|
||
const parseJsonArray = (raw: string, code: string, field: string) => {
|
||
try {
|
||
const parsed = JSON.parse(raw) as unknown
|
||
if (!Array.isArray(parsed) || parsed.some((item) => typeof item !== 'string')) {
|
||
throw new Error('INVALID')
|
||
}
|
||
return parsed.map((item) => String(item))
|
||
} catch {
|
||
throw new Error(`项目 ${code} 的 ${field} 配置损坏`)
|
||
}
|
||
}
|
||
|
||
const normalizeProjectInput = (candidate: {
|
||
code: string
|
||
name: string
|
||
secret: string
|
||
allowedHosts?: string[]
|
||
recipients?: ProjectRecipientInput[]
|
||
login?: ProjectLoginConfig | { enabled: false } | null
|
||
}) => {
|
||
const parsed = loadProjectConfigs(JSON.stringify([{
|
||
code: candidate.code,
|
||
name: candidate.name,
|
||
secret: candidate.secret,
|
||
allowedHosts: candidate.allowedHosts || [],
|
||
recipients: candidate.recipients?.map((item) => ({
|
||
alias: item.alias,
|
||
userId: item.userId,
|
||
})),
|
||
login: candidate.login === null ? undefined : candidate.login,
|
||
}]))
|
||
const normalized = parsed.get(candidate.code)
|
||
if (!normalized) throw new Error(`项目 ${candidate.code} 解析失败`)
|
||
return normalized
|
||
}
|
||
|
||
const projectConfigToLoginPayload = (login: ProjectLoginConfig | undefined) =>
|
||
login ? {
|
||
enabled: true as const,
|
||
callbackUrl: login.callbackUrl,
|
||
allowedRedirectPathPrefixes: [...login.allowedRedirectPathPrefixes],
|
||
} : undefined
|
||
|
||
export const maskUserId = (value: string) => {
|
||
if (value.length <= 6) return '***'
|
||
return `${value.slice(0, 3)}***${value.slice(-3)}`
|
||
}
|
||
|
||
const redactAuditDetails = (value: unknown, keyHint = ''): unknown => {
|
||
if (Array.isArray(value)) return value.map((item) => redactAuditDetails(item))
|
||
if (!value || typeof value !== 'object') {
|
||
if (typeof value === 'string' && /(secret|token|signature|authorization)/i.test(keyHint)) return '[redacted]'
|
||
if (typeof value === 'string' && /userId/i.test(keyHint)) return maskUserId(value)
|
||
return value
|
||
}
|
||
const output: Record<string, unknown> = {}
|
||
for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
|
||
if (/(secret|ciphertext|authTag|token|signature|authorization)/i.test(key)) {
|
||
output[key] = '[redacted]'
|
||
continue
|
||
}
|
||
if (/userId/i.test(key) && typeof item === 'string') {
|
||
output[key] = maskUserId(item)
|
||
continue
|
||
}
|
||
output[key] = redactAuditDetails(item, key)
|
||
}
|
||
return output
|
||
}
|
||
|
||
const createAuditDetailsJson = (details: unknown) => {
|
||
if (details === undefined) return null
|
||
return JSON.stringify(redactAuditDetails(details))
|
||
}
|
||
|
||
const buildProjectEnv = (projectCode: string, secret: string) => ({
|
||
YYD_NOTIFICATION_CENTER_URL: getPublicBaseUrl(),
|
||
YYD_NOTIFICATION_CENTER_PROJECT_CODE: projectCode,
|
||
YYD_NOTIFICATION_CENTER_PROJECT_SECRET: secret,
|
||
})
|
||
|
||
const buildProjectView = (input: {
|
||
code: string
|
||
name: string
|
||
isActive: boolean
|
||
allowedHosts: string[]
|
||
recipients: ProjectRecipientView[]
|
||
updatedAt: Date
|
||
deliverySummary: {
|
||
lastStatus: string | null
|
||
lastSentAt: Date | null
|
||
recentFailedCount: number
|
||
}
|
||
login?: ProjectLoginConfig
|
||
includeRecipients?: boolean
|
||
}): ProjectListItem => ({
|
||
name: input.name,
|
||
projectCode: input.code,
|
||
isActive: input.isActive,
|
||
allowedHosts: input.allowedHosts,
|
||
recipientMode: 'userId_or_alias',
|
||
recipientCount: input.recipients.length,
|
||
...(input.includeRecipients ? { recipients: input.recipients } : {}),
|
||
updatedAt: input.updatedAt,
|
||
deliverySummary: input.deliverySummary,
|
||
...(input.login ? { login: projectConfigToLoginPayload(input.login) } : {}),
|
||
})
|
||
|
||
export const createProjectSecret = () => crypto.randomBytes(32).toString('base64url')
|
||
|
||
const createProjectCode = () => `project_${crypto.randomBytes(6).toString('hex')}`
|
||
|
||
export const getAdminToken = () =>
|
||
clean(process.env.YYD_NOTIFICATION_ADMIN_API_SECRET || process.env.YYD_NOTIFICATION_ADMIN_TOKEN)
|
||
|
||
export const assertAdminRuntimeConfig = () => {
|
||
if (!getAdminToken()) throw new Error('YYD_NOTIFICATION_ADMIN_API_SECRET 未配置(可兼容旧变量 YYD_NOTIFICATION_ADMIN_TOKEN)')
|
||
decodeEncryptionKey(process.env.YYD_NOTIFICATION_CONFIG_ENCRYPTION_KEY || '')
|
||
}
|
||
|
||
export class AdminProjectRegistry {
|
||
private readonly encryptionKey: Buffer
|
||
|
||
private runtimeProjects = new Map<string, ProjectCacheEntry>()
|
||
|
||
private refreshedAt: Date | null = null
|
||
|
||
constructor(encryptionKeyRaw = process.env.YYD_NOTIFICATION_CONFIG_ENCRYPTION_KEY || '') {
|
||
this.encryptionKey = decodeEncryptionKey(encryptionKeyRaw)
|
||
}
|
||
|
||
private encryptValue(value: string): EncryptedStringPayload {
|
||
const iv = crypto.randomBytes(12)
|
||
const cipher = crypto.createCipheriv('aes-256-gcm', this.encryptionKey, iv)
|
||
const encrypted = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()])
|
||
return {
|
||
ciphertext: encrypted.toString('base64'),
|
||
nonce: iv.toString('base64'),
|
||
authTag: cipher.getAuthTag().toString('base64'),
|
||
}
|
||
}
|
||
|
||
private decryptValue(payload: EncryptedStringPayload) {
|
||
const decipher = crypto.createDecipheriv(
|
||
'aes-256-gcm',
|
||
this.encryptionKey,
|
||
Buffer.from(payload.nonce, 'base64'),
|
||
)
|
||
decipher.setAuthTag(Buffer.from(payload.authTag, 'base64'))
|
||
const decrypted = Buffer.concat([
|
||
decipher.update(Buffer.from(payload.ciphertext, 'base64')),
|
||
decipher.final(),
|
||
])
|
||
return decrypted.toString('utf8')
|
||
}
|
||
|
||
private encryptSecret(secret: string) {
|
||
const encrypted = this.encryptValue(secret)
|
||
return {
|
||
secretCiphertext: encrypted.ciphertext,
|
||
secretNonce: encrypted.nonce,
|
||
secretAuthTag: encrypted.authTag,
|
||
}
|
||
}
|
||
|
||
private decryptSecret(row: {
|
||
secretCiphertext: string
|
||
secretNonce: string
|
||
secretAuthTag: string
|
||
}) {
|
||
return this.decryptValue({
|
||
ciphertext: row.secretCiphertext,
|
||
nonce: row.secretNonce,
|
||
authTag: row.secretAuthTag,
|
||
})
|
||
}
|
||
|
||
private encryptRecipientUserId(userId: string) {
|
||
const encrypted = this.encryptValue(userId)
|
||
return {
|
||
userIdCiphertext: encrypted.ciphertext,
|
||
userIdNonce: encrypted.nonce,
|
||
userIdAuthTag: encrypted.authTag,
|
||
}
|
||
}
|
||
|
||
private decryptRecipientUserId(row: {
|
||
userIdCiphertext: string
|
||
userIdNonce: string
|
||
userIdAuthTag: string
|
||
}) {
|
||
return this.decryptValue({
|
||
ciphertext: row.userIdCiphertext,
|
||
nonce: row.userIdNonce,
|
||
authTag: row.userIdAuthTag,
|
||
})
|
||
}
|
||
|
||
private buildRecipientCreateRows(recipientAliases: Map<string, string>, inputRecipients: ProjectRecipientInput[]) {
|
||
const displayByNormalized = new Map<string, string>()
|
||
for (const item of inputRecipients) {
|
||
const aliasDisplay = clean(item.alias)
|
||
if (!aliasDisplay) continue
|
||
displayByNormalized.set(normalizeRecipientAlias(aliasDisplay), aliasDisplay)
|
||
}
|
||
return [...recipientAliases.entries()].map(([aliasNormalized, userId]) => ({
|
||
id: crypto.randomUUID(),
|
||
aliasDisplay: displayByNormalized.get(aliasNormalized) || aliasNormalized,
|
||
aliasNormalized,
|
||
...this.encryptRecipientUserId(userId),
|
||
}))
|
||
}
|
||
|
||
private decodeRecipientView(row: {
|
||
aliasDisplay: string
|
||
userIdCiphertext: string
|
||
userIdNonce: string
|
||
userIdAuthTag: string
|
||
}) {
|
||
return {
|
||
alias: row.aliasDisplay,
|
||
userId: this.decryptRecipientUserId(row),
|
||
}
|
||
}
|
||
|
||
private mapProjectRowToRuntimeEntry(row: {
|
||
code: string
|
||
name: string
|
||
secretCiphertext: string
|
||
secretNonce: string
|
||
secretAuthTag: string
|
||
allowedHostsJson: string
|
||
loginEnabled: boolean
|
||
loginCallbackUrl: string | null
|
||
loginAllowedRedirectPrefixesJson: string | null
|
||
isActive: boolean
|
||
recipients: Array<{ aliasNormalized: string; userIdCiphertext: string; userIdNonce: string; userIdAuthTag: string }>
|
||
}): ProjectCacheEntry {
|
||
const allowedHosts = parseJsonArray(row.allowedHostsJson, row.code, 'allowedHostsJson')
|
||
const loginPrefixes = row.loginAllowedRedirectPrefixesJson
|
||
? parseJsonArray(row.loginAllowedRedirectPrefixesJson, row.code, 'loginAllowedRedirectPrefixesJson')
|
||
: []
|
||
const recipientAliases = new Map<string, string>()
|
||
for (const recipient of row.recipients) recipientAliases.set(
|
||
recipient.aliasNormalized,
|
||
this.decryptRecipientUserId(recipient),
|
||
)
|
||
const config: ProjectConfig = {
|
||
code: row.code,
|
||
name: row.name,
|
||
secret: this.decryptSecret(row),
|
||
allowedHosts,
|
||
recipientAliases,
|
||
login: row.loginEnabled && row.loginCallbackUrl ? {
|
||
enabled: true,
|
||
callbackUrl: row.loginCallbackUrl,
|
||
allowedRedirectPathPrefixes: loginPrefixes,
|
||
} : undefined,
|
||
}
|
||
return { config, isActive: row.isActive }
|
||
}
|
||
|
||
async refresh() {
|
||
const rows = await prisma.notificationProject.findMany({
|
||
include: {
|
||
recipients: {
|
||
orderBy: { aliasDisplay: 'asc' },
|
||
},
|
||
},
|
||
orderBy: { code: 'asc' },
|
||
})
|
||
const next = new Map<string, ProjectCacheEntry>()
|
||
for (const row of rows) {
|
||
const entry = this.mapProjectRowToRuntimeEntry(row)
|
||
next.set(row.code, entry)
|
||
}
|
||
this.runtimeProjects = next
|
||
this.refreshedAt = new Date()
|
||
return {
|
||
refreshedAt: this.refreshedAt,
|
||
projectCount: [...next.values()].filter((item) => item.isActive).length,
|
||
}
|
||
}
|
||
|
||
getRuntimeProjectsSnapshot() {
|
||
const snapshot = new Map<string, ProjectConfig>()
|
||
for (const [code, entry] of this.runtimeProjects.entries()) {
|
||
if (entry.isActive) snapshot.set(code, entry.config)
|
||
}
|
||
return snapshot
|
||
}
|
||
|
||
getRefreshedAt() {
|
||
return this.refreshedAt
|
||
}
|
||
|
||
async getActiveProject(code: string) {
|
||
const entry = this.runtimeProjects.get(code)
|
||
return entry?.isActive ? entry.config : undefined
|
||
}
|
||
|
||
async getProjectByCode(code: string, includeRecipients = false) {
|
||
const row = await prisma.notificationProject.findUnique({
|
||
where: { code },
|
||
include: includeRecipients ? {
|
||
recipients: {
|
||
orderBy: { aliasDisplay: 'asc' },
|
||
},
|
||
} : undefined,
|
||
})
|
||
if (!row) throw new AdminApiError(404, 'PROJECT_NOT_FOUND', `项目 ${code} 不存在`)
|
||
return row
|
||
}
|
||
|
||
private async buildDeliverySummaryMap(projectCodes: string[]) {
|
||
const summaries = new Map<string, { lastStatus: string | null; lastSentAt: Date | null; recentFailedCount: number }>()
|
||
if (projectCodes.length === 0) return summaries
|
||
const recentSince = new Date(Date.now() - RECENT_FAILURE_WINDOW_DAYS * 24 * 60 * 60 * 1000)
|
||
const [recentFailed, latestDeliveries] = await prisma.$transaction([
|
||
prisma.notificationDelivery.groupBy({
|
||
by: ['projectCode'],
|
||
where: {
|
||
projectCode: { in: projectCodes },
|
||
status: 'failed',
|
||
createdAt: { gte: recentSince },
|
||
},
|
||
orderBy: { projectCode: 'asc' },
|
||
_count: { projectCode: true },
|
||
}),
|
||
prisma.notificationDelivery.findMany({
|
||
where: { projectCode: { in: projectCodes } },
|
||
orderBy: [{ createdAt: 'desc' }, { updatedAt: 'desc' }],
|
||
select: {
|
||
projectCode: true,
|
||
status: true,
|
||
sentAt: true,
|
||
},
|
||
}),
|
||
])
|
||
for (const code of projectCodes) {
|
||
summaries.set(code, { lastStatus: null, lastSentAt: null, recentFailedCount: 0 })
|
||
}
|
||
for (const row of recentFailed) {
|
||
const countPayload = row._count as { projectCode?: number } | undefined
|
||
summaries.set(row.projectCode, {
|
||
...(summaries.get(row.projectCode) || { lastStatus: null, lastSentAt: null, recentFailedCount: 0 }),
|
||
recentFailedCount: countPayload?.projectCode || 0,
|
||
})
|
||
}
|
||
for (const row of latestDeliveries) {
|
||
if (!summaries.has(row.projectCode)) continue
|
||
const current = summaries.get(row.projectCode)!
|
||
if (current.lastStatus !== null) continue
|
||
summaries.set(row.projectCode, {
|
||
...current,
|
||
lastStatus: row.status,
|
||
lastSentAt: row.sentAt,
|
||
})
|
||
}
|
||
return summaries
|
||
}
|
||
|
||
private async buildProjectViewFromRow(
|
||
row: Awaited<ReturnType<typeof prisma.notificationProject.findUniqueOrThrow>>,
|
||
options: { includeRecipients?: boolean; deliverySummaryMap?: Map<string, { lastStatus: string | null; lastSentAt: Date | null; recentFailedCount: number }> } = {},
|
||
) {
|
||
const runtimeEntry = this.mapProjectRowToRuntimeEntry({
|
||
...row,
|
||
recipients: 'recipients' in row && Array.isArray((row as { recipients?: unknown }).recipients)
|
||
? (row as { recipients: Array<{ aliasNormalized: string; userIdCiphertext: string; userIdNonce: string; userIdAuthTag: string }> }).recipients
|
||
: await prisma.notificationProjectRecipient.findMany({
|
||
where: { projectId: row.id },
|
||
orderBy: { aliasDisplay: 'asc' },
|
||
select: { aliasNormalized: true, userIdCiphertext: true, userIdNonce: true, userIdAuthTag: true },
|
||
}),
|
||
})
|
||
const recipientRows = 'recipients' in row && Array.isArray((row as { recipients?: unknown }).recipients)
|
||
? (row as { recipients: Array<{ aliasDisplay: string; userIdCiphertext: string; userIdNonce: string; userIdAuthTag: string }> }).recipients.map((item) => this.decodeRecipientView(item))
|
||
: await prisma.notificationProjectRecipient.findMany({
|
||
where: { projectId: row.id },
|
||
orderBy: { aliasDisplay: 'asc' },
|
||
select: { aliasDisplay: true, userIdCiphertext: true, userIdNonce: true, userIdAuthTag: true },
|
||
}).then((items) => items.map((item) => this.decodeRecipientView(item)))
|
||
const summary = options.deliverySummaryMap?.get(row.code) || { lastStatus: null, lastSentAt: null, recentFailedCount: 0 }
|
||
return buildProjectView({
|
||
code: row.code,
|
||
name: row.name,
|
||
isActive: row.isActive,
|
||
allowedHosts: [...runtimeEntry.config.allowedHosts],
|
||
recipients: recipientRows,
|
||
updatedAt: row.updatedAt,
|
||
deliverySummary: summary,
|
||
login: runtimeEntry.config.login,
|
||
includeRecipients: options.includeRecipients,
|
||
})
|
||
}
|
||
|
||
private async recordAudit(input: {
|
||
actor: AdminAuditActor
|
||
action: string
|
||
summary: string
|
||
targetProjectCode?: string
|
||
details?: unknown
|
||
}) {
|
||
await prisma.notificationAudit.create({
|
||
data: {
|
||
id: crypto.randomUUID(),
|
||
actorType: input.actor.actorType,
|
||
actorId: input.actor.actorId,
|
||
action: input.action,
|
||
targetProjectCode: input.targetProjectCode || null,
|
||
requestId: input.actor.requestId || null,
|
||
ipAddress: input.actor.ipAddress || null,
|
||
summary: input.summary,
|
||
detailsJson: createAuditDetailsJson(input.details),
|
||
},
|
||
})
|
||
}
|
||
|
||
private async loadExistingProjectForMutation(code: string) {
|
||
const row = await prisma.notificationProject.findUnique({
|
||
where: { code },
|
||
include: {
|
||
recipients: {
|
||
orderBy: { aliasDisplay: 'asc' },
|
||
},
|
||
},
|
||
})
|
||
if (!row) throw new AdminApiError(404, 'PROJECT_NOT_FOUND', `项目 ${code} 不存在`)
|
||
return row
|
||
}
|
||
|
||
async listProjects(includeRecipients = false) {
|
||
const rows = await prisma.notificationProject.findMany({
|
||
include: {
|
||
recipients: {
|
||
orderBy: { aliasDisplay: 'asc' },
|
||
},
|
||
},
|
||
orderBy: { code: 'asc' },
|
||
})
|
||
const summaryMap = await this.buildDeliverySummaryMap(rows.map((row) => row.code))
|
||
const projects = await Promise.all(rows.map((row) => this.buildProjectViewFromRow(row, {
|
||
includeRecipients,
|
||
deliverySummaryMap: summaryMap,
|
||
})))
|
||
return { projects }
|
||
}
|
||
|
||
async getProjectView(projectCode: string, includeRecipients = false) {
|
||
const row = await this.loadExistingProjectForMutation(projectCode)
|
||
const deliverySummaryMap = await this.buildDeliverySummaryMap([projectCode])
|
||
return {
|
||
project: await this.buildProjectViewFromRow(row, { includeRecipients, deliverySummaryMap }),
|
||
}
|
||
}
|
||
|
||
async createProject(input: ProjectUpsertInput, actor: AdminAuditActor) {
|
||
if (!clean(input.name)) throw new AdminApiError(422, 'PROJECT_INPUT_INVALID', 'name 不能为空')
|
||
const secret = createProjectSecret()
|
||
const requestedCode = clean(input.projectCode)
|
||
let normalized = normalizeProjectInput({
|
||
code: requestedCode || createProjectCode(),
|
||
name: input.name,
|
||
secret,
|
||
allowedHosts: input.allowedHosts || [],
|
||
recipients: input.recipients || [],
|
||
login: input.login,
|
||
})
|
||
const encrypted = this.encryptSecret(secret)
|
||
for (let attempt = 0; attempt < 5; attempt++) {
|
||
try {
|
||
await prisma.$transaction(async (tx) => {
|
||
const projectId = crypto.randomUUID()
|
||
await tx.notificationProject.create({
|
||
data: {
|
||
id: projectId,
|
||
code: normalized.code,
|
||
name: normalized.name,
|
||
...encrypted,
|
||
allowedHostsJson: JSON.stringify(normalized.allowedHosts),
|
||
loginEnabled: Boolean(normalized.login),
|
||
loginCallbackUrl: normalized.login?.callbackUrl || null,
|
||
loginAllowedRedirectPrefixesJson: normalized.login
|
||
? JSON.stringify(normalized.login.allowedRedirectPathPrefixes)
|
||
: null,
|
||
isActive: input.isActive !== false,
|
||
lastSecretRotatedAt: new Date(),
|
||
recipients: normalized.recipientAliases.size > 0 ? {
|
||
create: this.buildRecipientCreateRows(normalized.recipientAliases, input.recipients || []),
|
||
} : undefined,
|
||
},
|
||
})
|
||
await tx.notificationAudit.create({
|
||
data: {
|
||
id: crypto.randomUUID(),
|
||
actorType: actor.actorType,
|
||
actorId: actor.actorId,
|
||
action: 'project.create',
|
||
targetProjectCode: normalized.code,
|
||
requestId: actor.requestId || null,
|
||
ipAddress: actor.ipAddress || null,
|
||
summary: `创建项目 ${normalized.code}`,
|
||
detailsJson: createAuditDetailsJson({
|
||
allowedHosts: normalized.allowedHosts,
|
||
login: projectConfigToLoginPayload(normalized.login),
|
||
recipientCount: normalized.recipientAliases.size,
|
||
isActive: input.isActive !== false,
|
||
}),
|
||
},
|
||
})
|
||
})
|
||
await this.refresh()
|
||
return {
|
||
project: (await this.getProjectView(normalized.code, true)).project,
|
||
secret,
|
||
env: buildProjectEnv(normalized.code, secret),
|
||
}
|
||
} catch (error: any) {
|
||
if (clean(error?.code) !== 'P2002') throw error
|
||
if (requestedCode) {
|
||
throw new AdminApiError(409, 'PROJECT_CODE_CONFLICT', `项目 ${normalized.code} 已存在`)
|
||
}
|
||
normalized = normalizeProjectInput({
|
||
code: createProjectCode(),
|
||
name: input.name,
|
||
secret,
|
||
allowedHosts: input.allowedHosts || [],
|
||
recipients: input.recipients || [],
|
||
login: input.login,
|
||
})
|
||
}
|
||
}
|
||
throw new AdminApiError(503, 'PROJECT_CODE_GENERATION_FAILED', '自动生成项目代码失败,请稍后重试')
|
||
}
|
||
|
||
async updateProject(projectCode: string, input: ProjectUpdateInput, actor: AdminAuditActor) {
|
||
const current = await this.loadExistingProjectForMutation(projectCode)
|
||
const currentRuntime = this.mapProjectRowToRuntimeEntry(current)
|
||
const normalized = normalizeProjectInput({
|
||
code: current.code,
|
||
name: input.name ?? current.name,
|
||
secret: currentRuntime.config.secret,
|
||
allowedHosts: input.allowedHosts ?? currentRuntime.config.allowedHosts,
|
||
recipients: current.recipients.map((item) => ({
|
||
alias: item.aliasDisplay,
|
||
userId: this.decryptRecipientUserId(item),
|
||
})),
|
||
login: Object.prototype.hasOwnProperty.call(input, 'login')
|
||
? input.login
|
||
: currentRuntime.config.login,
|
||
})
|
||
await prisma.$transaction(async (tx) => {
|
||
await tx.notificationProject.update({
|
||
where: { code: projectCode },
|
||
data: {
|
||
name: normalized.name,
|
||
allowedHostsJson: JSON.stringify(normalized.allowedHosts),
|
||
loginEnabled: Boolean(normalized.login),
|
||
loginCallbackUrl: normalized.login?.callbackUrl || null,
|
||
loginAllowedRedirectPrefixesJson: normalized.login
|
||
? JSON.stringify(normalized.login.allowedRedirectPathPrefixes)
|
||
: null,
|
||
...(input.isActive === undefined ? {} : { isActive: input.isActive }),
|
||
},
|
||
})
|
||
await tx.notificationAudit.create({
|
||
data: {
|
||
id: crypto.randomUUID(),
|
||
actorType: actor.actorType,
|
||
actorId: actor.actorId,
|
||
action: 'project.update',
|
||
targetProjectCode: projectCode,
|
||
requestId: actor.requestId || null,
|
||
ipAddress: actor.ipAddress || null,
|
||
summary: `更新项目 ${projectCode}`,
|
||
detailsJson: createAuditDetailsJson({
|
||
name: normalized.name,
|
||
allowedHosts: normalized.allowedHosts,
|
||
login: projectConfigToLoginPayload(normalized.login),
|
||
isActive: input.isActive ?? current.isActive,
|
||
}),
|
||
},
|
||
})
|
||
})
|
||
await this.refresh()
|
||
return this.getProjectView(projectCode, true)
|
||
}
|
||
|
||
async replaceRecipients(projectCode: string, recipients: ProjectRecipientInput[], actor: AdminAuditActor) {
|
||
const current = await this.loadExistingProjectForMutation(projectCode)
|
||
const currentRuntime = this.mapProjectRowToRuntimeEntry(current)
|
||
const normalized = normalizeProjectInput({
|
||
code: current.code,
|
||
name: current.name,
|
||
secret: currentRuntime.config.secret,
|
||
allowedHosts: currentRuntime.config.allowedHosts,
|
||
recipients,
|
||
login: currentRuntime.config.login,
|
||
})
|
||
await prisma.$transaction(async (tx) => {
|
||
await tx.notificationProjectRecipient.deleteMany({ where: { projectId: current.id } })
|
||
if (normalized.recipientAliases.size > 0) {
|
||
await tx.notificationProjectRecipient.createMany({
|
||
data: this.buildRecipientCreateRows(normalized.recipientAliases, recipients).map((item) => ({
|
||
...item,
|
||
projectId: current.id,
|
||
})),
|
||
})
|
||
}
|
||
await tx.notificationAudit.create({
|
||
data: {
|
||
id: crypto.randomUUID(),
|
||
actorType: actor.actorType,
|
||
actorId: actor.actorId,
|
||
action: 'project.recipients.replace',
|
||
targetProjectCode: projectCode,
|
||
requestId: actor.requestId || null,
|
||
ipAddress: actor.ipAddress || null,
|
||
summary: `替换项目 ${projectCode} 收件人`,
|
||
detailsJson: createAuditDetailsJson({
|
||
recipientCount: normalized.recipientAliases.size,
|
||
recipients: [...normalized.recipientAliases.entries()].map(([alias, userId]) => ({ alias, userId })),
|
||
}),
|
||
},
|
||
})
|
||
})
|
||
await this.refresh()
|
||
return this.getProjectView(projectCode, true)
|
||
}
|
||
|
||
async rotateSecret(projectCode: string, actor: AdminAuditActor) {
|
||
await this.getProjectByCode(projectCode)
|
||
const secret = createProjectSecret()
|
||
const encrypted = this.encryptSecret(secret)
|
||
await prisma.$transaction(async (tx) => {
|
||
await tx.notificationProject.update({
|
||
where: { code: projectCode },
|
||
data: {
|
||
...encrypted,
|
||
lastSecretRotatedAt: new Date(),
|
||
},
|
||
})
|
||
await tx.notificationAudit.create({
|
||
data: {
|
||
id: crypto.randomUUID(),
|
||
actorType: actor.actorType,
|
||
actorId: actor.actorId,
|
||
action: 'project.rotate_secret',
|
||
targetProjectCode: projectCode,
|
||
requestId: actor.requestId || null,
|
||
ipAddress: actor.ipAddress || null,
|
||
summary: `轮换项目 ${projectCode} 密钥`,
|
||
detailsJson: createAuditDetailsJson({ rotatedAt: new Date().toISOString() }),
|
||
},
|
||
})
|
||
})
|
||
await this.refresh()
|
||
return {
|
||
project: (await this.getProjectView(projectCode, true)).project,
|
||
secret,
|
||
env: buildProjectEnv(projectCode, secret),
|
||
}
|
||
}
|
||
|
||
async revealSecret(projectCode: string, actor: AdminAuditActor) {
|
||
await this.getProjectByCode(projectCode)
|
||
let secret = ''
|
||
try {
|
||
const row = await prisma.notificationProject.findUnique({
|
||
where: { code: projectCode },
|
||
select: {
|
||
code: true,
|
||
secretCiphertext: true,
|
||
secretNonce: true,
|
||
secretAuthTag: true,
|
||
},
|
||
})
|
||
if (!row) throw new AdminApiError(404, 'PROJECT_NOT_FOUND', `项目 ${projectCode} 不存在`)
|
||
secret = this.decryptSecret(row)
|
||
} catch (error) {
|
||
if (error instanceof AdminApiError) throw error
|
||
throw new AdminApiError(503, 'PROJECT_SECRET_UNAVAILABLE', `项目 ${projectCode} 的密钥暂时不可读取,请检查加密配置`)
|
||
}
|
||
|
||
await this.recordAudit({
|
||
actor,
|
||
action: 'project.reveal_secret',
|
||
targetProjectCode: projectCode,
|
||
summary: `查看项目 ${projectCode} 当前密钥`,
|
||
details: {
|
||
revealedAt: new Date().toISOString(),
|
||
},
|
||
})
|
||
|
||
return {
|
||
projectCode,
|
||
project: (await this.getProjectView(projectCode, true)).project,
|
||
secret,
|
||
projectSecret: secret,
|
||
env: buildProjectEnv(projectCode, secret),
|
||
}
|
||
}
|
||
|
||
async deleteProject(projectCode: string, actor: AdminAuditActor) {
|
||
const current = await this.loadExistingProjectForMutation(projectCode)
|
||
const deliveryCount = await prisma.notificationDelivery.count({ where: { projectCode } })
|
||
await prisma.$transaction(async (tx) => {
|
||
await tx.notificationProject.delete({ where: { code: projectCode } })
|
||
await tx.notificationAudit.create({
|
||
data: {
|
||
id: crypto.randomUUID(),
|
||
actorType: actor.actorType,
|
||
actorId: actor.actorId,
|
||
action: 'project.delete',
|
||
targetProjectCode: projectCode,
|
||
requestId: actor.requestId || null,
|
||
ipAddress: actor.ipAddress || null,
|
||
summary: `删除项目 ${projectCode}`,
|
||
detailsJson: createAuditDetailsJson({
|
||
recipientCount: current.recipients.length,
|
||
deliveryCount,
|
||
}),
|
||
},
|
||
})
|
||
})
|
||
await this.refresh()
|
||
return { projectCode, deleted: true, deliveryCount }
|
||
}
|
||
|
||
async importFromEnvironment(actor: AdminAuditActor) {
|
||
const parsed = loadProjectConfigs()
|
||
if (parsed.size === 0) {
|
||
return {
|
||
importedProjectCodes: [] as string[],
|
||
skippedProjectCodes: [] as string[],
|
||
projectCount: this.getRuntimeProjectsSnapshot().size,
|
||
refreshedAt: this.getRefreshedAt(),
|
||
}
|
||
}
|
||
const existing = await prisma.notificationProject.findMany({
|
||
select: { code: true },
|
||
})
|
||
const existingCodes = new Set(existing.map((item) => item.code))
|
||
const importedProjectCodes: string[] = []
|
||
const skippedProjectCodes: string[] = []
|
||
await prisma.$transaction(async (tx) => {
|
||
for (const project of parsed.values()) {
|
||
if (existingCodes.has(project.code)) {
|
||
skippedProjectCodes.push(project.code)
|
||
continue
|
||
}
|
||
const secretPayload = this.encryptSecret(project.secret)
|
||
await tx.notificationProject.create({
|
||
data: {
|
||
id: crypto.randomUUID(),
|
||
code: project.code,
|
||
name: project.name,
|
||
...secretPayload,
|
||
allowedHostsJson: JSON.stringify(project.allowedHosts),
|
||
loginEnabled: Boolean(project.login),
|
||
loginCallbackUrl: project.login?.callbackUrl || null,
|
||
loginAllowedRedirectPrefixesJson: project.login
|
||
? JSON.stringify(project.login.allowedRedirectPathPrefixes)
|
||
: null,
|
||
isActive: true,
|
||
importedFromEnvAt: new Date(),
|
||
lastSecretRotatedAt: new Date(),
|
||
recipients: project.recipientAliases.size > 0 ? {
|
||
create: this.buildRecipientCreateRows(project.recipientAliases, [...project.recipientAliases.entries()].map(([alias, userId]) => ({
|
||
alias,
|
||
userId,
|
||
}))),
|
||
} : undefined,
|
||
},
|
||
})
|
||
importedProjectCodes.push(project.code)
|
||
}
|
||
await tx.notificationAudit.create({
|
||
data: {
|
||
id: crypto.randomUUID(),
|
||
actorType: actor.actorType,
|
||
actorId: actor.actorId,
|
||
action: 'runtime.import_env',
|
||
targetProjectCode: null,
|
||
requestId: actor.requestId || null,
|
||
ipAddress: actor.ipAddress || null,
|
||
summary: importedProjectCodes.length > 0
|
||
? `导入环境变量项目 ${importedProjectCodes.join(', ')}`
|
||
: '环境变量项目已存在,未覆盖',
|
||
detailsJson: createAuditDetailsJson({
|
||
importedProjectCodes,
|
||
skippedProjectCodes,
|
||
}),
|
||
},
|
||
})
|
||
})
|
||
const refreshed = await this.refresh()
|
||
return {
|
||
importedProjectCodes,
|
||
skippedProjectCodes,
|
||
projectCount: refreshed.projectCount,
|
||
refreshedAt: refreshed.refreshedAt,
|
||
}
|
||
}
|
||
|
||
async listRecipients(projectCode: string) {
|
||
const row = await this.loadExistingProjectForMutation(projectCode)
|
||
const recipients = row.recipients.map((item) => this.decodeRecipientView(item))
|
||
return {
|
||
recipients,
|
||
total: recipients.length,
|
||
}
|
||
}
|
||
|
||
async listDeliveries(input: {
|
||
projectCode?: string
|
||
status?: string
|
||
isTest?: boolean
|
||
take?: number
|
||
skip?: number
|
||
}) {
|
||
const where = {
|
||
...(input.projectCode ? { projectCode: input.projectCode } : {}),
|
||
...(input.status ? { status: input.status } : {}),
|
||
...(input.isTest === undefined ? {} : { isTest: input.isTest }),
|
||
}
|
||
const take = Math.min(100, Math.max(1, input.take || 20))
|
||
const skip = Math.max(0, input.skip || 0)
|
||
const [total, rows] = await prisma.$transaction([
|
||
prisma.notificationDelivery.count({ where }),
|
||
prisma.notificationDelivery.findMany({
|
||
where,
|
||
orderBy: [{ createdAt: 'desc' }, { updatedAt: 'desc' }],
|
||
skip,
|
||
take,
|
||
}),
|
||
])
|
||
return {
|
||
deliveries: rows.map((row) => ({
|
||
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,
|
||
})),
|
||
total,
|
||
}
|
||
}
|
||
|
||
async listAudits(input: {
|
||
projectCode?: string
|
||
action?: string
|
||
take?: number
|
||
skip?: number
|
||
}) {
|
||
const where = {
|
||
...(input.projectCode ? { targetProjectCode: input.projectCode } : {}),
|
||
...(input.action ? { action: input.action } : {}),
|
||
}
|
||
const take = Math.min(100, Math.max(1, input.take || 20))
|
||
const skip = Math.max(0, input.skip || 0)
|
||
const [total, rows] = await prisma.$transaction([
|
||
prisma.notificationAudit.count({ where }),
|
||
prisma.notificationAudit.findMany({
|
||
where,
|
||
orderBy: { createdAt: 'desc' },
|
||
skip,
|
||
take,
|
||
}),
|
||
])
|
||
return {
|
||
audits: rows.map((row) => ({
|
||
id: row.id,
|
||
actorType: row.actorType,
|
||
actorId: row.actorId,
|
||
action: row.action,
|
||
targetProjectCode: row.targetProjectCode,
|
||
requestId: row.requestId,
|
||
ipAddress: row.ipAddress,
|
||
summary: row.summary,
|
||
details: row.detailsJson ? JSON.parse(row.detailsJson) : null,
|
||
createdAt: row.createdAt,
|
||
})),
|
||
total,
|
||
}
|
||
}
|
||
|
||
async recordRuntimeRefresh(actor: AdminAuditActor) {
|
||
const refreshed = await this.refresh()
|
||
await this.recordAudit({
|
||
actor,
|
||
action: 'runtime.refresh',
|
||
summary: '刷新运行时项目缓存',
|
||
details: {
|
||
projectCount: refreshed.projectCount,
|
||
refreshedAt: refreshed.refreshedAt.toISOString(),
|
||
},
|
||
})
|
||
return refreshed
|
||
}
|
||
|
||
async createSystemAudit(action: string, summary: string, details?: unknown) {
|
||
await this.recordAudit({
|
||
actor: { actorType: 'system', actorId: 'bootstrap' },
|
||
action,
|
||
summary,
|
||
details,
|
||
})
|
||
}
|
||
|
||
async resolveProjectConfigForCreate(projectCode: string) {
|
||
const entry = await this.getActiveProject(projectCode)
|
||
if (!entry) throw new AdminApiError(404, 'PROJECT_NOT_FOUND', `项目 ${projectCode} 不存在或已停用`)
|
||
return entry
|
||
}
|
||
|
||
async maskProjectRecipientForAudit(projectCode: string, recipientAlias: string | undefined, recipientUserId: string | undefined) {
|
||
const recipient = clean(recipientAlias) || clean(recipientUserId)
|
||
return recipient ? {
|
||
projectCode,
|
||
recipient: clean(recipientAlias) ? normalizeRecipientAlias(recipientAlias) : maskUserId(clean(recipientUserId)),
|
||
} : { projectCode }
|
||
}
|
||
}
|