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
+30 -1
View File
@@ -19,8 +19,12 @@
// + entityVersion 自增 + 事务内 outbox 事件(P2 副本按 entityVersion 单调消费)
// + audit。幂等前态(同值 PATCH / 重复 DELETE)直接回显当前记录,不产生
// 第二次事件/审计(entityVersion 不动——副本侧按版本去重的不变量)。
import { Injectable } from '@nestjs/common';
// P4a Task 5 检查点:可见性真实迁移(transition/hideBatch)提交后评估用量
// 阈值告警(deleted 不计用量——软删除可能解除告警;hidden 仍计)。评估在
// 自开事务执行且失败只记日志,绝不影响可见性操作本身(见 evaluateUsageSafe)。
import { Injectable, Logger } from '@nestjs/common';
import { ErrorCodes, type components } from '@photo/contracts';
import { evaluateUsageSafe } from '@photo/db-cn';
import type { Prisma } from '@photo/db-cn';
import type { AuthUser } from '../auth/session.guard';
import { PermissionsService } from '../albums/permissions.service';
@@ -72,6 +76,8 @@ function toPhotoRecord(row: PhotoRow): PhotoRecord {
@Injectable()
export class PhotosService {
private readonly logger = new Logger(PhotosService.name);
constructor(
private readonly prisma: PrismaService,
private readonly permissions: PermissionsService,
@@ -232,6 +238,17 @@ export class PhotosService {
for (const photoId of photoIds) {
results.push(await this.hideOne(user, albumId, photoId));
}
// P4a Task 5 检查点:批量后评估一次(逐项不重复评估——隐藏不改存储,
// 检查点统一挂在可见性动作完成后;失败只记日志)。
await evaluateUsageSafe(this.prisma, (err) =>
this.logger.warn(
{
err: err instanceof Error ? err.message : String(err),
albumId,
},
'用量告警评估失败(批量隐藏后)',
),
);
return { results };
}
@@ -483,6 +500,18 @@ export class PhotosService {
target,
);
}
// P4a Task 5 检查点:真实迁移提交后评估(deleted 出存储口径、恢复发布回
// 计数口径都可能翻转告警状态);上方败方/幂等路径无状态变化,不评估。
await evaluateUsageSafe(this.prisma, (err) =>
this.logger.warn(
{
err: err instanceof Error ? err.message : String(err),
photoId: photo.id,
target,
},
'用量告警评估失败(可见性迁移后)',
),
);
return toPhotoRecord(updatedRow);
}
}