From bfba76b5e648be81b2b6132351bba2c02fc228b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Tue, 29 Sep 2026 02:20:21 +0800 Subject: [PATCH] =?UTF-8?q?fix(ads):=20=E7=BB=88=E5=AE=A1=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=E2=80=94=E2=80=94staging=20=E5=89=8D=E7=BC=80+?= =?UTF-8?q?=E8=BF=90=E8=A1=8C=E6=97=B6=E4=B8=8A=E9=99=90=E6=A0=B8=E9=AA=8C?= =?UTF-8?q?/=E5=A4=B1=E6=95=88=E7=BB=91=E5=AE=9A=E7=BC=96=E8=BE=91/?= =?UTF-8?q?=E7=99=BD=E5=90=8D=E5=8D=95=E5=8E=BB=20heic/=E6=9D=82=E9=A1=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- apps/admin/src/locales/langs/en.json | 5 +- apps/admin/src/locales/langs/zh.json | 5 +- apps/admin/src/types/api/ads.d.ts | 5 +- apps/admin/src/utils/ads/adsLogic.spec.ts | 39 ++++++- apps/admin/src/utils/ads/adsLogic.ts | 20 ++++ .../src/views/system/ads/AdsFormDialog.vue | 32 ++++-- apps/viewer/src/__tests__/activity.spec.ts | 49 ++++++++- packages/contracts/openapi.yaml | 12 +-- packages/contracts/src/generated/openapi.d.ts | 12 +-- services/api/src/ads/ads.service.ts | 72 +++++++++---- services/api/src/ads/dto.ts | 30 +++--- services/api/test/ads-extensions.spec.ts | 46 ++++---- services/api/test/ads.e2e-spec.ts | 101 +++++++++++++++++- 13 files changed, 349 insertions(+), 79 deletions(-) diff --git a/apps/admin/src/locales/langs/en.json b/apps/admin/src/locales/langs/en.json index 353e852..4018ae2 100644 --- a/apps/admin/src/locales/langs/en.json +++ b/apps/admin/src/locales/langs/en.json @@ -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": { diff --git a/apps/admin/src/locales/langs/zh.json b/apps/admin/src/locales/langs/zh.json index 8fa6723..8c98b56 100644 --- a/apps/admin/src/locales/langs/zh.json +++ b/apps/admin/src/locales/langs/zh.json @@ -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": { diff --git a/apps/admin/src/types/api/ads.d.ts b/apps/admin/src/types/api/ads.d.ts index a3a9173..b998cff 100644 --- a/apps/admin/src/types/api/ads.d.ts +++ b/apps/admin/src/types/api/ads.d.ts @@ -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 { diff --git a/apps/admin/src/utils/ads/adsLogic.spec.ts b/apps/admin/src/utils/ads/adsLogic.spec.ts index c484017..9133cad 100644 --- a/apps/admin/src/utils/ads/adsLogic.spec.ts +++ b/apps/admin/src/utils/ads/adsLogic.spec.ts @@ -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) diff --git a/apps/admin/src/utils/ads/adsLogic.ts b/apps/admin/src/utils/ads/adsLogic.ts index b706b51..609dd4f 100644 --- a/apps/admin/src/utils/ads/adsLogic.ts +++ b/apps/admin/src/utils/ads/adsLogic.ts @@ -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 diff --git a/apps/admin/src/views/system/ads/AdsFormDialog.vue b/apps/admin/src/views/system/ads/AdsFormDialog.vue index 1bbff33..fc8fb4f 100644 --- a/apps/admin/src/views/system/ads/AdsFormDialog.vue +++ b/apps/admin/src/views/system/ads/AdsFormDialog.vue @@ -3,7 +3,9 @@ complete 步,创建/更新端点核验后转存终键)+ 跳转链接(isValidAdLinkUrl 与服务端 pattern 同源)+ 备注名(≤120 可空)+ 绑定相册(多选远程搜索, /v1/albums 游标拉取 + 本地过滤;留空=全局投放)。编辑态换图才带 image - 三字段;albumIds 经 normalizeAlbumIds 归一(空→null=转全局)。 --> + 三字段;albumIds 经 normalizeAlbumIds 归一(空→null=转全局),且仅 + 实际变化时随 PATCH 携带(albumIdsChanged 序敏感比较——软删绑定的 + 未变集合不提交,避免服务端存在性校验 400 挡住其它字段的保存)。 -->