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",
|
"imageLabel": "Ad image",
|
||||||
"imagePick": "Choose Image",
|
"imagePick": "Choose Image",
|
||||||
"imageRepick": "Replace 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",
|
"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",
|
"imageTooLarge": "Image exceeds the 2MB limit",
|
||||||
"imageUploadFailed": "Image upload failed: {message}",
|
"imageUploadFailed": "Image upload failed: {message}",
|
||||||
"imageStagedTag": "New image pending save",
|
"imageStagedTag": "New image pending save",
|
||||||
@@ -973,6 +973,7 @@
|
|||||||
"albumsSearching": "Searching…",
|
"albumsSearching": "Searching…",
|
||||||
"albumsNoMatch": "No matching album",
|
"albumsNoMatch": "No matching album",
|
||||||
"albumsHint": "Leave empty = global (carousel on all album viewer pages)",
|
"albumsHint": "Leave empty = global (carousel on all album viewer pages)",
|
||||||
|
"staleAlbum": " (no longer available, removable)",
|
||||||
"submit": "Save"
|
"submit": "Save"
|
||||||
},
|
},
|
||||||
"create": {
|
"create": {
|
||||||
|
|||||||
@@ -956,9 +956,9 @@
|
|||||||
"imageLabel": "广告图",
|
"imageLabel": "广告图",
|
||||||
"imagePick": "选择图片",
|
"imagePick": "选择图片",
|
||||||
"imageRepick": "重选图片",
|
"imageRepick": "重选图片",
|
||||||
"imageHint": "建议 1200×360、不超过 2MB,支持 jpeg/png/heic",
|
"imageHint": "建议 1200×360、不超过 2MB,支持 jpeg/png(不支持 heic:部分浏览器无法显示)",
|
||||||
"imageRequired": "请先上传广告图",
|
"imageRequired": "请先上传广告图",
|
||||||
"imageInvalidType": "仅支持 jpeg/png/heic 图片",
|
"imageInvalidType": "仅支持 jpeg/png 图片(不支持 heic)",
|
||||||
"imageTooLarge": "图片超过 2MB 上限",
|
"imageTooLarge": "图片超过 2MB 上限",
|
||||||
"imageUploadFailed": "图片上传失败:{message}",
|
"imageUploadFailed": "图片上传失败:{message}",
|
||||||
"imageStagedTag": "新图待保存",
|
"imageStagedTag": "新图待保存",
|
||||||
@@ -973,6 +973,7 @@
|
|||||||
"albumsSearching": "搜索中…",
|
"albumsSearching": "搜索中…",
|
||||||
"albumsNoMatch": "无匹配相册",
|
"albumsNoMatch": "无匹配相册",
|
||||||
"albumsHint": "留空=全局投放(全部相册观众页轮播)",
|
"albumsHint": "留空=全局投放(全部相册观众页轮播)",
|
||||||
|
"staleAlbum": "(已失效,可移除)",
|
||||||
"submit": "保存"
|
"submit": "保存"
|
||||||
},
|
},
|
||||||
"create": {
|
"create": {
|
||||||
|
|||||||
Vendored
+3
-2
@@ -18,8 +18,9 @@
|
|||||||
declare namespace Api {
|
declare namespace Api {
|
||||||
/** 广告位域 */
|
/** 广告位域 */
|
||||||
namespace Ads {
|
namespace Ads {
|
||||||
/** 广告图 MIME 白名单(image/heic 为暂定项,解码能力待 G2 真机验证) */
|
/** 广告图 MIME 白名单(契约收窄为 jpeg/png:广告无变体流水线、原图直出,
|
||||||
type ImageContentType = 'image/jpeg' | 'image/png' | 'image/heic'
|
heic 在 Chromium/Firefox 桌面浏览器不渲染=破图,与照片域白名单分离) */
|
||||||
|
type ImageContentType = 'image/jpeg' | 'image/png'
|
||||||
|
|
||||||
/** staging 直传凭证(预签名 URL 模式,与相册资产 UploadCredential 同构) */
|
/** staging 直传凭证(预签名 URL 模式,与相册资产 UploadCredential 同构) */
|
||||||
interface UploadCredential {
|
interface UploadCredential {
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import zhMessages from '@/locales/langs/zh.json'
|
import zhMessages from '@/locales/langs/zh.json'
|
||||||
import enMessages from '@/locales/langs/en.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 投放范围分类', () => {
|
describe('scopeKind 投放范围分类', () => {
|
||||||
it('null / 空数组 → global;非空 → targeted', () => {
|
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 同源)', () => {
|
describe('isValidAdLinkUrl 表单链接校验(与服务端 pattern 同源)', () => {
|
||||||
it('http/https 前缀放行;其他协议/裸域名拒绝', () => {
|
it('http/https 前缀放行;其他协议/裸域名拒绝', () => {
|
||||||
expect(isValidAdLinkUrl('https://example.com/promo')).toBe(true)
|
expect(isValidAdLinkUrl('https://example.com/promo')).toBe(true)
|
||||||
|
|||||||
@@ -3,6 +3,9 @@
|
|||||||
*
|
*
|
||||||
* - scopeKind/normalizeAlbumIds 对齐服务端归一语义:albumIds null=全局投放,
|
* - scopeKind/normalizeAlbumIds 对齐服务端归一语义:albumIds null=全局投放,
|
||||||
* 空数组提交落库归一 null(AdCreateRequestDto 注释口径)
|
* 空数组提交落库归一 null(AdCreateRequestDto 注释口径)
|
||||||
|
* - albumIdsChanged 编辑态变更判定(序敏感深比较):仅变化时随 PATCH 携带
|
||||||
|
* albumIds——软删绑定的未变集合不再提交,避免服务端存在性校验 400 挡住
|
||||||
|
* 其它字段的保存
|
||||||
* - isValidAdLinkUrl 与服务端 pattern 同源(dto.ts LINK_URL_PATTERN:
|
* - isValidAdLinkUrl 与服务端 pattern 同源(dto.ts LINK_URL_PATTERN:
|
||||||
* ^https?:// 前缀 + 契约 maxLength 2048)
|
* ^https?:// 前缀 + 契约 maxLength 2048)
|
||||||
* - planAdjacentMove 上移/下移目标值计算:新建恒为尾部追加(父页传
|
* - planAdjacentMove 上移/下移目标值计算:新建恒为尾部追加(父页传
|
||||||
@@ -22,6 +25,23 @@ export function normalizeAlbumIds(v: string[] | null): string[] | null {
|
|||||||
return uniq.length === 0 ? null : uniq
|
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)。 */
|
/** 表单链接校验(与服务端 pattern 同源:^https?://,总长 ≤2048)。 */
|
||||||
export function isValidAdLinkUrl(u: string): boolean {
|
export function isValidAdLinkUrl(u: string): boolean {
|
||||||
return /^https?:\/\//.test(u) && u.length <= 2048
|
return /^https?:\/\//.test(u) && u.length <= 2048
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
complete 步,创建/更新端点核验后转存终键)+ 跳转链接(isValidAdLinkUrl
|
complete 步,创建/更新端点核验后转存终键)+ 跳转链接(isValidAdLinkUrl
|
||||||
与服务端 pattern 同源)+ 备注名(≤120 可空)+ 绑定相册(多选远程搜索,
|
与服务端 pattern 同源)+ 备注名(≤120 可空)+ 绑定相册(多选远程搜索,
|
||||||
/v1/albums 游标拉取 + 本地过滤;留空=全局投放)。编辑态换图才带 image
|
/v1/albums 游标拉取 + 本地过滤;留空=全局投放)。编辑态换图才带 image
|
||||||
三字段;albumIds 经 normalizeAlbumIds 归一(空→null=转全局)。 -->
|
三字段;albumIds 经 normalizeAlbumIds 归一(空→null=转全局),且仅
|
||||||
|
实际变化时随 PATCH 携带(albumIdsChanged 序敏感比较——软删绑定的
|
||||||
|
未变集合不提交,避免服务端存在性校验 400 挡住其它字段的保存)。 -->
|
||||||
<template>
|
<template>
|
||||||
<ElDialog
|
<ElDialog
|
||||||
v-model="visible"
|
v-model="visible"
|
||||||
@@ -27,7 +29,7 @@
|
|||||||
<input
|
<input
|
||||||
ref="fileInput"
|
ref="fileInput"
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/jpeg,image/png,image/heic"
|
accept="image/jpeg,image/png"
|
||||||
class="hidden"
|
class="hidden"
|
||||||
data-test="ad-form-file-input"
|
data-test="ad-form-file-input"
|
||||||
@change="onFileChange"
|
@change="onFileChange"
|
||||||
@@ -116,11 +118,13 @@
|
|||||||
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
|
||||||
import { fetchAlbumList } from '@/api/albums'
|
import { fetchAlbumList } from '@/api/albums'
|
||||||
import { fetchCreateAd, fetchInitAdUpload, fetchUpdateAd } from '@/api/ads'
|
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) */
|
/** 广告图字节上限(契约 AD_UPLOAD_MAX_BYTES=2MB,独立于照片域 50MB) */
|
||||||
const AD_UPLOAD_MAX_BYTES = 2 * 1024 * 1024
|
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:列表端点无标题过滤,本地匹配;管理
|
/** 相册搜索拉取页数上限(每页 50:列表端点无标题过滤,本地匹配;管理
|
||||||
规模有界,5 页 ≈ 250 本封顶,超出以 ID 兜底标签回显) */
|
规模有界,5 页 ≈ 250 本封顶,超出以 ID 兜底标签回显) */
|
||||||
@@ -269,6 +273,9 @@
|
|||||||
album.id.toLowerCase().includes(keyword)
|
album.id.toLowerCase().includes(keyword)
|
||||||
)
|
)
|
||||||
.map((album) => ({ id: album.id, label: `${titleOf(album)}(${album.id})` }))
|
.map((album) => ({ id: album.id, label: `${titleOf(album)}(${album.id})` }))
|
||||||
|
} catch {
|
||||||
|
// 拉取失败静默吸收:http 层已统一弹 toast(ElMessage),此处仅保持
|
||||||
|
// 旧选项(finally 复位 searching),避免未处理拒绝穿透 ElSelect。
|
||||||
} finally {
|
} finally {
|
||||||
searching.value = false
|
searching.value = false
|
||||||
}
|
}
|
||||||
@@ -276,13 +283,17 @@
|
|||||||
|
|
||||||
/** 打开即预载选项(新建态同样预载——避免未输入时误显「无匹配相册」,
|
/** 打开即预载选项(新建态同样预载——避免未输入时误显「无匹配相册」,
|
||||||
对照 SlotBindDialog 打开即 searchAlbums('') 先例);编辑态已绑定 ID
|
对照 SlotBindDialog 打开即 searchAlbums('') 先例);编辑态已绑定 ID
|
||||||
超出拉取上限或相册已删除的以裸 ID 兜底(绑定不级联清理,容失效) */
|
超出拉取上限或相册已删除的以裸 ID 兜底并加「已失效」后缀(绑定不
|
||||||
|
级联清理,容失效)——提示运营可手动移除;不移除也能保存其它字段
|
||||||
|
(未变化的 albumIds 不随 PATCH 提交,见 submit) */
|
||||||
async function preloadOptions(): Promise<void> {
|
async function preloadOptions(): Promise<void> {
|
||||||
await searchAlbums('')
|
await searchAlbums('')
|
||||||
if (props.ad === null) return
|
if (props.ad === null) return
|
||||||
const ids = props.ad.albumIds ?? []
|
const ids = props.ad.albumIds ?? []
|
||||||
const known = new Set(options.value.map((option) => option.id))
|
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]
|
if (missing.length) options.value = [...missing, ...options.value]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -318,6 +329,13 @@
|
|||||||
uploadError.value = ''
|
uploadError.value = ''
|
||||||
const title = form.value.title.trim()
|
const title = form.value.title.trim()
|
||||||
const albumIds = normalizeAlbumIds(form.value.albumIds)
|
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
|
submitting.value = true
|
||||||
try {
|
try {
|
||||||
const saved =
|
const saved =
|
||||||
@@ -326,7 +344,7 @@
|
|||||||
// title 服务端 @IsNotEmpty:空串不可提交,清空=保持原值
|
// title 服务端 @IsNotEmpty:空串不可提交,清空=保持原值
|
||||||
...(title === '' ? {} : { title }),
|
...(title === '' ? {} : { title }),
|
||||||
linkUrl: form.value.linkUrl.trim(),
|
linkUrl: form.value.linkUrl.trim(),
|
||||||
albumIds,
|
...albumIdsDelta,
|
||||||
...(staging.value !== null
|
...(staging.value !== null
|
||||||
? {
|
? {
|
||||||
imageStagingKey: staging.value.objectKey,
|
imageStagingKey: staging.value.objectKey,
|
||||||
|
|||||||
@@ -2,7 +2,14 @@ import { flushPromises, mount, type VueWrapper } from '@vue/test-utils';
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { nextTick } from 'vue';
|
import { nextTick } from 'vue';
|
||||||
import App from '../App.vue';
|
import App from '../App.vue';
|
||||||
import { ApiError, getPublicAlbum, getPublicPhotos, type PublicAlbum } from '../api/client';
|
import {
|
||||||
|
ApiError,
|
||||||
|
getPublicAds,
|
||||||
|
getPublicAlbum,
|
||||||
|
getPublicPhotos,
|
||||||
|
type PublicAd,
|
||||||
|
type PublicAlbum,
|
||||||
|
} from '../api/client';
|
||||||
import { createViewerI18n, type ViewerI18n } from '../i18n';
|
import { createViewerI18n, type ViewerI18n } from '../i18n';
|
||||||
import ActivityPage from '../pages/ActivityPage.vue';
|
import ActivityPage from '../pages/ActivityPage.vue';
|
||||||
import { router } from '../router';
|
import { router } from '../router';
|
||||||
@@ -12,13 +19,15 @@ import { makeAlbum, makeEnAlbum } from './fixtures';
|
|||||||
// getPublicPhotos(Task 7 起活动页内嵌 PhotoFeed,需喂空页避免真实 fetch),
|
// getPublicPhotos(Task 7 起活动页内嵌 PhotoFeed,需喂空页避免真实 fetch),
|
||||||
// 保证 ApiError/errors 映射等真实逻辑参与断言。Task 10 起活动页组装 WS/浏览
|
// 保证 ApiError/errors 映射等真实逻辑参与断言。Task 10 起活动页组装 WS/浏览
|
||||||
// 上报:RealtimeClient 以无操作桩替换(jsdom WebSocket 会发起真实连接)、
|
// 上报:RealtimeClient 以无操作桩替换(jsdom WebSocket 会发起真实连接)、
|
||||||
// postAlbumVisit 替身防真实 fetch。
|
// postAlbumVisit 替身防真实 fetch;广告位轮播(Task 10)getPublicAds 同样
|
||||||
|
// 替身(相册加载成功后页面会独立拉取广告,不替身会发起真实 fetch)。
|
||||||
vi.mock('../api/client', async (importOriginal) => {
|
vi.mock('../api/client', async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import('../api/client')>();
|
const actual = await importOriginal<typeof import('../api/client')>();
|
||||||
return {
|
return {
|
||||||
...actual,
|
...actual,
|
||||||
getPublicAlbum: vi.fn(),
|
getPublicAlbum: vi.fn(),
|
||||||
getPublicPhotos: vi.fn().mockResolvedValue({ items: [], nextCursor: null, hasMore: false }),
|
getPublicPhotos: vi.fn().mockResolvedValue({ items: [], nextCursor: null, hasMore: false }),
|
||||||
|
getPublicAds: vi.fn().mockResolvedValue({ items: [] }),
|
||||||
postAlbumVisit: vi.fn().mockResolvedValue({ viewCount: 0, counted: false }),
|
postAlbumVisit: vi.fn().mockResolvedValue({ viewCount: 0, counted: false }),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -33,6 +42,7 @@ vi.mock('../realtime/ws', () => {
|
|||||||
|
|
||||||
const mockedGetPublicAlbum = vi.mocked(getPublicAlbum);
|
const mockedGetPublicAlbum = vi.mocked(getPublicAlbum);
|
||||||
const mockedGetPublicPhotos = vi.mocked(getPublicPhotos);
|
const mockedGetPublicPhotos = vi.mocked(getPublicPhotos);
|
||||||
|
const mockedGetPublicAds = vi.mocked(getPublicAds);
|
||||||
|
|
||||||
// 逐挂载独立 i18n 实例 + 逐用例卸载:vue-i18n 在 app.unmount 时 dispose 共享实例
|
// 逐挂载独立 i18n 实例 + 逐用例卸载:vue-i18n 在 app.unmount 时 dispose 共享实例
|
||||||
// (见 i18n/index.ts 注释),复用单例会让后续用例的 locale 写入失效;残留组件的
|
// (见 i18n/index.ts 注释),复用单例会让后续用例的 locale 写入失效;残留组件的
|
||||||
@@ -63,9 +73,11 @@ async function pickLanguage(wrapper: VueWrapper, option: '中文' | 'English'):
|
|||||||
describe('activity page (Task 6)', () => {
|
describe('activity page (Task 6)', () => {
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
|
sessionStorage.clear();
|
||||||
i18n = createViewerI18n('zh-CN');
|
i18n = createViewerI18n('zh-CN');
|
||||||
document.title = '';
|
document.title = '';
|
||||||
mockedGetPublicAlbum.mockReset().mockResolvedValue(makeAlbum());
|
mockedGetPublicAlbum.mockReset().mockResolvedValue(makeAlbum());
|
||||||
|
mockedGetPublicAds.mockReset().mockResolvedValue({ items: [] });
|
||||||
await router.push('/a/tok-activity');
|
await router.push('/a/tok-activity');
|
||||||
await router.isReady();
|
await router.isReady();
|
||||||
});
|
});
|
||||||
@@ -264,4 +276,37 @@ describe('activity page (Task 6)', () => {
|
|||||||
expect(chips[1].classes()).toContain('active');
|
expect(chips[1].classes()).toContain('active');
|
||||||
expect(chips[1].text()).toBe('Check-in Wall');
|
expect(chips[1].text()).toBe('Check-in Wall');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// —— 广告位轮播(Task 10 终审补位):会话级关闭记忆在页面(组件只 emit
|
||||||
|
// close,无 sessionStorage 副作用)—— dismissed 时不拉取不渲染、
|
||||||
|
// close 后写键并清空,两段都在页面级守护 ——
|
||||||
|
|
||||||
|
function makeAd(adId: string): PublicAd {
|
||||||
|
return { adId, imageUrl: `https://ad.test/${adId}.jpg`, linkUrl: `https://link.test/${adId}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
it('skips the ads request and carousel when the session dismissed flag is set', async () => {
|
||||||
|
sessionStorage.setItem('viewer.adsDismissed', '1');
|
||||||
|
mockedGetPublicAds.mockResolvedValue({ items: [makeAd('dismissed-ad')] });
|
||||||
|
const wrapper = mountPage();
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
// 命中会话级记忆 → 连请求都省(设计:关闭后本会话不再拉取)。
|
||||||
|
expect(mockedGetPublicAds).not.toHaveBeenCalled();
|
||||||
|
expect(wrapper.find('.ad-carousel').exists()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dismissing the carousel writes the sessionStorage flag and clears it from the page', async () => {
|
||||||
|
mockedGetPublicAds.mockResolvedValue({ items: [makeAd('ad-1')] });
|
||||||
|
const wrapper = mountPage();
|
||||||
|
await flushPromises();
|
||||||
|
|
||||||
|
expect(wrapper.find('.ad-carousel').exists()).toBe(true);
|
||||||
|
await wrapper.get('.ad-close').trigger('click');
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
// 页面 close 处理器:写会话级键 + 清空数据(轮播整块缺席)。
|
||||||
|
expect(sessionStorage.getItem('viewer.adsDismissed')).toBe('1');
|
||||||
|
expect(wrapper.find('.ad-carousel').exists()).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4860,8 +4860,8 @@ components:
|
|||||||
description: 申明字节数(须与 staging 对象一致;上限 AD_UPLOAD_MAX_BYTES=2MB)。
|
description: 申明字节数(须与 staging 对象一致;上限 AD_UPLOAD_MAX_BYTES=2MB)。
|
||||||
imageContentType:
|
imageContentType:
|
||||||
type: string
|
type: string
|
||||||
enum: [image/jpeg, image/png, image/heic]
|
enum: [image/jpeg, image/png]
|
||||||
description: 申明类型(须与 staging 对象一致);image/heic 为暂定项,解码能力待 G2 真机验证后确认。
|
description: 申明类型(须与 staging 对象一致);heic 除外:广告无变体流水线、原图直出,桌面浏览器(Chromium/Firefox)不渲染。
|
||||||
sortOrder:
|
sortOrder:
|
||||||
type: integer
|
type: integer
|
||||||
minimum: 0
|
minimum: 0
|
||||||
@@ -4913,8 +4913,8 @@ components:
|
|||||||
description: 换图申明字节数(上限 AD_UPLOAD_MAX_BYTES=2MB)。
|
description: 换图申明字节数(上限 AD_UPLOAD_MAX_BYTES=2MB)。
|
||||||
imageContentType:
|
imageContentType:
|
||||||
type: string
|
type: string
|
||||||
enum: [image/jpeg, image/png, image/heic]
|
enum: [image/jpeg, image/png]
|
||||||
description: 换图申明类型。
|
description: 换图申明类型;heic 除外(同 AdCreateRequest.imageContentType:广告无变体流水线,桌面浏览器不渲染)。
|
||||||
|
|
||||||
AdUploadInitRequest:
|
AdUploadInitRequest:
|
||||||
type: object
|
type: object
|
||||||
@@ -4932,8 +4932,8 @@ components:
|
|||||||
description: 申明字节数(上限 AD_UPLOAD_MAX_BYTES=2MB,超限 400)。
|
description: 申明字节数(上限 AD_UPLOAD_MAX_BYTES=2MB,超限 400)。
|
||||||
contentType:
|
contentType:
|
||||||
type: string
|
type: string
|
||||||
enum: [image/jpeg, image/png, image/heic]
|
enum: [image/jpeg, image/png]
|
||||||
description: MIME 类型白名单;image/heic 为暂定项,解码能力待 G2 真机验证后确认。
|
description: MIME 类型白名单;heic 除外:广告无变体流水线、原图直出,桌面浏览器(Chromium/Firefox)不渲染。
|
||||||
|
|
||||||
AdUploadInitResponse:
|
AdUploadInitResponse:
|
||||||
type: object
|
type: object
|
||||||
|
|||||||
+6
-6
@@ -2479,10 +2479,10 @@ export interface components {
|
|||||||
/** @description 申明字节数(须与 staging 对象一致;上限 AD_UPLOAD_MAX_BYTES=2MB)。 */
|
/** @description 申明字节数(须与 staging 对象一致;上限 AD_UPLOAD_MAX_BYTES=2MB)。 */
|
||||||
imageBytes: number;
|
imageBytes: number;
|
||||||
/**
|
/**
|
||||||
* @description 申明类型(须与 staging 对象一致);image/heic 为暂定项,解码能力待 G2 真机验证后确认。
|
* @description 申明类型(须与 staging 对象一致);heic 除外:广告无变体流水线、原图直出,桌面浏览器(Chromium/Firefox)不渲染。
|
||||||
* @enum {string}
|
* @enum {string}
|
||||||
*/
|
*/
|
||||||
imageContentType: "image/jpeg" | "image/png" | "image/heic";
|
imageContentType: "image/jpeg" | "image/png";
|
||||||
/** @description 排序权重(缺省 0)。 */
|
/** @description 排序权重(缺省 0)。 */
|
||||||
sortOrder?: number;
|
sortOrder?: number;
|
||||||
/** @description 绑定相册 ID 列表;未提供或空=全局。 */
|
/** @description 绑定相册 ID 列表;未提供或空=全局。 */
|
||||||
@@ -2510,10 +2510,10 @@ export interface components {
|
|||||||
/** @description 换图申明字节数(上限 AD_UPLOAD_MAX_BYTES=2MB)。 */
|
/** @description 换图申明字节数(上限 AD_UPLOAD_MAX_BYTES=2MB)。 */
|
||||||
imageBytes?: number;
|
imageBytes?: number;
|
||||||
/**
|
/**
|
||||||
* @description 换图申明类型。
|
* @description 换图申明类型;heic 除外(同 AdCreateRequest.imageContentType:广告无变体流水线,桌面浏览器不渲染)。
|
||||||
* @enum {string}
|
* @enum {string}
|
||||||
*/
|
*/
|
||||||
imageContentType?: "image/jpeg" | "image/png" | "image/heic";
|
imageContentType?: "image/jpeg" | "image/png";
|
||||||
};
|
};
|
||||||
/** @description 广告图直传初始化请求(无会话概念,幂等由创建端 copyIfAbsent 承担)。 */
|
/** @description 广告图直传初始化请求(无会话概念,幂等由创建端 copyIfAbsent 承担)。 */
|
||||||
AdUploadInitRequest: {
|
AdUploadInitRequest: {
|
||||||
@@ -2522,10 +2522,10 @@ export interface components {
|
|||||||
/** @description 申明字节数(上限 AD_UPLOAD_MAX_BYTES=2MB,超限 400)。 */
|
/** @description 申明字节数(上限 AD_UPLOAD_MAX_BYTES=2MB,超限 400)。 */
|
||||||
bytes: number;
|
bytes: number;
|
||||||
/**
|
/**
|
||||||
* @description MIME 类型白名单;image/heic 为暂定项,解码能力待 G2 真机验证后确认。
|
* @description MIME 类型白名单;heic 除外:广告无变体流水线、原图直出,桌面浏览器(Chromium/Firefox)不渲染。
|
||||||
* @enum {string}
|
* @enum {string}
|
||||||
*/
|
*/
|
||||||
contentType: "image/jpeg" | "image/png" | "image/heic";
|
contentType: "image/jpeg" | "image/png";
|
||||||
};
|
};
|
||||||
AdUploadInitResponse: {
|
AdUploadInitResponse: {
|
||||||
/** @description staging 键 photo-live/{env}/ads/staging/{cuid}.{ext}(创建/更新时引用)。 */
|
/** @description staging 键 photo-live/{env}/ads/staging/{cuid}.{ext}(创建/更新时引用)。 */
|
||||||
|
|||||||
@@ -10,13 +10,16 @@
|
|||||||
// · staging 键 adsStagingObjectKey(cuid, ext)(@photo/storage 权威布局
|
// · staging 键 adsStagingObjectKey(cuid, ext)(@photo/storage 权威布局
|
||||||
// photo-live/{env}/ads/staging/{cuid}.{ext});cuidLikeId 应用侧生成
|
// photo-live/{env}/ads/staging/{cuid}.{ext});cuidLikeId 应用侧生成
|
||||||
// (键含自引用键段,无行可回填);扩展名复用 uploads/support.extOfFilename
|
// (键含自引用键段,无行可回填);扩展名复用 uploads/support.extOfFilename
|
||||||
// 白名单归一(jpeg→jpg);
|
// 白名单归一(jpeg→jpg)后以广告域集合 AD_IMAGE_EXTENSIONS 二次裁决
|
||||||
|
// (两域白名单已分离,heic 剔除——广告无变体流水线,桌面浏览器不渲染);
|
||||||
|
// verifyStaging 另核验键前缀(仅本域 staging 布局)与运行时字节上限
|
||||||
|
// (init 申明管不住实际 PUT 体,create/update 是超限唯一核验点);
|
||||||
// · presignPut 参数序:(objectKey, bytes, contentType, ttlSeconds)——
|
// · presignPut 参数序:(objectKey, bytes, contentType, ttlSeconds)——
|
||||||
// StoragePort 接口权威;TTL 惰性读 SIGNED_URL_TTL_SECONDS(同上传域);
|
// StoragePort 接口权威;TTL 惰性读 SIGNED_URL_TTL_SECONDS(同上传域);
|
||||||
// · create:requireAdmin → 校验 albumIds(去重 + 相册存在性 count,不等
|
// · create:requireAdmin → 校验 albumIds(去重 + 相册存在性 count,不等
|
||||||
// 400 VALIDATION_FAILED;未提供/null/[] 归一 null=全局投放)→ head(staging)
|
// 400 VALIDATION_FAILED;未提供/null/[] 归一 null=全局投放)→ verifyStaging
|
||||||
// 三核(缺失/bytes≠申明/contentType≠申明 → 400 UPLOAD_VERIFY_FAILED,
|
// 五关(键前缀/扩展名/存在性/运行时 2MB 上限/申明一致性)→ $transaction{
|
||||||
// assets complete 同口径)→ $transaction{ copyIfAbsent(staging→终键 v1)
|
// copyIfAbsent(staging→终键 v1)
|
||||||
// → ads.insert → outbox ad.updated v1(albumId 恒空串信封约定)→ audit
|
// → ads.insert → outbox ad.updated v1(albumId 恒空串信封约定)→ audit
|
||||||
// ad.create }。copy 在事务内先于 insert(红线顺序):copy 后事务失败=孤儿
|
// ad.create }。copy 在事务内先于 insert(红线顺序):copy 后事务失败=孤儿
|
||||||
// 终键对象,OSS 生命周期兜底(banner 同款语义);
|
// 终键对象,OSS 生命周期兜底(banner 同款语义);
|
||||||
@@ -36,6 +39,7 @@ import { HttpStatus, Inject, Injectable } from '@nestjs/common';
|
|||||||
import { AD_UPLOAD_MAX_BYTES, ErrorCodes } from '@photo/contracts';
|
import { AD_UPLOAD_MAX_BYTES, ErrorCodes } from '@photo/contracts';
|
||||||
import type { components } from '@photo/contracts';
|
import type { components } from '@photo/contracts';
|
||||||
import {
|
import {
|
||||||
|
OBJECT_KEY_ROOT,
|
||||||
StorageError,
|
StorageError,
|
||||||
adObjectKey,
|
adObjectKey,
|
||||||
adsStagingObjectKey,
|
adsStagingObjectKey,
|
||||||
@@ -58,10 +62,11 @@ import {
|
|||||||
buildAdDeletedPayload,
|
buildAdDeletedPayload,
|
||||||
buildAdUpdatedPayload,
|
buildAdUpdatedPayload,
|
||||||
} from './ad-payload';
|
} from './ad-payload';
|
||||||
import type {
|
import {
|
||||||
AdCreateRequestDto,
|
AD_IMAGE_CONTENT_TYPES,
|
||||||
AdUpdateRequestDto,
|
type AdCreateRequestDto,
|
||||||
AdUploadInitRequestDto,
|
type AdUpdateRequestDto,
|
||||||
|
type AdUploadInitRequestDto,
|
||||||
} from './dto';
|
} from './dto';
|
||||||
|
|
||||||
export type AdUploadInitResponse = components['schemas']['AdUploadInitResponse'];
|
export type AdUploadInitResponse = components['schemas']['AdUploadInitResponse'];
|
||||||
@@ -69,11 +74,24 @@ export type AdminAdItem = components['schemas']['AdminAdItem'];
|
|||||||
export type AdminAdListResponse = components['schemas']['AdminAdListResponse'];
|
export type AdminAdListResponse = components['schemas']['AdminAdListResponse'];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* staging/终键扩展名合法集(init 扩展名白名单的归一值域)。导出供
|
* staging/终键扩展名合法集(init 扩展名白名单的归一值域):从广告域自身
|
||||||
* test/ads-extensions.spec.ts 与上传域白名单做 1:1 一致性守护(三处同源:
|
* 的 AD_IMAGE_CONTENT_TYPES 派生(jpeg→jpg 归一)。与照片域白名单自此
|
||||||
* UPLOAD_CONTENT_TYPES → EXTENSION_WHITELIST 归一值 → 本集合)。
|
* 分离——heic 已剔除(广告无变体流水线、原图直出,Chromium/Firefox 桌面
|
||||||
|
* 浏览器不渲染=破图;照片域保留 heic 由变体流水线兜底解码)。导出供
|
||||||
|
* test/ads-extensions.spec.ts 守护(contentType ↔ 扩展名集合恒等)。
|
||||||
*/
|
*/
|
||||||
export const AD_IMAGE_EXTENSIONS = new Set(['jpg', 'png', 'heic']);
|
export const AD_IMAGE_EXTENSIONS = new Set(
|
||||||
|
AD_IMAGE_CONTENT_TYPES.map((contentType) =>
|
||||||
|
contentType === 'image/jpeg' ? 'jpg' : contentType.split('/')[1],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 广告 staging 键前缀(verifyStaging 白名单核验位):init 派生的键恒满足
|
||||||
|
* (adsStagingObjectKey 同源派生);create/update 提交的任意其他键(照片域
|
||||||
|
* staging/原图/banner 等)在此拦截——防未经验证的对象被提升为公开广告。
|
||||||
|
*/
|
||||||
|
const ADS_STAGING_KEY_PREFIX = `${OBJECT_KEY_ROOT}/ads/staging/`;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* updateAd 审计 metadata.fields 的字段名序列(契约 AdUpdateRequest 属性序,
|
* updateAd 审计 metadata.fields 的字段名序列(契约 AdUpdateRequest 属性序,
|
||||||
@@ -147,8 +165,11 @@ export class AdsService {
|
|||||||
throw validationFailed(`广告图超过大小限额(limit=${AD_UPLOAD_MAX_BYTES})`);
|
throw validationFailed(`广告图超过大小限额(limit=${AD_UPLOAD_MAX_BYTES})`);
|
||||||
}
|
}
|
||||||
const ext = extOfFilename(dto.filename);
|
const ext = extOfFilename(dto.filename);
|
||||||
if (ext === null) {
|
// 扩展名双重核验:extOfFilename 走与照片域共享的白名单(仍认 heic),
|
||||||
throw validationFailed('文件扩展名不接受(jpg/jpeg/png/heic)');
|
// 此处以广告域集合二次裁决——init 即拒绝 .heic staging 键,杜绝
|
||||||
|
// 「init 放行 → create 拒绝」的链路中段断裂(两域白名单已分离)。
|
||||||
|
if (ext === null || !AD_IMAGE_EXTENSIONS.has(ext)) {
|
||||||
|
throw validationFailed('文件扩展名不接受(jpg/jpeg/png)');
|
||||||
}
|
}
|
||||||
const objectKey = adsStagingObjectKey(cuidLikeId(), ext);
|
const objectKey = adsStagingObjectKey(cuidLikeId(), ext);
|
||||||
const credential = await this.storage.presignPut(
|
const credential = await this.storage.presignPut(
|
||||||
@@ -243,7 +264,7 @@ export class AdsService {
|
|||||||
* 部分更新(契约 updateAd):仅提交的字段被修改,空请求体 400(embed 更新
|
* 部分更新(契约 updateAd):仅提交的字段被修改,空请求体 400(embed 更新
|
||||||
* 口径)。albumIds:未提供=不动,null/[]=转全局,数组=去重整体替换(存在
|
* 口径)。albumIds:未提供=不动,null/[]=转全局,数组=去重整体替换(存在
|
||||||
* 性校验在事务内)。换图:imageStagingKey/imageBytes/imageContentType 三者
|
* 性校验在事务内)。换图:imageStagingKey/imageBytes/imageContentType 三者
|
||||||
* 必须同时提供(缺一 400,服务层裁决),staging head 三核(事务外)后转存
|
* 必须同时提供(缺一 400,服务层裁决),verifyStaging 核验(事务外)后转存
|
||||||
* 版本化终键 v{N+1}(事务内 copy 先于行更新——同 create 红线顺序;旧对象
|
* 版本化终键 v{N+1}(事务内 copy 先于行更新——同 create 红线顺序;旧对象
|
||||||
* 不回收,OSS 生命周期兜底)。条件更新 WHERE entityVersion=读取版本(embed
|
* 不回收,OSS 生命周期兜底)。条件更新 WHERE entityVersion=读取版本(embed
|
||||||
* 乐观重试模式):并发败方整事务重跑,保证终键版本段与行 entityVersion
|
* 乐观重试模式):并发败方整事务重跑,保证终键版本段与行 entityVersion
|
||||||
@@ -463,24 +484,35 @@ export class AdsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* staging 对象三核(assets complete 同口径):存在性 / bytes / contentType
|
* staging 对象核验(五关):键前缀(仅本域 staging 布局——防照片域
|
||||||
* 与申明一致,任一不符 400 UPLOAD_VERIFY_FAILED(失败是请求级错误,可重新
|
* staging/原图/banner 等任意键被提升为公开广告)→ 扩展名(键尾派生,
|
||||||
* init+PUT 后重试)。扩展名自 staging 键尾派生(init 扩展名白名单归一值)
|
* 合法集同 init)→ 存在性 → 运行时字节上限(init 的申明字节管不住实际
|
||||||
* 并校验合法集,供版本化终键派生。
|
* PUT 体——预签名 PUT 无服务端强制,create/update 是超限的唯一核验点,
|
||||||
|
* 400 VALIDATION_FAILED 报文含限额值)→ bytes/contentType 与申明一致
|
||||||
|
* (assets complete 同口径,任一不符 400 UPLOAD_VERIFY_FAILED——失败是
|
||||||
|
* 请求级错误,可重新 init+PUT 后重试)。
|
||||||
*/
|
*/
|
||||||
private async verifyStaging(
|
private async verifyStaging(
|
||||||
key: string,
|
key: string,
|
||||||
bytes: number,
|
bytes: number,
|
||||||
contentType: string,
|
contentType: string,
|
||||||
): Promise<StagingImage> {
|
): Promise<StagingImage> {
|
||||||
|
if (!key.startsWith(ADS_STAGING_KEY_PREFIX)) {
|
||||||
|
throw validationFailed('imageStagingKey 非广告 staging 键');
|
||||||
|
}
|
||||||
const ext = extOfObjectKey(key);
|
const ext = extOfObjectKey(key);
|
||||||
if (ext === null || !AD_IMAGE_EXTENSIONS.has(ext)) {
|
if (ext === null || !AD_IMAGE_EXTENSIONS.has(ext)) {
|
||||||
throw validationFailed('imageStagingKey 扩展名不接受(jpg/png/heic)');
|
throw validationFailed('imageStagingKey 扩展名不接受(jpg/png)');
|
||||||
}
|
}
|
||||||
const head = await this.storage.head(key);
|
const head = await this.storage.head(key);
|
||||||
if (head === null) {
|
if (head === null) {
|
||||||
throw uploadVerifyFailed('广告图对象不存在或尚未上传');
|
throw uploadVerifyFailed('广告图对象不存在或尚未上传');
|
||||||
}
|
}
|
||||||
|
if (head.bytes > AD_UPLOAD_MAX_BYTES) {
|
||||||
|
throw validationFailed(
|
||||||
|
`广告图超过大小限额(limit=${AD_UPLOAD_MAX_BYTES})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
if (head.bytes !== bytes) {
|
if (head.bytes !== bytes) {
|
||||||
throw uploadVerifyFailed(
|
throw uploadVerifyFailed(
|
||||||
`字节数与申明不符(expected=${bytes}, actual=${head.bytes})`,
|
`字节数与申明不符(expected=${bytes}, actual=${head.bytes})`,
|
||||||
|
|||||||
+18
-12
@@ -10,8 +10,10 @@
|
|||||||
// (换图三字段须同时提供,服务层裁决);
|
// (换图三字段须同时提供,服务层裁决);
|
||||||
// · 大小限额与 filename 扩展名白名单是服务层校验(同 UploadInitRequestDto
|
// · 大小限额与 filename 扩展名白名单是服务层校验(同 UploadInitRequestDto
|
||||||
// 约定:限额报文需含限额值、扩展名经 extOfFilename 归一后派生对象键);
|
// 约定:限额报文需含限额值、扩展名经 extOfFilename 归一后派生对象键);
|
||||||
// contentType 与照片直传共用白名单 UPLOAD_CONTENT_TYPES(image/heic
|
// contentType 用广告域独立白名单 AD_IMAGE_CONTENT_TYPES(与照片直传的
|
||||||
// 暂定,G2 真机验证)。
|
// UPLOAD_CONTENT_TYPES 自此分离:广告无变体流水线、原图直出,heic 在
|
||||||
|
// Chromium/Firefox 桌面浏览器不渲染=破图,故剔除;照片域保留 heic 由
|
||||||
|
// 变体流水线兜底解码)。
|
||||||
import {
|
import {
|
||||||
IsArray,
|
IsArray,
|
||||||
IsBoolean,
|
IsBoolean,
|
||||||
@@ -24,14 +26,18 @@ import {
|
|||||||
MaxLength,
|
MaxLength,
|
||||||
Min,
|
Min,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
import {
|
|
||||||
UPLOAD_CONTENT_TYPES,
|
|
||||||
type UploadContentType,
|
|
||||||
} from '../uploads/support';
|
|
||||||
|
|
||||||
/** 契约 linkUrl 校验(AdCreateRequest/AdUpdateRequest 同一 pattern)。 */
|
/** 契约 linkUrl 校验(AdCreateRequest/AdUpdateRequest 同一 pattern)。 */
|
||||||
const LINK_URL_PATTERN = /^https?:\/\//;
|
const LINK_URL_PATTERN = /^https?:\/\//;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 广告图 contentType 白名单(契约 enum [image/jpeg, image/png])。与照片域
|
||||||
|
* UPLOAD_CONTENT_TYPES 分离(见文件头注);ads.service 的
|
||||||
|
* AD_IMAGE_EXTENSIONS 与 test/ads-extensions.spec.ts 均由此派生守护。
|
||||||
|
*/
|
||||||
|
export const AD_IMAGE_CONTENT_TYPES = ['image/jpeg', 'image/png'] as const;
|
||||||
|
export type AdImageContentType = (typeof AD_IMAGE_CONTENT_TYPES)[number];
|
||||||
|
|
||||||
export class AdUploadInitRequestDto {
|
export class AdUploadInitRequestDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsNotEmpty()
|
@IsNotEmpty()
|
||||||
@@ -42,8 +48,8 @@ export class AdUploadInitRequestDto {
|
|||||||
@Min(1)
|
@Min(1)
|
||||||
bytes!: number;
|
bytes!: number;
|
||||||
|
|
||||||
@IsIn(UPLOAD_CONTENT_TYPES)
|
@IsIn(AD_IMAGE_CONTENT_TYPES)
|
||||||
contentType!: UploadContentType;
|
contentType!: AdImageContentType;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AdCreateRequestDto {
|
export class AdCreateRequestDto {
|
||||||
@@ -66,8 +72,8 @@ export class AdCreateRequestDto {
|
|||||||
@Min(1)
|
@Min(1)
|
||||||
imageBytes!: number;
|
imageBytes!: number;
|
||||||
|
|
||||||
@IsIn(UPLOAD_CONTENT_TYPES)
|
@IsIn(AD_IMAGE_CONTENT_TYPES)
|
||||||
imageContentType!: UploadContentType;
|
imageContentType!: AdImageContentType;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@@ -124,6 +130,6 @@ export class AdUpdateRequestDto {
|
|||||||
imageBytes?: number;
|
imageBytes?: number;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsIn(UPLOAD_CONTENT_TYPES)
|
@IsIn(AD_IMAGE_CONTENT_TYPES)
|
||||||
imageContentType?: UploadContentType;
|
imageContentType?: AdImageContentType;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,39 +1,49 @@
|
|||||||
// services/api/test/ads-extensions.spec.ts —— 广告图扩展名白名单一致性守护
|
// services/api/test/ads-extensions.spec.ts —— 广告图扩展名白名单守护
|
||||||
// (ads-carousel Task 7 质量审查遗留项,随 Task 8 提交)。三处白名单必须恒等:
|
// (ads-carousel Task 7 质量审查遗留项,随 Task 8 提交;终审 Fix 3 改为
|
||||||
// · uploads/support.ts UPLOAD_CONTENT_TYPES(契约 contentType 白名单);
|
// 广告域自洽守护)。守护标的:
|
||||||
// · uploads/support.ts EXTENSION_WHITELIST(模块私有:filename→归一扩展名);
|
// · AD_IMAGE_EXTENSIONS(ads.service,staging/终键扩展名合法集)必须恒等
|
||||||
// · ads/ads.service.ts AD_IMAGE_EXTENSIONS(staging/终键扩展名合法集)。
|
// 于从 AD_IMAGE_CONTENT_TYPES(ads/dto,契约 contentType 白名单)派生
|
||||||
// 漂移场景(守护标的):上传域新增 contentType(如 image/webp)而广告域未跟
|
// 的归一扩展名集合(jpeg→jpg)——contentType 与键扩展名两端口永不漂移;
|
||||||
// 进——init 会放行 .webp staging 键(extOfFilename 走共享白名单),但
|
// · heic 剔除是永久约束:广告无变体流水线、原图直出,Chromium/Firefox
|
||||||
// verifyStaging 的 AD_IMAGE_EXTENSIONS 核验会拒绝,创建/换图在链路中段断裂。
|
// 桌面浏览器不渲染 heic(=破图)。
|
||||||
// 纯函数 spec(无 DB/网络):从导出的 UPLOAD_CONTENT_TYPES 派生归一化扩展名
|
// 不再对照片域 UPLOAD_CONTENT_TYPES/EXTENSION_WHITELIST 做一致性断言——
|
||||||
// 集合(jpeg→jpg,同 EXTENSION_WHITELIST 归一关系),断言与 AD_IMAGE_EXTENSIONS
|
// 两域白名单自此分离(照片直传保留 heic,变体流水线兜底解码)。共享的
|
||||||
// 恒等;EXTENSION_WHITELIST 不可导出,经 extOfFilename 行为面覆盖(每个合法
|
// uploads/support.extOfFilename 仍放行 .heic 文件名(照片域依赖),广告
|
||||||
// 扩展名归一通过、jpeg 双扩展名归一 jpg、白名单外拒绝)。
|
// init 以 AD_IMAGE_EXTENSIONS 二次核验拒绝(见 ads.service.initUpload)。
|
||||||
|
// 纯函数 spec(无 DB/网络)。
|
||||||
import { AD_IMAGE_EXTENSIONS } from '../src/ads/ads.service';
|
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> {
|
function derivedExtensions(): Set<string> {
|
||||||
return new Set(
|
return new Set(
|
||||||
UPLOAD_CONTENT_TYPES.map((contentType) => {
|
AD_IMAGE_CONTENT_TYPES.map((contentType) => {
|
||||||
const sub = contentType.split('/')[1] ?? '';
|
const sub = contentType.split('/')[1] ?? '';
|
||||||
return sub === 'jpeg' ? 'jpg' : sub;
|
return sub === 'jpeg' ? 'jpg' : sub;
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('AD_IMAGE_EXTENSIONS 与上传域白名单一致性(守护断言)', () => {
|
describe('AD_IMAGE_EXTENSIONS ↔ AD_IMAGE_CONTENT_TYPES 一致性(守护断言)', () => {
|
||||||
it('AD_IMAGE_EXTENSIONS === 从 UPLOAD_CONTENT_TYPES 派生的归一化扩展名集合', () => {
|
it('AD_IMAGE_EXTENSIONS === 从 AD_IMAGE_CONTENT_TYPES 派生的归一化扩展名集合', () => {
|
||||||
expect(AD_IMAGE_EXTENSIONS).toEqual(derivedExtensions());
|
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 键后缀恒落在合法集内)', () => {
|
it('每个合法扩展名经 extOfFilename 归一通过(staging 键后缀恒落在合法集内)', () => {
|
||||||
for (const ext of derivedExtensions()) {
|
for (const ext of derivedExtensions()) {
|
||||||
expect(extOfFilename(`banner.${ext}`)).toBe(ext);
|
expect(extOfFilename(`banner.${ext}`)).toBe(ext);
|
||||||
}
|
}
|
||||||
// jpeg 双扩展名归一 jpg(EXTENSION_WHITELIST 映射,大小写不敏感)。
|
// jpeg 双扩展名归一 jpg(共享 EXTENSION_WHITELIST 映射,大小写不敏感)。
|
||||||
expect(extOfFilename('banner.JPEG')).toBe('jpg');
|
expect(extOfFilename('banner.JPEG')).toBe('jpg');
|
||||||
|
// 共享 extOfFilename 仍放行 .heic(照片域白名单,两域分离后各自演进);
|
||||||
|
// 广告 init 的拒绝点在服务层 AD_IMAGE_EXTENSIONS 二次核验,非此处。
|
||||||
|
expect(extOfFilename('banner.heic')).toBe('heic');
|
||||||
// 白名单外/无扩展名 → null(init 与 verifyStaging 均拒绝)。
|
// 白名单外/无扩展名 → null(init 与 verifyStaging 均拒绝)。
|
||||||
expect(extOfFilename('banner.gif')).toBeNull();
|
expect(extOfFilename('banner.gif')).toBeNull();
|
||||||
expect(extOfFilename('banner')).toBeNull();
|
expect(extOfFilename('banner')).toBeNull();
|
||||||
|
|||||||
@@ -44,6 +44,14 @@
|
|||||||
// ⑮ 权限:非 admin 全端点 403(list/create/patch/delete);匿名 401;
|
// ⑮ 权限:非 admin 全端点 403(list/create/patch/delete);匿名 401;
|
||||||
// ⑯ 同步贯通(衔接 Task 5 的 prismaOv 断言):创建→drainSender→ads_ov 行
|
// ⑯ 同步贯通(衔接 Task 5 的 prismaOv 断言):创建→drainSender→ads_ov 行
|
||||||
// 就位;停用→drainSender→行删;删除→drainSender→行删(账本收敛墓碑版)。
|
// 就位;停用→drainSender→行删;删除→drainSender→行删(账本收敛墓碑版)。
|
||||||
|
//
|
||||||
|
// 终审修复追加段(verifyStaging 硬化 + heic 白名单收窄):
|
||||||
|
// ⑰ 非 ads/staging 前缀键(照片域路径冒充 staging,真实 PUT 对象后创建)→
|
||||||
|
// 400 VALIDATION_FAILED——防任意域对象被提升为公开广告;
|
||||||
|
// 合法前缀 + 实际 2MB+1 字节(init 申明管不住 PUT 体)→ 400
|
||||||
|
// VALIDATION_FAILED(报文含限额值——create 是运行时上限唯一核验点);
|
||||||
|
// ⑧ 追加 heic 矩阵:contentType=image/heic 与 .heic 文件名(类型合法)均
|
||||||
|
// 400(白名单收窄为 jpeg/png,heic 桌面浏览器不渲染)。
|
||||||
import { randomBytes } from 'node:crypto';
|
import { randomBytes } from 'node:crypto';
|
||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
import { type NestExpressApplication } from '@nestjs/platform-express';
|
import { type NestExpressApplication } from '@nestjs/platform-express';
|
||||||
@@ -57,7 +65,11 @@ import {
|
|||||||
type AdUpdatedPayload,
|
type AdUpdatedPayload,
|
||||||
ErrorCodes,
|
ErrorCodes,
|
||||||
} from '@photo/contracts';
|
} from '@photo/contracts';
|
||||||
import { createStorageFromEnv, type StoragePort } from '@photo/storage';
|
import {
|
||||||
|
OBJECT_KEY_ROOT,
|
||||||
|
createStorageFromEnv,
|
||||||
|
type StoragePort,
|
||||||
|
} from '@photo/storage';
|
||||||
import { AppCnModule } from '../src/app-cn.module';
|
import { AppCnModule } from '../src/app-cn.module';
|
||||||
import { AppOvModule } from '../src/app-ov.module';
|
import { AppOvModule } from '../src/app-ov.module';
|
||||||
import { seedAdmin } from '../src/scripts/seed-admin';
|
import { seedAdmin } from '../src/scripts/seed-admin';
|
||||||
@@ -532,6 +544,26 @@ describe('ads domain (e2e)', () => {
|
|||||||
});
|
});
|
||||||
expect(noExt.status).toBe(400);
|
expect(noExt.status).toBe(400);
|
||||||
expect(noExt.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
|
expect(noExt.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
|
||||||
|
|
||||||
|
// heic 收窄(终审 Fix 3):contentType 非广告白名单 → DTO @IsIn 拒绝。
|
||||||
|
const heicType = await post({
|
||||||
|
filename: 'a.heic',
|
||||||
|
bytes: 100,
|
||||||
|
contentType: 'image/heic',
|
||||||
|
});
|
||||||
|
expect(heicType.status).toBe(400);
|
||||||
|
expect(heicType.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
|
||||||
|
|
||||||
|
// heic 文件名 + 合法 contentType:共享 extOfFilename 仍认 heic(照片域
|
||||||
|
// 白名单),init 以广告域 AD_IMAGE_EXTENSIONS 二次核验拒绝(杜绝 init
|
||||||
|
// 放行 .heic staging 键 → create 中段断裂)。
|
||||||
|
const heicExt = await post({
|
||||||
|
filename: 'a.heic',
|
||||||
|
bytes: 100,
|
||||||
|
contentType: 'image/jpeg',
|
||||||
|
});
|
||||||
|
expect(heicExt.status).toBe(400);
|
||||||
|
expect(heicExt.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Task 7 追加段:/v1/admin/ads CRUD(cn HTTP + outbox + audit)──
|
// ── Task 7 追加段:/v1/admin/ads CRUD(cn HTTP + outbox + audit)──
|
||||||
@@ -947,4 +979,71 @@ describe('ads domain (e2e)', () => {
|
|||||||
).toBeNull();
|
).toBeNull();
|
||||||
await expectOvAppliedVersion(adId, 3);
|
await expectOvAppliedVersion(adId, 3);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── 终审修复追加段:verifyStaging 硬化(键前缀 + 运行时字节上限)──
|
||||||
|
|
||||||
|
it('⑰ 非 ads/staging 前缀键 / 超 2MB 实际字节 → 400 VALIDATION_FAILED', async () => {
|
||||||
|
const rowsBefore = await prismaCn.ads.count();
|
||||||
|
|
||||||
|
// (i) 照片域路径冒充 staging:扩展名/字节/类型三核全会过,仅键前缀不符
|
||||||
|
//(防照片域 staging/原图/banner 等任意键被提升为公开广告)。绕过 init
|
||||||
|
// 端点、以测试 storage 直接 presignPut 后 PUT 真实对象,再走创建。
|
||||||
|
const foreignKey = `${OBJECT_KEY_ROOT}/albums/fake/staging/x.jpg`;
|
||||||
|
const foreignCred = await storage.presignPut(
|
||||||
|
foreignKey,
|
||||||
|
100,
|
||||||
|
'image/jpeg',
|
||||||
|
300,
|
||||||
|
);
|
||||||
|
const putForeign = await fetch(foreignCred.url, {
|
||||||
|
method: foreignCred.method,
|
||||||
|
headers: foreignCred.headers,
|
||||||
|
body: new Uint8Array(Buffer.alloc(100, 9)),
|
||||||
|
});
|
||||||
|
expect(putForeign.ok).toBe(true);
|
||||||
|
const foreign = await request(cnBase)
|
||||||
|
.post('/v1/admin/ads')
|
||||||
|
.set('Authorization', `Bearer ${adminToken}`)
|
||||||
|
.send({
|
||||||
|
linkUrl: 'https://sponsor.example/landing',
|
||||||
|
imageStagingKey: foreignKey,
|
||||||
|
imageBytes: 100,
|
||||||
|
imageContentType: 'image/jpeg',
|
||||||
|
});
|
||||||
|
expect(foreign.status).toBe(400);
|
||||||
|
expect(foreign.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
|
||||||
|
|
||||||
|
// (ii) 合法前缀 + 实际 2MB+1 字节:init 的申明字节管不住 PUT 体(预签名
|
||||||
|
// PUT 无服务端强制),create 是运行时上限唯一核验点;申明如实(DTO 无
|
||||||
|
// @Max)仍须 400,报文含限额值。
|
||||||
|
const overKey = `${OBJECT_KEY_ROOT}/ads/staging/e2e-oversize-${randomBytes(4).toString('hex')}.jpg`;
|
||||||
|
const overCred = await storage.presignPut(
|
||||||
|
overKey,
|
||||||
|
AD_UPLOAD_MAX_BYTES + 1,
|
||||||
|
'image/jpeg',
|
||||||
|
300,
|
||||||
|
);
|
||||||
|
const putOver = await fetch(overCred.url, {
|
||||||
|
method: overCred.method,
|
||||||
|
headers: overCred.headers,
|
||||||
|
body: new Uint8Array(Buffer.alloc(AD_UPLOAD_MAX_BYTES + 1, 9)),
|
||||||
|
});
|
||||||
|
expect(putOver.ok).toBe(true);
|
||||||
|
const over = await request(cnBase)
|
||||||
|
.post('/v1/admin/ads')
|
||||||
|
.set('Authorization', `Bearer ${adminToken}`)
|
||||||
|
.send({
|
||||||
|
linkUrl: 'https://sponsor.example/landing',
|
||||||
|
imageStagingKey: overKey,
|
||||||
|
imageBytes: AD_UPLOAD_MAX_BYTES + 1,
|
||||||
|
imageContentType: 'image/jpeg',
|
||||||
|
});
|
||||||
|
expect(over.status).toBe(400);
|
||||||
|
expect(over.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
|
||||||
|
// 服务层限额报文含限额值(运行时上限与 init 同一口径)。
|
||||||
|
expect(over.body.message).toContain(String(AD_UPLOAD_MAX_BYTES));
|
||||||
|
|
||||||
|
// 两笔拒绝均不落行(失败是请求级错误,DB 存量不受污染)。
|
||||||
|
expect(await prismaCn.ads.count()).toBe(rowsBefore);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user