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> 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 = {} for (const [key, item] of Object.entries(value as Record)) { 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() 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, inputRecipients: ProjectRecipientInput[]) { const displayByNormalized = new Map() 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() 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() 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() 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() 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>, options: { includeRecipients?: boolean; deliverySummaryMap?: Map } = {}, ) { 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 } } }