fix(ads): 终审修复——staging 前缀+运行时上限核验/失效绑定编辑/白名单去 heic/杂项

- verifyStaging 硬化(services/api/src/ads/ads.service.ts):新增键前缀核验
  (仅 OBJECT_KEY_ROOT/ads/staging/ 布局,防照片域任意键被提升为公开广告)
  与运行时 2MB 上限(head.bytes > AD_UPLOAD_MAX_BYTES → 400 报文含限额值
  ——init 申明管不住实际 PUT 体,create/update 是唯一核验点);e2e ⑰ 以
  测试 storage 直接 presignPut 真实对象覆盖两分支
- 失效绑定编辑 400(admin AdsFormDialog):PATCH 仅在 albumIds 实际变化时
  携带(albumIdsChanged 序敏感深比较,纯函数落 adsLogic + vitest 用例);
  preloadOptions 解析失败的绑定 ID 加「已失效,可移除」后缀(zh/en 新增
  adsManage.form.staleAlbum)——软删绑定不再挡住其它字段保存
- 白名单去 heic(原图直出无变体兜底,Chromium/Firefox 不渲染=破图):
  contracts 三处 enum 收窄 [image/jpeg, image/png] 并重生成类型;dto 定义
  广告域独立 AD_IMAGE_CONTENT_TYPES(与照片域 UPLOAD_CONTENT_TYPES 分离),
  AD_IMAGE_EXTENSIONS 由其派生;init 对 .heic 文件名以广告域集合二次核验
  拒绝(共享 extOfFilename 仍认 heic,杜绝 init 放行→create 中段断裂);
  e2e heic 矩阵 400;admin accept/文案/类型同步收窄
- searchAlbums 补 catch(http 层已弹 toast,本地静默保持旧选项)
- ActivityPage 关闭记忆测试补位(activity.spec):dismissed 不拉取不渲染、
  close 写 sessionStorage 并清空轮播两用例(getPublicAds 入替身防真实 fetch)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-29 02:20:21 +08:00
parent 041cfc77b7
commit bfba76b5e6
13 changed files with 349 additions and 79 deletions
+28 -18
View File
@@ -1,39 +1,49 @@
// services/api/test/ads-extensions.spec.ts —— 广告图扩展名白名单一致性守护
// (ads-carousel Task 7 质量审查遗留项,随 Task 8 提交)。三处白名单必须恒等:
// · uploads/support.ts UPLOAD_CONTENT_TYPES(契约 contentType 白名单);
// · uploads/support.ts EXTENSION_WHITELIST(模块私有:filename→归一扩展名);
// · ads/ads.service.ts AD_IMAGE_EXTENSIONS(staging/终键扩展名合法集)。
// 漂移场景(守护标的):上传域新增 contentType(如 image/webp)而广告域未跟
// 进——init 会放行 .webp staging 键(extOfFilename 走共享白名单),但
// verifyStaging 的 AD_IMAGE_EXTENSIONS 核验会拒绝,创建/换图在链路中段断裂。
// 纯函数 spec(无 DB/网络):从导出的 UPLOAD_CONTENT_TYPES 派生归一化扩展名
// 集合(jpeg→jpg,同 EXTENSION_WHITELIST 归一关系),断言与 AD_IMAGE_EXTENSIONS
// 恒等;EXTENSION_WHITELIST 不可导出,经 extOfFilename 行为面覆盖(每个合法
// 扩展名归一通过、jpeg 双扩展名归一 jpg、白名单外拒绝)。
// services/api/test/ads-extensions.spec.ts —— 广告图扩展名白名单守护
// (ads-carousel Task 7 质量审查遗留项,随 Task 8 提交;终审 Fix 3 改为
// 广告域自洽守护)。守护标的:
// · AD_IMAGE_EXTENSIONS(ads.service,staging/终键扩展名合法集)必须恒等
// 于从 AD_IMAGE_CONTENT_TYPES(ads/dto,契约 contentType 白名单)派生
// 的归一扩展名集合(jpeg→jpg)——contentType 与键扩展名两端口永不漂移;
// · heic 剔除是永久约束:广告无变体流水线、原图直出,Chromium/Firefox
// 桌面浏览器不渲染 heic(=破图)。
// 不再对照片域 UPLOAD_CONTENT_TYPES/EXTENSION_WHITELIST 做一致性断言——
// 两域白名单自此分离(照片直传保留 heic,变体流水线兜底解码)。共享的
// uploads/support.extOfFilename 仍放行 .heic 文件名(照片域依赖),广告
// init 以 AD_IMAGE_EXTENSIONS 二次核验拒绝(见 ads.service.initUpload)。
// 纯函数 spec(无 DB/网络)。
import { AD_IMAGE_EXTENSIONS } from '../src/ads/ads.service';
import { UPLOAD_CONTENT_TYPES, extOfFilename } from '../src/uploads/support';
import { AD_IMAGE_CONTENT_TYPES } from '../src/ads/dto';
import { extOfFilename } from '../src/uploads/support';
/** UPLOAD_CONTENT_TYPES → 归一化扩展名集合(jpeg→jpg;子类型段即扩展名)。 */
/** AD_IMAGE_CONTENT_TYPES → 归一化扩展名集合(jpeg→jpg;子类型段即扩展名)。 */
function derivedExtensions(): Set<string> {
return new Set(
UPLOAD_CONTENT_TYPES.map((contentType) => {
AD_IMAGE_CONTENT_TYPES.map((contentType) => {
const sub = contentType.split('/')[1] ?? '';
return sub === 'jpeg' ? 'jpg' : sub;
}),
);
}
describe('AD_IMAGE_EXTENSIONS 与上传域白名单一致性(守护断言)', () => {
it('AD_IMAGE_EXTENSIONS === 从 UPLOAD_CONTENT_TYPES 派生的归一化扩展名集合', () => {
describe('AD_IMAGE_EXTENSIONS ↔ AD_IMAGE_CONTENT_TYPES 一致性(守护断言)', () => {
it('AD_IMAGE_EXTENSIONS === 从 AD_IMAGE_CONTENT_TYPES 派生的归一化扩展名集合', () => {
expect(AD_IMAGE_EXTENSIONS).toEqual(derivedExtensions());
});
it('heic 已永久剔除:contentType 集与扩展名集均不含 heic', () => {
expect(AD_IMAGE_CONTENT_TYPES).not.toContain('image/heic');
expect(AD_IMAGE_EXTENSIONS.has('heic')).toBe(false);
});
it('每个合法扩展名经 extOfFilename 归一通过(staging 键后缀恒落在合法集内)', () => {
for (const ext of derivedExtensions()) {
expect(extOfFilename(`banner.${ext}`)).toBe(ext);
}
// jpeg 双扩展名归一 jpg(EXTENSION_WHITELIST 映射,大小写不敏感)。
// jpeg 双扩展名归一 jpg(共享 EXTENSION_WHITELIST 映射,大小写不敏感)。
expect(extOfFilename('banner.JPEG')).toBe('jpg');
// 共享 extOfFilename 仍放行 .heic(照片域白名单,两域分离后各自演进);
// 广告 init 的拒绝点在服务层 AD_IMAGE_EXTENSIONS 二次核验,非此处。
expect(extOfFilename('banner.heic')).toBe('heic');
// 白名单外/无扩展名 → null(init 与 verifyStaging 均拒绝)。
expect(extOfFilename('banner.gif')).toBeNull();
expect(extOfFilename('banner')).toBeNull();