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
@@ -3,7 +3,9 @@
complete 步,创建/更新端点核验后转存终键)+ 跳转链接(isValidAdLinkUrl
与服务端 pattern 同源)+ 备注名(≤120 可空)+ 绑定相册(多选远程搜索,
/v1/albums 游标拉取 + 本地过滤;留空=全局投放)。编辑态换图才带 image
三字段;albumIds 经 normalizeAlbumIds 归一(空→null=转全局)。 -->
三字段;albumIds 经 normalizeAlbumIds 归一(空→null=转全局),且仅
实际变化时随 PATCH 携带(albumIdsChanged 序敏感比较——软删绑定的
未变集合不提交,避免服务端存在性校验 400 挡住其它字段的保存)。 -->
<template>
<ElDialog
v-model="visible"
@@ -27,7 +29,7 @@
<input
ref="fileInput"
type="file"
accept="image/jpeg,image/png,image/heic"
accept="image/jpeg,image/png"
class="hidden"
data-test="ad-form-file-input"
@change="onFileChange"
@@ -116,11 +118,13 @@
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { fetchAlbumList } from '@/api/albums'
import { fetchCreateAd, fetchInitAdUpload, fetchUpdateAd } from '@/api/ads'
import { isValidAdLinkUrl, normalizeAlbumIds } from '@/utils/ads/adsLogic'
import { albumIdsChanged, isValidAdLinkUrl, normalizeAlbumIds } from '@/utils/ads/adsLogic'
/** 广告图字节上限(契约 AD_UPLOAD_MAX_BYTES=2MB,独立于照片域 50MB) */
const AD_UPLOAD_MAX_BYTES = 2 * 1024 * 1024
const CONTENT_TYPES = new Set(['image/jpeg', 'image/png', 'image/heic'])
/** 广告图 MIME 白名单(契约收窄为 jpeg/png:广告无变体流水线、原图直出,
heic 桌面浏览器不渲染=破图——与照片域白名单分离) */
const CONTENT_TYPES = new Set(['image/jpeg', 'image/png'])
/** 相册搜索拉取页数上限(每页 50:列表端点无标题过滤,本地匹配;管理
规模有界,5 页 ≈ 250 本封顶,超出以 ID 兜底标签回显) */
@@ -269,6 +273,9 @@
album.id.toLowerCase().includes(keyword)
)
.map((album) => ({ id: album.id, label: `${titleOf(album)}(${album.id})` }))
} catch {
// 拉取失败静默吸收:http 层已统一弹 toast(ElMessage),此处仅保持
// 旧选项(finally 复位 searching),避免未处理拒绝穿透 ElSelect。
} finally {
searching.value = false
}
@@ -276,13 +283,17 @@
/** 打开即预载选项(新建态同样预载——避免未输入时误显「无匹配相册」,
对照 SlotBindDialog 打开即 searchAlbums('') 先例);编辑态已绑定 ID
超出拉取上限或相册已删除的以裸 ID 兜底(绑定不级联清理,容失效) */
超出拉取上限或相册已删除的以裸 ID 兜底并加「已失效」后缀(绑定不
级联清理,容失效)——提示运营可手动移除;不移除也能保存其它字段
(未变化的 albumIds 不随 PATCH 提交,见 submit) */
async function preloadOptions(): Promise<void> {
await searchAlbums('')
if (props.ad === null) return
const ids = props.ad.albumIds ?? []
const known = new Set(options.value.map((option) => option.id))
const missing = ids.filter((id) => !known.has(id)).map((id) => ({ id, label: id }))
const missing = ids
.filter((id) => !known.has(id))
.map((id) => ({ id, label: `${id}${t('adsManage.form.staleAlbum')}` }))
if (missing.length) options.value = [...missing, ...options.value]
}
@@ -318,6 +329,13 @@
uploadError.value = ''
const title = form.value.title.trim()
const albumIds = normalizeAlbumIds(form.value.albumIds)
// 编辑态仅 albumIds 实际变化时携带(与行值序敏感深比较,两侧同为
// normalizeAlbumIds 归一值):软删绑定的未变集合不再提交——服务端对
// 整组 albumIds 做存在性校验会 400,跳过提交让其它字段仍可保存。
const albumIdsDelta =
props.ad !== null && !albumIdsChanged(normalizeAlbumIds(props.ad.albumIds), albumIds)
? {}
: { albumIds }
submitting.value = true
try {
const saved =
@@ -326,7 +344,7 @@
// title 服务端 @IsNotEmpty:空串不可提交,清空=保持原值
...(title === '' ? {} : { title }),
linkUrl: form.value.linkUrl.trim(),
albumIds,
...albumIdsDelta,
...(staging.value !== null
? {
imageStagingKey: staging.value.objectKey,