Files
photo-live/services/api/src/public/public.service.ts
T
浅念 8e674a902e feat(albums): soft-delete albums (admin+owner, hidden & irreversible, both app entries)
契约 deleteAlbum(DELETE /albums/{albumId} → 204 幂等)+ status enum 'deleted':
- 服务端:softDelete(canView 404→canManage 403 防枚举;乐观锁 claim +
  outbox album.updated priority=high + audit album.delete);列表/详情/更新/
  成员/资产/分类/水印/照片/展示位绑定全部 deleted→404;公开端 cn
  (status!=='active')与 ov(isAlbumVisibleOv)天然屏蔽;ov 副本经
  album.updated 载荷 status 直投自动下线
- Admin:列表表格操作「删除」+ 详情页头「删除相册」(确认含不可恢复说明)
- App:操作面板「删除相册」(红字、二次确认、成功退回列表并刷新)
- 验证:API 56/56、App 397/397(analyze 0)、admin typecheck/lint/test/build 绿

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-23 10:13:08 +08:00

422 lines
18 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/api/src/public/public.service.ts —— 公开(观众端)读逻辑(Task 22
// 起步:getPublicAlbum;Task 23 增补 listPublicPhotos / getPublicPhoto)。
// 契约权威:packages/contracts/openapi.yaml tags [public]。要点:
// · 相册定位只认 publicToken + status=active + publicEnabled——三者任一不满足
// 统一 404 ALBUM_NOT_PUBLIC(同码防枚举,持有链接即可访问,无认证语义)。
// · 语言回退(需求 4.7):请求 locale ∈ 契约枚举(zh-CN/en)且相册有该语言
// 翻译条目 → 用之;否则回退 defaultLocale(创建/更新路径保证 defaultLocale
// 条目必在,缺行属装配异常按 5xx 兜底)。非法 locale 值不报 400(Task 5
// 决策),与缺省同样走回退;resolvedLocale 向客户端指示实际返回语言。
// · 分类名称同语言解析:resolved locale → defaultLocale → 任一既有条目
// (确定性:locale 升序取首);分类创建必附翻译,末级分支纯防御。
// · banner(P1b Task 3 起对接上传链路):album_assets(kind=banner,
// state='effective') 中按语言偏好解析(需求 4.7)——resolved locale 行 →
// 全局行(locale NULL)→ null;同级取最新(createdAt,id DESC 决胜,同
// (kind, locale) 再上传即替换语义)。
// · 签名 URL 一律现签(SIGNED_URL_TTL_SECONDS 惰性读取,SigV4 本地计算无
// 网络副作用);单图定位端点是契约规定的过期刷新入口。
// · viewCount:P1a 占位 null(§7.1 /visits 端点后续提供)。
// · 限流:公开端点 P1a 不做(P2 引入点赞限流时一并评估),此处仅记录约定。
import { HttpStatus, Inject, Injectable } from '@nestjs/common';
import type { components } from '@photo/contracts';
import { ErrorCodes } from '@photo/contracts';
import type { Prisma } from '@photo/db-cn';
import type { StoragePort } from '@photo/storage';
import { ApiException } from '../api-exception';
import { ALBUM_LOCALES, type AlbumLocale } from '../albums/dto';
import { validationFailed } from '../albums/support';
import {
decodeKeysetCursor,
encodeKeysetCursor,
keysetWhereDesc,
} from '../shared/cursor';
import { PrismaService } from '../prisma/prisma.service';
import { STORAGE_PORT } from '../uploads/storage.provider';
import { signedUrlTtlSeconds } from '../uploads/support';
import { albumNotPublic, photoNotPublic } from './support';
export type PublicAlbumResponse = components['schemas']['PublicAlbum'];
export type PublicPhotoListResponse =
components['schemas']['PublicPhotoList'];
export type PublicPhotoDetailResponse =
components['schemas']['PublicPhotoDetail'];
type SignedUrlResponse = components['schemas']['SignedUrl'];
type AlbumRow = Prisma.albumsGetPayload<Prisma.albumsDefaultArgs>;
type AlbumTranslationRow = Prisma.album_translationsGetPayload<
Prisma.album_translationsDefaultArgs
>;
type PhotoVariantRow = Prisma.photo_variantsGetPayload<
Prisma.photo_variantsDefaultArgs
>;
/** 契约 listPublicPhotos:limit 缺省 30,上下界 1..60(§7 建议 30-60)。 */
export const DEFAULT_PUBLIC_PHOTO_PAGE_SIZE = 30;
export const MAX_PUBLIC_PHOTO_PAGE_SIZE = 60;
@Injectable()
export class PublicService {
constructor(
private readonly prisma: PrismaService,
@Inject(STORAGE_PORT) private readonly storage: StoragePort,
) {}
/**
* 公开相册信息:token → 相册(active+public)→ 翻译回退解析 → 分类(同语言
* 名称)→ banner 签名。响应不含工作人员信息与内部存储键(契约 PublicAlbum)。
*/
async getPublicAlbum(
token: string,
localeRaw: string | undefined,
): Promise<PublicAlbumResponse> {
const album = await this.findPublicAlbum(token);
const translations = await this.prisma.album_translations.findMany({
where: { albumId: album.id },
orderBy: { locale: 'asc' },
});
const resolved = this.resolveTranslation(
album.defaultLocale,
translations,
localeRaw,
);
// 分类:sortOrder 升序(契约),id 决胜保证确定性输出。
const categories = await this.prisma.categories.findMany({
where: { albumId: album.id },
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
});
const categoryTranslations =
categories.length === 0
? []
: await this.prisma.category_translations.findMany({
where: { categoryId: { in: categories.map((c) => c.id) } },
orderBy: { locale: 'asc' },
});
const namesByCategory = new Map<string, Map<string, string>>();
for (const row of categoryTranslations) {
let bucket = namesByCategory.get(row.categoryId);
if (bucket === undefined) {
bucket = new Map();
namesByCategory.set(row.categoryId, bucket);
}
bucket.set(row.locale, row.name);
}
// banner(P1b Task 3 起对接上传链路):仅 state='effective' 行参与;语言
// 偏好解析(需求 4.7)——resolved locale 行 → 全局行(locale NULL)→ null。
// 以「实际展示语言」(translation 解析结果)为偏好驱动:banner 跟随观众
// 看到的文案语言,而非原始请求值(请求 fr 回退 zh 时配 zh banner)。
// 同级多行取最新(createdAt,id DESC 决胜):同 (kind, locale) 再上传即替换
// 语义——新行生效、旧行保留,此处取最新 effective 行。
const bannerAssets = await this.prisma.album_assets.findMany({
where: { albumId: album.id, kind: 'banner', state: 'effective' },
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
});
let bannerRow =
bannerAssets.find((row) => row.locale === resolved.locale) ??
bannerAssets.find((row) => row.locale === null) ??
null;
// 宣传图/封面共用(用户决策 2026-09-23):banner 缺失回退最新 effective
// cover 行(传一张即可两用;与 ov 公开侧同语义)。
if (bannerRow === null) {
bannerRow = await this.prisma.album_assets.findFirst({
where: { albumId: album.id, kind: 'cover', state: 'effective' },
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
});
}
const banner =
bannerRow === null ? null : await this.presign(bannerRow.objectKey);
return {
publicToken: album.publicToken,
resolvedLocale: resolved.locale,
defaultLocale: album.defaultLocale as AlbumLocale,
timezone: album.timezone,
startAt: album.startAt.toISOString(),
endAt: album.endAt === null ? null : album.endAt.toISOString(),
translation: {
locale: resolved.locale,
title: resolved.row.title,
description: resolved.row.description,
venue: resolved.row.venue,
address: resolved.row.address,
},
categories: categories.map((c) => ({
id: c.id,
name: this.resolveCategoryName(
namesByCategory.get(c.id),
resolved.locale,
album.defaultLocale,
),
sortOrder: c.sortOrder,
})),
banner,
// P1a 占位:浏览计数由后续 /visits 端点提供(技术实施方案 §7.1)。
viewCount: null,
// 下载两级开关(P4a Task 4 契约 PublicAlbum 新增必返字段):cn 同形返回
// (下载端点本身仅 ov 域装配——观众动作端点,见 interactions 模块)。
downloadEnabled: album.downloadEnabled,
originalDownloadEnabled: album.originalDownloadEnabled,
};
}
/**
* 公开照片列表(Task 23):仅 published,(publishedAt,id) 双列稳定倒序键集
* 游标(shared/cursor,同批 publishedAt 以 id DESC 决胜)。签名 URL 逐条现签
* (2×30 次 presign 为 P1a 可接受量级——SigV4 本地计算无网络往返;P5 若成
* 瓶颈再考虑批量/缩短 TTL 策略,见任务注记)。locale 参数接受但列表响应无
* 语言敏感字段,语义上仅保证「不因非法值报 400」(Task 5 决策)。
*/
async listPublicPhotos(
token: string,
query: {
category?: string;
cursor?: string;
limit: number;
},
): Promise<PublicPhotoListResponse> {
const album = await this.findPublicAlbum(token);
// category 过滤:须为本相册分类;幽灵/跨册同码 400(契约 category 属查询
// 参数校验;不区分两种失败,防探测)。
if (query.category !== undefined && query.category !== '') {
const category = await this.prisma.categories.findUnique({
where: { id: query.category },
select: { albumId: true },
});
if (category === null || category.albumId !== album.id) {
throw validationFailed('category 不属于该相册');
}
}
let decoded: ReturnType<typeof decodeKeysetCursor> = null;
if (query.cursor !== undefined && query.cursor !== '') {
decoded = decodeKeysetCursor(query.cursor);
if (decoded === null) {
throw validationFailed('cursor 不合法');
}
}
const where: Prisma.photosWhereInput = {
albumId: album.id,
visibility: 'published',
...(query.category !== undefined && query.category !== ''
? { categoryId: query.category }
: {}),
};
if (decoded !== null) {
Object.assign(where, keysetWhereDesc('publishedAt', 'id', decoded));
}
const rows = await this.prisma.photos.findMany({
where,
orderBy: [{ publishedAt: 'desc' }, { id: 'desc' }],
take: query.limit + 1, // 多取一条探测 hasMore(同相册列表)
});
const hasMore = rows.length > query.limit;
const page = hasMore ? rows.slice(0, query.limit) : rows;
// 变体行二次查询(扁平外键,应用层组装;发布事务保证 published 照片必有两
// 变体行)。缺行/缺尺寸属 post-publish 不变量破坏:防御性跳过而非 5xx——
// 公开页不因单张脏数据整页失败;宽高缺失同理(契约 width/height 必返)。
const variants =
page.length === 0
? []
: await this.prisma.photo_variants.findMany({
where: { photoId: { in: page.map((p) => p.id) } },
});
const variantsByPhoto = new Map<string, Map<string, PhotoVariantRow>>();
for (const row of variants) {
let bucket = variantsByPhoto.get(row.photoId);
if (bucket === undefined) {
bucket = new Map();
variantsByPhoto.set(row.photoId, bucket);
}
bucket.set(row.variant, row);
}
// P4a Task 3 水印浏览图选择(cn):albums.watermarkEnabled(有效版本开关)
// 且照片有 watermark 变体行 → 现签水印对象键;否则 browse。行是对象 put
// 成功后才落库的(worker 先写对象再 upsert 行),故行存在 ⇒ 对象存在;
// 失败照片行保留旧版本键(重生成失败不覆盖行——A20「失败照片保留旧图」),
// 新发布照片行在最新版本键(WatermarkJobCreated:新照片即刻套用)——两者
// 都直接用行的 objectKey,无需版本比较。disable 版任务逐张删行:删到哪张
// 哪张回落 browse(渐进撤下,语义见 contracts watermark.ts)。
const serveWatermark = album.watermarkEnabled;
const items: PublicPhotoListResponse['items'] = [];
for (const photo of page) {
const bucket = variantsByPhoto.get(photo.id);
const thumbRow = bucket?.get('thumb');
const browseRow = bucket?.get('browse');
if (thumbRow === undefined || browseRow === undefined) {
continue;
}
const watermarkRow = serveWatermark
? bucket?.get('watermark')
: undefined;
const browseKey =
watermarkRow !== undefined ? watermarkRow.objectKey : browseRow.objectKey;
// 宽高取 browse 变体尺寸(契约「浏览图宽高,用于列表占位」);变体行缺
// 尺寸时回退 photos 主行(原始尺寸),两侧均缺 → 同样跳过(必返字段)。
// 水印图与 browse 同源同尺寸(compose 基于 browse 管线),占位一致。
const width = browseRow.width ?? photo.width;
const height = browseRow.height ?? photo.height;
if (width === null || height === null) {
continue;
}
items.push({
photoId: photo.id,
width,
height,
thumb: await this.presign(thumbRow.objectKey),
browse: await this.presign(browseKey),
likeCount: photo.likeCount,
// published 行的 publishedAt 必非空(worker 发布事务写入);?? 兜底仅
// 收窄类型,不构成可观察行为。
publishedAt: (photo.publishedAt ?? new Date(0)).toISOString(),
});
}
// 游标取自「DB 页末行」而非「末个发射条目」:被跳过的行不应在下一页重扫
// (末行被跳过时若以末个发射条目为游标,跳过的行会被反复重取再跳过;取
// DB 行位置则一次越过)。
const last = page.at(-1);
const nextCursor =
hasMore && last !== undefined
? encodeKeysetCursor({
sort: (last.publishedAt ?? new Date(0)).toISOString(),
id: last.id,
})
: null;
return { items, nextCursor, hasMore: nextCursor !== null };
}
/**
* 公开单图(Task 24):深链定位 + 签名 URL 过期后的刷新入口(每次现签)。
* 服务端校验相册与照片归属:照片不存在 / 不属于该相册 / 非 published
* (隐藏、软删除后的旧链接)统一 404 PHOTO_NOT_PUBLIC(不泄露状态,A16)。
* 变体行缺失的防御语义同列表(跳过)——此处无「跳过」可选,落 404。
*/
async getPublicPhoto(
token: string,
photoId: string,
): Promise<PublicPhotoDetailResponse> {
const album = await this.findPublicAlbum(token);
const photo = await this.prisma.photos.findUnique({
where: { id: photoId },
});
if (
photo === null ||
photo.albumId !== album.id ||
photo.visibility !== 'published'
) {
throw photoNotPublic();
}
const variants = await this.prisma.photo_variants.findMany({
where: { photoId: photo.id },
});
const thumbRow = variants.find((v) => v.variant === 'thumb');
const browseRow = variants.find((v) => v.variant === 'browse');
// 水印浏览图选择同列表(有效开关 + 行存在 ⇒ 用行的对象键;A20 失败照片
// 保留旧图——行未被失败任务覆盖,仍指向旧版本键)。
const watermarkRow = album.watermarkEnabled
? variants.find((v) => v.variant === 'watermark')
: undefined;
const width = browseRow?.width ?? photo.width;
const height = browseRow?.height ?? photo.height;
if (
thumbRow === undefined ||
browseRow === undefined ||
width === null ||
height === null
) {
// post-publish 不变量破坏:公开面按「不可展示」处理(同列表跳过语义)。
throw photoNotPublic();
}
const browseKey =
watermarkRow !== undefined ? watermarkRow.objectKey : browseRow.objectKey;
return {
photoId: photo.id,
width,
height,
thumb: await this.presign(thumbRow.objectKey),
browse: await this.presign(browseKey),
likeCount: photo.likeCount,
publishedAt: (photo.publishedAt ?? new Date(0)).toISOString(),
categoryId: photo.categoryId,
};
}
/**
* 相册定位:publicToken 唯一索引;不存在 / offline / 未公开统一 404
* ALBUM_NOT_PUBLIC。
*/
private async findPublicAlbum(token: string): Promise<AlbumRow> {
const album = await this.prisma.albums.findUnique({
where: { publicToken: token },
});
if (album === null || album.status !== 'active' || !album.publicEnabled) {
throw albumNotPublic();
}
return album;
}
/**
* 语言回退解析(需求 4.7):请求 locale 合法(契约枚举)且有翻译条目 → 用之;
* 否则 defaultLocale。defaultLocale 条目由创建/更新路径的跨字段校验保证必在,
* 缺行属装配异常,按 5xx 兜底(公开端点不得以半翻译状态 200)。
*/
private resolveTranslation(
defaultLocale: string,
rows: AlbumTranslationRow[],
requested: string | undefined,
): { locale: AlbumLocale; row: AlbumTranslationRow } {
const byLocale = new Map(rows.map((row) => [row.locale, row]));
const defaultRow = byLocale.get(defaultLocale);
if (defaultRow === undefined) {
throw new ApiException(
ErrorCodes.INTERNAL_ERROR,
'Internal server error',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
if (
requested !== undefined &&
(ALBUM_LOCALES as readonly string[]).includes(requested)
) {
const row = byLocale.get(requested);
if (row !== undefined) {
return { locale: requested as AlbumLocale, row };
}
}
return { locale: defaultLocale as AlbumLocale, row: defaultRow };
}
/**
* 分类名称解析:resolved locale → defaultLocale → 任一既有条目(locale 升序
* 首,确定性输出)。分类创建必附翻译,末两级纯防御(名称为契约必返 string,
* 不返回 null——真缺行时回退空串并保持 200,公开页不因单分类脏数据整页 5xx)。
*/
private resolveCategoryName(
names: Map<string, string> | undefined,
resolvedLocale: AlbumLocale,
defaultLocale: string,
): string {
if (names === undefined || names.size === 0) {
return '';
}
return (
names.get(resolvedLocale) ??
names.get(defaultLocale) ??
([...names.entries()].sort(([a], [b]) => (a < b ? -1 : 1))[0][1] as string)
);
}
/** 现签短期 GET 凭证(TTL 惰性读取;expiresAt 与 URL 同源推导)。 */
private async presign(objectKey: string): Promise<SignedUrlResponse> {
const ttl = signedUrlTtlSeconds();
const url = await this.storage.presignGet(objectKey, ttl);
return { url, expiresAt: new Date(Date.now() + ttl * 1000).toISOString() };
}
}