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:
浅念
2026-09-21 14:09:35 +08:00
parent 3b5f8d2d77
commit 8f4ad11bd5
21 changed files with 1428 additions and 40 deletions
+68 -3
View File
@@ -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() };
}
}