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:
@@ -0,0 +1,34 @@
|
||||
-- P4a Task 5:用量阈值(单行配置表,迁移内种子行)+ 阈值告警账本(A23 后台侧)。
|
||||
-- 校对手决:Prisma 生成 DDL 固定 utf8mb4_unicode_ci,逐表改写为库级约定的
|
||||
-- utf8mb4_0900_bin(id/kind/albumId 等字符串列的大小写敏感语义依赖它)。
|
||||
-- 「同 kind+albumId 活跃告警唯一」为应用级约束(MySQL 无部分唯一索引):
|
||||
-- evaluateUsage 以单行配置 FOR UPDATE 作串行化锚点(packages/db-cn/src/usage.ts)。
|
||||
-- 阈值两维缺省 NULL=停用;种子行固定 id 见 USAGE_THRESHOLDS_SINGLETON_ID。
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `usage_thresholds` (
|
||||
`id` VARCHAR(191) NOT NULL,
|
||||
`storageBytes` BIGINT NULL,
|
||||
`perAlbumBytes` BIGINT NULL,
|
||||
`updatedAt` DATETIME(6) NOT NULL,
|
||||
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin;
|
||||
|
||||
-- 单行种子(PUT /admin/usage/thresholds 恒 upsert 本行;缺失行由 evaluate
|
||||
-- 兜底视作双维停用,但迁移种子保证常规路径下不变量成立)。
|
||||
INSERT INTO `usage_thresholds` (`id`, `storageBytes`, `perAlbumBytes`, `updatedAt`)
|
||||
VALUES ('usage-thresholds-single-row', NULL, NULL, CURRENT_TIMESTAMP(6));
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `usage_alerts` (
|
||||
`id` VARCHAR(191) NOT NULL,
|
||||
`kind` VARCHAR(16) NOT NULL,
|
||||
`albumId` VARCHAR(64) NULL,
|
||||
`message` VARCHAR(1024) NOT NULL,
|
||||
`createdAt` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||
`resolvedAt` DATETIME(6) NULL,
|
||||
|
||||
INDEX `usage_alerts_resolvedAt_idx`(`resolvedAt`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin;
|
||||
@@ -256,3 +256,24 @@ model outbox_events {
|
||||
deliveredAt DateTime? @db.DateTime(6)
|
||||
@@index([deliveredAt, occurredAt])
|
||||
}
|
||||
// —— 用量阈值与告警(P4a Task 5,A23 后台侧)——
|
||||
// usage_thresholds:单行配置表(固定 id,迁移内种子行;维度 null=告警停用)。
|
||||
// usage_alerts:阈值告警账本——超阈写入一次直到回落(resolvedAt 置位);
|
||||
// 「同 kind+albumId 活跃告警唯一」为应用级约束(MySQL 无部分唯一索引,
|
||||
// evaluateUsage 以单行配置 FOR UPDATE 作串行化锚点闭合先查后写竞态,见
|
||||
// packages/db-cn/src/usage.ts)。告警接收人通知渠道 P5。
|
||||
model usage_thresholds {
|
||||
id String @id // 固定单行 id(USAGE_THRESHOLDS_SINGLETON_ID;迁移种子)
|
||||
storageBytes BigInt? // 全库存储阈值(字节);null=维度停用
|
||||
perAlbumBytes BigInt? // 单相册存储阈值(字节);null=维度停用
|
||||
updatedAt DateTime @updatedAt @db.DateTime(6)
|
||||
}
|
||||
model usage_alerts {
|
||||
id String @id @default(cuid())
|
||||
kind String @db.VarChar(16) // storage|perAlbum
|
||||
albumId String? @db.VarChar(64) // kind=perAlbum 必有;storage 为 null
|
||||
message String @db.VarChar(1024)
|
||||
createdAt DateTime @default(now()) @db.DateTime(6)
|
||||
resolvedAt DateTime? @db.DateTime(6) // 非空=已解除(阈值上调/用量回落/维度停用)
|
||||
@@index([resolvedAt])
|
||||
}
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
// packages/db-cn/src/index.ts —— CN 侧唯一 Prisma client 出口。
|
||||
// api(PrismaService)与 worker(Task 20)都从本包导入,schema/迁移只此一份。
|
||||
// commonjs 包:消费方(api/worker)均为 CJS,tsc 直出无需打包。
|
||||
// P4a Task 5 起增补用量域共享核心(usage.ts:聚合 + 阈值告警评估——api 与
|
||||
// worker 的检查点共用,见该文件头)。
|
||||
export { PrismaClient } from '@prisma/client';
|
||||
export type { Prisma } from '@prisma/client';
|
||||
export {
|
||||
computeUsageAggregates,
|
||||
evaluateUsage,
|
||||
evaluateUsageSafe,
|
||||
USAGE_ALERTS_LIST_LIMIT,
|
||||
USAGE_THRESHOLDS_SINGLETON_ID,
|
||||
} from './usage';
|
||||
export type {
|
||||
UsageAlbumAggregate,
|
||||
UsageAggregates,
|
||||
UsageAlertKind,
|
||||
} from './usage';
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
// packages/db-cn/src/usage.ts —— 用量聚合与阈值告警评估核心(P4a Task 5,
|
||||
// A23 后台侧)。放本包的原因:评估检查点分布在 api(photos 可见性迁移后、
|
||||
// PUT /admin/usage/thresholds 后)与 worker(发布事务/水印重生成完成后)两侧,
|
||||
// 两服务共享的 cn 域基础设施只有本包(schema + client),评估是纯 DB 逻辑
|
||||
// (photos/photo_variants SUM + usage_* 行维护),不涉契约形状——不放
|
||||
// @photo/contracts(其无 Prisma 依赖面)。
|
||||
//
|
||||
// 口径(契约 UsageSummary 为权威):
|
||||
// · 计数范围 = visibility != deleted 的全部照片(含 hidden/unpublished——
|
||||
// 存储是存储;软删除行不计,物理清除属 P4a 运维流程);
|
||||
// · originalBytes = photos.bytes SUM;variantBytes = photo_variants.bytes
|
||||
// SUM(经 photos JOIN 圈定相册与非 deleted 口径);实时 SUM(P1 规模够用,
|
||||
// P5 换物化/OSS 用量 API,登记偏差);
|
||||
// · BigInt 运输决策:契约 int64 → openapi-typescript 生成 number;阈值/
|
||||
// 聚合在库内为 BIGINT(Prisma BigInt),出口统一 Number() 收敛——上限
|
||||
// 2^53 字节(≈9PB)远超单实例照片库量级,安全(api 侧 DTO 另以
|
||||
// MAX_SAFE_INTEGER 拒绝超界输入,杜绝静默精度丢失)。
|
||||
//
|
||||
// evaluateUsage 事务内先行 `SELECT ... FOR UPDATE` 锁单行配置:并发评估互相
|
||||
// 排队,使「查活跃告警→无则写入」的应用级唯一(同 kind+albumId 活跃告警唯一,
|
||||
// MySQL 无部分唯一索引)无竞态窗口。
|
||||
import type { Prisma } from '@prisma/client';
|
||||
|
||||
/** usage_thresholds 单行配置固定 id(迁移种子行;reset 后重种同值)。 */
|
||||
export const USAGE_THRESHOLDS_SINGLETON_ID = 'usage-thresholds-single-row';
|
||||
|
||||
/** 告警列表上限(summary 消费侧:活跃在前 + 近期已解除,合计不超此数)。 */
|
||||
export const USAGE_ALERTS_LIST_LIMIT = 100;
|
||||
|
||||
/** 活跃告警 kind 值(与契约 UsageAlert.kind 枚举同值)。 */
|
||||
export type UsageAlertKind = 'storage' | 'perAlbum';
|
||||
|
||||
/** GB 换算(告警文案展示用;原始字节数同时入文案,阈值边界不失真)。 */
|
||||
const BYTES_PER_GB = 1073741824;
|
||||
|
||||
function formatGb(bytes: number): string {
|
||||
return `${(bytes / BYTES_PER_GB).toFixed(2)}GB`;
|
||||
}
|
||||
|
||||
/** 单相册聚合(内部形状;totalBytes = originalBytes + variantBytes)。 */
|
||||
export interface UsageAlbumAggregate {
|
||||
photoCount: number;
|
||||
originalBytes: number;
|
||||
variantBytes: number;
|
||||
totalBytes: number;
|
||||
}
|
||||
|
||||
export interface UsageAggregates {
|
||||
perAlbum: Map<string, UsageAlbumAggregate>;
|
||||
totals: { photoCount: number; totalBytes: number };
|
||||
}
|
||||
|
||||
/** $queryRaw 变体连接聚合行(CAST SIGNED → BIGINT → JS BigInt)。 */
|
||||
interface VariantAggRow {
|
||||
albumId: string;
|
||||
variantBytes: bigint;
|
||||
}
|
||||
|
||||
/**
|
||||
* 用量聚合(非 deleted 口径):photos groupBy(计数 + 原图字节)+ 变体连接
|
||||
* 聚合(raw SQL——photo_variants 无 albumId 列,Prisma groupBy 无法跨表)。
|
||||
* SUM 走 photos (albumId,…) / photo_variants (photoId) 索引,P4 规模下检查点
|
||||
* 逐次直查可接受(计划登记:P5 换物化)。
|
||||
*/
|
||||
export async function computeUsageAggregates(
|
||||
db: Prisma.TransactionClient,
|
||||
): Promise<UsageAggregates> {
|
||||
const photoRows = await db.photos.groupBy({
|
||||
by: ['albumId'],
|
||||
_count: { _all: true },
|
||||
_sum: { bytes: true },
|
||||
where: { visibility: { not: 'deleted' } },
|
||||
});
|
||||
const variantRows = await db.$queryRaw<VariantAggRow[]>`
|
||||
SELECT p.albumId AS albumId, CAST(COALESCE(SUM(v.bytes), 0) AS SIGNED) AS variantBytes
|
||||
FROM photo_variants v
|
||||
JOIN photos p ON p.id = v.photoId
|
||||
WHERE p.visibility <> 'deleted'
|
||||
GROUP BY p.albumId
|
||||
`;
|
||||
const variantByAlbum = new Map(
|
||||
variantRows.map((row) => [row.albumId, Number(row.variantBytes)]),
|
||||
);
|
||||
|
||||
const perAlbum = new Map<string, UsageAlbumAggregate>();
|
||||
let totalCount = 0;
|
||||
let totalBytes = 0;
|
||||
for (const row of photoRows) {
|
||||
const photoCount = row._count._all;
|
||||
const originalBytes = row._sum.bytes ?? 0;
|
||||
const variantBytes = variantByAlbum.get(row.albumId) ?? 0;
|
||||
const albumTotal = originalBytes + variantBytes;
|
||||
perAlbum.set(row.albumId, {
|
||||
photoCount,
|
||||
originalBytes,
|
||||
variantBytes,
|
||||
totalBytes: albumTotal,
|
||||
});
|
||||
totalCount += photoCount;
|
||||
totalBytes += albumTotal;
|
||||
}
|
||||
return { perAlbum, totals: { photoCount: totalCount, totalBytes } };
|
||||
}
|
||||
|
||||
/** 相册标题(defaultLocale 翻译优先,缺失回退任一翻译,再回退 albumId)。 */
|
||||
async function albumTitles(
|
||||
db: Prisma.TransactionClient,
|
||||
albumIds: string[],
|
||||
): Promise<Map<string, string>> {
|
||||
if (albumIds.length === 0) {
|
||||
return new Map();
|
||||
}
|
||||
const albums = await db.albums.findMany({
|
||||
where: { id: { in: albumIds } },
|
||||
select: { id: true, defaultLocale: true },
|
||||
});
|
||||
const translations = await db.album_translations.findMany({
|
||||
where: { albumId: { in: albumIds } },
|
||||
orderBy: { locale: 'asc' }, // 回退取序稳定(非 defaultLocale 时)
|
||||
});
|
||||
const byAlbum = new Map<string, { defaultLocale: string; titles: Map<string, string> }>();
|
||||
for (const album of albums) {
|
||||
byAlbum.set(album.id, { defaultLocale: album.defaultLocale, titles: new Map() });
|
||||
}
|
||||
for (const t of translations) {
|
||||
byAlbum.get(t.albumId)?.titles.set(t.locale, t.title);
|
||||
}
|
||||
const titles = new Map<string, string>();
|
||||
for (const [id, info] of byAlbum) {
|
||||
titles.set(id, info.titles.get(info.defaultLocale) ?? [...info.titles.values()][0] ?? id);
|
||||
}
|
||||
return titles;
|
||||
}
|
||||
|
||||
/** 超阈且无活跃告警时写入一条(去重:活跃存在即跳过,文案保持首警值)。 */
|
||||
async function ensureActiveAlert(
|
||||
db: Prisma.TransactionClient,
|
||||
kind: UsageAlertKind,
|
||||
albumId: string | null,
|
||||
message: string,
|
||||
): Promise<void> {
|
||||
const existing = await db.usage_alerts.findFirst({
|
||||
where: { kind, albumId, resolvedAt: null },
|
||||
select: { id: true },
|
||||
});
|
||||
if (existing === null) {
|
||||
await db.usage_alerts.create({ data: { kind, albumId, message } });
|
||||
}
|
||||
}
|
||||
|
||||
/** 解除活跃告警(阈值上调/用量回落/维度停用)。keepAlbumIds 给定时仅解除其外。 */
|
||||
async function resolveActiveAlerts(
|
||||
db: Prisma.TransactionClient,
|
||||
kind: UsageAlertKind,
|
||||
keepAlbumIds?: string[],
|
||||
): Promise<void> {
|
||||
const where: Prisma.usage_alertsWhereInput = { kind, resolvedAt: null };
|
||||
if (keepAlbumIds !== undefined && keepAlbumIds.length > 0) {
|
||||
where.albumId = { notIn: keepAlbumIds };
|
||||
}
|
||||
await db.usage_alerts.updateMany({
|
||||
where,
|
||||
data: { resolvedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 阈值告警评估(事务体;api/worker 检查点在业务事务提交后调用):
|
||||
* · storage:totalBytes > storageBytes → ensure(storage);否则(含阈值清空
|
||||
* =维度停用)解除全部活跃 storage 告警;
|
||||
* · perAlbum:逐相册 totalBytes > perAlbumBytes → ensure(perAlbum, albumId)
|
||||
* (文案含相册标题);已回落/相册已删/维度停用 → 解除对应活跃告警。
|
||||
* 单行配置 FOR UPDATE 是并发评估的串行化锚点(文件头)。
|
||||
*/
|
||||
export async function evaluateUsage(
|
||||
db: Prisma.TransactionClient,
|
||||
): Promise<void> {
|
||||
await db.$queryRaw`SELECT id FROM usage_thresholds WHERE id = ${USAGE_THRESHOLDS_SINGLETON_ID} FOR UPDATE`;
|
||||
const thresholds = await db.usage_thresholds.findUnique({
|
||||
where: { id: USAGE_THRESHOLDS_SINGLETON_ID },
|
||||
});
|
||||
const { perAlbum, totals } = await computeUsageAggregates(db);
|
||||
|
||||
const storageLimit =
|
||||
thresholds?.storageBytes === undefined || thresholds.storageBytes === null
|
||||
? null
|
||||
: Number(thresholds.storageBytes);
|
||||
if (storageLimit !== null && totals.totalBytes > storageLimit) {
|
||||
await ensureActiveAlert(
|
||||
db,
|
||||
'storage',
|
||||
null,
|
||||
`全库存储用量 ${totals.totalBytes} 字节(${formatGb(totals.totalBytes)}),超过阈值 ${storageLimit} 字节(${formatGb(storageLimit)})`,
|
||||
);
|
||||
} else {
|
||||
await resolveActiveAlerts(db, 'storage');
|
||||
}
|
||||
|
||||
const perAlbumLimit =
|
||||
thresholds?.perAlbumBytes === undefined || thresholds.perAlbumBytes === null
|
||||
? null
|
||||
: Number(thresholds.perAlbumBytes);
|
||||
if (perAlbumLimit !== null) {
|
||||
const over = [...perAlbum.entries()]
|
||||
.filter(([, agg]) => agg.totalBytes > perAlbumLimit)
|
||||
.map(([albumId, agg]) => ({ albumId, totalBytes: agg.totalBytes }));
|
||||
const titles = await albumTitles(db, over.map((o) => o.albumId));
|
||||
for (const { albumId, totalBytes: albumTotal } of over) {
|
||||
await ensureActiveAlert(
|
||||
db,
|
||||
'perAlbum',
|
||||
albumId,
|
||||
`相册「${titles.get(albumId) ?? albumId}」存储用量 ${albumTotal} 字节(${formatGb(albumTotal)}),超过阈值 ${perAlbumLimit} 字节(${formatGb(perAlbumLimit)})`,
|
||||
);
|
||||
}
|
||||
await resolveActiveAlerts(
|
||||
db,
|
||||
'perAlbum',
|
||||
over.map((o) => o.albumId),
|
||||
);
|
||||
} else {
|
||||
await resolveActiveAlerts(db, 'perAlbum');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查点包装:自开评估事务;失败绝不拖垮业务主流程(照片已发布/已删除是
|
||||
* 既成事实,告警是辅助账本——失败只交 onError 记录,下一检查点自然补评估)。
|
||||
* 参数为结构化最小接口:PrismaClient / PrismaService 均满足。
|
||||
*/
|
||||
export async function evaluateUsageSafe(
|
||||
prisma: {
|
||||
$transaction: <T>(
|
||||
fn: (tx: Prisma.TransactionClient) => Promise<T>,
|
||||
) => Promise<T>;
|
||||
},
|
||||
onError?: (err: unknown) => void,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await prisma.$transaction((tx) => evaluateUsage(tx));
|
||||
} catch (err) {
|
||||
onError?.(err);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,10 @@ import {
|
||||
ListObjectsV2Command,
|
||||
S3Client,
|
||||
} from '@aws-sdk/client-s3';
|
||||
import { PrismaClient } from '@photo/db-cn';
|
||||
import {
|
||||
PrismaClient,
|
||||
USAGE_THRESHOLDS_SINGLETON_ID,
|
||||
} from '@photo/db-cn';
|
||||
|
||||
export const RESET_TABLES: readonly string[] = [
|
||||
// 照片与上传域 + outbox(Task 10):均为扁平字符串外键(无 FK 约束),
|
||||
@@ -24,6 +27,9 @@ export const RESET_TABLES: readonly string[] = [
|
||||
'photo_variants',
|
||||
'upload_sessions',
|
||||
'photos',
|
||||
// 用量域(P4a Task 5):告警账本先于阈值单行(重种在 resetDb 尾部)。
|
||||
'usage_alerts',
|
||||
'usage_thresholds',
|
||||
// 水印域(P4a Task 3):任务/配置账本先于 albums(watermarkVersion 等位列
|
||||
// 随 albums 清零)。
|
||||
'watermark_jobs',
|
||||
@@ -109,6 +115,11 @@ export async function resetDb(): Promise<void> {
|
||||
for (const table of RESET_TABLES) {
|
||||
await prisma.$executeRawUnsafe(`TRUNCATE TABLE ${table}`);
|
||||
}
|
||||
// 用量阈值单行被 TRUNCATE 后重种(迁移同值种子;保证「单行恒存在」
|
||||
// 不变量对后续 spec 成立——发布路径的评估检查点依赖它,缺行时兜底
|
||||
// 视作双维停用,跨 spec 的阈值残留则被清除)。
|
||||
await prisma.$executeRaw`INSERT INTO usage_thresholds (id, storageBytes, perAlbumBytes, updatedAt)
|
||||
VALUES (${USAGE_THRESHOLDS_SINGLETON_ID}, NULL, NULL, CURRENT_TIMESTAMP(6))`;
|
||||
} finally {
|
||||
await prisma.$executeRawUnsafe('SET FOREIGN_KEY_CHECKS = 1');
|
||||
await prisma.$disconnect();
|
||||
|
||||
@@ -0,0 +1,418 @@
|
||||
// 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 });
|
||||
});
|
||||
});
|
||||
@@ -10,7 +10,10 @@
|
||||
// bump entityVersion、不再补 outbox——事务即幂等边界。
|
||||
// 变体对象先于事务写入 MinIO(键含 version 段,内容确定,重复写无害);流式拉取
|
||||
// 原图整缓冲驻内存(50MB 上限内可接受,P5 再考虑流式)。
|
||||
// P4a Task 5 检查点:发布事务提交后评估用量阈值告警(新增照片存储可能超阈;
|
||||
// 评估自开事务且失败只记日志——发布是既成事实,见 evaluateUsageSafe)。
|
||||
import { Readable } from 'node:stream';
|
||||
import { evaluateUsageSafe } from '@photo/db-cn';
|
||||
import type { Prisma, PrismaClient } from '@photo/db-cn';
|
||||
import type { StoragePort } from '@photo/storage';
|
||||
import {
|
||||
@@ -208,6 +211,15 @@ export class PublishService {
|
||||
return 'published' as const;
|
||||
});
|
||||
this.logger?.info({ photoId, outcome }, '照片处理完成');
|
||||
if (outcome === 'published') {
|
||||
// P4a Task 5 检查点(仅真实发布路径;already/skipped 无存储变化)。
|
||||
await evaluateUsageSafe(this.prisma, (err) =>
|
||||
this.logger?.warn(
|
||||
{ photoId, err: err instanceof Error ? err.message : String(err) },
|
||||
'用量告警评估失败(发布后)',
|
||||
),
|
||||
);
|
||||
}
|
||||
return outcome;
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,10 @@
|
||||
// 完成事务:job 置 done + 推进 albums「有效版本位」(watermarkEffectiveVersion
|
||||
// /watermarkEnabled,仅更高版本推进——乱序完成的旧任务自然让位)+ entityVersion+1
|
||||
// + outbox album.updated(载荷经 contracts 共享构建器,ov 副本据此切换模式键)。
|
||||
// P4a Task 5 检查点:任务完成后评估用量阈值告警(水印变体行切换改变存储;
|
||||
// 评估自开事务且失败只记日志——任务 done 是既成事实,见 evaluateUsageSafe)。
|
||||
import { Readable } from 'node:stream';
|
||||
import { evaluateUsageSafe } from '@photo/db-cn';
|
||||
import type { Prisma, PrismaClient } from '@photo/db-cn';
|
||||
import {
|
||||
buildAlbumUpdatedPayloadCore,
|
||||
@@ -145,6 +148,16 @@ export class WatermarkRegenService {
|
||||
}
|
||||
|
||||
await this.completeJob(jobRowId, job.albumId, job.configVersion, config);
|
||||
// P4a Task 5 检查点:完成事务提交后评估(变体行已切换,存储口径随之变化)。
|
||||
await evaluateUsageSafe(this.prisma, (err) =>
|
||||
this.logger?.warn(
|
||||
{
|
||||
jobId: jobRowId,
|
||||
err: err instanceof Error ? err.message : String(err),
|
||||
},
|
||||
'用量告警评估失败(水印任务完成后)',
|
||||
),
|
||||
);
|
||||
this.logger?.info(
|
||||
{ jobId: jobRowId, albumId: job.albumId, configVersion: job.configVersion, total: processed, failed },
|
||||
'水印重生成任务完成',
|
||||
|
||||
@@ -10,7 +10,10 @@ import {
|
||||
ListObjectsV2Command,
|
||||
S3Client,
|
||||
} from '@aws-sdk/client-s3';
|
||||
import { PrismaClient } from '@photo/db-cn';
|
||||
import {
|
||||
PrismaClient,
|
||||
USAGE_THRESHOLDS_SINGLETON_ID,
|
||||
} from '@photo/db-cn';
|
||||
|
||||
export const RESET_TABLES: readonly string[] = [
|
||||
// 与 api/test/reset.ts 同表同序(子表在前)。
|
||||
@@ -19,6 +22,9 @@ export const RESET_TABLES: readonly string[] = [
|
||||
'photo_variants',
|
||||
'upload_sessions',
|
||||
'photos',
|
||||
// 用量域(P4a Task 5,同 api/test/reset.ts:告警先于阈值单行,重种在尾部)。
|
||||
'usage_alerts',
|
||||
'usage_thresholds',
|
||||
// 水印域(P4a Task 3,同 api/test/reset.ts)。
|
||||
'watermark_jobs',
|
||||
'watermark_configs',
|
||||
@@ -100,6 +106,10 @@ export async function resetDb(): Promise<void> {
|
||||
for (const table of RESET_TABLES) {
|
||||
await prisma.$executeRawUnsafe(`TRUNCATE TABLE ${table}`);
|
||||
}
|
||||
// 用量阈值单行重种(同 api/test/reset.ts:保证「单行恒存在」不变量,
|
||||
// 并清除跨 spec 的阈值残留)。
|
||||
await prisma.$executeRaw`INSERT INTO usage_thresholds (id, storageBytes, perAlbumBytes, updatedAt)
|
||||
VALUES (${USAGE_THRESHOLDS_SINGLETON_ID}, NULL, NULL, CURRENT_TIMESTAMP(6))`;
|
||||
} finally {
|
||||
await prisma.$executeRawUnsafe('SET FOREIGN_KEY_CHECKS = 1');
|
||||
await prisma.$disconnect();
|
||||
|
||||
Reference in New Issue
Block a user