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 { 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,
|
||||
|
||||
@@ -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,
|
||||
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<PublicPhotoDetailResponse> {
|
||||
return this.publicService.getPublicPhoto(token, photoId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<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
|
||||
* ALBUM_NOT_PUBLIC。
|
||||
|
||||
Reference in New Issue
Block a user