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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user