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
+38 -1
View File
@@ -1,7 +1,13 @@
import { describe, expect, it } from 'vitest'
import zhMessages from '@/locales/langs/zh.json'
import enMessages from '@/locales/langs/en.json'
import { isValidAdLinkUrl, normalizeAlbumIds, planAdjacentMove, scopeKind } from './adsLogic'
import {
albumIdsChanged,
isValidAdLinkUrl,
normalizeAlbumIds,
planAdjacentMove,
scopeKind
} from './adsLogic'
describe('scopeKind 投放范围分类', () => {
it('null / 空数组 → global;非空 → targeted', () => {
@@ -25,6 +31,37 @@ describe('normalizeAlbumIds 提交前归一化(服务端语义对齐)', () =
})
})
describe('albumIdsChanged 编辑态变更判定(序敏感深比较)', () => {
it('未变化(同序同元素 / 双 null / 双空归一 null)→ false:软删绑定的未变集合不随 PATCH 提交', () => {
expect(albumIdsChanged(['a', 'b'], ['a', 'b'])).toBe(false)
expect(albumIdsChanged(null, null)).toBe(false)
// 两侧均为 normalizeAlbumIds 归一值:空数组归一 null,双空视同未变化。
expect(albumIdsChanged(normalizeAlbumIds([]), normalizeAlbumIds([]))).toBe(false)
expect(albumIdsChanged(null, normalizeAlbumIds([]))).toBe(false)
})
it('实际变化(增删元素 / null↔数组)→ true:携带 albumIds 整体替换', () => {
expect(albumIdsChanged(['a'], ['a', 'b'])).toBe(true)
expect(albumIdsChanged(['a', 'b'], ['b'])).toBe(true)
expect(albumIdsChanged(null, ['a'])).toBe(true)
expect(albumIdsChanged(['a'], null)).toBe(true)
})
it('数组序敏感:同元素不同序视为变化(服务端整体替换语义,序也是载荷一部分)', () => {
expect(albumIdsChanged(['a', 'b'], ['b', 'a'])).toBe(true)
})
it('软删场景兜底:行值含失效 ID 而表单未动 → false(其它字段仍可保存)', () => {
const rowValue = normalizeAlbumIds(['stale-album', 'live-album'])
const formValue = normalizeAlbumIds(['stale-album', 'live-album'])
expect(albumIdsChanged(rowValue, formValue)).toBe(false)
// 运营在「已失效」标记提示下手动移除 → 变化,提交剩余集合。
expect(
albumIdsChanged(rowValue, normalizeAlbumIds(['live-album']))
).toBe(true)
})
})
describe('isValidAdLinkUrl 表单链接校验(与服务端 pattern 同源)', () => {
it('http/https 前缀放行;其他协议/裸域名拒绝', () => {
expect(isValidAdLinkUrl('https://example.com/promo')).toBe(true)
+20
View File
@@ -3,6 +3,9 @@
*
* - scopeKind/normalizeAlbumIds 对齐服务端归一语义:albumIds null=全局投放,
* 空数组提交落库归一 null(AdCreateRequestDto 注释口径)
* - albumIdsChanged 编辑态变更判定(序敏感深比较):仅变化时随 PATCH 携带
* albumIds——软删绑定的未变集合不再提交,避免服务端存在性校验 400 挡住
* 其它字段的保存
* - isValidAdLinkUrl 与服务端 pattern 同源(dto.ts LINK_URL_PATTERN:
* ^https?:// 前缀 + 契约 maxLength 2048)
* - planAdjacentMove 上移/下移目标值计算:新建恒为尾部追加(父页传
@@ -22,6 +25,23 @@ export function normalizeAlbumIds(v: string[] | null): string[] | null {
return uniq.length === 0 ? null : uniq
}
/**
* 编辑态 albumIds 变更判定:两侧均为 normalizeAlbumIds 归一值(去重/空→
* null),数组序敏感逐位比较——仅实际变化时随 PATCH 携带。未变化的集合
* 不提交:绑定相册被软删后服务端存在性校验会对整组 albumIds 400,跳过
* 提交让其它字段(title/linkUrl/换图)仍可保存(失效 ID 由表单选项的
* 「已失效」标记提示运营手动移除)。
*/
export function albumIdsChanged(
before: string[] | null,
after: string[] | null
): boolean {
if (before === null || after === null) return before !== after
return !(
before.length === after.length && before.every((id, i) => id === after[i])
)
}
/** 表单链接校验(与服务端 pattern 同源:^https?://,总长 ≤2048)。 */
export function isValidAdLinkUrl(u: string): boolean {
return /^https?:\/\//.test(u) && u.length <= 2048