Files
photo-live/services/api/src/admin/usage.service.ts
T
浅念 4937eeecff feat(api): usage summary with threshold alerts
P4a Task 5(A23 后台侧):用量统计与阈值告警后端全链。

- cn 迁移(校对手决 utf8mb4_0900_bin):usage_thresholds 单行配置表
  (迁移内种子行,storageBytes/perAlbumBytes BigInt?,null=维度停用)+
  usage_alerts 告警账本(kind storage|perAlbum、albumId?、resolvedAt?
  索引)。「同 kind+albumId 活跃告警唯一」为应用级约束(MySQL 无部分
  唯一索引):评估事务以单行配置 FOR UPDATE 作串行化锚点闭合竞态。
- 共享核心 packages/db-cn/src/usage.ts:computeUsageAggregates(非
  deleted 口径实时 SUM:photos groupBy + 变体 JOIN raw)+ evaluateUsage
  (storage/perAlbum 两维超阈 ensure、回落/停用 resolve、去重)+
  evaluateUsageSafe(自开事务、失败仅记日志不拖垮业务主流程)。api 与
  worker 共用(放置原因见文件头)。
- api:GET /admin/usage/summary(相册聚合 + totals + 阈值 + 告警:活跃
  在前、近期解除补位,合计 100)与 PUT /admin/usage/thresholds(缺省
  保持/显式 null 清除 + 契约「即刻重新评估」);仅 admin。BigInt 运输
  决策:库内 BIGINT、JSON 出入口 Number() 收敛(2^53≈9PB 上限,DTO 以
  MAX_SAFE_INTEGER 拒绝超界输入杜绝静默失真)。
- 检查点(提交后评估):worker 发布事务后(仅真实 published)、worker
  水印任务完成后、api photos 可见性迁移(PATCH/软删除)与批量隐藏后、
  阈值 PUT 后。
- tests:usage e2e +6(汇总数学/降序/标题、403/401、400 校验矩阵、
  perAlbum 告警产生-去重-上调解除、storage 告警-null 清除解除、hidden
  计入+deleted 回落且穿过 api 检查点);两侧 reset 增补 usage 表截断 +
  单行重种。全量回归:api e2e 214/214、api 单测 51/51、worker 单测
  14/14 + e2e 13/13。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-20 19:01:41 +08:00

178 lines
6.6 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.
// 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<UsageSummaryResponse> {
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<string, Map<string, string>>();
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<UsageThresholdsResponse> {
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(),
};
}