// services/api/src/admin/usage.service.ts —— 用量汇总与阈值管理(P4a Task 5, // A23 后台侧)。契约权威:openapi /admin/usage/summary|thresholds // (getUsageSummary/updateUsageThresholds)。仅 admin(AuthUser.role,403 // AUTH_FORBIDDEN)。 // 聚合/评估核心在 @photo/db-cn usage.ts(worker 检查点共用);本服务只做: // · summary:聚合 + 相册标题(defaultLocale 解析)+ 阈值 + 告警列表 // (活跃在前、其后近期已解除,合计上限 100——契约「createdAt 倒序,含已 // 解除」在「未解决优先呈现」运营语义下的落地); // · updateThresholds:单行 upsert(缺省字段保持不变、显式 null 清除)+ 契约 // 「修改后即刻重新评估」——评估在 upsert 提交后自开事务执行(失败上抛: // 阈值已落库是事实,500 提示管理员重试;重试幂等)。 import { HttpStatus, Injectable } from '@nestjs/common'; import { ErrorCodes, type components } from '@photo/contracts'; import { computeUsageAggregates, evaluateUsage, USAGE_ALERTS_LIST_LIMIT, USAGE_THRESHOLDS_SINGLETON_ID, } from '@photo/db-cn'; import { ApiException } from '../api-exception'; import type { AuthUser } from '../auth/session.guard'; import { PrismaService } from '../prisma/prisma.service'; import type { UsageThresholdsUpdateDto } from './usage.dto'; export type UsageSummaryResponse = components['schemas']['UsageSummary']; export type UsageThresholdsResponse = components['schemas']['UsageThresholds']; export type UsageAlbumItem = components['schemas']['UsageAlbum']; export type UsageAlertItem = components['schemas']['UsageAlert']; function requireAdmin(user: AuthUser): void { if (user.role !== 'admin') { throw new ApiException( ErrorCodes.AUTH_FORBIDDEN, '仅管理员可管理用量阈值与查看用量统计', HttpStatus.FORBIDDEN, ); } } @Injectable() export class UsageService { constructor(private readonly prisma: PrismaService) {} /** 用量汇总(实时 SUM + 阈值 + 告警;相册按 totalBytes 降序,同值按 id 稳定)。 */ async summary(user: AuthUser): Promise { requireAdmin(user); const [albums, translations, thresholds, aggregates] = await Promise.all([ this.prisma.albums.findMany({ select: { id: true, defaultLocale: true }, orderBy: { id: 'asc' }, }), this.prisma.album_translations.findMany({ orderBy: { locale: 'asc' }, // 标题回退取序稳定 }), this.prisma.usage_thresholds.findUnique({ where: { id: USAGE_THRESHOLDS_SINGLETON_ID }, }), computeUsageAggregates(this.prisma), ]); const titlesByAlbum = new Map>(); for (const t of translations) { let titles = titlesByAlbum.get(t.albumId); if (titles === undefined) { titles = new Map(); titlesByAlbum.set(t.albumId, titles); } titles.set(t.locale, t.title); } const albumItems: UsageAlbumItem[] = albums.map((album) => { const agg = aggregates.perAlbum.get(album.id); const titles = titlesByAlbum.get(album.id); return { albumId: album.id, // defaultLocale 翻译缺失(脏数据)回退任一翻译,再回退空串——单册脏 // 数据不拖垮整页(同成员/照片列表先例)。 title: titles?.get(album.defaultLocale) ?? [...(titles?.values() ?? [])][0] ?? '', photoCount: agg?.photoCount ?? 0, originalBytes: agg?.originalBytes ?? 0, variantBytes: agg?.variantBytes ?? 0, totalBytes: agg?.totalBytes ?? 0, }; }); albumItems.sort((a, b) => b.totalBytes - a.totalBytes || (a.albumId < b.albumId ? -1 : 1)); const activeAlerts = await this.prisma.usage_alerts.findMany({ where: { resolvedAt: null }, orderBy: [{ createdAt: 'desc' }, { id: 'desc' }], take: USAGE_ALERTS_LIST_LIMIT, }); // 已解除告警按「近期解除优先」补位(resolvedAt 倒序,合计不超上限)。 const resolvedAlerts = activeAlerts.length >= USAGE_ALERTS_LIST_LIMIT ? [] : await this.prisma.usage_alerts.findMany({ where: { resolvedAt: { not: null } }, orderBy: [{ resolvedAt: 'desc' }, { id: 'desc' }], take: USAGE_ALERTS_LIST_LIMIT - activeAlerts.length, }); return { albums: albumItems, totals: { photoCount: aggregates.totals.photoCount, totalBytes: aggregates.totals.totalBytes, }, thresholds: { storageBytes: thresholds?.storageBytes == null ? null : Number(thresholds.storageBytes), perAlbumBytes: thresholds?.perAlbumBytes == null ? null : Number(thresholds.perAlbumBytes), }, alerts: [...activeAlerts, ...resolvedAlerts].map(toUsageAlertItem), }; } /** * 阈值更新(部分更新语义)+ 即刻重新评估(超阈产生新告警/阈值上调解除旧 * 告警)。评估事务在 upsert 提交后执行。 */ async updateThresholds( user: AuthUser, dto: UsageThresholdsUpdateDto, ): Promise { requireAdmin(user); const current = await this.prisma.usage_thresholds.findUnique({ where: { id: USAGE_THRESHOLDS_SINGLETON_ID }, }); const nextStorage = dto.storageBytes === undefined ? (current?.storageBytes ?? null) : dto.storageBytes === null ? null : BigInt(dto.storageBytes); const nextPerAlbum = dto.perAlbumBytes === undefined ? (current?.perAlbumBytes ?? null) : dto.perAlbumBytes === null ? null : BigInt(dto.perAlbumBytes); await this.prisma.usage_thresholds.upsert({ where: { id: USAGE_THRESHOLDS_SINGLETON_ID }, update: { storageBytes: nextStorage, perAlbumBytes: nextPerAlbum }, create: { id: USAGE_THRESHOLDS_SINGLETON_ID, storageBytes: nextStorage, perAlbumBytes: nextPerAlbum, }, }); await this.prisma.$transaction((tx) => evaluateUsage(tx)); return { storageBytes: nextStorage === null ? null : Number(nextStorage), perAlbumBytes: nextPerAlbum === null ? null : Number(nextPerAlbum), }; } } function toUsageAlertItem(row: { id: string; kind: string; albumId: string | null; message: string; createdAt: Date; resolvedAt: Date | null; }): UsageAlertItem { return { id: row.id, kind: row.kind as UsageAlertItem['kind'], albumId: row.albumId, message: row.message, createdAt: row.createdAt.toISOString(), resolvedAt: row.resolvedAt === null ? null : row.resolvedAt.toISOString(), }; }