Files
photo-live/services/api/test/usage.e2e-spec.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

419 lines
15 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/test/usage.e2e-spec.ts —— P4a Task 5 用量统计与阈值告警 e2e。
// 真实 AppCnModule × MySQL(cn 单侧——用量域不涉 ov/同步/MinIO 对象);照片
// 直种 + 直改 bytes 伪造超阈(避免真实上传 GB 级对象),评估经 PUT thresholds
// 的「即刻重新评估」契约语义触发。覆盖:
// ① 汇总形状与数学:相册聚合(原图/变体/合计/照片数)+ 全库 totals +
// totalBytes 降序 + 标题 defaultLocale 解析 + 缺省阈值 null/null;
// ② 访问控制:staff GET/PUT 403 AUTH_FORBIDDEN;未认证 401;
// ③ 入参校验:负值/小数/字符串/超 2^53 → 400 VALIDATION_FAILED;空体=全保持;
// ④ perAlbum 告警全链(A23):直改 bytes 超阈 → PUT → summary 活跃告警
// (kind/albumId/message 含字节数与相册标题)→ 再次评估去重(活跃唯一)
// → 阈值上调 → resolvedAt 置位(后台可见已解除告警);
// ⑤ storage 告警:全库 totals 超阈 → 活跃(albumId=null,message 含全库
// 字节数)→ 显式 null 清除维度 → 旧告警解除;
// ⑥ 计数口径 + api 侧检查点:hidden 计入用量(PATCH 隐藏后 totals 不变,
// 穿过可见性迁移检查点);deleted 不计(API 软删除后 totals 回落,穿过
// 软删除检查点)。
import { randomBytes } from 'node:crypto';
import { NestFactory } from '@nestjs/core';
import { type NestExpressApplication } from '@nestjs/platform-express';
import request from 'supertest';
import { PrismaClient } from '@photo/db-cn';
import { ErrorCodes } from '@photo/contracts';
import { AppCnModule } from '../src/app-cn.module';
import { hashSessionToken } from '../src/auth/session.guard';
import { configureApp } from '../src/shared/configure-app';
import { resetDb } from './reset';
jest.setTimeout(120_000);
/** cuid 形状 ID(直种照片用,同 batch-ops 约定)。 */
function cuidLike(alias: string): string {
return `c${Date.now().toString(36)}${alias}${randomBytes(6).toString('hex')}`;
}
describe('usage summary + threshold alerts (e2e)', () => {
let cnApp: NestExpressApplication;
let cnBase: string;
let prismaCn: PrismaClient;
let adminId: string;
let ownerAId: string;
let staffXId: string;
let adminToken: string;
let ownerAToken: string;
let staffXToken: string;
let albumAId: string;
let albumBId: string;
/** 直种照片 id(④ 超阈伪造与 ⑥ 软删除用)。 */
let pa1Id: string;
let pa4Id: string;
/** 直种照片(无真实对象:本 spec 只断言聚合/告警,不读图)。 */
async function seedPhoto(
alias: string,
albumId: string,
opts: {
bytes: number;
visibility: 'published' | 'hidden' | 'unpublished';
variants: { variant: 'thumb' | 'browse'; bytes: number }[];
},
): Promise<string> {
const photoId = cuidLike(alias);
await prismaCn.photos.create({
data: {
id: photoId,
albumId,
uploaderId: ownerAId,
objectKey: `usage-test/originals/${photoId}.jpg`,
bytes: opts.bytes,
width: 800,
height: 600,
uploadedAt: new Date(),
publishedAt:
opts.visibility === 'unpublished' ? null : new Date(),
processingState: 'ready',
visibility: opts.visibility,
entityVersion: 2,
},
});
for (const v of opts.variants) {
await prismaCn.photo_variants.create({
data: {
photoId,
variant: v.variant,
objectKey: `usage-test/variants/${photoId}/${v.variant}.jpg`,
bytes: v.bytes,
version: 1,
},
});
}
return photoId;
}
function getSummary(token: string) {
return request(cnBase)
.get('/v1/admin/usage/summary')
.set('Authorization', `Bearer ${token}`);
}
function putThresholds(token: string, body: object) {
return request(cnBase)
.put('/v1/admin/usage/thresholds')
.set('Authorization', `Bearer ${token}`)
.send(body);
}
beforeAll(async () => {
await resetDb();
cnApp = await NestFactory.create<NestExpressApplication>(AppCnModule, {
rawBody: true,
});
configureApp(cnApp);
await cnApp.listen(0);
cnBase = await cnApp.getUrl();
prismaCn = new PrismaClient();
const mkUser = (account: string, role: 'staff' | 'admin') =>
prismaCn.users.create({
data: { account, role, passwordHash: 'x', displayName: account },
select: { id: true },
});
adminId = (await mkUser('usage-admin', 'admin')).id;
ownerAId = (await mkUser('usage-owner', 'staff')).id;
staffXId = (await mkUser('usage-staff-x', 'staff')).id;
const mkToken = async (userId: string): Promise<string> => {
const token = randomBytes(32).toString('hex');
await prismaCn.sessions.create({
data: {
userId,
tokenHash: hashSessionToken(token),
expiresAt: new Date(Date.now() + 3_600_000),
},
});
return token;
};
adminToken = await mkToken(adminId);
ownerAToken = await mkToken(ownerAId);
staffXToken = await mkToken(staffXId);
const mkAlbum = async (title: string) => {
const res = await request(cnBase)
.post('/v1/albums')
.set('Authorization', `Bearer ${ownerAToken}`)
.send({
translations: [{ locale: 'zh-CN', title }],
defaultLocale: 'zh-CN',
startAt: '2026-10-01T10:00:00.000Z',
});
expect(res.status).toBe(201);
return res.body.id as string;
};
albumAId = await mkAlbum('用量相册甲');
albumBId = await mkAlbum('用量相册乙');
// 相册 A:published(1000, thumb100+browse250) + hidden(500, browse50) +
// unpublished(300, 无变体) + published(200, 无变体——⑥ 软删除用)。
// 相册 B:published(100)。① 的期望值据此推算。
pa1Id = await seedPhoto('usagePa1', albumAId, {
bytes: 1000,
visibility: 'published',
variants: [
{ variant: 'thumb', bytes: 100 },
{ variant: 'browse', bytes: 250 },
],
});
await seedPhoto('usagePa2', albumAId, {
bytes: 500,
visibility: 'hidden',
variants: [{ variant: 'browse', bytes: 50 }],
});
await seedPhoto('usagePa3', albumAId, {
bytes: 300,
visibility: 'unpublished',
variants: [],
});
pa4Id = await seedPhoto('usagePa4', albumAId, {
bytes: 200,
visibility: 'published',
variants: [],
});
await seedPhoto('usagePb1', albumBId, {
bytes: 100,
visibility: 'published',
variants: [],
});
});
afterAll(async () => {
await cnApp.close();
await prismaCn.$disconnect();
});
it('① 汇总形状与数学:相册聚合/totals/降序/标题/缺省阈值/空告警', async () => {
const res = await getSummary(adminToken);
expect(res.status).toBe(200);
expect(Object.keys(res.body).sort()).toEqual([
'albums',
'alerts',
'thresholds',
'totals',
]);
// 相册条目按 totalBytes 降序(A 2400 > B 100)。
expect(res.body.albums).toHaveLength(2);
expect(res.body.albums[0]).toEqual({
albumId: albumAId,
title: '用量相册甲',
photoCount: 4,
originalBytes: 2000,
variantBytes: 400,
totalBytes: 2400,
});
expect(res.body.albums[1]).toEqual({
albumId: albumBId,
title: '用量相册乙',
photoCount: 1,
originalBytes: 100,
variantBytes: 0,
totalBytes: 100,
});
expect(res.body.totals).toEqual({ photoCount: 5, totalBytes: 2500 });
expect(res.body.thresholds).toEqual({
storageBytes: null,
perAlbumBytes: null,
});
expect(res.body.alerts).toEqual([]);
});
it('② 访问控制:staff 403;未认证 401', async () => {
const staffGet = await getSummary(staffXToken);
expect(staffGet.status).toBe(403);
expect(staffGet.body.code).toBe(ErrorCodes.AUTH_FORBIDDEN);
const staffPut = await putThresholds(staffXToken, {
perAlbumBytes: 1000,
});
expect(staffPut.status).toBe(403);
expect(staffPut.body.code).toBe(ErrorCodes.AUTH_FORBIDDEN);
const anon = await request(cnBase).get('/v1/admin/usage/summary');
expect(anon.status).toBe(401);
expect(anon.body.code).toBe(ErrorCodes.AUTH_TOKEN_EXPIRED);
});
it('③ 入参校验:负值/小数/字符串/超 2^53 → 400;空体 = 全保持', async () => {
for (const bad of [-1, 1.5, 'abc', 9007199254740992]) {
const res = await putThresholds(adminToken, { storageBytes: bad });
expect(res.status).toBe(400);
expect(res.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
}
const negative = await putThresholds(adminToken, { perAlbumBytes: -5 });
expect(negative.status).toBe(400);
expect(negative.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
// 空体:两维缺省=保持(仍为 null)。
const empty = await putThresholds(adminToken, {});
expect(empty.status).toBe(200);
expect(empty.body).toEqual({ storageBytes: null, perAlbumBytes: null });
});
it('④ perAlbum 告警:超阈产生(含字节数/标题)→ 去重 → 上调解除', async () => {
// 直改 DB 伪造超阈(避免上传 GB 级真实对象)。
await prismaCn.photos.update({
where: { id: pa1Id },
data: { bytes: 10_000_000 },
});
// 相册 A 合计 = 10_000_000 + 500 + 300 + 200(原图)+ 400(变体)= 10_001_400。
const put = await putThresholds(adminToken, { perAlbumBytes: 5_000_000 });
expect(put.status).toBe(200);
expect(put.body).toEqual({ storageBytes: null, perAlbumBytes: 5_000_000 });
const res = await getSummary(adminToken);
expect(res.status).toBe(200);
expect(res.body.thresholds).toEqual({
storageBytes: null,
perAlbumBytes: 5_000_000,
});
const active = res.body.alerts.filter(
(a: { resolvedAt: string | null }) => a.resolvedAt === null,
);
expect(active).toHaveLength(1);
expect(active[0].kind).toBe('perAlbum');
expect(active[0].albumId).toBe(albumAId);
expect(active[0].message).toContain('用量相册甲');
// 字面量经模板插值为无分隔符十进制(10001400 = 10_001_400)。
expect(active[0].message).toContain('10001400');
expect(active[0].message).toContain('5000000');
// 去重:再次评估(PUT storage 维度触发)不产生第二条活跃告警。
const reevaluate = await putThresholds(adminToken, {
storageBytes: 2_000_000_000,
});
expect(reevaluate.status).toBe(200);
expect(
await prismaCn.usage_alerts.count({
where: { kind: 'perAlbum', albumId: albumAId, resolvedAt: null },
}),
).toBe(1);
// 阈值上调超过实际用量 → 旧告警 resolvedAt 置位,活跃清空。
const raise = await putThresholds(adminToken, { perAlbumBytes: 20_000_000 });
expect(raise.status).toBe(200);
expect(raise.body).toEqual({
storageBytes: 2_000_000_000,
perAlbumBytes: 20_000_000,
});
expect(
await prismaCn.usage_alerts.count({
where: { kind: 'perAlbum', resolvedAt: null },
}),
).toBe(0);
const resolvedRow = await prismaCn.usage_alerts.findFirstOrThrow({
where: { kind: 'perAlbum', albumId: albumAId },
});
expect(resolvedRow.resolvedAt).not.toBeNull();
// summary 仍可见已解除告警(含 resolvedAt)。
const after = await getSummary(adminToken);
const resolvedShown = after.body.alerts.find(
(a: { kind: string }) => a.kind === 'perAlbum',
);
expect(resolvedShown.resolvedAt).not.toBeNull();
});
it('⑤ storage 告警:totals 超阈产生(albumId=null)→ null 清除维度解除', async () => {
// 全库 totals = 10_001_400 + 100(相册 B)= 10_001_500 > 5_000_000。
const put = await putThresholds(adminToken, { storageBytes: 5_000_000 });
expect(put.status).toBe(200);
expect(put.body).toEqual({
storageBytes: 5_000_000,
perAlbumBytes: 20_000_000,
});
const res = await getSummary(adminToken);
const active = res.body.alerts.filter(
(a: { resolvedAt: string | null }) => a.resolvedAt === null,
);
expect(active).toHaveLength(1);
expect(active[0].kind).toBe('storage');
expect(active[0].albumId).toBeNull();
// 全库 totals 10001500 = 10_001_500(无分隔符十进制)。
expect(active[0].message).toContain('10001500');
expect(active[0].message).toContain('5000000');
// 显式 null 清除维度(契约:清除后该维度停用)→ 活跃告警解除。
const clear = await putThresholds(adminToken, { storageBytes: null });
expect(clear.status).toBe(200);
expect(clear.body).toEqual({
storageBytes: null,
perAlbumBytes: 20_000_000,
});
expect(
await prismaCn.usage_alerts.count({
where: { kind: 'storage', resolvedAt: null },
}),
).toBe(0);
const resolvedRow = await prismaCn.usage_alerts.findFirstOrThrow({
where: { kind: 'storage' },
});
expect(resolvedRow.resolvedAt).not.toBeNull();
});
it('⑥ 口径与 api 检查点:hidden 计入(PATCH 后不变);deleted 不计(软删后回落)', async () => {
// 恢复 pa1 字节并清空两维阈值(后续断言回到可控基线)。
await prismaCn.photos.update({
where: { id: pa1Id },
data: { bytes: 1000 },
});
const reset = await putThresholds(adminToken, {
storageBytes: null,
perAlbumBytes: null,
});
expect(reset.body).toEqual({ storageBytes: null, perAlbumBytes: null });
// PATCH 隐藏(穿过可见性迁移检查点):hidden 仍计用量——totals 不变。
const hide = await request(cnBase)
.patch(`/v1/photos/${pa1Id}`)
.set('Authorization', `Bearer ${ownerAToken}`)
.send({ visibility: 'hidden' });
expect(hide.status).toBe(200);
expect(hide.body.visibility).toBe('hidden');
const duringHidden = await getSummary(adminToken);
const albumADuring = (
duringHidden.body.albums as { albumId: string }[]
).find((a) => a.albumId === albumAId);
expect(albumADuring).toMatchObject({
photoCount: 4,
originalBytes: 2000,
variantBytes: 400,
totalBytes: 2400,
});
// 软删除(穿过软删除检查点):deleted 不计——相册 A 与全库回落。
const del = await request(cnBase)
.delete(`/v1/photos/${pa4Id}`)
.set('Authorization', `Bearer ${ownerAToken}`);
expect(del.status).toBe(204);
const after = await getSummary(adminToken);
const albumAAfter = (
after.body.albums as { albumId: string }[]
).find((a) => a.albumId === albumAId);
expect(albumAAfter).toMatchObject({
photoCount: 3,
originalBytes: 1800,
variantBytes: 400,
totalBytes: 2200,
});
expect(after.body.totals).toEqual({ photoCount: 4, totalBytes: 2300 });
});
});