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>
This commit is contained in:
浅念
2026-09-20 19:01:41 +08:00
parent 2bb6f291d3
commit 4937eeecff
14 changed files with 1063 additions and 8 deletions
+8 -5
View File
@@ -1,12 +1,15 @@
// services/api/src/admin/admin.module.ts —— 管理辅助域模块(P1b Task 4)。
// 装配 UsersLookupController/UsersLookupService;仅依赖 @Global 的 PrismaService
// (SessionGuard 同样只依赖 Prisma),无需导入其他功能模块。
// services/api/src/admin/admin.module.ts —— 管理辅助域模块(P1b Task 4 起步;
// P4a Task 5 增补用量统计/阈值告警端点)。装配 UsersLookupController/
// UsersLookupService 与 UsageController/UsageService;均仅依赖 @Global 的
// PrismaService(SessionGuard 同样只依赖 Prisma),无需导入其他功能模块。
import { Module } from '@nestjs/common';
import { UsageController } from './usage.controller';
import { UsageService } from './usage.service';
import { UsersLookupController } from './users-lookup.controller';
import { UsersLookupService } from './users-lookup.service';
@Module({
controllers: [UsersLookupController],
providers: [UsersLookupService],
controllers: [UsersLookupController, UsageController],
providers: [UsersLookupService, UsageService],
})
export class AdminModule {}
@@ -0,0 +1,42 @@
// services/api/src/admin/usage.controller.ts —— /admin/usage 端点(P4a Task 5,
// A23 后台侧)。方法名与契约 operationId 一一对应(getUsageSummary/
// updateUsageThresholds,tag [admin])。控制器级 SessionGuard:两端点全需认证;
// admin 判定在服务层(AuthUser.role,403 AUTH_FORBIDDEN)。控制器只做认证用户
// 与 DTO 透传;DTO 值导入(emitDecoratorMetadata 设计期元数据)。
import {
Body,
Controller,
Get,
Put,
Req,
UseGuards,
} from '@nestjs/common';
import { userOf } from '../albums/albums.controller';
import { AuthenticatedRequest, SessionGuard } from '../auth/session.guard';
import { UsageThresholdsUpdateDto } from './usage.dto';
import {
UsageService,
type UsageSummaryResponse,
type UsageThresholdsResponse,
} from './usage.service';
@Controller('admin/usage')
@UseGuards(SessionGuard)
export class UsageController {
constructor(private readonly usageService: UsageService) {}
/** 用量汇总:按相册聚合 + 阈值 + 告警列表。 */
@Get('summary')
getUsageSummary(@Req() req: AuthenticatedRequest): Promise<UsageSummaryResponse> {
return this.usageService.summary(userOf(req));
}
/** 阈值部分更新(缺省保持、null 清除)+ 即刻重新评估。 */
@Put('thresholds')
updateUsageThresholds(
@Body() dto: UsageThresholdsUpdateDto,
@Req() req: AuthenticatedRequest,
): Promise<UsageThresholdsResponse> {
return this.usageService.updateThresholds(userOf(req), dto);
}
}
+27
View File
@@ -0,0 +1,27 @@
// services/api/src/admin/usage.dto.ts —— PUT /admin/usage/thresholds 入参 DTO
// (P4a Task 5)。契约 UsageThresholdsUpdate:字段缺省=保持不变,显式 null=
// 清除该阈值(维度停用)。class-validator @IsOptional 对 null 同样放行(值
// 原样保留在 DTO 实例上),缺省/null/给值三态可辨。
// BigInt 运输决策(对齐 packages/db-cn/src/usage.ts 文件头):契约 int64 经
// openapi-typescript 生成 number;库内 BIGINT。请求侧以 MAX_SAFE_INTEGER 为
// 上界——超 2^53 的 JSON 数值在 JS 已失真,宁可 400 不静默落错值(真实照片
// 库量级 ≪ 9PB,上界不构成实际限制)。
import { IsInt, IsOptional, Max, Min } from 'class-validator';
/** 契约 minimum: 0;上界见文件头(MAX_SAFE_INTEGER = 2^53-1)。 */
const BYTES_MIN = 0;
const BYTES_MAX = Number.MAX_SAFE_INTEGER;
export class UsageThresholdsUpdateDto {
@IsOptional()
@IsInt()
@Min(BYTES_MIN)
@Max(BYTES_MAX)
storageBytes?: number | null;
@IsOptional()
@IsInt()
@Min(BYTES_MIN)
@Max(BYTES_MAX)
perAlbumBytes?: number | null;
}
+177
View File
@@ -0,0 +1,177 @@
// 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(),
};
}