4937eeecff
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>
118 lines
3.6 KiB
TypeScript
118 lines
3.6 KiB
TypeScript
// services/worker/test/reset.ts
|
||
// worker e2e 共用隔离工具(镜像 services/api/test/reset.ts):每个 e2e spec 的
|
||
// beforeAll 调用 resetDb()。MySQL 逐表 TRUNCATE(SET FOREIGN_KEY_CHECKS=0 包裹,
|
||
// users 被子表引用);MinIO 侧清理 photo-live/dev 前缀对象(worker 的 variants
|
||
// 写入与 api 的 staging/originals 同前缀)。S3Client 为 devDependencies
|
||
// (与 @photo/storage 内部同款)——StoragePort 不含列举/删除(YAGNI),隔离工具
|
||
// 直连 S3 协议。
|
||
import {
|
||
DeleteObjectsCommand,
|
||
ListObjectsV2Command,
|
||
S3Client,
|
||
} from '@aws-sdk/client-s3';
|
||
import {
|
||
PrismaClient,
|
||
USAGE_THRESHOLDS_SINGLETON_ID,
|
||
} from '@photo/db-cn';
|
||
|
||
export const RESET_TABLES: readonly string[] = [
|
||
// 与 api/test/reset.ts 同表同序(子表在前)。
|
||
'outbox_events',
|
||
'photo_jobs',
|
||
'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',
|
||
'category_translations',
|
||
'categories',
|
||
'album_assets',
|
||
'album_translations',
|
||
'album_members',
|
||
'albums',
|
||
'audit_logs',
|
||
'sessions',
|
||
'users',
|
||
];
|
||
|
||
/** e2e 对象隔离边界:与 api src/uploads/support.ts 的 OBJECT_KEY_ROOT 一致。 */
|
||
const RESET_OBJECT_PREFIX = 'photo-live/dev/';
|
||
|
||
/** 删除 MinIO 中 RESET_OBJECT_PREFIX 前缀的全部对象(分页列举 + 批量删除)。 */
|
||
async function resetMinioObjects(): Promise<void> {
|
||
const required = [
|
||
'S3_ENDPOINT',
|
||
'S3_REGION',
|
||
'S3_ACCESS_KEY',
|
||
'S3_SECRET_KEY',
|
||
'S3_BUCKET',
|
||
] as const;
|
||
const missing = required.filter((key) => !process.env[key]);
|
||
if (missing.length > 0) {
|
||
throw new Error(`resetMinioObjects: 缺少环境变量 ${missing.join(', ')}`);
|
||
}
|
||
const client = new S3Client({
|
||
endpoint: process.env.S3_ENDPOINT,
|
||
region: process.env.S3_REGION,
|
||
credentials: {
|
||
accessKeyId: process.env.S3_ACCESS_KEY as string,
|
||
secretAccessKey: process.env.S3_SECRET_KEY as string,
|
||
},
|
||
forcePathStyle: true,
|
||
});
|
||
const bucket = process.env.S3_BUCKET as string;
|
||
try {
|
||
for (;;) {
|
||
const listed = await client.send(
|
||
new ListObjectsV2Command({
|
||
Bucket: bucket,
|
||
Prefix: RESET_OBJECT_PREFIX,
|
||
MaxKeys: 1000,
|
||
}),
|
||
);
|
||
const objects = (listed.Contents ?? []).filter(
|
||
(obj) => typeof obj.Key === 'string',
|
||
);
|
||
if (objects.length === 0) {
|
||
break;
|
||
}
|
||
await client.send(
|
||
new DeleteObjectsCommand({
|
||
Bucket: bucket,
|
||
Delete: {
|
||
Objects: objects.map((obj) => ({ Key: obj.Key as string })),
|
||
Quiet: true,
|
||
},
|
||
}),
|
||
);
|
||
if (!listed.IsTruncated) {
|
||
break;
|
||
}
|
||
}
|
||
} finally {
|
||
await client.destroy();
|
||
}
|
||
}
|
||
|
||
export async function resetDb(): Promise<void> {
|
||
await resetMinioObjects();
|
||
const prisma = new PrismaClient();
|
||
try {
|
||
await prisma.$executeRawUnsafe('SET FOREIGN_KEY_CHECKS = 0');
|
||
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();
|
||
}
|
||
}
|