From 6d61d117d546dbdce4dab4dedd6741b3cd1b7577 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Thu, 17 Sep 2026 23:51:29 +0800 Subject: [PATCH] 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 --- services/api/src/app-cn.module.ts | 3 + services/api/src/photos/dto.ts | 14 + services/api/src/photos/photos.controller.ts | 50 ++ services/api/src/photos/photos.module.ts | 14 + services/api/src/photos/photos.service.ts | 277 +++++++ services/api/src/photos/support.ts | 30 + services/api/src/public/public.controller.ts | 13 + services/api/src/public/public.service.ts | 51 +- services/api/test/photos.e2e-spec.ts | 746 +++++++++++++++++++ turbo.json | 2 + 10 files changed, 1199 insertions(+), 1 deletion(-) create mode 100644 services/api/src/photos/dto.ts create mode 100644 services/api/src/photos/photos.controller.ts create mode 100644 services/api/src/photos/photos.module.ts create mode 100644 services/api/src/photos/photos.service.ts create mode 100644 services/api/src/photos/support.ts create mode 100644 services/api/test/photos.e2e-spec.ts diff --git a/services/api/src/app-cn.module.ts b/services/api/src/app-cn.module.ts index 12b55aa..62b195d 100644 --- a/services/api/src/app-cn.module.ts +++ b/services/api/src/app-cn.module.ts @@ -19,6 +19,7 @@ import { AlbumsModule } from './albums/albums.module'; import { AllExceptionsFilter } from './all-exceptions.filter'; import { AuthModule } from './auth/auth.module'; import { HealthController } from './health.controller'; +import { PhotosModule } from './photos/photos.module'; import { PrismaModule } from './prisma/prisma.module'; import { PublicModule } from './public/public.module'; import { RequestIdMiddleware, resolveRequestId } from './request-id.middleware'; @@ -37,6 +38,8 @@ import type { ServerRequestWithId } from './shared/request-with-id'; UploadsModule, // 公开(观众端)域(Task 22 getPublicAlbum;Task 23 公开照片读)。 PublicModule, + // 照片管理域(Task 24:可见性生命周期 PATCH/DELETE)。 + PhotosModule, LoggerModule.forRoot({ pinoHttp: { // 与 RequestIdMiddleware 共用同一解析函数:优先透传 x-request-id, diff --git a/services/api/src/photos/dto.ts b/services/api/src/photos/dto.ts new file mode 100644 index 0000000..b340678 --- /dev/null +++ b/services/api/src/photos/dto.ts @@ -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; +} diff --git a/services/api/src/photos/photos.controller.ts b/services/api/src/photos/photos.controller.ts new file mode 100644 index 0000000..632ea7d --- /dev/null +++ b/services/api/src/photos/photos.controller.ts @@ -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 { + 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 { + await this.photosService.remove(userOf(req), photoId); + } +} diff --git a/services/api/src/photos/photos.module.ts b/services/api/src/photos/photos.module.ts new file mode 100644 index 0000000..8a34d8a --- /dev/null +++ b/services/api/src/photos/photos.module.ts @@ -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 {} diff --git a/services/api/src/photos/photos.service.ts b/services/api/src/photos/photos.service.ts new file mode 100644 index 0000000..19c2bf8 --- /dev/null +++ b/services/api/src/photos/photos.service.ts @@ -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; +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 { + 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 { + 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 { + 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 { + 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, + }; + } +} diff --git a/services/api/src/photos/support.ts b/services/api/src/photos/support.ts new file mode 100644 index 0000000..dfe8429 --- /dev/null +++ b/services/api/src/photos/support.ts @@ -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}`); +} diff --git a/services/api/src/public/public.controller.ts b/services/api/src/public/public.controller.ts index 26ea6bb..92c9096 100644 --- a/services/api/src/public/public.controller.ts +++ b/services/api/src/public/public.controller.ts @@ -21,6 +21,7 @@ import { DEFAULT_PUBLIC_PHOTO_PAGE_SIZE, MAX_PUBLIC_PHOTO_PAGE_SIZE, type PublicAlbumResponse, + type PublicPhotoDetailResponse, type PublicPhotoListResponse, PublicService, } from './public.service'; @@ -80,4 +81,16 @@ export class PublicController { limit: parsePublicPhotoLimit(limit), }); } + + /** + * 公开单图:深链定位;隐藏/删除/换册后的旧链接 404 PHOTO_NOT_PUBLIC。 + * 每次调用现签 thumb/browse(契约规定的过期刷新入口)。 + */ + @Get(':token/photos/:photoId') + getPublicPhoto( + @Param('token') token: string, + @Param('photoId') photoId: string, + ): Promise { + return this.publicService.getPublicPhoto(token, photoId); + } } diff --git a/services/api/src/public/public.service.ts b/services/api/src/public/public.service.ts index a7f63ba..ee3b432 100644 --- a/services/api/src/public/public.service.ts +++ b/services/api/src/public/public.service.ts @@ -31,7 +31,7 @@ import { import { PrismaService } from '../prisma/prisma.service'; import { STORAGE_PORT } from '../uploads/storage.provider'; import { signedUrlTtlSeconds } from '../uploads/support'; -import { albumNotPublic } from './support'; +import { albumNotPublic, photoNotPublic } from './support'; export type PublicAlbumResponse = components['schemas']['PublicAlbum']; export type PublicPhotoListResponse = @@ -255,6 +255,55 @@ export class PublicService { return { items, nextCursor, hasMore: nextCursor !== null }; } + /** + * 公开单图(Task 24):深链定位 + 签名 URL 过期后的刷新入口(每次现签)。 + * 服务端校验相册与照片归属:照片不存在 / 不属于该相册 / 非 published + * (隐藏、软删除后的旧链接)统一 404 PHOTO_NOT_PUBLIC(不泄露状态,A16)。 + * 变体行缺失的防御语义同列表(跳过)——此处无「跳过」可选,落 404。 + */ + async getPublicPhoto( + token: string, + photoId: string, + ): Promise { + 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 * ALBUM_NOT_PUBLIC。 diff --git a/services/api/test/photos.e2e-spec.ts b/services/api/test/photos.e2e-spec.ts new file mode 100644 index 0000000..3cc5b5c --- /dev/null +++ b/services/api/test/photos.e2e-spec.ts @@ -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 { + 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 => { + 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 { + 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) { + return request(app.getHttpServer()) + .get(`/${API_PREFIX}/public/albums/${publicToken}/photos`) + .query(query ?? {}); + } + + /** 全量公开列表的 id 集(hasMore 分页下循环取尽,断言「出现/消失」用)。 */ + async function publicPhotoIds(): Promise { + 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); + }); + }); +}); diff --git a/turbo.json b/turbo.json index dbef68f..d528ab7 100644 --- a/turbo.json +++ b/turbo.json @@ -1,4 +1,6 @@ { + "$schema": "https://turbo.build/schema.json", + "concurrency": "1", "tasks": { "build": { "dependsOn": ["^build"], "outputs": ["dist/**"] }, "lint": {},