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:
@@ -956,9 +956,9 @@
|
||||
"imageLabel": "Ad image",
|
||||
"imagePick": "Choose Image",
|
||||
"imageRepick": "Replace Image",
|
||||
"imageHint": "Recommended 1200×360, up to 2MB; jpeg/png/heic",
|
||||
"imageHint": "Recommended 1200×360, up to 2MB; jpeg/png (heic not supported: some browsers cannot display it)",
|
||||
"imageRequired": "Please upload an ad image first",
|
||||
"imageInvalidType": "Only jpeg/png/heic images are supported",
|
||||
"imageInvalidType": "Only jpeg/png images are supported (heic not supported)",
|
||||
"imageTooLarge": "Image exceeds the 2MB limit",
|
||||
"imageUploadFailed": "Image upload failed: {message}",
|
||||
"imageStagedTag": "New image pending save",
|
||||
@@ -973,6 +973,7 @@
|
||||
"albumsSearching": "Searching…",
|
||||
"albumsNoMatch": "No matching album",
|
||||
"albumsHint": "Leave empty = global (carousel on all album viewer pages)",
|
||||
"staleAlbum": " (no longer available, removable)",
|
||||
"submit": "Save"
|
||||
},
|
||||
"create": {
|
||||
|
||||
@@ -956,9 +956,9 @@
|
||||
"imageLabel": "广告图",
|
||||
"imagePick": "选择图片",
|
||||
"imageRepick": "重选图片",
|
||||
"imageHint": "建议 1200×360、不超过 2MB,支持 jpeg/png/heic",
|
||||
"imageHint": "建议 1200×360、不超过 2MB,支持 jpeg/png(不支持 heic:部分浏览器无法显示)",
|
||||
"imageRequired": "请先上传广告图",
|
||||
"imageInvalidType": "仅支持 jpeg/png/heic 图片",
|
||||
"imageInvalidType": "仅支持 jpeg/png 图片(不支持 heic)",
|
||||
"imageTooLarge": "图片超过 2MB 上限",
|
||||
"imageUploadFailed": "图片上传失败:{message}",
|
||||
"imageStagedTag": "新图待保存",
|
||||
@@ -973,6 +973,7 @@
|
||||
"albumsSearching": "搜索中…",
|
||||
"albumsNoMatch": "无匹配相册",
|
||||
"albumsHint": "留空=全局投放(全部相册观众页轮播)",
|
||||
"staleAlbum": "(已失效,可移除)",
|
||||
"submit": "保存"
|
||||
},
|
||||
"create": {
|
||||
|
||||
Vendored
+3
-2
@@ -18,8 +18,9 @@
|
||||
declare namespace Api {
|
||||
/** 广告位域 */
|
||||
namespace Ads {
|
||||
/** 广告图 MIME 白名单(image/heic 为暂定项,解码能力待 G2 真机验证) */
|
||||
type ImageContentType = 'image/jpeg' | 'image/png' | 'image/heic'
|
||||
/** 广告图 MIME 白名单(契约收窄为 jpeg/png:广告无变体流水线、原图直出,
|
||||
heic 在 Chromium/Firefox 桌面浏览器不渲染=破图,与照片域白名单分离) */
|
||||
type ImageContentType = 'image/jpeg' | 'image/png'
|
||||
|
||||
/** staging 直传凭证(预签名 URL 模式,与相册资产 UploadCredential 同构) */
|
||||
interface UploadCredential {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user