YouYiDianNotificationCenter/src/admin-project-registry.ts

1044 lines
35 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

import 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 }
}
}