feat(api): photo visibility lifecycle with audit and outbox events
Serializes turbo tasks (concurrency=1): api and worker e2e suites share one MySQL/MinIO and resetDb TRUNCATEs raced when run in parallel. Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -19,6 +19,7 @@ import { AlbumsModule } from './albums/albums.module';
|
|||||||
import { AllExceptionsFilter } from './all-exceptions.filter';
|
import { AllExceptionsFilter } from './all-exceptions.filter';
|
||||||
import { AuthModule } from './auth/auth.module';
|
import { AuthModule } from './auth/auth.module';
|
||||||
import { HealthController } from './health.controller';
|
import { HealthController } from './health.controller';
|
||||||
|
import { PhotosModule } from './photos/photos.module';
|
||||||
import { PrismaModule } from './prisma/prisma.module';
|
import { PrismaModule } from './prisma/prisma.module';
|
||||||
import { PublicModule } from './public/public.module';
|
import { PublicModule } from './public/public.module';
|
||||||
import { RequestIdMiddleware, resolveRequestId } from './request-id.middleware';
|
import { RequestIdMiddleware, resolveRequestId } from './request-id.middleware';
|
||||||
@@ -37,6 +38,8 @@ import type { ServerRequestWithId } from './shared/request-with-id';
|
|||||||
UploadsModule,
|
UploadsModule,
|
||||||
// 公开(观众端)域(Task 22 getPublicAlbum;Task 23 公开照片读)。
|
// 公开(观众端)域(Task 22 getPublicAlbum;Task 23 公开照片读)。
|
||||||
PublicModule,
|
PublicModule,
|
||||||
|
// 照片管理域(Task 24:可见性生命周期 PATCH/DELETE)。
|
||||||
|
PhotosModule,
|
||||||
LoggerModule.forRoot({
|
LoggerModule.forRoot({
|
||||||
pinoHttp: {
|
pinoHttp: {
|
||||||
// 与 RequestIdMiddleware 共用同一解析函数:优先透传 x-request-id,
|
// 与 RequestIdMiddleware 共用同一解析函数:优先透传 x-request-id,
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
// services/api/src/photos/dto.ts —— 照片可见性端点入参 DTO(Task 24)。
|
||||||
|
// 校验约束对齐契约 PhotoVisibilityUpdateRequest:visibility 仅允许
|
||||||
|
// hidden/published(enum 之外任何值 → 400 VALIDATION_FAILED);deleted 只能经
|
||||||
|
// DELETE 进入,不开放 PATCH 直达(单向终态,见 photos.service 状态机)。
|
||||||
|
import { IsIn } from 'class-validator';
|
||||||
|
|
||||||
|
/** 契约 PhotoVisibilityUpdateRequest.visibility 取值(hidden↔published 间切换)。 */
|
||||||
|
export const PHOTO_VISIBILITY_TARGETS = ['hidden', 'published'] as const;
|
||||||
|
export type PhotoVisibilityTarget = (typeof PHOTO_VISIBILITY_TARGETS)[number];
|
||||||
|
|
||||||
|
export class PhotoVisibilityUpdateDto {
|
||||||
|
@IsIn(PHOTO_VISIBILITY_TARGETS)
|
||||||
|
visibility!: PhotoVisibilityTarget;
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
// services/api/src/photos/photos.controller.ts —— /photos 端点(Task 24)。
|
||||||
|
// 方法名与契约 operationId 一一对应(updatePhoto/deletePhoto)。控制器级
|
||||||
|
// SessionGuard:两端点全需认证(契约 bearerAuth)。职责仅透传认证用户与
|
||||||
|
// 参数;404/403/400 映射与状态机裁决在服务层。注意 DTO 值导入(非
|
||||||
|
// import type):@Body 参数类型经 emitDecoratorMetadata 进入设计期元数据。
|
||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
Param,
|
||||||
|
Patch,
|
||||||
|
Req,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { userOf } from '../albums/albums.controller';
|
||||||
|
import { AuthenticatedRequest, SessionGuard } from '../auth/session.guard';
|
||||||
|
import { PhotosService, type PhotoRecord } from './photos.service';
|
||||||
|
import { PhotoVisibilityUpdateDto } from './dto';
|
||||||
|
|
||||||
|
@Controller('photos')
|
||||||
|
@UseGuards(SessionGuard)
|
||||||
|
export class PhotosController {
|
||||||
|
constructor(private readonly photosService: PhotosService) {}
|
||||||
|
|
||||||
|
/** 调整可见性(hidden↔published):按相册角色与上传人共同判定权限。 */
|
||||||
|
@Patch(':photoId')
|
||||||
|
updatePhoto(
|
||||||
|
@Param('photoId') photoId: string,
|
||||||
|
@Body() dto: PhotoVisibilityUpdateDto,
|
||||||
|
@Req() req: AuthenticatedRequest,
|
||||||
|
): Promise<PhotoRecord> {
|
||||||
|
return this.photosService.updateVisibility(
|
||||||
|
userOf(req),
|
||||||
|
photoId,
|
||||||
|
dto.visibility,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 软删除:行/对象保留,公开端点即刻不可见;幂等重复删除 204。 */
|
||||||
|
@Delete(':photoId')
|
||||||
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
async deletePhoto(
|
||||||
|
@Param('photoId') photoId: string,
|
||||||
|
@Req() req: AuthenticatedRequest,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.photosService.remove(userOf(req), photoId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
// services/api/src/photos/photos.module.ts —— 照片管理模块(Task 24)。
|
||||||
|
// 装配:PhotosController/PhotosService + PermissionsService(经 AlbumsModule
|
||||||
|
// 导入复用 A02 逐次权限判定;同 uploads 模块的装配先例)。
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AlbumsModule } from '../albums/albums.module';
|
||||||
|
import { PhotosController } from './photos.controller';
|
||||||
|
import { PhotosService } from './photos.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [AlbumsModule],
|
||||||
|
controllers: [PhotosController],
|
||||||
|
providers: [PhotosService],
|
||||||
|
})
|
||||||
|
export class PhotosModule {}
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
// services/api/src/photos/photos.service.ts —— 照片可见性生命周期(Task 24:
|
||||||
|
// updatePhoto 隐藏/恢复发布、deletePhoto 软删除)。契约权威: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 { HttpStatus, Injectable } from '@nestjs/common';
|
||||||
|
import type { components } from '@photo/contracts';
|
||||||
|
import { ErrorCodes } from '@photo/contracts';
|
||||||
|
import type { PhotoPublishedPayload } from '@photo/contracts';
|
||||||
|
import type { Prisma } from '@photo/db-cn';
|
||||||
|
import { ApiException } from '../api-exception';
|
||||||
|
import type { AuthUser } from '../auth/session.guard';
|
||||||
|
import { PermissionsService } from '../albums/permissions.service';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import type { PhotoVisibilityTarget } from './dto';
|
||||||
|
import {
|
||||||
|
photoForbidden,
|
||||||
|
photoNotFound,
|
||||||
|
visibilityTransitionFailed,
|
||||||
|
} from './support';
|
||||||
|
|
||||||
|
export type PhotoRecord = components['schemas']['PhotoRecord'];
|
||||||
|
|
||||||
|
type PhotoRow = Prisma.photosGetPayload<Prisma.photosDefaultArgs>;
|
||||||
|
type PhotoVariantRow = Prisma.photo_variantsGetPayload<
|
||||||
|
Prisma.photo_variantsDefaultArgs
|
||||||
|
>;
|
||||||
|
|
||||||
|
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,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 调整可见性(契约 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 },
|
||||||
|
});
|
||||||
|
const payload = this.publishedPayload(photo, variants, nextVersion);
|
||||||
|
await tx.outbox_events.create({
|
||||||
|
data: {
|
||||||
|
type: 'photo.published',
|
||||||
|
albumId: photo.albumId,
|
||||||
|
photoId: photo.id,
|
||||||
|
entityVersion: nextVersion,
|
||||||
|
// 接口类型无隐式索引签名,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,
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 恢复发布的 outbox 载荷(契约 PhotoPublishedPayload 全集)。能走到「隐藏」
|
||||||
|
* 前态的照片必经发布事务(变体行+尺寸+publishedAt 齐备);缺任一项属
|
||||||
|
* post-publish 不变量破坏——5xx 回滚整个迁移(照片保持 hidden,不向 P2
|
||||||
|
* 副本发出键为空的事件),交由人工介入而非带病发布。
|
||||||
|
*/
|
||||||
|
private publishedPayload(
|
||||||
|
photo: PhotoRow,
|
||||||
|
variants: PhotoVariantRow[],
|
||||||
|
entityVersion: number,
|
||||||
|
): PhotoPublishedPayload {
|
||||||
|
const thumb = variants.find((v) => v.variant === 'thumb');
|
||||||
|
const browse = variants.find((v) => v.variant === 'browse');
|
||||||
|
if (
|
||||||
|
thumb === undefined ||
|
||||||
|
browse === undefined ||
|
||||||
|
photo.width === null ||
|
||||||
|
photo.height === null ||
|
||||||
|
photo.publishedAt === null
|
||||||
|
) {
|
||||||
|
throw new ApiException(
|
||||||
|
ErrorCodes.INTERNAL_ERROR,
|
||||||
|
'Internal server error',
|
||||||
|
HttpStatus.INTERNAL_SERVER_ERROR,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
photoId: photo.id,
|
||||||
|
albumId: photo.albumId,
|
||||||
|
objectKey: photo.objectKey,
|
||||||
|
thumbKey: thumb.objectKey,
|
||||||
|
browseKey: browse.objectKey,
|
||||||
|
width: photo.width,
|
||||||
|
height: photo.height,
|
||||||
|
publishedAt: photo.publishedAt.toISOString(),
|
||||||
|
entityVersion,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
// services/api/src/photos/support.ts —— 照片域共用小工具(Task 24)。
|
||||||
|
import { HttpStatus } from '@nestjs/common';
|
||||||
|
import { ErrorCodes } from '@photo/contracts';
|
||||||
|
import { ApiException } from '../api-exception';
|
||||||
|
import { validationFailed } from '../albums/support';
|
||||||
|
|
||||||
|
/** 统一 404 PHOTO_NOT_FOUND:照片不存在 / 相册不可见(防 id 枚举,契约语义)。 */
|
||||||
|
export function photoNotFound(): ApiException {
|
||||||
|
return new ApiException(
|
||||||
|
ErrorCodes.PHOTO_NOT_FOUND,
|
||||||
|
'照片不存在或不可见',
|
||||||
|
HttpStatus.NOT_FOUND,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 统一 403:可见但无该照片的管理权限(协作者对他人上传,契约 AUTH_FORBIDDEN)。 */
|
||||||
|
export function photoForbidden(message = '无该照片的管理权限'): ApiException {
|
||||||
|
return new ApiException(ErrorCodes.AUTH_FORBIDDEN, message, HttpStatus.FORBIDDEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 400:可见性状态机拒绝(契约 updatePhoto 400 语义——目标态不可达,如
|
||||||
|
* unpublished 直发 published、deleted 出态、unpublished 隐藏)。
|
||||||
|
*/
|
||||||
|
export function visibilityTransitionFailed(
|
||||||
|
from: string,
|
||||||
|
to: string,
|
||||||
|
): ApiException {
|
||||||
|
return validationFailed(`可见性不允许从 ${from} 变更为 ${to}`);
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
DEFAULT_PUBLIC_PHOTO_PAGE_SIZE,
|
DEFAULT_PUBLIC_PHOTO_PAGE_SIZE,
|
||||||
MAX_PUBLIC_PHOTO_PAGE_SIZE,
|
MAX_PUBLIC_PHOTO_PAGE_SIZE,
|
||||||
type PublicAlbumResponse,
|
type PublicAlbumResponse,
|
||||||
|
type PublicPhotoDetailResponse,
|
||||||
type PublicPhotoListResponse,
|
type PublicPhotoListResponse,
|
||||||
PublicService,
|
PublicService,
|
||||||
} from './public.service';
|
} from './public.service';
|
||||||
@@ -80,4 +81,16 @@ export class PublicController {
|
|||||||
limit: parsePublicPhotoLimit(limit),
|
limit: parsePublicPhotoLimit(limit),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 公开单图:深链定位;隐藏/删除/换册后的旧链接 404 PHOTO_NOT_PUBLIC。
|
||||||
|
* 每次调用现签 thumb/browse(契约规定的过期刷新入口)。
|
||||||
|
*/
|
||||||
|
@Get(':token/photos/:photoId')
|
||||||
|
getPublicPhoto(
|
||||||
|
@Param('token') token: string,
|
||||||
|
@Param('photoId') photoId: string,
|
||||||
|
): Promise<PublicPhotoDetailResponse> {
|
||||||
|
return this.publicService.getPublicPhoto(token, photoId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ import {
|
|||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { STORAGE_PORT } from '../uploads/storage.provider';
|
import { STORAGE_PORT } from '../uploads/storage.provider';
|
||||||
import { signedUrlTtlSeconds } from '../uploads/support';
|
import { signedUrlTtlSeconds } from '../uploads/support';
|
||||||
import { albumNotPublic } from './support';
|
import { albumNotPublic, photoNotPublic } from './support';
|
||||||
|
|
||||||
export type PublicAlbumResponse = components['schemas']['PublicAlbum'];
|
export type PublicAlbumResponse = components['schemas']['PublicAlbum'];
|
||||||
export type PublicPhotoListResponse =
|
export type PublicPhotoListResponse =
|
||||||
@@ -255,6 +255,55 @@ export class PublicService {
|
|||||||
return { items, nextCursor, hasMore: nextCursor !== 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');
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
photoId: photo.id,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
thumb: await this.presign(thumbRow.objectKey),
|
||||||
|
browse: await this.presign(browseRow.objectKey),
|
||||||
|
likeCount: photo.likeCount,
|
||||||
|
publishedAt: (photo.publishedAt ?? new Date(0)).toISOString(),
|
||||||
|
categoryId: photo.categoryId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 相册定位:publicToken 唯一索引;不存在 / offline / 未公开统一 404
|
* 相册定位:publicToken 唯一索引;不存在 / offline / 未公开统一 404
|
||||||
* ALBUM_NOT_PUBLIC。
|
* ALBUM_NOT_PUBLIC。
|
||||||
|
|||||||
@@ -0,0 +1,746 @@
|
|||||||
|
// services/api/test/photos.e2e-spec.ts —— Task 24 照片可见性生命周期 e2e
|
||||||
|
// (先写后实现)。真实 app + MySQL + 真实 MinIO(变体对象)。覆盖:
|
||||||
|
// ① 公开单图 getPublicPhoto:published → 200 PublicPhotoDetail(+categoryId,
|
||||||
|
// 签名 URL 现签可取回);换册 token / hidden / unpublished / 幽灵 id →
|
||||||
|
// 404 PHOTO_NOT_PUBLIC(旧深链不泄露状态,A16);
|
||||||
|
// ② PATCH /photos/{id} 隐藏:上传协作者(自己照片)→ 200 PhotoRecord;
|
||||||
|
// 相册 owner(他人上传)→ 200;其他协作者 → 403;外人员工 → 404
|
||||||
|
// (PHOTO_NOT_FOUND,防枚举);隐藏后公开列表即刻排除、单图 404;
|
||||||
|
// ③ outbox photo.hidden(entityVersion+1)+ audit photo.hide;
|
||||||
|
// ④ 恢复发布:hidden→published 仅 owner/admin → 200(上传协作者 403,
|
||||||
|
// P1a 从严默认,待定需求决策);列表重新出现;outbox 再发
|
||||||
|
// photo.published(新行、entityVersion 再+1);publishedAt 不变(时间轴
|
||||||
|
// 不因隐藏/恢复漂移);
|
||||||
|
// ⑤ DELETE 软删除 → 204;DB visibility=deleted 且行/对象键保留;公开面
|
||||||
|
// 即刻消失;outbox photo.deleted + audit photo.delete;deleted 后恢复
|
||||||
|
// 发布 → 400(员工侧终态,物理清除/恢复属 P4a 管理流程);协作者删自己
|
||||||
|
// ✓、删他人 ✗ 403;
|
||||||
|
// ⑥ 状态机 400:非法 visibility 值;unpublished→published(发布属 worker
|
||||||
|
// 处理链路);unpublished→hidden;幂等同值 PATCH → 200 且无新事件。
|
||||||
|
import { randomBytes } from 'node:crypto';
|
||||||
|
import { type INestApplication } from '@nestjs/common';
|
||||||
|
import { Test } from '@nestjs/testing';
|
||||||
|
import request from 'supertest';
|
||||||
|
import sharp from 'sharp';
|
||||||
|
import { PrismaClient } from '@photo/db-cn';
|
||||||
|
import { ErrorCodes } from '@photo/contracts';
|
||||||
|
import {
|
||||||
|
createStorageFromEnv,
|
||||||
|
type MinioStorageAdapter,
|
||||||
|
} from '@photo/storage';
|
||||||
|
import { AppCnModule } from '../src/app-cn.module';
|
||||||
|
import { hashSessionToken } from '../src/auth/session.guard';
|
||||||
|
import { API_PREFIX } from '../src/shared/constants';
|
||||||
|
import { resetDb } from './reset';
|
||||||
|
|
||||||
|
jest.setTimeout(120_000);
|
||||||
|
|
||||||
|
/** 契约 pattern ^[0-9a-z]{20,32}$:24 字符 [0-9a-z](同 albums.service 生成式)。 */
|
||||||
|
function makePublicToken(): string {
|
||||||
|
const alphabet = '0123456789abcdefghijklmnopqrstuvwxyz';
|
||||||
|
let token = '';
|
||||||
|
const bytes = randomBytes(24);
|
||||||
|
for (let i = 0; i < 24; i++) {
|
||||||
|
token += alphabet[bytes[i] % alphabet.length];
|
||||||
|
}
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** cuid 形状 ID(对象键含 photoId,须先有 ID 再一次 insert 成形,同 api 约定)。 */
|
||||||
|
function cuidLike(alias: string): string {
|
||||||
|
return `c${Date.now().toString(36)}${alias}${randomBytes(6).toString('hex')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 测试内生成真实 JPEG(无二进制入库,同 worker publish e2e)。 */
|
||||||
|
async function makeJpeg(width: number, height: number): Promise<Buffer> {
|
||||||
|
return sharp({
|
||||||
|
create: { width, height, channels: 3, background: { r: 96, g: 130, b: 182 } },
|
||||||
|
})
|
||||||
|
.jpeg({ quality: 85 })
|
||||||
|
.toBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
const KEY_ROOT = 'photo-live/dev';
|
||||||
|
const variantKey = (
|
||||||
|
albumId: string,
|
||||||
|
photoId: string,
|
||||||
|
variant: 'thumb' | 'browse',
|
||||||
|
) => `${KEY_ROOT}/albums/${albumId}/variants/${photoId}/1/${variant}.jpg`;
|
||||||
|
|
||||||
|
/** 契约 PhotoRecord 的必返字段全集(形状精确断言用)。 */
|
||||||
|
const PHOTO_RECORD_KEYS = [
|
||||||
|
'albumId',
|
||||||
|
'bytes',
|
||||||
|
'categoryId',
|
||||||
|
'height',
|
||||||
|
'likeCount',
|
||||||
|
'photoId',
|
||||||
|
'processingState',
|
||||||
|
'publishedAt',
|
||||||
|
'shotAt',
|
||||||
|
'uploadedAt',
|
||||||
|
'uploaderId',
|
||||||
|
'visibility',
|
||||||
|
'width',
|
||||||
|
].sort();
|
||||||
|
|
||||||
|
describe('photos visibility (e2e)', () => {
|
||||||
|
let app: INestApplication;
|
||||||
|
let prisma: PrismaClient;
|
||||||
|
let storage: MinioStorageAdapter;
|
||||||
|
|
||||||
|
let albumId: string;
|
||||||
|
let publicToken: string;
|
||||||
|
let catAId: string;
|
||||||
|
|
||||||
|
let ownerId: string;
|
||||||
|
let collabId: string;
|
||||||
|
let otherCollabId: string;
|
||||||
|
let adminId: string;
|
||||||
|
let ownerToken: string;
|
||||||
|
let collabToken: string;
|
||||||
|
let otherCollabToken: string;
|
||||||
|
let outsiderToken: string;
|
||||||
|
let adminToken: string;
|
||||||
|
|
||||||
|
// 生命周期照片(依序:隐藏→恢复→软删除)。
|
||||||
|
let lifecycleId: string;
|
||||||
|
let lifecyclePublishedAt: Date;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await resetDb();
|
||||||
|
const moduleRef = await Test.createTestingModule({
|
||||||
|
imports: [AppCnModule],
|
||||||
|
}).compile();
|
||||||
|
app = moduleRef.createNestApplication();
|
||||||
|
app.setGlobalPrefix(API_PREFIX);
|
||||||
|
await app.init();
|
||||||
|
|
||||||
|
prisma = new PrismaClient();
|
||||||
|
storage = createStorageFromEnv();
|
||||||
|
|
||||||
|
// 账号:owner(相册创建者)/ collab(上传协作者)/ otherCollab / admin /
|
||||||
|
// outsider(外人员工,非成员)。
|
||||||
|
const mkUser = (account: string, role = 'staff') =>
|
||||||
|
prisma.users.create({
|
||||||
|
data: { account, role, passwordHash: 'x', displayName: account },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
ownerId = (await mkUser('ph-owner')).id;
|
||||||
|
collabId = (await mkUser('ph-collab')).id;
|
||||||
|
otherCollabId = (await mkUser('ph-other')).id;
|
||||||
|
adminId = (await mkUser('ph-admin', 'admin')).id;
|
||||||
|
const outsiderId = (await mkUser('ph-outsider')).id;
|
||||||
|
|
||||||
|
const mkToken = async (userId: string): Promise<string> => {
|
||||||
|
const token = randomBytes(32).toString('hex');
|
||||||
|
await prisma.sessions.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
tokenHash: hashSessionToken(token),
|
||||||
|
expiresAt: new Date(Date.now() + 3_600_000),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return token;
|
||||||
|
};
|
||||||
|
ownerToken = await mkToken(ownerId);
|
||||||
|
collabToken = await mkToken(collabId);
|
||||||
|
otherCollabToken = await mkToken(otherCollabId);
|
||||||
|
outsiderToken = await mkToken(outsiderId);
|
||||||
|
adminToken = await mkToken(adminId);
|
||||||
|
|
||||||
|
// 主相册:owner + 两协作者成员行;分类 catA。
|
||||||
|
const album = await prisma.albums.create({
|
||||||
|
data: {
|
||||||
|
publicToken: makePublicToken(),
|
||||||
|
ownerId,
|
||||||
|
startAt: new Date(),
|
||||||
|
defaultLocale: 'zh-CN',
|
||||||
|
},
|
||||||
|
select: { id: true, publicToken: true },
|
||||||
|
});
|
||||||
|
albumId = album.id;
|
||||||
|
publicToken = album.publicToken;
|
||||||
|
await prisma.album_translations.create({
|
||||||
|
data: { albumId, locale: 'zh-CN', title: '生命周期相册' },
|
||||||
|
});
|
||||||
|
await prisma.album_members.createMany({
|
||||||
|
data: [
|
||||||
|
{ albumId, userId: ownerId, role: 'owner', status: 'active' },
|
||||||
|
{ albumId, userId: collabId, role: 'collaborator', status: 'active' },
|
||||||
|
{
|
||||||
|
albumId,
|
||||||
|
userId: otherCollabId,
|
||||||
|
role: 'collaborator',
|
||||||
|
status: 'active',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const cat = await prisma.categories.create({
|
||||||
|
data: { albumId, sortOrder: 1 },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
catAId = cat.id;
|
||||||
|
await prisma.category_translations.create({
|
||||||
|
data: { categoryId: catAId, locale: 'zh-CN', name: '主分类' },
|
||||||
|
});
|
||||||
|
|
||||||
|
// 另一册(token 不匹配用例):独立相册 + 一张 published 照片。
|
||||||
|
const otherAlbum = await prisma.albums.create({
|
||||||
|
data: {
|
||||||
|
publicToken: makePublicToken(),
|
||||||
|
ownerId: 'u-ph-other-album',
|
||||||
|
startAt: new Date(),
|
||||||
|
defaultLocale: 'zh-CN',
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
await prisma.album_translations.create({
|
||||||
|
data: { albumId: otherAlbum.id, locale: 'zh-CN', title: '另一册' },
|
||||||
|
});
|
||||||
|
await seedPhoto('phOtherAlbum', otherAlbum.id, 'u-ph-other-album', {
|
||||||
|
visibility: 'published',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await app.close();
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 直种照片:photos 行 + 两条 photo_variants + MinIO 真实变体对象(默认)。
|
||||||
|
* entityVersion=2 对齐 worker 发布事务落库形状(unpublished 种 1 亦可,此处
|
||||||
|
* 统一 2,版本断言以「迁移前实测值」为基线,不依赖种子常量)。
|
||||||
|
*/
|
||||||
|
async function seedPhoto(
|
||||||
|
alias: string,
|
||||||
|
targetAlbumId: string,
|
||||||
|
uploaderId: string,
|
||||||
|
options: {
|
||||||
|
visibility?: string;
|
||||||
|
categoryId?: string | null;
|
||||||
|
withVariants?: boolean;
|
||||||
|
processingState?: string;
|
||||||
|
} = {},
|
||||||
|
): Promise<string> {
|
||||||
|
const photoId = cuidLike(alias);
|
||||||
|
const [thumb, browse] = await Promise.all([
|
||||||
|
makeJpeg(480, 300),
|
||||||
|
makeJpeg(800, 500),
|
||||||
|
]);
|
||||||
|
if (options.withVariants !== false) {
|
||||||
|
await storage.put(variantKey(targetAlbumId, photoId, 'thumb'), thumb, 'image/jpeg');
|
||||||
|
await storage.put(variantKey(targetAlbumId, photoId, 'browse'), browse, 'image/jpeg');
|
||||||
|
}
|
||||||
|
await prisma.photos.create({
|
||||||
|
data: {
|
||||||
|
id: photoId,
|
||||||
|
albumId: targetAlbumId,
|
||||||
|
uploaderId,
|
||||||
|
categoryId: options.categoryId ?? null,
|
||||||
|
objectKey: `${KEY_ROOT}/albums/${targetAlbumId}/originals/${photoId}.jpg`,
|
||||||
|
bytes: browse.length,
|
||||||
|
width: 800,
|
||||||
|
height: 500,
|
||||||
|
uploadedAt: new Date(),
|
||||||
|
publishedAt: options.visibility === 'unpublished' ? null : new Date(),
|
||||||
|
processingState: options.processingState ?? 'ready',
|
||||||
|
visibility: options.visibility ?? 'published',
|
||||||
|
entityVersion: 2,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (options.withVariants !== false) {
|
||||||
|
await prisma.photo_variants.createMany({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
photoId,
|
||||||
|
variant: 'thumb',
|
||||||
|
objectKey: variantKey(targetAlbumId, photoId, 'thumb'),
|
||||||
|
bytes: thumb.length,
|
||||||
|
version: 1,
|
||||||
|
width: 480,
|
||||||
|
height: 300,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
photoId,
|
||||||
|
variant: 'browse',
|
||||||
|
objectKey: variantKey(targetAlbumId, photoId, 'browse'),
|
||||||
|
bytes: browse.length,
|
||||||
|
version: 1,
|
||||||
|
width: 800,
|
||||||
|
height: 500,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return photoId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchPhoto(token: string, photoId: string, body: object) {
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.patch(`/${API_PREFIX}/photos/${photoId}`)
|
||||||
|
.set('Authorization', `Bearer ${token}`)
|
||||||
|
.send(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deletePhoto(token: string, photoId: string) {
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.delete(`/${API_PREFIX}/photos/${photoId}`)
|
||||||
|
.set('Authorization', `Bearer ${token}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPublicPhoto(token: string, photoId: string) {
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.get(`/${API_PREFIX}/public/albums/${token}/photos/${photoId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function listPublicPhotos(query?: Record<string, string | number>) {
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.get(`/${API_PREFIX}/public/albums/${publicToken}/photos`)
|
||||||
|
.query(query ?? {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 全量公开列表的 id 集(hasMore 分页下循环取尽,断言「出现/消失」用)。 */
|
||||||
|
async function publicPhotoIds(): Promise<string[]> {
|
||||||
|
const ids: string[] = [];
|
||||||
|
let cursor: string | undefined;
|
||||||
|
for (;;) {
|
||||||
|
const res = await listPublicPhotos(
|
||||||
|
cursor === undefined ? { limit: 60 } : { limit: 60, cursor },
|
||||||
|
);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
ids.push(...res.body.items.map((p: { photoId: string }) => p.photoId));
|
||||||
|
if (res.body.nextCursor === null) {
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
cursor = res.body.nextCursor as string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('公开单图 getPublicPhoto', () => {
|
||||||
|
let singleId: string;
|
||||||
|
let hiddenId: string;
|
||||||
|
let unpubId: string;
|
||||||
|
let otherAlbumPhotoId: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
singleId = await seedPhoto('phSingle', albumId, collabId, {
|
||||||
|
categoryId: catAId,
|
||||||
|
});
|
||||||
|
hiddenId = await seedPhoto('phHidden', albumId, collabId, {
|
||||||
|
visibility: 'hidden',
|
||||||
|
});
|
||||||
|
unpubId = await seedPhoto('phUnpub', albumId, collabId, {
|
||||||
|
visibility: 'unpublished',
|
||||||
|
processingState: 'uploaded',
|
||||||
|
withVariants: false,
|
||||||
|
});
|
||||||
|
const other = await prisma.photos.findFirstOrThrow({
|
||||||
|
where: { albumId: { not: albumId }, visibility: 'published' },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
otherAlbumPhotoId = other.id;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('published → 200 PublicPhotoDetail:categoryId + 现签 thumb/browse 可取回', async () => {
|
||||||
|
const res = await getPublicPhoto(publicToken, singleId);
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(Object.keys(res.body).sort()).toEqual([
|
||||||
|
'browse',
|
||||||
|
'categoryId',
|
||||||
|
'height',
|
||||||
|
'likeCount',
|
||||||
|
'photoId',
|
||||||
|
'publishedAt',
|
||||||
|
'thumb',
|
||||||
|
'width',
|
||||||
|
]);
|
||||||
|
expect(res.body.photoId).toBe(singleId);
|
||||||
|
expect(res.body.categoryId).toBe(catAId);
|
||||||
|
expect(res.body.width).toBe(800);
|
||||||
|
expect(res.body.height).toBe(500);
|
||||||
|
expect(res.body.likeCount).toBe(0);
|
||||||
|
|
||||||
|
for (const key of ['thumb', 'browse'] as const) {
|
||||||
|
const signed = res.body[key] as { url: string; expiresAt: string };
|
||||||
|
expect(Date.parse(signed.expiresAt)).toBeGreaterThan(Date.now());
|
||||||
|
const fetched = await fetch(signed.url);
|
||||||
|
expect(fetched.status).toBe(200);
|
||||||
|
expect(
|
||||||
|
Buffer.from(await fetched.arrayBuffer()).length,
|
||||||
|
).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('换册照片 / hidden / unpublished / 幽灵 id → 404 PHOTO_NOT_PUBLIC', async () => {
|
||||||
|
// 换册:照片真实存在且 published,但不属于本 token 相册。
|
||||||
|
const mismatch = await getPublicPhoto(publicToken, otherAlbumPhotoId);
|
||||||
|
expect(mismatch.status).toBe(404);
|
||||||
|
expect(mismatch.body.code).toBe(ErrorCodes.PHOTO_NOT_PUBLIC);
|
||||||
|
|
||||||
|
for (const photoId of [hiddenId, unpubId, 'no-such-photo']) {
|
||||||
|
const res = await getPublicPhoto(publicToken, photoId);
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
expect(res.body.code).toBe(ErrorCodes.PHOTO_NOT_PUBLIC);
|
||||||
|
expect(typeof res.body.requestId).toBe('string');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('PATCH /photos/{id} 隐藏与权限矩阵', () => {
|
||||||
|
let ownId: string; // collab 上传
|
||||||
|
let ownerPhotoId: string; // owner 上传
|
||||||
|
let otherTargetId: string; // collab 上传(otherCollab 尝试 → 403)
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
ownId = await seedPhoto('phOwn', albumId, collabId);
|
||||||
|
ownerPhotoId = await seedPhoto('phOwnerUp', albumId, ownerId);
|
||||||
|
otherTargetId = await seedPhoto('phOtherTarget', albumId, collabId);
|
||||||
|
lifecycleId = await seedPhoto('phLife', albumId, collabId);
|
||||||
|
lifecyclePublishedAt = (
|
||||||
|
await prisma.photos.findUniqueOrThrow({
|
||||||
|
where: { id: lifecycleId },
|
||||||
|
select: { publishedAt: true },
|
||||||
|
})
|
||||||
|
).publishedAt as Date;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('上传协作者隐藏自己的照片 → 200 PhotoRecord;公开列表即刻排除、单图 404', async () => {
|
||||||
|
const before = await prisma.photos.findUniqueOrThrow({
|
||||||
|
where: { id: lifecycleId },
|
||||||
|
select: { entityVersion: true },
|
||||||
|
});
|
||||||
|
const ids = await publicPhotoIds();
|
||||||
|
expect(ids).toContain(lifecycleId);
|
||||||
|
|
||||||
|
const res = await patchPhoto(collabToken, lifecycleId, {
|
||||||
|
visibility: 'hidden',
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(Object.keys(res.body).sort()).toEqual(PHOTO_RECORD_KEYS);
|
||||||
|
expect(res.body).toMatchObject({
|
||||||
|
photoId: lifecycleId,
|
||||||
|
albumId,
|
||||||
|
uploaderId: collabId,
|
||||||
|
visibility: 'hidden',
|
||||||
|
processingState: 'ready',
|
||||||
|
likeCount: 0,
|
||||||
|
});
|
||||||
|
expect(res.body.publishedAt).not.toBeNull();
|
||||||
|
|
||||||
|
// A16 核心:公开面即刻不可见。
|
||||||
|
expect(await publicPhotoIds()).not.toContain(lifecycleId);
|
||||||
|
const single = await getPublicPhoto(publicToken, lifecycleId);
|
||||||
|
expect(single.status).toBe(404);
|
||||||
|
expect(single.body.code).toBe(ErrorCodes.PHOTO_NOT_PUBLIC);
|
||||||
|
|
||||||
|
// 版本推进:2 → 3。
|
||||||
|
const after = await prisma.photos.findUniqueOrThrow({
|
||||||
|
where: { id: lifecycleId },
|
||||||
|
select: { entityVersion: true, visibility: true },
|
||||||
|
});
|
||||||
|
expect(after.entityVersion).toBe(before.entityVersion + 1);
|
||||||
|
expect(after.visibility).toBe('hidden');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('outbox photo.hidden + audit photo.hide(entityVersion 对齐)', async () => {
|
||||||
|
const event = await prisma.outbox_events.findFirstOrThrow({
|
||||||
|
where: { photoId: lifecycleId, type: 'photo.hidden' },
|
||||||
|
});
|
||||||
|
expect(event.albumId).toBe(albumId);
|
||||||
|
expect(event.entityVersion).toBe(3);
|
||||||
|
expect(event.deliveredAt).toBeNull();
|
||||||
|
expect(event.payload).toMatchObject({
|
||||||
|
photoId: lifecycleId,
|
||||||
|
albumId,
|
||||||
|
visibility: 'hidden',
|
||||||
|
entityVersion: 3,
|
||||||
|
});
|
||||||
|
|
||||||
|
const audit = await prisma.audit_logs.findFirstOrThrow({
|
||||||
|
where: {
|
||||||
|
action: 'photo.hide',
|
||||||
|
actorId: collabId,
|
||||||
|
resourceId: lifecycleId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(audit.metadata).toMatchObject({ from: 'published', to: 'hidden' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('相册 owner 隐藏他人上传 → 200;其他协作者 → 403;外人员工 → 404(防枚举)', async () => {
|
||||||
|
const ownerRes = await patchPhoto(ownerToken, ownId, {
|
||||||
|
visibility: 'hidden',
|
||||||
|
});
|
||||||
|
expect(ownerRes.status).toBe(200);
|
||||||
|
expect(ownerRes.body.visibility).toBe('hidden');
|
||||||
|
|
||||||
|
const otherRes = await patchPhoto(otherCollabToken, otherTargetId, {
|
||||||
|
visibility: 'hidden',
|
||||||
|
});
|
||||||
|
expect(otherRes.status).toBe(403);
|
||||||
|
expect(otherRes.body.code).toBe(ErrorCodes.AUTH_FORBIDDEN);
|
||||||
|
|
||||||
|
const outsiderRes = await patchPhoto(outsiderToken, otherTargetId, {
|
||||||
|
visibility: 'hidden',
|
||||||
|
});
|
||||||
|
expect(outsiderRes.status).toBe(404);
|
||||||
|
expect(outsiderRes.body.code).toBe(ErrorCodes.PHOTO_NOT_FOUND);
|
||||||
|
|
||||||
|
// owner 自己上传的照片隐藏(同一人两径均 200)。
|
||||||
|
const ownerOwn = await patchPhoto(ownerToken, ownerPhotoId, {
|
||||||
|
visibility: 'hidden',
|
||||||
|
});
|
||||||
|
expect(ownerOwn.status).toBe(200);
|
||||||
|
|
||||||
|
// 幽灵照片 id:任何角色 404。
|
||||||
|
const ghost = await patchPhoto(ownerToken, 'no-such-photo', {
|
||||||
|
visibility: 'hidden',
|
||||||
|
});
|
||||||
|
expect(ghost.status).toBe(404);
|
||||||
|
expect(ghost.body.code).toBe(ErrorCodes.PHOTO_NOT_FOUND);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('恢复发布:上传协作者(自己 hidden 照片)→ 403;owner → 200,列表重现,outbox 再发 photo.published', async () => {
|
||||||
|
// 协作者不可恢复(P1a 从严默认)。
|
||||||
|
const collabRes = await patchPhoto(collabToken, lifecycleId, {
|
||||||
|
visibility: 'published',
|
||||||
|
});
|
||||||
|
expect(collabRes.status).toBe(403);
|
||||||
|
expect(collabRes.body.code).toBe(ErrorCodes.AUTH_FORBIDDEN);
|
||||||
|
|
||||||
|
const ownerRes = await patchPhoto(ownerToken, lifecycleId, {
|
||||||
|
visibility: 'published',
|
||||||
|
});
|
||||||
|
expect(ownerRes.status).toBe(200);
|
||||||
|
expect(ownerRes.body.visibility).toBe('published');
|
||||||
|
|
||||||
|
// 公开面恢复。
|
||||||
|
expect(await publicPhotoIds()).toContain(lifecycleId);
|
||||||
|
const single = await getPublicPhoto(publicToken, lifecycleId);
|
||||||
|
expect(single.status).toBe(200);
|
||||||
|
|
||||||
|
// publishedAt 不变:隐藏/恢复不重排时间轴。
|
||||||
|
const row = await prisma.photos.findUniqueOrThrow({
|
||||||
|
where: { id: lifecycleId },
|
||||||
|
select: { publishedAt: true, entityVersion: true },
|
||||||
|
});
|
||||||
|
expect((row.publishedAt as Date).getTime()).toBe(
|
||||||
|
lifecyclePublishedAt.getTime(),
|
||||||
|
);
|
||||||
|
expect(row.entityVersion).toBe(4);
|
||||||
|
|
||||||
|
// outbox:photo.published 新行(entityVersion=4),载荷为契约全集。
|
||||||
|
const events = await prisma.outbox_events.findMany({
|
||||||
|
where: { photoId: lifecycleId },
|
||||||
|
orderBy: { entityVersion: 'asc' },
|
||||||
|
});
|
||||||
|
expect(events.map((e) => [e.type, e.entityVersion])).toEqual([
|
||||||
|
['photo.hidden', 3],
|
||||||
|
['photo.published', 4],
|
||||||
|
]);
|
||||||
|
expect(events[1].payload).toEqual({
|
||||||
|
photoId: lifecycleId,
|
||||||
|
albumId,
|
||||||
|
objectKey: `${KEY_ROOT}/albums/${albumId}/originals/${lifecycleId}.jpg`,
|
||||||
|
thumbKey: variantKey(albumId, lifecycleId, 'thumb'),
|
||||||
|
browseKey: variantKey(albumId, lifecycleId, 'browse'),
|
||||||
|
width: 800,
|
||||||
|
height: 500,
|
||||||
|
publishedAt: lifecyclePublishedAt.toISOString(),
|
||||||
|
entityVersion: 4,
|
||||||
|
});
|
||||||
|
|
||||||
|
const audit = await prisma.audit_logs.findFirstOrThrow({
|
||||||
|
where: {
|
||||||
|
action: 'photo.publish',
|
||||||
|
actorId: ownerId,
|
||||||
|
resourceId: lifecycleId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(audit.metadata).toMatchObject({ from: 'hidden', to: 'published' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('admin 恢复发布 → 200(公司管理员全量)', async () => {
|
||||||
|
const adminHiddenId = await seedPhoto('phAdminRepublish', albumId, collabId, {
|
||||||
|
visibility: 'hidden',
|
||||||
|
});
|
||||||
|
const res = await patchPhoto(adminToken, adminHiddenId, {
|
||||||
|
visibility: 'published',
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.visibility).toBe('published');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('状态机 400:unpublished→published(发布属处理链路)/ unpublished→hidden;非法值 → 400', async () => {
|
||||||
|
const unpubId = await seedPhoto('phSmUnpub', albumId, collabId, {
|
||||||
|
visibility: 'unpublished',
|
||||||
|
processingState: 'uploaded',
|
||||||
|
withVariants: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const toPublished = await patchPhoto(collabToken, unpubId, {
|
||||||
|
visibility: 'published',
|
||||||
|
});
|
||||||
|
expect(toPublished.status).toBe(400);
|
||||||
|
expect(toPublished.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
|
||||||
|
|
||||||
|
const toHidden = await patchPhoto(collabToken, unpubId, {
|
||||||
|
visibility: 'hidden',
|
||||||
|
});
|
||||||
|
expect(toHidden.status).toBe(400);
|
||||||
|
expect(toHidden.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
|
||||||
|
|
||||||
|
// 契约 enum 之外的任何值(含 deleted——软删除只走 DELETE)。
|
||||||
|
for (const garbage of ['deleted', 'public', 42]) {
|
||||||
|
const res = await patchPhoto(collabToken, lifecycleId, {
|
||||||
|
visibility: garbage,
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
|
||||||
|
}
|
||||||
|
// visibility 缺失 → 400。
|
||||||
|
const missing = await request(app.getHttpServer())
|
||||||
|
.patch(`/${API_PREFIX}/photos/${lifecycleId}`)
|
||||||
|
.set('Authorization', `Bearer ${collabToken}`)
|
||||||
|
.send({});
|
||||||
|
expect(missing.status).toBe(400);
|
||||||
|
|
||||||
|
// 状态机拒绝不产生事件/审计。
|
||||||
|
expect(
|
||||||
|
await prisma.outbox_events.count({ where: { photoId: unpubId } }),
|
||||||
|
).toBe(0);
|
||||||
|
expect(
|
||||||
|
await prisma.audit_logs.count({ where: { resourceId: unpubId } }),
|
||||||
|
).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('幂等同值 PATCH → 200 当前记录且无新事件/审计(entityVersion 不动)', async () => {
|
||||||
|
const eventsBefore = await prisma.outbox_events.count({
|
||||||
|
where: { photoId: lifecycleId },
|
||||||
|
});
|
||||||
|
const res = await patchPhoto(collabToken, lifecycleId, {
|
||||||
|
visibility: 'published',
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.visibility).toBe('published');
|
||||||
|
expect(await prisma.outbox_events.count({ where: { photoId: lifecycleId } })).toBe(
|
||||||
|
eventsBefore,
|
||||||
|
);
|
||||||
|
const row = await prisma.photos.findUniqueOrThrow({
|
||||||
|
where: { id: lifecycleId },
|
||||||
|
select: { entityVersion: true },
|
||||||
|
});
|
||||||
|
expect(row.entityVersion).toBe(4);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DELETE /photos/{id} 软删除', () => {
|
||||||
|
let otherOwnedId: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
otherOwnedId = await seedPhoto('phDelOther', albumId, ownerId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('其他协作者删他人上传 → 403;协作者删自己 → 204;行/对象键保留', async () => {
|
||||||
|
const forbidden = await deletePhoto(otherCollabToken, otherOwnedId);
|
||||||
|
expect(forbidden.status).toBe(403);
|
||||||
|
expect(forbidden.body.code).toBe(ErrorCodes.AUTH_FORBIDDEN);
|
||||||
|
|
||||||
|
const res = await deletePhoto(collabToken, lifecycleId);
|
||||||
|
expect(res.status).toBe(204);
|
||||||
|
expect(res.body).toEqual({});
|
||||||
|
|
||||||
|
// DB:软删除——行保留、visibility=deleted、对象键原样、版本推进。
|
||||||
|
const row = await prisma.photos.findUniqueOrThrow({
|
||||||
|
where: { id: lifecycleId },
|
||||||
|
});
|
||||||
|
expect(row.visibility).toBe('deleted');
|
||||||
|
expect(row.entityVersion).toBe(5);
|
||||||
|
expect(row.objectKey).toBe(
|
||||||
|
`${KEY_ROOT}/albums/${albumId}/originals/${lifecycleId}.jpg`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 公开面即刻消失(列表 + 旧深链单图)。
|
||||||
|
expect(await publicPhotoIds()).not.toContain(lifecycleId);
|
||||||
|
const single = await getPublicPhoto(publicToken, lifecycleId);
|
||||||
|
expect(single.status).toBe(404);
|
||||||
|
expect(single.body.code).toBe(ErrorCodes.PHOTO_NOT_PUBLIC);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('outbox photo.deleted + audit photo.delete', async () => {
|
||||||
|
const event = await prisma.outbox_events.findFirstOrThrow({
|
||||||
|
where: { photoId: lifecycleId, type: 'photo.deleted' },
|
||||||
|
});
|
||||||
|
expect(event.entityVersion).toBe(5);
|
||||||
|
expect(event.payload).toMatchObject({
|
||||||
|
photoId: lifecycleId,
|
||||||
|
albumId,
|
||||||
|
visibility: 'deleted',
|
||||||
|
entityVersion: 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
const audit = await prisma.audit_logs.findFirstOrThrow({
|
||||||
|
where: {
|
||||||
|
action: 'photo.delete',
|
||||||
|
actorId: collabId,
|
||||||
|
resourceId: lifecycleId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(audit.metadata).toMatchObject({ from: 'published', to: 'deleted' });
|
||||||
|
|
||||||
|
// 生命周期全事件序:hidden(3) → published(4) → deleted(5)。
|
||||||
|
const events = await prisma.outbox_events.findMany({
|
||||||
|
where: { photoId: lifecycleId },
|
||||||
|
orderBy: { entityVersion: 'asc' },
|
||||||
|
});
|
||||||
|
expect(events.map((e) => [e.type, e.entityVersion])).toEqual([
|
||||||
|
['photo.hidden', 3],
|
||||||
|
['photo.published', 4],
|
||||||
|
['photo.deleted', 5],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deleted 为员工侧终态:恢复发布 → 400;重复 DELETE → 幂等 204 无新事件', async () => {
|
||||||
|
const res = await patchPhoto(ownerToken, lifecycleId, {
|
||||||
|
visibility: 'published',
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
|
||||||
|
|
||||||
|
const eventsBefore = await prisma.outbox_events.count({
|
||||||
|
where: { photoId: lifecycleId },
|
||||||
|
});
|
||||||
|
const again = await deletePhoto(collabToken, lifecycleId);
|
||||||
|
expect(again.status).toBe(204);
|
||||||
|
expect(
|
||||||
|
await prisma.outbox_events.count({ where: { photoId: lifecycleId } }),
|
||||||
|
).toBe(eventsBefore);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('owner 删除他人上传 → 204;外人员工 → 404;幽灵 id → 404', async () => {
|
||||||
|
const ownerRes = await deletePhoto(ownerToken, otherOwnedId);
|
||||||
|
expect(ownerRes.status).toBe(204);
|
||||||
|
|
||||||
|
const outsiderRes = await deletePhoto(outsiderToken, otherOwnedId);
|
||||||
|
expect(outsiderRes.status).toBe(404);
|
||||||
|
expect(outsiderRes.body.code).toBe(ErrorCodes.PHOTO_NOT_FOUND);
|
||||||
|
|
||||||
|
const ghost = await deletePhoto(ownerToken, 'no-such-photo');
|
||||||
|
expect(ghost.status).toBe(404);
|
||||||
|
expect(ghost.body.code).toBe(ErrorCodes.PHOTO_NOT_FOUND);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('未认证 PATCH/DELETE → 401', async () => {
|
||||||
|
const patch = await request(app.getHttpServer())
|
||||||
|
.patch(`/${API_PREFIX}/photos/${otherOwnedId}`)
|
||||||
|
.send({ visibility: 'hidden' });
|
||||||
|
expect(patch.status).toBe(401);
|
||||||
|
expect(patch.body.code).toBe(ErrorCodes.AUTH_TOKEN_EXPIRED);
|
||||||
|
|
||||||
|
const del = await request(app.getHttpServer())
|
||||||
|
.delete(`/${API_PREFIX}/photos/${otherOwnedId}`);
|
||||||
|
expect(del.status).toBe(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
{
|
{
|
||||||
|
"$schema": "https://turbo.build/schema.json",
|
||||||
|
"concurrency": "1",
|
||||||
"tasks": {
|
"tasks": {
|
||||||
"build": { "dependsOn": ["^build"], "outputs": ["dist/**"] },
|
"build": { "dependsOn": ["^build"], "outputs": ["dist/**"] },
|
||||||
"lint": {},
|
"lint": {},
|
||||||
|
|||||||
Reference in New Issue
Block a user