diff --git a/packages/db-cn/prisma/migrations/20260920104345_usage_thresholds_alerts/migration.sql b/packages/db-cn/prisma/migrations/20260920104345_usage_thresholds_alerts/migration.sql new file mode 100644 index 0000000..0817f50 --- /dev/null +++ b/packages/db-cn/prisma/migrations/20260920104345_usage_thresholds_alerts/migration.sql @@ -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; diff --git a/packages/db-cn/prisma/schema.prisma b/packages/db-cn/prisma/schema.prisma index faa22c1..df0e274 100644 --- a/packages/db-cn/prisma/schema.prisma +++ b/packages/db-cn/prisma/schema.prisma @@ -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]) +} diff --git a/packages/db-cn/src/index.ts b/packages/db-cn/src/index.ts index 87595fd..cd5595f 100644 --- a/packages/db-cn/src/index.ts +++ b/packages/db-cn/src/index.ts @@ -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'; diff --git a/packages/db-cn/src/usage.ts b/packages/db-cn/src/usage.ts new file mode 100644 index 0000000..d98f5cf --- /dev/null +++ b/packages/db-cn/src/usage.ts @@ -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; + 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 { + const photoRows = await db.photos.groupBy({ + by: ['albumId'], + _count: { _all: true }, + _sum: { bytes: true }, + where: { visibility: { not: 'deleted' } }, + }); + const variantRows = await db.$queryRaw` + 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(); + 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> { + 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 }>(); + 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(); + 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 { + 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 { + 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 { + 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: ( + fn: (tx: Prisma.TransactionClient) => Promise, + ) => Promise; + }, + onError?: (err: unknown) => void, +): Promise { + try { + await prisma.$transaction((tx) => evaluateUsage(tx)); + } catch (err) { + onError?.(err); + } +} diff --git a/services/api/src/admin/admin.module.ts b/services/api/src/admin/admin.module.ts index 3f9e51c..92e3eed 100644 --- a/services/api/src/admin/admin.module.ts +++ b/services/api/src/admin/admin.module.ts @@ -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 {} diff --git a/services/api/src/admin/usage.controller.ts b/services/api/src/admin/usage.controller.ts new file mode 100644 index 0000000..d4dc38a --- /dev/null +++ b/services/api/src/admin/usage.controller.ts @@ -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 { + return this.usageService.summary(userOf(req)); + } + + /** 阈值部分更新(缺省保持、null 清除)+ 即刻重新评估。 */ + @Put('thresholds') + updateUsageThresholds( + @Body() dto: UsageThresholdsUpdateDto, + @Req() req: AuthenticatedRequest, + ): Promise { + return this.usageService.updateThresholds(userOf(req), dto); + } +} diff --git a/services/api/src/admin/usage.dto.ts b/services/api/src/admin/usage.dto.ts new file mode 100644 index 0000000..5c6c3eb --- /dev/null +++ b/services/api/src/admin/usage.dto.ts @@ -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; +} diff --git a/services/api/src/admin/usage.service.ts b/services/api/src/admin/usage.service.ts new file mode 100644 index 0000000..09580f2 --- /dev/null +++ b/services/api/src/admin/usage.service.ts @@ -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 { + 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>(); + 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 { + 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(), + }; +} diff --git a/services/api/src/photos/photos.service.ts b/services/api/src/photos/photos.service.ts index 02f990a..64748ef 100644 --- a/services/api/src/photos/photos.service.ts +++ b/services/api/src/photos/photos.service.ts @@ -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); } } diff --git a/services/api/test/reset.ts b/services/api/test/reset.ts index 4cd8775..8f23cfe 100644 --- a/services/api/test/reset.ts +++ b/services/api/test/reset.ts @@ -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 { 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(); diff --git a/services/api/test/usage.e2e-spec.ts b/services/api/test/usage.e2e-spec.ts new file mode 100644 index 0000000..44e4f4e --- /dev/null +++ b/services/api/test/usage.e2e-spec.ts @@ -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 { + 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(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 => { + 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 }); + }); +}); diff --git a/services/worker/src/publish.service.ts b/services/worker/src/publish.service.ts index 1c46f48..e74c11b 100644 --- a/services/worker/src/publish.service.ts +++ b/services/worker/src/publish.service.ts @@ -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; } diff --git a/services/worker/src/watermark.service.ts b/services/worker/src/watermark.service.ts index bcf08cb..db3f0da 100644 --- a/services/worker/src/watermark.service.ts +++ b/services/worker/src/watermark.service.ts @@ -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 }, '水印重生成任务完成', diff --git a/services/worker/test/reset.ts b/services/worker/test/reset.ts index bacee2f..a0bf6c7 100644 --- a/services/worker/test/reset.ts +++ b/services/worker/test/reset.ts @@ -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 { 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();