feat(contracts,api): UI-01 cover, preview, scope=own and embed config extensions
契约扩展(全部可选/可空,旧客户端零感知)+ cn/ov 实现 + e2e 权限矩阵:
- AlbumSummary += cover(kind=cover state=effective 最新行的短期签名 URL,
列表/详情按页批量组装非 N+1;未配置为 null)
- AdminPhoto += preview{thumb,browse}(变体齐备且未删除才签发;隐藏仅
admin/owner;协作者仅自己上传,scope=own 服务端过滤 uploaderId,
成员资格逐请求裁决)
- listAlbumPhotos += scope=all|own(缺省 all 行为不变:仅 owner/admin,
协作者 403;own 任何有效成员)
- EmbedSlotSettings += imageFit/offsetX/offsetBottom/zIndex(cn PATCH 校验
→ slot-state 投影 → ov 公开回读;settings 为 Json 列免迁移,读侧
normalizeStoredSettings 兜旧形状默认值)
- Flutter 模型同步(SignedUrl/PhotoPreview/AlbumPhotoScope,可空容错)
e2e:ui01-contracts 9 例(封面选取/字节取回/N+1 计数断言、preview 权限
矩阵、scope 过滤与 403/404/400)+ embed ⑨ 配置链路;全量 e2e 257 通过。
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -13,12 +13,18 @@
|
||||
// 映射,P1a 规模下先查成员 albumId 集再 id IN ——成员相册数为个位到十位级,
|
||||
// 不构成规模问题;若 P5 需要,可换 $queryRaw 子查询,接口不变)。
|
||||
// 排序 createdAt DESC, id DESC(稳定决胜),游标经 shared/cursor 键集推进。
|
||||
// · 封面摘要(UI-01):AlbumSummary.cover = album_assets kind=cover、
|
||||
// state=effective 的最新一行的短期签名 URL(未配置为 null)。列表按页
|
||||
// 一次 findMany 批量取(orderBy createdAt/id DESC 后每相册取首行,同
|
||||
// banner「新行生效」替换语义),presign 为 SigV4 本地计算——两者都不随
|
||||
// 相册数放大,杜绝逐相册 N+1。
|
||||
// 响应映射严格契约 camelCase(DB snake 列名 → AlbumSummary/Album 形状)。
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { HttpStatus, Injectable } from '@nestjs/common';
|
||||
import { HttpStatus, Inject, Injectable } from '@nestjs/common';
|
||||
import { ErrorCodes } from '@photo/contracts';
|
||||
import type { components } from '@photo/contracts';
|
||||
import type { Prisma } from '@photo/db-cn';
|
||||
import type { StoragePort } from '@photo/storage';
|
||||
import { ApiException } from '../api-exception';
|
||||
import type { AuthUser } from '../auth/session.guard';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
@@ -28,6 +34,8 @@ import {
|
||||
keysetWhereDesc,
|
||||
} from '../shared/cursor';
|
||||
import { buildAlbumUpdatedPayload } from '../sync/payloads';
|
||||
import { STORAGE_PORT } from '../uploads/storage.provider';
|
||||
import { signedUrlTtlSeconds } from '../uploads/support';
|
||||
import type {
|
||||
AlbumCreateRequestDto,
|
||||
AlbumTransferRequestDto,
|
||||
@@ -46,6 +54,7 @@ export type AlbumTranslationResponse =
|
||||
export type AlbumSummaryResponse = components['schemas']['AlbumSummary'];
|
||||
export type AlbumResponse = components['schemas']['Album'];
|
||||
export type AlbumListResponse = components['schemas']['AlbumList'];
|
||||
type SignedUrlResponse = components['schemas']['SignedUrl'];
|
||||
|
||||
const PUBLIC_TOKEN_LENGTH = 24;
|
||||
const PUBLIC_TOKEN_ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz';
|
||||
@@ -97,6 +106,7 @@ function toTranslationResponse(
|
||||
function toSummaryResponse(
|
||||
album: AlbumRow,
|
||||
translations: AlbumTranslationRow[],
|
||||
cover: SignedUrlResponse | null = null,
|
||||
): AlbumSummaryResponse {
|
||||
return {
|
||||
id: album.id,
|
||||
@@ -118,6 +128,9 @@ function toSummaryResponse(
|
||||
// P2 Task 9(A27 同步时间展示):统计副本最近回传应用时刻;null=尚无回传。
|
||||
statsSyncedAt:
|
||||
album.statsSyncedAt === null ? null : album.statsSyncedAt.toISOString(),
|
||||
// UI-01 封面摘要:未配置封面(无 effective cover 行)为 null;签名 URL
|
||||
// 短期有效,过期经列表/详情重取刷新。
|
||||
cover,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -126,6 +139,7 @@ export class AlbumsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly permissions: PermissionsService,
|
||||
@Inject(STORAGE_PORT) private readonly storage: StoragePort,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -647,6 +661,29 @@ export class AlbumsService {
|
||||
}
|
||||
}
|
||||
|
||||
// UI-01 封面摘要:本页相册一次批量查询(kind=cover、state=effective,
|
||||
// createdAt/id DESC 后每相册首行即最新 effective 行——同 banner 替换语义,
|
||||
// cover 无 locale 偏好)。presign 为本地计算,循环不产生网络/查询放大。
|
||||
const covers = await this.prisma.album_assets.findMany({
|
||||
where: {
|
||||
albumId: { in: page.map((a) => a.id) },
|
||||
kind: 'cover',
|
||||
state: 'effective',
|
||||
},
|
||||
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
|
||||
select: { albumId: true, objectKey: true },
|
||||
});
|
||||
const coverKeyByAlbum = new Map<string, string>();
|
||||
for (const row of covers) {
|
||||
if (!coverKeyByAlbum.has(row.albumId)) {
|
||||
coverKeyByAlbum.set(row.albumId, row.objectKey);
|
||||
}
|
||||
}
|
||||
const coverByAlbum = new Map<string, SignedUrlResponse>();
|
||||
for (const [albumId, objectKey] of coverKeyByAlbum) {
|
||||
coverByAlbum.set(albumId, await this.presign(objectKey));
|
||||
}
|
||||
|
||||
const last = page.at(-1);
|
||||
// 契约 CursorPageMeta 不变式:hasMore=true 当且仅当 nextCursor≠null——
|
||||
// 以 nextCursor 派生 hasMore,结构上保证两者成对(防御未来调用方绕过
|
||||
@@ -659,7 +696,13 @@ export class AlbumsService {
|
||||
})
|
||||
: null;
|
||||
return {
|
||||
items: page.map((a) => toSummaryResponse(a, byAlbumId.get(a.id) ?? [])),
|
||||
items: page.map((a) =>
|
||||
toSummaryResponse(
|
||||
a,
|
||||
byAlbumId.get(a.id) ?? [],
|
||||
coverByAlbum.get(a.id) ?? null,
|
||||
),
|
||||
),
|
||||
nextCursor,
|
||||
hasMore: nextCursor !== null,
|
||||
};
|
||||
@@ -710,7 +753,7 @@ export class AlbumsService {
|
||||
}
|
||||
}
|
||||
return {
|
||||
...toSummaryResponse(album, translations),
|
||||
...toSummaryResponse(album, translations, await this.latestCoverOf(albumId)),
|
||||
categories: categories.map((c) => ({
|
||||
id: c.id,
|
||||
albumId: c.albumId,
|
||||
@@ -719,4 +762,26 @@ export class AlbumsService {
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 相册最新生效封面(kind=cover、state=effective,createdAt/id DESC 首行)
|
||||
* 的短期签名 URL;未配置封面返回 null。
|
||||
*/
|
||||
private async latestCoverOf(
|
||||
albumId: string,
|
||||
): Promise<SignedUrlResponse | null> {
|
||||
const row = await this.prisma.album_assets.findFirst({
|
||||
where: { albumId, kind: 'cover', state: 'effective' },
|
||||
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
|
||||
select: { objectKey: true },
|
||||
});
|
||||
return row === null ? null : this.presign(row.objectKey);
|
||||
}
|
||||
|
||||
/** 现签短期 GET 凭证(TTL 惰性读取;SigV4 本地计算无网络副作用)。 */
|
||||
private async presign(objectKey: string): Promise<SignedUrlResponse> {
|
||||
const ttl = signedUrlTtlSeconds();
|
||||
const url = await this.storage.presignGet(objectKey, ttl);
|
||||
return { url, expiresAt: new Date(Date.now() + ttl * 1000).toISOString() };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,9 @@
|
||||
// (契约评审修正:含照片条数与最新发布时间——新照片发布必变,避免仅比对
|
||||
// 实体版本时新照片小版本导致 304 陈旧);If-None-Match 匹配 → 304 无body
|
||||
// (判定在控制器,服务只产出 body+ETag)。空照片列:后两元取 0 与空串。
|
||||
// UI-01:响应携带 settings(ov 副本 JSON 经 normalizeStoredSettings 归一化
|
||||
// ——缺字段(UI-01 前旧投影行)按契约默认值补齐,嵌入端据此应用后台配置)。
|
||||
// 设置变更必递增 configVersion(cn 管理面约定),ETag 随之变化,无需改四元组。
|
||||
import { HttpStatus, Inject, Injectable } from '@nestjs/common';
|
||||
import type { components } from '@photo/contracts';
|
||||
import {
|
||||
@@ -30,6 +33,7 @@ import { OvPrismaService } from '../prisma-ov/ov-prisma.module';
|
||||
import { OvRedisService } from '../redis-ov/ov-redis.module';
|
||||
import { signedUrlTtlSeconds } from '../uploads/support';
|
||||
import { STORAGE_PORT_OV } from '../public-ov/storage-ov.provider';
|
||||
import { normalizeStoredSettings } from '../embed/embed.service';
|
||||
import type { StoragePort } from '@photo/storage';
|
||||
|
||||
export type PublicEmbedSlotResponse = components['schemas']['PublicEmbedSlot'];
|
||||
@@ -152,6 +156,8 @@ export class EmbedOvService {
|
||||
body: {
|
||||
slotToken,
|
||||
configVersion: slot.configVersion,
|
||||
// UI-01:后台展示设置归一化全集(旧投影行缺字段 → 契约默认值)。
|
||||
settings: normalizeStoredSettings(slot.settings),
|
||||
album: {
|
||||
publicToken: album.publicToken,
|
||||
title,
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
// · name 1..64(VarChar(64) 列宽);
|
||||
// · settings 整体提交语义:创建可整体缺省(各字段按缺省值落库,服务层
|
||||
// 归一),提供时字段齐全——嵌套 DTO 无 @IsOptional,缺字段 400;
|
||||
// UI-01 新增字段(imageFit/offsetX/offsetBottom/zIndex)例外:全部
|
||||
// @IsOptional(旧客户端/旧 JSON 兼容),缺省由服务层按契约默认值归一;
|
||||
// · albumId [string,'null']:非 null 绑定/换绑(≤64,绑定存在性校验在
|
||||
// 服务层事务内),null 解绑。@IsOptional 对 undefined/null 均跳过校验
|
||||
// 且 null 值保留在 DTO 上——服务层以 `!== undefined` 区分「未提交」与
|
||||
@@ -35,6 +37,15 @@ const EMBED_SLOT_SIZES = ['sm', 'md'] as const;
|
||||
export const EMBED_INTERVAL_MS_MIN = 3000;
|
||||
export const EMBED_INTERVAL_MS_MAX = 30000;
|
||||
|
||||
/** UI-01 新增字段枚举与边界(全部可选:缺省由服务层归一化为默认值)。 */
|
||||
const EMBED_SLOT_IMAGE_FITS = ['contain', 'cover'] as const;
|
||||
export const EMBED_OFFSET_X_MIN = 0;
|
||||
export const EMBED_OFFSET_X_MAX = 120;
|
||||
export const EMBED_OFFSET_BOTTOM_MIN = 0;
|
||||
export const EMBED_OFFSET_BOTTOM_MAX = 240;
|
||||
export const EMBED_Z_INDEX_MIN = 1;
|
||||
export const EMBED_Z_INDEX_MAX = 2147483000;
|
||||
|
||||
export class EmbedSlotSettingsDto {
|
||||
@IsIn(EMBED_SLOT_POSITIONS)
|
||||
position!: (typeof EMBED_SLOT_POSITIONS)[number];
|
||||
@@ -52,6 +63,34 @@ export class EmbedSlotSettingsDto {
|
||||
|
||||
@IsBoolean()
|
||||
collapsedDefault!: boolean;
|
||||
|
||||
// —— UI-01 可选扩展(旧 JSON 兼容:缺省按默认值归一化落库)——
|
||||
|
||||
/** 图片适配方式;缺省 contain。 */
|
||||
@IsOptional()
|
||||
@IsIn(EMBED_SLOT_IMAGE_FITS)
|
||||
imageFit?: (typeof EMBED_SLOT_IMAGE_FITS)[number];
|
||||
|
||||
/** 距宿主边缘水平偏移(0..120px);缺省 16。 */
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(EMBED_OFFSET_X_MIN)
|
||||
@Max(EMBED_OFFSET_X_MAX)
|
||||
offsetX?: number;
|
||||
|
||||
/** 距宿主底部垂直偏移(0..240px);缺省 24。 */
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(EMBED_OFFSET_BOTTOM_MIN)
|
||||
@Max(EMBED_OFFSET_BOTTOM_MAX)
|
||||
offsetBottom?: number;
|
||||
|
||||
/** 宿主页面层级;缺省 1000。 */
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(EMBED_Z_INDEX_MIN)
|
||||
@Max(EMBED_Z_INDEX_MAX)
|
||||
zIndex?: number;
|
||||
}
|
||||
|
||||
export class EmbedSlotCreateRequestDto {
|
||||
|
||||
@@ -52,13 +52,17 @@ export type EmbedSlotSettingsResponse =
|
||||
const PUBLIC_TOKEN_LENGTH = 24;
|
||||
const PUBLIC_TOKEN_ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz';
|
||||
|
||||
/** 契约 EmbedSlotSettings 各字段缺省值(整体缺省时落库形态)。 */
|
||||
/** 契约 EmbedSlotSettings 各字段缺省值(整体缺省时落库形态;UI-01 新字段同列)。 */
|
||||
export const DEFAULT_EMBED_SLOT_SETTINGS: EmbedSlotSettingsResponse = {
|
||||
position: 'right',
|
||||
size: 'md',
|
||||
intervalMs: 5000,
|
||||
lang: 'auto',
|
||||
collapsedDefault: false,
|
||||
imageFit: 'contain',
|
||||
offsetX: 16,
|
||||
offsetBottom: 24,
|
||||
zIndex: 1000,
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -82,7 +86,12 @@ function generatePublicToken(): string {
|
||||
return token;
|
||||
}
|
||||
|
||||
/** 入参 settings → 归一化存储值(null/undefined=整体缺省 → 契约缺省值)。 */
|
||||
/**
|
||||
* 入参 settings → 归一化存储值(null/undefined=整体缺省 → 契约缺省值)。
|
||||
* UI-01 新字段可缺省(旧客户端兼容):缺省按契约默认值归一,落库恒为全集
|
||||
* ——旧存储行(无新字段)的读取侧归一在 embed-ov 公开响应完成(cn 管理
|
||||
* 响应经本服务 toResponse 不做读归一,行由本函数写入即已全集)。
|
||||
*/
|
||||
function normalizeSettings(
|
||||
settings: EmbedSlotSettingsDto | null | undefined,
|
||||
): EmbedSlotSettingsResponse {
|
||||
@@ -95,6 +104,34 @@ function normalizeSettings(
|
||||
intervalMs: settings.intervalMs,
|
||||
lang: settings.lang,
|
||||
collapsedDefault: settings.collapsedDefault,
|
||||
imageFit: settings.imageFit ?? DEFAULT_EMBED_SLOT_SETTINGS.imageFit,
|
||||
offsetX: settings.offsetX ?? DEFAULT_EMBED_SLOT_SETTINGS.offsetX,
|
||||
offsetBottom:
|
||||
settings.offsetBottom ?? DEFAULT_EMBED_SLOT_SETTINGS.offsetBottom,
|
||||
zIndex: settings.zIndex ?? DEFAULT_EMBED_SLOT_SETTINGS.zIndex,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 存储行 settings JSON → 归一化全集(readOnly 读取侧,cn 管理响应与 ov 公开
|
||||
* 响应共用):UI-01 前落库的旧行无新字段,缺字段按契约默认值补齐——响应
|
||||
* 恒为全集(契约「响应中字段恒齐全」),不要求全量改写旧记录。
|
||||
*/
|
||||
export function normalizeStoredSettings(
|
||||
stored: Prisma.JsonValue,
|
||||
): EmbedSlotSettingsResponse {
|
||||
const row = (stored ?? {}) as Partial<EmbedSlotSettingsResponse>;
|
||||
return {
|
||||
position: row.position ?? DEFAULT_EMBED_SLOT_SETTINGS.position,
|
||||
size: row.size ?? DEFAULT_EMBED_SLOT_SETTINGS.size,
|
||||
intervalMs: row.intervalMs ?? DEFAULT_EMBED_SLOT_SETTINGS.intervalMs,
|
||||
lang: row.lang ?? DEFAULT_EMBED_SLOT_SETTINGS.lang,
|
||||
collapsedDefault:
|
||||
row.collapsedDefault ?? DEFAULT_EMBED_SLOT_SETTINGS.collapsedDefault,
|
||||
imageFit: row.imageFit ?? DEFAULT_EMBED_SLOT_SETTINGS.imageFit,
|
||||
offsetX: row.offsetX ?? DEFAULT_EMBED_SLOT_SETTINGS.offsetX,
|
||||
offsetBottom: row.offsetBottom ?? DEFAULT_EMBED_SLOT_SETTINGS.offsetBottom,
|
||||
zIndex: row.zIndex ?? DEFAULT_EMBED_SLOT_SETTINGS.zIndex,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -354,7 +391,8 @@ export class EmbedSlotsService {
|
||||
albumId: slot.albumId,
|
||||
albumTitle,
|
||||
configVersion: slot.configVersion,
|
||||
settings: slot.settings as EmbedSlotSettingsResponse,
|
||||
// 读取侧归一化:UI-01 前旧行缺新字段 → 契约默认值补齐(响应恒全集)。
|
||||
settings: normalizeStoredSettings(slot.settings),
|
||||
updatedAt: slot.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -22,7 +22,11 @@ import {
|
||||
import { userOf } from '../albums/albums.controller';
|
||||
import { AuthenticatedRequest, SessionGuard } from '../auth/session.guard';
|
||||
import { parseCursor, parseLimit } from '../shared/pagination';
|
||||
import { PhotoHideBatchDto, parseVisibilityFilter } from './dto';
|
||||
import {
|
||||
PhotoHideBatchDto,
|
||||
parseScopeFilter,
|
||||
parseVisibilityFilter,
|
||||
} from './dto';
|
||||
import {
|
||||
PhotosService,
|
||||
type AdminPhotoListResponse,
|
||||
@@ -37,12 +41,15 @@ export class AlbumPhotosController {
|
||||
/**
|
||||
* 管理照片列表:全部可见性状态可见(区别于公开列表仅 published),
|
||||
* (uploadedAt,id) 稳定倒序键集游标;条目内嵌 uploader 脱敏信息。
|
||||
* scope=own(UI-01):协作者显式查询自己上传(服务端过滤);缺省 all
|
||||
* 行为不变(仅 owner/admin)。
|
||||
*/
|
||||
@Get()
|
||||
listAlbumPhotos(
|
||||
@Param('albumId') albumId: string,
|
||||
@Query('visibility') visibility: string | undefined,
|
||||
@Query('categoryId') categoryId: string | undefined,
|
||||
@Query('scope') scope: string | undefined,
|
||||
@Query('cursor') cursor: string | undefined,
|
||||
@Query('limit') limit: string | undefined,
|
||||
@Req() req: AuthenticatedRequest,
|
||||
@@ -51,6 +58,7 @@ export class AlbumPhotosController {
|
||||
visibility: parseVisibilityFilter(visibility),
|
||||
// 空串视为缺省(同公开列表 category 规整惯例)。
|
||||
categoryId: categoryId === '' ? undefined : categoryId,
|
||||
scope: parseScopeFilter(scope),
|
||||
cursor: parseCursor(cursor),
|
||||
limit: parseLimit(limit),
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// services/api/src/photos/dto.ts —— 照片域入参规整(Task 24 起步:PATCH body;
|
||||
// P1b Task 4 增补:管理列表 visibility 查询参数;P4a Task 2 增补:批量隐藏
|
||||
// 请求体)。校验约束对齐契约:
|
||||
// 请求体;UI-01 增补:管理列表 scope 查询参数 all|own)。校验约束对齐契约:
|
||||
// PhotoVisibilityUpdateRequest.visibility 仅允许 hidden/published(enum 之外
|
||||
// 任何值 → 400 VALIDATION_FAILED);deleted 只能经 DELETE 进入,不开放 PATCH
|
||||
// 直达(单向终态,见 photos.service 状态机)。
|
||||
@@ -67,3 +67,28 @@ export function parseVisibilityFilter(
|
||||
}
|
||||
return raw as PhotoVisibility;
|
||||
}
|
||||
|
||||
/** 契约 listAlbumPhotos scope 参数取值(UI-01:all=缺省既有行为;own=仅自己上传)。 */
|
||||
export const ALBUM_PHOTO_SCOPES = ['all', 'own'] as const;
|
||||
export type AlbumPhotoScope = (typeof ALBUM_PHOTO_SCOPES)[number];
|
||||
|
||||
/**
|
||||
* 管理列表 scope 查询参数(UI-01):空串视为缺省(同 visibility 规整惯例,
|
||||
* ?scope= 等同未提交= all);非空须为 all|own,否则 400 VALIDATION_FAILED。
|
||||
* 权限差异(collab 仅 own、all 仅 owner/admin)在服务层裁决。
|
||||
*/
|
||||
export function parseScopeFilter(
|
||||
raw: string | undefined,
|
||||
): AlbumPhotoScope | undefined {
|
||||
if (raw === undefined || raw === '') {
|
||||
return undefined;
|
||||
}
|
||||
if (!(ALBUM_PHOTO_SCOPES as readonly string[]).includes(raw)) {
|
||||
throw new ApiException(
|
||||
ErrorCodes.VALIDATION_FAILED,
|
||||
'scope 取值非法',
|
||||
HttpStatus.BAD_REQUEST,
|
||||
);
|
||||
}
|
||||
return raw as AlbumPhotoScope;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
// services/api/src/photos/photos.module.ts —— 照片管理模块(Task 24;P1b Task 4
|
||||
// 增补管理列表控制器)。装配:PhotosController/AlbumPhotosController/
|
||||
// PhotosService + PermissionsService(经 AlbumsModule 导入复用 A02 逐次权限
|
||||
// 判定;同 uploads 模块的装配先例)。
|
||||
// 增补管理列表控制器;UI-01 增补 StoragePort——管理列表 preview 现签)。
|
||||
// 装配:PhotosController/AlbumPhotosController/PhotosService +
|
||||
// PermissionsService(经 AlbumsModule 导入复用 A02 逐次权限判定;同 uploads
|
||||
// 模块的装配先例)+ storageProvider(与 AlbumsModule 同一 DI 模式:直接复用
|
||||
// provider 工厂而非 import UploadsModule,避免模块环)。
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AlbumsModule } from '../albums/albums.module';
|
||||
import { storageProvider } from '../uploads/storage.provider';
|
||||
import { AlbumPhotosController } from './album-photos.controller';
|
||||
import { PhotosController } from './photos.controller';
|
||||
import { PhotosService } from './photos.service';
|
||||
@@ -11,6 +14,6 @@ import { PhotosService } from './photos.service';
|
||||
@Module({
|
||||
imports: [AlbumsModule],
|
||||
controllers: [PhotosController, AlbumPhotosController],
|
||||
providers: [PhotosService],
|
||||
providers: [PhotosService, storageProvider],
|
||||
})
|
||||
export class PhotosModule {}
|
||||
|
||||
@@ -15,6 +15,11 @@
|
||||
// 权限(§2 矩阵,A02 逐次校验):canManagePhoto = admin 全部 / 相册 owner 全部 /
|
||||
// 协作者仅自己上传。404/403 映射:照片不存在或相册不可见(非成员)→ 404
|
||||
// PHOTO_NOT_FOUND(防枚举);可见但无管理权(协作者对他人照片)→ 403。
|
||||
// UI-01 增补:listAlbumPhotos scope=own(协作者显式查询自己上传,服务端过滤
|
||||
// uploaderId,成员资格逐请求检查——沿用 canViewAlbum 逐次直查口径)+ 条目
|
||||
// preview(受权限管理的 thumb/browse 短期签名 URL;变体按页一次批量查询,
|
||||
// 逐张裁决:无 thumb/browse 变体、已删除 → null;隐藏 → 仅 admin/owner;
|
||||
// 无权签发不改变 PhotoRecord 本身——记录可见性与图片可见性分离)。
|
||||
// 每次真实状态迁移:条件更新(WHERE visibility=期望前态,并发下恰一人胜出)
|
||||
// + entityVersion 自增 + 事务内 outbox 事件(P2 副本按 entityVersion 单调消费)
|
||||
// + audit。幂等前态(同值 PATCH / 重复 DELETE)直接回显当前记录,不产生
|
||||
@@ -22,10 +27,11 @@
|
||||
// P4a Task 5 检查点:可见性真实迁移(transition/hideBatch)提交后评估用量
|
||||
// 阈值告警(deleted 不计用量——软删除可能解除告警;hidden 仍计)。评估在
|
||||
// 自开事务执行且失败只记日志,绝不影响可见性操作本身(见 evaluateUsageSafe)。
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { ErrorCodes, type components } from '@photo/contracts';
|
||||
import { evaluateUsageSafe } from '@photo/db-cn';
|
||||
import type { Prisma } from '@photo/db-cn';
|
||||
import type { StoragePort } from '@photo/storage';
|
||||
import type { AuthUser } from '../auth/session.guard';
|
||||
import { PermissionsService } from '../albums/permissions.service';
|
||||
import {
|
||||
@@ -40,7 +46,13 @@ import {
|
||||
keysetWhereDesc,
|
||||
} from '../shared/cursor';
|
||||
import { buildPhotoPublishedPayload } from '../sync/payloads';
|
||||
import type { PhotoVisibility, PhotoVisibilityTarget } from './dto';
|
||||
import { STORAGE_PORT } from '../uploads/storage.provider';
|
||||
import { signedUrlTtlSeconds } from '../uploads/support';
|
||||
import type {
|
||||
AlbumPhotoScope,
|
||||
PhotoVisibility,
|
||||
PhotoVisibilityTarget,
|
||||
} from './dto';
|
||||
import {
|
||||
photoForbidden,
|
||||
photoNotFound,
|
||||
@@ -51,6 +63,8 @@ export type PhotoRecord = components['schemas']['PhotoRecord'];
|
||||
export type AdminPhotoListResponse = components['schemas']['AdminPhotoList'];
|
||||
export type BatchHideResult = components['schemas']['BatchHideResult'];
|
||||
export type BatchHideItem = components['schemas']['BatchHideItem'];
|
||||
export type PhotoPreviewResponse = components['schemas']['PhotoPreview'];
|
||||
type SignedUrlResponse = components['schemas']['SignedUrl'];
|
||||
|
||||
type PhotoRow = Prisma.photosGetPayload<Prisma.photosDefaultArgs>;
|
||||
|
||||
@@ -81,6 +95,7 @@ export class PhotosService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly permissions: PermissionsService,
|
||||
@Inject(STORAGE_PORT) private readonly storage: StoragePort,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -91,6 +106,12 @@ export class PhotosService {
|
||||
* (协作者)→ 403 AUTH_FORBIDDEN;admin 对幽灵 id 权限恒真,空相册兜底 404。
|
||||
* 排序 (uploadedAt,id) DESC 键集游标(shared/cursor)。
|
||||
*
|
||||
* scope(UI-01):缺省/'all'=上述既有行为(仅 owner/admin,协作者 403 不
|
||||
* 变);'own'=显式查询自己上传——任何有效成员可用(canViewAlbum 逐请求
|
||||
* 直查成员行:被移除后下一次请求即刻 404),服务端追加 uploaderId 过滤;
|
||||
* admin/owner 同样可用 own(过滤为自己上传的历史)。preview(UI-01)逐张
|
||||
* 裁决见 previewOf。
|
||||
*
|
||||
* 未完成上传(pending_upload、uploadedAt NULL)不入列表:uploadedAt 既是契约
|
||||
* 必返字段又是键集排序键——NULL 行既无法以契约形状返回(toPhotoRecord 的
|
||||
* new Date(0) 兜底会伪造 1970 时间轴),也无法参与键集谓词(SQL 对 NULL 的
|
||||
@@ -102,14 +123,19 @@ export class PhotosService {
|
||||
query: {
|
||||
visibility?: PhotoVisibility;
|
||||
categoryId?: string;
|
||||
scope?: AlbumPhotoScope;
|
||||
cursor?: string;
|
||||
limit: number;
|
||||
},
|
||||
): Promise<AdminPhotoListResponse> {
|
||||
const scope: AlbumPhotoScope = query.scope ?? 'all';
|
||||
if (!(await this.permissions.canViewAlbum(user, albumId))) {
|
||||
throw albumNotFound();
|
||||
}
|
||||
if (!(await this.permissions.canManageAlbum(user, albumId))) {
|
||||
// manager=admin/owner:全量可见 + 隐藏照片可签发预览。scope=all(缺省)
|
||||
// 仅 manager(协作者 403,既有行为不变);scope=own 任何有效成员可用。
|
||||
const manager = await this.permissions.canManageAlbum(user, albumId);
|
||||
if (scope === 'all' && !manager) {
|
||||
throw albumForbidden('仅创建者或管理员可访问管理列表');
|
||||
}
|
||||
// admin 对不存在 id 的 canView/canManage 恒真(非 admin 的可见成员行本身
|
||||
@@ -151,6 +177,9 @@ export class PhotosService {
|
||||
...(query.categoryId !== undefined
|
||||
? { categoryId: query.categoryId }
|
||||
: {}),
|
||||
// scope=own:服务端过滤(客户端传 uploaderId 不可信),与 canViewAlbum
|
||||
// 同请求内裁决——权限撤销后下一次请求即刻失效。
|
||||
...(scope === 'own' ? { uploaderId: user.id } : {}),
|
||||
};
|
||||
if (decoded !== null) {
|
||||
Object.assign(where, keysetWhereDesc('uploadedAt', 'id', decoded));
|
||||
@@ -173,23 +202,51 @@ export class PhotosService {
|
||||
});
|
||||
const userById = new Map(users.map((u) => [u.id, u]));
|
||||
|
||||
const items = page.flatMap((row) => {
|
||||
// UI-01 preview:本页变体一次批量查询(photoId IN 页内 id 集,杜绝逐张
|
||||
// N+1);裁决在 previewOf(presign 为 SigV4 本地计算,无网络/查询放大)。
|
||||
const variantRows =
|
||||
page.length === 0
|
||||
? []
|
||||
: await this.prisma.photo_variants.findMany({
|
||||
where: { photoId: { in: page.map((p) => p.id) } },
|
||||
select: { photoId: true, variant: true, objectKey: true },
|
||||
});
|
||||
const variantKeysByPhoto = new Map<
|
||||
string,
|
||||
{ thumb?: string; browse?: string }
|
||||
>();
|
||||
for (const row of variantRows) {
|
||||
const bucket = variantKeysByPhoto.get(row.photoId) ?? {};
|
||||
if (row.variant === 'thumb') {
|
||||
bucket.thumb = row.objectKey;
|
||||
} else if (row.variant === 'browse') {
|
||||
bucket.browse = row.objectKey;
|
||||
}
|
||||
variantKeysByPhoto.set(row.photoId, bucket);
|
||||
}
|
||||
|
||||
const items: AdminPhotoListResponse['items'] = [];
|
||||
for (const row of page) {
|
||||
const uploader = userById.get(row.uploaderId);
|
||||
// 用户行缺失(P1a 无用户删除,仅可能来自手工改库):跳过而非 5xx(同
|
||||
// 成员列表先例,单条脏数据不拖垮整页)。
|
||||
return uploader === undefined
|
||||
? []
|
||||
: [
|
||||
{
|
||||
...toPhotoRecord(row),
|
||||
uploader: {
|
||||
userId: uploader.id,
|
||||
account: uploader.account,
|
||||
displayName: uploader.displayName,
|
||||
},
|
||||
},
|
||||
];
|
||||
});
|
||||
if (uploader === undefined) {
|
||||
continue;
|
||||
}
|
||||
items.push({
|
||||
...toPhotoRecord(row),
|
||||
uploader: {
|
||||
userId: uploader.id,
|
||||
account: uploader.account,
|
||||
displayName: uploader.displayName,
|
||||
},
|
||||
preview: await this.previewOf(
|
||||
row,
|
||||
variantKeysByPhoto.get(row.id),
|
||||
manager,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
const last = page.at(-1);
|
||||
const nextCursor =
|
||||
@@ -203,6 +260,40 @@ export class PhotosService {
|
||||
return { items, nextCursor, hasMore: nextCursor !== null };
|
||||
}
|
||||
|
||||
/**
|
||||
* UI-01 受权限管理的预览裁决:thumb 与 browse 变体齐备且未删除才签发;
|
||||
* 隐藏照片仅 manager(admin/owner)签发——协作者即便自己上传(scope=own
|
||||
* 可见记录)也不获得隐藏图 URL(「隐藏是运营动作」,与恢复发布同一权限
|
||||
* 口径)。无变体(处理未完成/失败)与已删除返回 null,PhotoRecord 本身
|
||||
* 不受影响(记录可见性与图片可见性分离)。签名短期有效,过期经列表重取。
|
||||
*/
|
||||
private async previewOf(
|
||||
row: PhotoRow,
|
||||
keys: { thumb?: string; browse?: string } | undefined,
|
||||
manager: boolean,
|
||||
): Promise<PhotoPreviewResponse | null> {
|
||||
if (keys?.thumb === undefined || keys?.browse === undefined) {
|
||||
return null;
|
||||
}
|
||||
if (row.visibility === 'deleted') {
|
||||
return null;
|
||||
}
|
||||
if (row.visibility === 'hidden' && !manager) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
thumb: await this.presign(keys.thumb),
|
||||
browse: await this.presign(keys.browse),
|
||||
};
|
||||
}
|
||||
|
||||
/** 现签短期 GET 凭证(TTL 惰性读取;SigV4 本地计算无网络副作用)。 */
|
||||
private async presign(objectKey: string): Promise<SignedUrlResponse> {
|
||||
const ttl = signedUrlTtlSeconds();
|
||||
const url = await this.storage.presignGet(objectKey, ttl);
|
||||
return { url, expiresAt: new Date(Date.now() + ttl * 1000).toISOString() };
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量隐藏(契约 hidePhotosBatch,P4a A16):逐项处理、部分成功——单项失败
|
||||
* (不存在/已删除/状态不可切换)只标记该项 ok=false + code,不影响其余项。
|
||||
|
||||
Reference in New Issue
Block a user