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:
@@ -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)。 */
|
||||
|
||||
Reference in New Issue
Block a user