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
+108 -17
View File
@@ -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,不影响其余项。