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:
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user