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
+165
View File
@@ -22,6 +22,8 @@
// 版本直投生效(乱序收敛);
// ⑧ 限速:压小窗口(EMBED_WINDOW_REQUESTS 惰性 env)→ 超额 429
// RATE_LIMITED(304 也消耗预算)。
// ⑨ UI-01 配置字段全链路:新字段创建/校验(越界/非法 400)/旧形状归一化
// → slot-state 投影 → ov 公开 settings 全集;旧投影行读取侧默认值归一。
// 真实 MySQL×2 + MinIO(真实 JPEG 上传发布)+ HMAC 同步链路字节一致。
import { randomBytes } from 'node:crypto';
import { NestFactory } from '@nestjs/core';
@@ -281,12 +283,17 @@ describe('embed slots (e2e)', () => {
},
});
expect(second.status).toBe(201);
// UI-01:旧形状提交(无新字段)→ 响应为归一化全集(新字段按默认值)。
expect(second.body.settings).toEqual({
position: 'left',
size: 'sm',
intervalMs: 8000,
lang: 'zh-CN',
collapsedDefault: true,
imageFit: 'contain',
offsetX: 16,
offsetBottom: 24,
zIndex: 1000,
});
// 校验失败:settings 缺字段 / intervalMs 越界 / 空 name。
@@ -748,6 +755,164 @@ describe('embed slots (e2e)', () => {
}
}
});
it('⑨ UI-01 配置字段:cn 校验/归一化 → slot-state 投影 → ov 公开 settings', async () => {
// 清限速计数(⑧ 压窗残留不影响本用例;默认窗口 120/60s 内用不顶)。
const keys = await redisOv.keys('embed:window:*');
if (keys.length > 0) {
await redisOv.del(keys);
}
// 创建即带全量新字段 → cn 响应回显(admin 全集形状)。
const created = await request(cnBase)
.post('/v1/admin/embed-slots')
.set('Authorization', `Bearer ${adminToken}`)
.send({
name: 'UI01 配置位',
settings: {
position: 'left',
size: 'md',
intervalMs: 6000,
lang: 'auto',
collapsedDefault: true,
imageFit: 'cover',
offsetX: 40,
offsetBottom: 80,
zIndex: 5000,
},
});
expect(created.status).toBe(201);
const ui01SlotId = created.body.id as string;
const ui01Token = created.body.publicToken as string;
expect(created.body.settings).toEqual({
position: 'left',
size: 'md',
intervalMs: 6000,
lang: 'auto',
collapsedDefault: true,
imageFit: 'cover',
offsetX: 40,
offsetBottom: 80,
zIndex: 5000,
});
// 新字段校验:非法枚举/越界/非整数 → 400(老字段约束不回归)。
const validBase = {
position: 'left',
size: 'md',
intervalMs: 6000,
lang: 'auto',
collapsedDefault: true,
imageFit: 'cover',
offsetX: 40,
offsetBottom: 80,
zIndex: 5000,
} as Record<string, unknown>;
for (const bad of [
{ imageFit: 'fill' },
{ offsetX: 121 },
{ offsetX: -1 },
{ offsetX: 1.5 },
{ offsetBottom: 241 },
{ zIndex: 0 },
{ zIndex: 2147483001 },
]) {
const res = await request(cnBase)
.patch(`/v1/admin/embed-slots/${ui01SlotId}`)
.set('Authorization', `Bearer ${adminToken}`)
.send({ settings: { ...validBase, ...bad } });
expect(res.status).toBe(400);
expect(res.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
}
// 旧形状整体替换(无新字段)→ 归一化落库(新字段回默认值)+ configVersion+1。
const legacyShape = {
position: 'left',
size: 'sm',
intervalMs: 6000,
lang: 'auto',
collapsedDefault: false,
};
const patched = await request(cnBase)
.patch(`/v1/admin/embed-slots/${ui01SlotId}`)
.set('Authorization', `Bearer ${adminToken}`)
.send({ settings: legacyShape });
expect(patched.status).toBe(200);
expect(patched.body.configVersion).toBe(2);
const normalizedFull = {
...legacyShape,
imageFit: 'contain',
offsetX: 16,
offsetBottom: 24,
zIndex: 1000,
};
expect(patched.body.settings).toEqual(normalizedFull);
// 新字段显式 PATCH → 回显显式值。
const explicit = await request(cnBase)
.patch(`/v1/admin/embed-slots/${ui01SlotId}`)
.set('Authorization', `Bearer ${adminToken}`)
.send({
settings: {
...legacyShape,
imageFit: 'cover',
offsetX: 8,
offsetBottom: 120,
zIndex: 999,
},
});
expect(explicit.status).toBe(200);
const explicitFull = {
...legacyShape,
imageFit: 'cover',
offsetX: 8,
offsetBottom: 120,
zIndex: 999,
};
expect(explicit.body.settings).toEqual(explicitFull);
// 绑定相册 A → 排空发送器 → ov 公开响应 settings = 投影完整全集。
const bound = await request(cnBase)
.patch(`/v1/admin/embed-slots/${ui01SlotId}`)
.set('Authorization', `Bearer ${adminToken}`)
.send({ albumId: albumAId });
expect(bound.status).toBe(200);
await drainSender();
const feed = await getEmbed(ui01Token);
expect(feed.status).toBe(200);
expect(feed.body.settings).toEqual(explicitFull);
// 既有字段不回归:album/photos 照常返回。
expect(feed.body.album.publicToken).toBe(albumAToken);
expect(feed.body.photos.length).toBeGreaterThan(0);
// 旧形状存量投影行(UI-01 前 ov 副本):直改副本 settings 模拟旧投影 →
// 公开响应按契约默认值归一(读取侧归一化,无需回填事件)。
await prismaOv.embed_slots_ov.update({
where: { slotToken: ui01Token },
data: {
settings: {
position: 'right',
size: 'md',
intervalMs: 5000,
lang: 'auto',
collapsedDefault: false,
},
},
});
const staleFeed = await getEmbed(ui01Token);
expect(staleFeed.status).toBe(200);
expect(staleFeed.body.settings).toEqual({
position: 'right',
size: 'md',
intervalMs: 5000,
lang: 'auto',
collapsedDefault: false,
imageFit: 'contain',
offsetX: 16,
offsetBottom: 24,
zIndex: 1000,
});
});
});
/** ETag 第二元:副本最新已发布照片的 publishedAt 毫秒(缺行 0)。 */