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>
299 lines
12 KiB
TypeScript
299 lines
12 KiB
TypeScript
// services/worker/src/publish.service.ts —— 发布事务(Task 21,「上传即公开」的心脏)。
|
||
// 处理成功 = 单个 Prisma 事务内原子完成三件事:
|
||
// ① photo_variants 两条 upsert(thumb/browse);
|
||
// ② photos 条件更新(WHERE processingState IN ('uploaded','processing_failed')):
|
||
// ready + published + publishedAt + 尺寸 + entityVersion+1(每次产生事件的
|
||
// 状态迁移自增;失败重试成功也切 published——上传即公开语义);
|
||
// ③ outbox_events 写入 photo.published(payload = 契约 PhotoPublishedPayload,
|
||
// entityVersion 与 ② 提交后一致;P2 消费按 eventId 去重、entityVersion 单调)。
|
||
// 条件更新匹配 0 行(并发/重复投递已处理过)→ 幂等 no-op('already'),不再
|
||
// 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 {
|
||
variantsObjectKey,
|
||
watermarkVariantObjectKey,
|
||
} from '@photo/storage';
|
||
import {
|
||
parseStoredWatermarkConfig,
|
||
type PhotoPublishedPayload,
|
||
type StoredWatermarkConfig,
|
||
} from '@photo/contracts';
|
||
import type { Logger } from 'pino';
|
||
import { runPipeline } from './pipeline';
|
||
import { composeWatermarkBrowse, type WatermarkComposeResult } from './watermark';
|
||
|
||
/** 变体键版本段(对应 photo_variants.version;水印/规格升级时递增)。 */
|
||
const VARIANT_VERSION = 1;
|
||
|
||
/** 可处理态:首次上传(uploaded)与失败重试(processing_failed,终态仍 published)。 */
|
||
const PROCESSABLE_STATES = ['uploaded', 'processing_failed'] as const;
|
||
|
||
export type ProcessOutcome =
|
||
| 'published' // 本次处理并切 published(含失败重试成功)
|
||
| 'already' // 重复投递:已 ready(或并发赢家已处理),幂等 no-op
|
||
| 'skipped'; // 照片不存在 / 状态不可处理(pending_upload、processing 等)
|
||
|
||
/** 发布路径的水印产出现状(P4a Task 3,见 resolveWatermarkTargets 注释)。 */
|
||
interface PublishWatermarkTarget {
|
||
version: number;
|
||
config: StoredWatermarkConfig;
|
||
out: WatermarkComposeResult;
|
||
}
|
||
|
||
export class PublishService {
|
||
constructor(
|
||
private readonly prisma: PrismaClient,
|
||
private readonly storage: StoragePort,
|
||
private readonly logger?: Logger,
|
||
) {}
|
||
|
||
/**
|
||
* 处理一张照片:拉原图 → sharp 流水线 → 上传变体 → 原子发布事务。
|
||
* PipelineError/存储错误原样抛出(processor 交给 BullMQ 重试,3 次耗尽置
|
||
* processing_failed)。不抛错路径绝不留下半发布状态。
|
||
*/
|
||
async processPhoto(photoId: string): Promise<ProcessOutcome> {
|
||
const photo = await this.prisma.photos.findUnique({
|
||
where: { id: photoId },
|
||
select: {
|
||
albumId: true,
|
||
categoryId: true,
|
||
objectKey: true,
|
||
processingState: true,
|
||
},
|
||
});
|
||
if (photo === null) {
|
||
this.logger?.warn({ photoId }, '照片不存在,跳过处理');
|
||
return 'skipped';
|
||
}
|
||
if (photo.processingState === 'ready') {
|
||
// 快路径:已发布(重复投递)。真正的幂等保障仍是事务内条件更新——
|
||
// 并发窗口内两方都读到 uploaded 时,恰一方 claim 成功。
|
||
return 'already';
|
||
}
|
||
if (!(PROCESSABLE_STATES as readonly string[]).includes(photo.processingState)) {
|
||
this.logger?.warn(
|
||
{ photoId, processingState: photo.processingState },
|
||
'照片状态不可处理,跳过',
|
||
);
|
||
return 'skipped';
|
||
}
|
||
|
||
const input = await this.readOriginal(photo.objectKey);
|
||
const pipeline = await runPipeline(input); // 失败抛 PipelineError → 重试
|
||
const thumbKey = variantsObjectKey(photo.albumId, photoId, VARIANT_VERSION, 'thumb');
|
||
const browseKey = variantsObjectKey(photo.albumId, photoId, VARIANT_VERSION, 'browse');
|
||
await this.storage.put(thumbKey, pipeline.thumb, 'image/jpeg');
|
||
await this.storage.put(browseKey, pipeline.browse, 'image/jpeg');
|
||
|
||
// P4a Task 3:发布路径水印变体(「新照片默认套用最新版本」的落点)。
|
||
// 对象先于事务写(键含版本段、内容确定,重复写无害),行 upsert 在发布
|
||
// 事务内与 thumb/browse 同生共死。
|
||
const watermarkTargets = await this.produceWatermarkVariants(
|
||
photo.albumId,
|
||
photoId,
|
||
input,
|
||
);
|
||
|
||
const publishedAt = new Date();
|
||
const outcome = await this.prisma.$transaction(async (tx) => {
|
||
// 条件更新 = 处理权抢占:WHERE 仍处可处理态。匹配 0 → 已被处理,幂等返回。
|
||
const claimed = await tx.photos.updateMany({
|
||
where: { id: photoId, processingState: { in: [...PROCESSABLE_STATES] } },
|
||
data: {
|
||
processingState: 'ready',
|
||
visibility: 'published',
|
||
publishedAt,
|
||
width: pipeline.width,
|
||
height: pipeline.height,
|
||
errorCode: null, // processing_failed 重试成功后清除错误码
|
||
entityVersion: { increment: 1 },
|
||
},
|
||
});
|
||
if (claimed.count === 0) {
|
||
return 'already' as const;
|
||
}
|
||
const claimedPhoto = await tx.photos.findUniqueOrThrow({
|
||
where: { id: photoId },
|
||
select: { entityVersion: true },
|
||
});
|
||
await tx.photo_variants.upsert({
|
||
where: { photoId_variant: { photoId, variant: 'thumb' } },
|
||
update: {
|
||
objectKey: thumbKey,
|
||
bytes: pipeline.thumb.length,
|
||
version: VARIANT_VERSION,
|
||
width: pipeline.thumbWidth,
|
||
height: pipeline.thumbHeight,
|
||
},
|
||
create: {
|
||
photoId,
|
||
variant: 'thumb',
|
||
objectKey: thumbKey,
|
||
bytes: pipeline.thumb.length,
|
||
version: VARIANT_VERSION,
|
||
width: pipeline.thumbWidth,
|
||
height: pipeline.thumbHeight,
|
||
},
|
||
});
|
||
await tx.photo_variants.upsert({
|
||
where: { photoId_variant: { photoId, variant: 'browse' } },
|
||
update: {
|
||
objectKey: browseKey,
|
||
bytes: pipeline.browse.length,
|
||
version: VARIANT_VERSION,
|
||
width: pipeline.browseWidth,
|
||
height: pipeline.browseHeight,
|
||
},
|
||
create: {
|
||
photoId,
|
||
variant: 'browse',
|
||
objectKey: browseKey,
|
||
bytes: pipeline.browse.length,
|
||
version: VARIANT_VERSION,
|
||
width: pipeline.browseWidth,
|
||
height: pipeline.browseHeight,
|
||
},
|
||
});
|
||
// 水印变体行(targets 已按版本升序,末次 upsert 后行版本 = 最大写入版本)。
|
||
for (const target of watermarkTargets) {
|
||
const key = watermarkVariantObjectKey(photo.albumId, photoId, target.version);
|
||
await tx.photo_variants.upsert({
|
||
where: { photoId_variant: { photoId, variant: 'watermark' } },
|
||
update: {
|
||
objectKey: key,
|
||
bytes: target.out.buffer.length,
|
||
version: target.version,
|
||
width: target.out.width,
|
||
height: target.out.height,
|
||
},
|
||
create: {
|
||
photoId,
|
||
variant: 'watermark',
|
||
objectKey: key,
|
||
bytes: target.out.buffer.length,
|
||
version: target.version,
|
||
width: target.out.width,
|
||
height: target.out.height,
|
||
},
|
||
});
|
||
}
|
||
const payload: PhotoPublishedPayload = {
|
||
photoId,
|
||
albumId: photo.albumId,
|
||
categoryId: photo.categoryId,
|
||
objectKey: photo.objectKey,
|
||
thumbKey,
|
||
browseKey,
|
||
width: pipeline.width,
|
||
height: pipeline.height,
|
||
publishedAt: publishedAt.toISOString(),
|
||
entityVersion: claimedPhoto.entityVersion,
|
||
};
|
||
await tx.outbox_events.create({
|
||
data: {
|
||
type: 'photo.published',
|
||
albumId: photo.albumId,
|
||
photoId,
|
||
entityVersion: claimedPhoto.entityVersion,
|
||
// 契约接口无 Prisma Json 输入所需的索引签名,结构化拷贝落 Json 列。
|
||
payload: payload as unknown as Prisma.InputJsonValue,
|
||
occurredAt: publishedAt,
|
||
},
|
||
});
|
||
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;
|
||
}
|
||
|
||
/** 流式拉取原图并聚合为单缓冲(S3 Body → chunks → concat)。 */
|
||
private async readOriginal(objectKey: string): Promise<Buffer> {
|
||
const stream = (await this.storage.getStream(objectKey)) as Readable;
|
||
const chunks: Buffer[] = [];
|
||
for await (const chunk of stream) {
|
||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array));
|
||
}
|
||
return Buffer.concat(chunks);
|
||
}
|
||
|
||
/**
|
||
* 发布路径的水印变体产出现状(P4a Task 3)。目标版本集合的推导(见
|
||
* contracts watermark.ts 版本语义):
|
||
* · 最新配置(albums.watermarkVersion)开启 → 写最新版键;
|
||
* · 相册「有效版本」(watermarkEffectiveVersion,ov 侧仍在服务的旧版)与
|
||
* 最新版不同且开启 → **双写**:重生成窗口内发布的新照片同时落有效版键,
|
||
* 杜绝 ov 模式键指向尚未生成的对象(破图窗口);cn 侧行版本取最大值;
|
||
* · 最新配置关闭但有效配置仍开启(关闭版重生成进行中)→ 写有效版键:
|
||
* cn 公开侧跟有效位(watermarkEnabled 仍 true)继续服务旧水印行,直至
|
||
* 关闭任务逐张删行渐进回落 browse;ov 侧模式键在切换前继续有对象可服务
|
||
* (窗口内两地域一致展示旧图——A20 原子切换语义)。
|
||
* 两配置一致时仅合成一次(同 config 结果缓存)。
|
||
*/
|
||
private async produceWatermarkVariants(
|
||
albumId: string,
|
||
photoId: string,
|
||
input: Buffer,
|
||
): Promise<PublishWatermarkTarget[]> {
|
||
const album = await this.prisma.albums.findUnique({
|
||
where: { id: albumId },
|
||
select: {
|
||
watermarkVersion: true,
|
||
watermarkEnabled: true,
|
||
watermarkEffectiveVersion: true,
|
||
},
|
||
});
|
||
if (album === null) {
|
||
return [];
|
||
}
|
||
const versions = new Set<number>();
|
||
if (album.watermarkVersion > 0) {
|
||
versions.add(album.watermarkVersion);
|
||
}
|
||
if (album.watermarkEnabled && album.watermarkEffectiveVersion > 0) {
|
||
versions.add(album.watermarkEffectiveVersion);
|
||
}
|
||
if (versions.size === 0) {
|
||
return [];
|
||
}
|
||
const configRows = await this.prisma.watermark_configs.findMany({
|
||
where: { albumId, configVersion: { in: [...versions] } },
|
||
});
|
||
const byVersion = new Map<number, StoredWatermarkConfig>();
|
||
for (const row of configRows) {
|
||
const parsed = parseStoredWatermarkConfig(row.config);
|
||
if (parsed !== null) {
|
||
byVersion.set(row.configVersion, parsed);
|
||
}
|
||
}
|
||
const targets: PublishWatermarkTarget[] = [];
|
||
for (const version of [...versions].sort((a, b) => a - b)) {
|
||
const config = byVersion.get(version);
|
||
if (config === undefined || !config.enabled) {
|
||
continue;
|
||
}
|
||
const out = await composeWatermarkBrowse(input, config);
|
||
const key = watermarkVariantObjectKey(albumId, photoId, version);
|
||
await this.storage.put(key, out.buffer, 'image/jpeg');
|
||
targets.push({ version, config, out });
|
||
}
|
||
return targets;
|
||
}
|
||
}
|