Files
photo-live/services/worker/src/publish.service.ts
T
浅念 4937eeecff 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>
2026-09-20 19:01:41 +08:00

299 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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;
}
}