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
+52 -20
View File
@@ -10,13 +10,16 @@
// · staging 键 adsStagingObjectKey(cuid, ext)(@photo/storage 权威布局
// photo-live/{env}/ads/staging/{cuid}.{ext});cuidLikeId 应用侧生成
// (键含自引用键段,无行可回填);扩展名复用 uploads/support.extOfFilename
// 白名单归一(jpeg→jpg);
// 白名单归一(jpeg→jpg)后以广告域集合 AD_IMAGE_EXTENSIONS 二次裁决
// (两域白名单已分离,heic 剔除——广告无变体流水线,桌面浏览器不渲染);
// verifyStaging 另核验键前缀(仅本域 staging 布局)与运行时字节上限
// (init 申明管不住实际 PUT 体,create/update 是超限唯一核验点);
// · presignPut 参数序:(objectKey, bytes, contentType, ttlSeconds)——
// StoragePort 接口权威;TTL 惰性读 SIGNED_URL_TTL_SECONDS(同上传域);
// · create:requireAdmin → 校验 albumIds(去重 + 相册存在性 count,不等
// 400 VALIDATION_FAILED;未提供/null/[] 归一 null=全局投放)→ head(staging)
// 三核(缺失/bytes≠申明/contentType≠申明 → 400 UPLOAD_VERIFY_FAILED,
// assets complete 同口径)→ $transaction{ copyIfAbsent(staging→终键 v1)
// 400 VALIDATION_FAILED;未提供/null/[] 归一 null=全局投放)→ verifyStaging
// 五关(键前缀/扩展名/存在性/运行时 2MB 上限/申明一致性)→ $transaction{
// copyIfAbsent(staging→终键 v1)
// → ads.insert → outbox ad.updated v1(albumId 恒空串信封约定)→ audit
// ad.create }。copy 在事务内先于 insert(红线顺序):copy 后事务失败=孤儿
// 终键对象,OSS 生命周期兜底(banner 同款语义);
@@ -36,6 +39,7 @@ import { HttpStatus, Inject, Injectable } from '@nestjs/common';
import { AD_UPLOAD_MAX_BYTES, ErrorCodes } from '@photo/contracts';
import type { components } from '@photo/contracts';
import {
OBJECT_KEY_ROOT,
StorageError,
adObjectKey,
adsStagingObjectKey,
@@ -58,10 +62,11 @@ import {
buildAdDeletedPayload,
buildAdUpdatedPayload,
} from './ad-payload';
import type {
AdCreateRequestDto,
AdUpdateRequestDto,
AdUploadInitRequestDto,
import {
AD_IMAGE_CONTENT_TYPES,
type AdCreateRequestDto,
type AdUpdateRequestDto,
type AdUploadInitRequestDto,
} from './dto';
export type AdUploadInitResponse = components['schemas']['AdUploadInitResponse'];
@@ -69,11 +74,24 @@ export type AdminAdItem = components['schemas']['AdminAdItem'];
export type AdminAdListResponse = components['schemas']['AdminAdListResponse'];
/**
* staging/终键扩展名合法集(init 扩展名白名单的归一值域)。导出供
* test/ads-extensions.spec.ts 与上传域白名单做 1:1 一致性守护(三处同源:
* UPLOAD_CONTENT_TYPES → EXTENSION_WHITELIST 归一值 → 本集合)。
* staging/终键扩展名合法集(init 扩展名白名单的归一值域):从广告域自身
* 的 AD_IMAGE_CONTENT_TYPES 派生(jpeg→jpg 归一)。与照片域白名单自此
* 分离——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 属性序,
@@ -147,8 +165,11 @@ export class AdsService {
throw validationFailed(`广告图超过大小限额(limit=${AD_UPLOAD_MAX_BYTES})`);
}
const ext = extOfFilename(dto.filename);
if (ext === null) {
throw validationFailed('文件扩展名不接受(jpg/jpeg/png/heic)');
// 扩展名双重核验:extOfFilename 走与照片域共享的白名单(仍认 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 credential = await this.storage.presignPut(
@@ -243,7 +264,7 @@ export class AdsService {
* 部分更新(契约 updateAd):仅提交的字段被修改,空请求体 400(embed 更新
* 口径)。albumIds:未提供=不动,null/[]=转全局,数组=去重整体替换(存在
* 性校验在事务内)。换图:imageStagingKey/imageBytes/imageContentType 三者
* 必须同时提供(缺一 400,服务层裁决),staging head 三核(事务外)后转存
* 必须同时提供(缺一 400,服务层裁决),verifyStaging 核验(事务外)后转存
* 版本化终键 v{N+1}(事务内 copy 先于行更新——同 create 红线顺序;旧对象
* 不回收,OSS 生命周期兜底)。条件更新 WHERE entityVersion=读取版本(embed
* 乐观重试模式):并发败方整事务重跑,保证终键版本段与行 entityVersion
@@ -463,24 +484,35 @@ export class AdsService {
}
/**
* staging 对象三核(assets complete 同口径):存在性 / bytes / contentType
* 与申明一致,任一不符 400 UPLOAD_VERIFY_FAILED(失败是请求级错误,可重新
* init+PUT 后重试)。扩展名自 staging 键尾派生(init 扩展名白名单归一值)
* 并校验合法集,供版本化终键派生。
* staging 对象核验(五关):键前缀(仅本域 staging 布局——防照片域
* staging/原图/banner 等任意键被提升为公开广告)→ 扩展名(键尾派生,
* 合法集同 init)→ 存在性 → 运行时字节上限(init 的申明字节管不住实际
* PUT 体——预签名 PUT 无服务端强制,create/update 是超限的唯一核验点,
* 400 VALIDATION_FAILED 报文含限额值)→ bytes/contentType 与申明一致
* (assets complete 同口径,任一不符 400 UPLOAD_VERIFY_FAILED——失败是
* 请求级错误,可重新 init+PUT 后重试)。
*/
private async verifyStaging(
key: string,
bytes: number,
contentType: string,
): Promise<StagingImage> {
if (!key.startsWith(ADS_STAGING_KEY_PREFIX)) {
throw validationFailed('imageStagingKey 非广告 staging 键');
}
const ext = extOfObjectKey(key);
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);
if (head === null) {
throw uploadVerifyFailed('广告图对象不存在或尚未上传');
}
if (head.bytes > AD_UPLOAD_MAX_BYTES) {
throw validationFailed(
`广告图超过大小限额(limit=${AD_UPLOAD_MAX_BYTES})`,
);
}
if (head.bytes !== bytes) {
throw uploadVerifyFailed(
`字节数与申明不符(expected=${bytes}, actual=${head.bytes})`,
+18 -12
View File
@@ -10,8 +10,10 @@
// (换图三字段须同时提供,服务层裁决);
// · 大小限额与 filename 扩展名白名单是服务层校验(同 UploadInitRequestDto
// 约定:限额报文需含限额值、扩展名经 extOfFilename 归一后派生对象键);
// contentType 与照片直传共用白名单 UPLOAD_CONTENT_TYPES(image/heic
// 暂定,G2 真机验证)。
// contentType 用广告域独立白名单 AD_IMAGE_CONTENT_TYPES(与照片直传的
// UPLOAD_CONTENT_TYPES 自此分离:广告无变体流水线、原图直出,heic 在
// Chromium/Firefox 桌面浏览器不渲染=破图,故剔除;照片域保留 heic 由
// 变体流水线兜底解码)。
import {
IsArray,
IsBoolean,
@@ -24,14 +26,18 @@ import {
MaxLength,
Min,
} from 'class-validator';
import {
UPLOAD_CONTENT_TYPES,
type UploadContentType,
} from '../uploads/support';
/** 契约 linkUrl 校验(AdCreateRequest/AdUpdateRequest 同一 pattern)。 */
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 {
@IsString()
@IsNotEmpty()
@@ -42,8 +48,8 @@ export class AdUploadInitRequestDto {
@Min(1)
bytes!: number;
@IsIn(UPLOAD_CONTENT_TYPES)
contentType!: UploadContentType;
@IsIn(AD_IMAGE_CONTENT_TYPES)
contentType!: AdImageContentType;
}
export class AdCreateRequestDto {
@@ -66,8 +72,8 @@ export class AdCreateRequestDto {
@Min(1)
imageBytes!: number;
@IsIn(UPLOAD_CONTENT_TYPES)
imageContentType!: UploadContentType;
@IsIn(AD_IMAGE_CONTENT_TYPES)
imageContentType!: AdImageContentType;
@IsOptional()
@IsInt()
@@ -124,6 +130,6 @@ export class AdUpdateRequestDto {
imageBytes?: number;
@IsOptional()
@IsIn(UPLOAD_CONTENT_TYPES)
imageContentType?: UploadContentType;
@IsIn(AD_IMAGE_CONTENT_TYPES)
imageContentType?: AdImageContentType;
}
+28 -18
View File
@@ -1,39 +1,49 @@
// services/api/test/ads-extensions.spec.ts —— 广告图扩展名白名单一致性守护
// (ads-carousel Task 7 质量审查遗留项,随 Task 8 提交)。三处白名单必须恒等:
// · uploads/support.ts UPLOAD_CONTENT_TYPES(契约 contentType 白名单);
// · uploads/support.ts EXTENSION_WHITELIST(模块私有:filename→归一扩展名);
// · ads/ads.service.ts AD_IMAGE_EXTENSIONS(staging/终键扩展名合法集)。
// 漂移场景(守护标的):上传域新增 contentType(如 image/webp)而广告域未跟
// 进——init 会放行 .webp staging 键(extOfFilename 走共享白名单),但
// verifyStaging 的 AD_IMAGE_EXTENSIONS 核验会拒绝,创建/换图在链路中段断裂。
// 纯函数 spec(无 DB/网络):从导出的 UPLOAD_CONTENT_TYPES 派生归一化扩展名
// 集合(jpeg→jpg,同 EXTENSION_WHITELIST 归一关系),断言与 AD_IMAGE_EXTENSIONS
// 恒等;EXTENSION_WHITELIST 不可导出,经 extOfFilename 行为面覆盖(每个合法
// 扩展名归一通过、jpeg 双扩展名归一 jpg、白名单外拒绝)。
// services/api/test/ads-extensions.spec.ts —— 广告图扩展名白名单守护
// (ads-carousel Task 7 质量审查遗留项,随 Task 8 提交;终审 Fix 3 改为
// 广告域自洽守护)。守护标的:
// · AD_IMAGE_EXTENSIONS(ads.service,staging/终键扩展名合法集)必须恒等
// 于从 AD_IMAGE_CONTENT_TYPES(ads/dto,契约 contentType 白名单)派生
// 的归一扩展名集合(jpeg→jpg)——contentType 与键扩展名两端口永不漂移;
// · heic 剔除是永久约束:广告无变体流水线、原图直出,Chromium/Firefox
// 桌面浏览器不渲染 heic(=破图)。
// 不再对照片域 UPLOAD_CONTENT_TYPES/EXTENSION_WHITELIST 做一致性断言——
// 两域白名单自此分离(照片直传保留 heic,变体流水线兜底解码)。共享的
// uploads/support.extOfFilename 仍放行 .heic 文件名(照片域依赖),广告
// init 以 AD_IMAGE_EXTENSIONS 二次核验拒绝(见 ads.service.initUpload)。
// 纯函数 spec(无 DB/网络)。
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> {
return new Set(
UPLOAD_CONTENT_TYPES.map((contentType) => {
AD_IMAGE_CONTENT_TYPES.map((contentType) => {
const sub = contentType.split('/')[1] ?? '';
return sub === 'jpeg' ? 'jpg' : sub;
}),
);
}
describe('AD_IMAGE_EXTENSIONS 与上传域白名单一致性(守护断言)', () => {
it('AD_IMAGE_EXTENSIONS === 从 UPLOAD_CONTENT_TYPES 派生的归一化扩展名集合', () => {
describe('AD_IMAGE_EXTENSIONS ↔ AD_IMAGE_CONTENT_TYPES 一致性(守护断言)', () => {
it('AD_IMAGE_EXTENSIONS === 从 AD_IMAGE_CONTENT_TYPES 派生的归一化扩展名集合', () => {
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 键后缀恒落在合法集内)', () => {
for (const ext of derivedExtensions()) {
expect(extOfFilename(`banner.${ext}`)).toBe(ext);
}
// jpeg 双扩展名归一 jpg(EXTENSION_WHITELIST 映射,大小写不敏感)。
// jpeg 双扩展名归一 jpg(共享 EXTENSION_WHITELIST 映射,大小写不敏感)。
expect(extOfFilename('banner.JPEG')).toBe('jpg');
// 共享 extOfFilename 仍放行 .heic(照片域白名单,两域分离后各自演进);
// 广告 init 的拒绝点在服务层 AD_IMAGE_EXTENSIONS 二次核验,非此处。
expect(extOfFilename('banner.heic')).toBe('heic');
// 白名单外/无扩展名 → null(init 与 verifyStaging 均拒绝)。
expect(extOfFilename('banner.gif')).toBeNull();
expect(extOfFilename('banner')).toBeNull();
+100 -1
View File
@@ -44,6 +44,14 @@
// ⑮ 权限:非 admin 全端点 403(list/create/patch/delete);匿名 401;
// ⑯ 同步贯通(衔接 Task 5 的 prismaOv 断言):创建→drainSender→ads_ov 行
// 就位;停用→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 { NestFactory } from '@nestjs/core';
import { type NestExpressApplication } from '@nestjs/platform-express';
@@ -57,7 +65,11 @@ import {
type AdUpdatedPayload,
ErrorCodes,
} 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 { AppOvModule } from '../src/app-ov.module';
import { seedAdmin } from '../src/scripts/seed-admin';
@@ -532,6 +544,26 @@ describe('ads domain (e2e)', () => {
});
expect(noExt.status).toBe(400);
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)──
@@ -947,4 +979,71 @@ describe('ads domain (e2e)', () => {
).toBeNull();
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);
});
});