Files
photo-live/services/api/src/photos/photos.service.ts
T
2026-09-18 15:21:41 +08:00

374 lines
15 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/photos/photos.service.ts —— 照片可见性生命周期(Task 24:
// updatePhoto 隐藏/恢复发布、deletePhoto 软删除)+ 管理照片列表(P1b Task 4:
// listAlbumPhotos,全状态可见 + uploader 脱敏内嵌 + (uploadedAt,id) 键集游标)。
// 契约权威:openapi.yaml tags [photos]。状态机(visibility,docs §5.5「对外可见性」):
// unpublished ──(worker 发布事务, Task 21)──▶ published
// published ◀──────(manager)──────▶ hidden 双向,管理者可来回
// unpublished/published/hidden ──(manager)──▶ deleted 单向终态(软删除)
// · unpublished →published:发布是处理流水线(变体+outbox photo.published)
// 的产物,PATCH 不承担 —— 400(状态机拒绝,走完上传/处理链路自然发布)。
// · deleted 出态一律 400:P1a 判定 deleted 为员工侧终态,恢复/重发布仅
// P4a 管理员物理清除流程的相邻能力(重新上传即新照片新 ID)。
// · 恢复发布(hidden→published)P1a 默认仅相册创建者/公司管理员:隐藏是
// 单人可做的轻操作,重新进入公开面是运营决策——从严默认,需求侧(§2
// 「管理自己照片」边界)最终裁决前不放宽。已登记为待定需求决策。
// 权限(§2 矩阵,A02 逐次校验):canManagePhoto = admin 全部 / 相册 owner 全部 /
// 协作者仅自己上传。404/403 映射:照片不存在或相册不可见(非成员)→ 404
// PHOTO_NOT_FOUND(防枚举);可见但无管理权(协作者对他人照片)→ 403。
// 每次真实状态迁移:条件更新(WHERE visibility=期望前态,并发下恰一人胜出)
// + entityVersion 自增 + 事务内 outbox 事件(P2 副本按 entityVersion 单调消费)
// + audit。幂等前态(同值 PATCH / 重复 DELETE)直接回显当前记录,不产生
// 第二次事件/审计(entityVersion 不动——副本侧按版本去重的不变量)。
import { Injectable } from '@nestjs/common';
import type { components } from '@photo/contracts';
import type { Prisma } from '@photo/db-cn';
import type { AuthUser } from '../auth/session.guard';
import { PermissionsService } from '../albums/permissions.service';
import {
albumForbidden,
albumNotFound,
validationFailed,
} from '../albums/support';
import { PrismaService } from '../prisma/prisma.service';
import {
decodeKeysetCursor,
encodeKeysetCursor,
keysetWhereDesc,
} from '../shared/cursor';
import { buildPhotoPublishedPayload } from '../sync/payloads';
import type { PhotoVisibility, PhotoVisibilityTarget } from './dto';
import {
photoForbidden,
photoNotFound,
visibilityTransitionFailed,
} from './support';
export type PhotoRecord = components['schemas']['PhotoRecord'];
export type AdminPhotoListResponse = components['schemas']['AdminPhotoList'];
type PhotoRow = Prisma.photosGetPayload<Prisma.photosDefaultArgs>;
function toPhotoRecord(row: PhotoRow): PhotoRecord {
return {
photoId: row.id,
albumId: row.albumId,
categoryId: row.categoryId,
uploaderId: row.uploaderId,
visibility: row.visibility as PhotoRecord['visibility'],
processingState: row.processingState as PhotoRecord['processingState'],
bytes: row.bytes,
width: row.width,
height: row.height,
shotAt: row.shotAt === null ? null : row.shotAt.toISOString(),
// 契约 uploadedAt 必返 string;真实链路(complete)恒写入。?? 兜底仅收窄
// 类型(直种的脏行),不构成可观察行为。
uploadedAt: (row.uploadedAt ?? new Date(0)).toISOString(),
publishedAt: row.publishedAt === null ? null : row.publishedAt.toISOString(),
likeCount: row.likeCount,
};
}
@Injectable()
export class PhotosService {
constructor(
private readonly prisma: PrismaService,
private readonly permissions: PermissionsService,
) {}
/**
* 管理照片列表(P1b Task 4,契约 listAlbumPhotos):区别于公开列表——全部
* 可见性状态可见(unpublished/published/hidden/deleted),条目内嵌 uploader
* 脱敏信息(account/displayName,无凭据字段)。权限顺序防枚举(同 members):
* canViewAlbum=false → 404 ALBUM_NOT_FOUND;可见但 canManageAlbum=false
* (协作者)→ 403 AUTH_FORBIDDEN;admin 对幽灵 id 权限恒真,空相册兜底 404。
* 排序 (uploadedAt,id) DESC 键集游标(shared/cursor)。
*
* 未完成上传(pending_upload、uploadedAt NULL)不入列表:uploadedAt 既是契约
* 必返字段又是键集排序键——NULL 行既无法以契约形状返回(toPhotoRecord 的
* new Date(0) 兜底会伪造 1970 时间轴),也无法参与键集谓词(SQL 对 NULL 的
* 比较恒 UNKNOWN,跨页会被静默跳过)。进行中的会话由 uploads 历史端点表达。
*/
async listAlbumPhotos(
user: AuthUser,
albumId: string,
query: {
visibility?: PhotoVisibility;
categoryId?: string;
cursor?: string;
limit: number;
},
): Promise<AdminPhotoListResponse> {
if (!(await this.permissions.canViewAlbum(user, albumId))) {
throw albumNotFound();
}
if (!(await this.permissions.canManageAlbum(user, albumId))) {
throw albumForbidden('仅创建者或管理员可访问管理列表');
}
// admin 对不存在 id 的 canView/canManage 恒真(非 admin 的可见成员行本身
// 蕴含相册存在),空相册兜底同码 404——契约「相册不存在,或当前用户不可见」。
const albumExists = await this.prisma.albums.findUnique({
where: { id: albumId },
select: { id: true },
});
if (albumExists === null) {
throw albumNotFound();
}
// categoryId:须为本相册分类;幽灵/跨册同码 400(同公开列表语义,不区分
// 两种失败,防探测)。
if (query.categoryId !== undefined) {
const category = await this.prisma.categories.findUnique({
where: { id: query.categoryId },
select: { albumId: true },
});
if (category === null || category.albumId !== albumId) {
throw validationFailed('categoryId 不属于该相册');
}
}
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,
uploadedAt: { not: null },
...(query.visibility !== undefined
? { visibility: query.visibility }
: {}),
...(query.categoryId !== undefined
? { categoryId: query.categoryId }
: {}),
};
if (decoded !== null) {
Object.assign(where, keysetWhereDesc('uploadedAt', 'id', decoded));
}
const rows = await this.prisma.photos.findMany({
where,
orderBy: [{ uploadedAt: 'desc' }, { id: 'desc' }],
take: query.limit + 1, // 多取一条探测 hasMore(同相册/成员列表)
});
const hasMore = rows.length > query.limit;
const page = hasMore ? rows.slice(0, query.limit) : rows;
// users 无 relation(扁平外键):二次查询应用层组装 uploader 脱敏信息。
const users =
page.length === 0
? []
: await this.prisma.users.findMany({
where: { id: { in: page.map((p) => p.uploaderId) } },
select: { id: true, account: true, displayName: true },
});
const userById = new Map(users.map((u) => [u.id, u]));
const items = page.flatMap((row) => {
const uploader = userById.get(row.uploaderId);
// 用户行缺失(P1a 无用户删除,仅可能来自手工改库):跳过而非 5xx(同
// 成员列表先例,单条脏数据不拖垮整页)。
return uploader === undefined
? []
: [
{
...toPhotoRecord(row),
uploader: {
userId: uploader.id,
account: uploader.account,
displayName: uploader.displayName,
},
},
];
});
const last = page.at(-1);
const nextCursor =
hasMore && last !== undefined
? encodeKeysetCursor({
// where 已排除 NULL:断言收窄供类型系统(键集取页末行 DB 位置)。
sort: (last.uploadedAt ?? new Date(0)).toISOString(),
id: last.id,
})
: null;
return { items, nextCursor, hasMore: nextCursor !== null };
}
/**
* 调整可见性(契约 updatePhoto):hidden↔published 间切换。权限 → 状态机 →
* 条件更新事务(entityVersion+1 + outbox + audit)→ 回读回显。
*/
async updateVisibility(
user: AuthUser,
photoId: string,
target: PhotoVisibilityTarget,
): Promise<PhotoRecord> {
const photo = await this.locateManagedPhoto(user, photoId);
// 幂等前态:目标即当前态——无迁移无事件,直接回显(entityVersion 不动)。
if (photo.visibility === target) {
return toPhotoRecord(photo);
}
if (target === 'hidden') {
// 仅 published → hidden:隐藏语义是「从公开面撤下」,unpublished 尚未
// 公开、deleted 已终态,均不可达。
if (photo.visibility !== 'published') {
throw visibilityTransitionFailed(photo.visibility, target);
}
return this.transition(user, photo, 'hidden');
}
// target === 'published':仅 hidden → published(恢复发布)。unpublished 的
// 发布属 worker 处理链路;deleted 终态。
if (photo.visibility !== 'hidden') {
throw visibilityTransitionFailed(photo.visibility, target);
}
// 恢复发布 P1a 从严:仅相册创建者/公司管理员(待定需求决策,文件头注)。
if (!(await this.permissions.canManageAlbum(user, photo.albumId))) {
throw photoForbidden('仅创建者或管理员可恢复发布');
}
return this.transition(user, photo, 'published');
}
/**
* 软删除(契约 deletePhoto):任意非 deleted 前态单向进入 deleted。行/对象/
* 变体全部保留(物理清除属 P4a 运维流程);公开端点即刻不可见(读侧只认
* published)。重复删除幂等 204,不再发事件。
*/
async remove(user: AuthUser, photoId: string): Promise<void> {
const photo = await this.locateManagedPhoto(user, photoId);
if (photo.visibility === 'deleted') {
return;
}
await this.transition(user, photo, 'deleted');
}
/**
* 照片定位 + 权限映射:不存在 → 404;相册不可见(非成员)→ 同码 404(防
* id 枚举);可见但无管理权 → 403。返回照片行(状态机裁决用)。
*/
private async locateManagedPhoto(
user: AuthUser,
photoId: string,
): Promise<PhotoRow> {
const photo = await this.prisma.photos.findUnique({
where: { id: photoId },
});
if (photo === null) {
throw photoNotFound();
}
if (!(await this.permissions.canViewAlbum(user, photo.albumId))) {
throw photoNotFound();
}
if (!(await this.permissions.canManagePhoto(user, photoId))) {
throw photoForbidden();
}
return photo;
}
/**
* 状态迁移事务:条件更新 WHERE visibility=前态(并发下败方 count=0 → 重读
* 后按「已达目标态」幂等回显或 400 状态冲突)+ outbox(photo.hidden/
* photo.published/photo.deleted,entityVersion=迁移后版本)+ audit
* (photo.hide/photo.publish/photo.delete)。恢复发布的 outbox 载荷复用契约
* PhotoPublishedPayload 全集(P2 副本一张照片只需一种 published 载荷形状);
* hidden/deleted 载荷 P1a 无契约消费者,落最小事实 {photoId,albumId,
* visibility,entityVersion}。publishedAt 不随恢复发布改写——隐藏是即时
* 撤下,时间轴(列表排序依据)保持原发布时刻。
*/
private async transition(
user: AuthUser,
photo: PhotoRow,
target: 'hidden' | 'published' | 'deleted',
): Promise<PhotoRecord> {
const nextVersion = photo.entityVersion + 1;
let updatedRow: PhotoRow | null = null;
await this.prisma.$transaction(async (tx) => {
const race = await tx.photos.updateMany({
where: { id: photo.id, visibility: photo.visibility },
data: { visibility: target, entityVersion: { increment: 1 } },
});
if (race.count === 0) {
// 并发败方:不猜并发结果,交给外层重读裁决。
return;
}
if (target === 'published') {
const variants = await tx.photo_variants.findMany({
where: { photoId: photo.id },
});
// 载荷构建与 bootstrap/reconcile 快照共用同一出口(sync/payloads.ts),
// 保证两类事件逐字节同形;post-publish 不变量破坏时 5xx 回滚整个迁移。
const payload = buildPhotoPublishedPayload(photo, variants, nextVersion);
await tx.outbox_events.create({
data: {
type: 'photo.published',
albumId: photo.albumId,
photoId: photo.id,
entityVersion: nextVersion,
// P2 Task 3:恢复发布为常规优先级(显式声明,不依赖列默认)。
priority: 'normal',
// 接口类型无隐式索引签名,Prisma InputJsonValue 需显式收窄
//(同 worker publish.service 先例)。
payload: payload as unknown as Prisma.InputJsonValue,
},
});
} else {
await tx.outbox_events.create({
data: {
type: target === 'hidden' ? 'photo.hidden' : 'photo.deleted',
albumId: photo.albumId,
photoId: photo.id,
entityVersion: nextVersion,
// P2 Task 3:隐藏/删除是「即刻从公开面撤下」的墓碑类事件——高优先级,
// 发送器批次内优先投递(worker 首次发布的 photo.published 走列默认
// normal,无需改动)。
priority: 'high',
payload: {
photoId: photo.id,
albumId: photo.albumId,
visibility: target,
entityVersion: nextVersion,
},
},
});
}
await tx.audit_logs.create({
data: {
actorId: user.id,
action:
target === 'hidden'
? 'photo.hide'
: target === 'published'
? 'photo.publish'
: 'photo.delete',
resourceId: photo.id,
metadata: { from: photo.visibility, to: target },
},
});
updatedRow = {
...photo,
visibility: target,
entityVersion: nextVersion,
};
});
if (updatedRow === null) {
// 条件更新败北:重读当前态——已达目标态按幂等回显,否则 400 状态冲突
// (客户端可凭 PhotoRecord.visibility 重新裁决)。
const current = await this.prisma.photos.findUnique({
where: { id: photo.id },
});
if (current !== null && current.visibility === target) {
return toPhotoRecord(current);
}
throw visibilityTransitionFailed(
current?.visibility ?? photo.visibility,
target,
);
}
return toPhotoRecord(updatedRow);
}
}