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() };
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user