Files
photo-live/services/api/src/photos/photos.service.ts
T

374 lines
15 KiB
TypeScript
Raw Normal View History

// 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);
}
}