feat(api): 广告位 CRUD——outbox ad.* 事件/audit/停用高优先级/版本化换图

Task 7(ads-carousel):/v1/admin/ads list/create/patch/delete 四端点。
- 事务三写:ads 行 + outbox(ad.updated 全量快照/ad.deleted 墓碑)+ audit
  (ad.create/update/delete);ad.* 信封 albumId 恒空串;停用迁移 true→false
  与删除均 priority='high';
- 创建/换图:staging head 三核(存在/bytes/contentType,assets complete 同
  口径)→ 事务内 copyIfAbsent 先于行写入(失败=孤儿终键对象,OSS 生命周期
  兜底)→ 版本化终键 adObjectKey(adId, v, ext);
- 更新:embed 乐观重试模式(条件 updateMany WHERE entityVersion),保证换图
  终键版本段与行版本严格对齐;换图三字段同时性校验(缺一 400);albumIds
  去重+相册存在性 count 校验([]/null 归一全局 null);
- 删除:条件 deleteMany 重跑保墓碑 entityVersion=末+1;重复删除 404;
- 载荷构建器独立 ad-payload.ts(Task 8 对账共用,逐字节同形约定);
- dto:AdCreateRequestDto.albumIds 收窄为 string[] | null(JSON null 在
  @IsOptional 下可达,embed settings 先例);
- e2e:describe 更名 ads domain (e2e),追加 ⑨-⑯ 八用例(真图直传/定向/
  非法 linkUrl/字段级 PATCH/换图/DELETE/权限/同步贯通),16/16 通过。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-28 23:57:43 +08:00
parent 121afadf1e
commit 4979091d2c
6 changed files with 1079 additions and 25 deletions
+49
View File
@@ -0,0 +1,49 @@
// services/api/src/ads/ad-payload.ts —— ad.* outbox 事件载荷构建器
// (ads-carousel Task 7)。独立于 ads.service 成文件:Task 8(SyncService
// 对账/reconcile 广告实体)将 import 同一构建器,保证「业务事务内即时事件」
// 与「对账快照事件」载荷逐字节同形(ov 侧按 (entityId, entityVersion) 幂等,
// 形状漂移会造成两类事件交替时误判字段缺失——sync/payloads.ts 同一约定;
// ads 为新域,独立文件不回混既有 albums/photos 聚合根分组)。纯函数:输入
// cn ads 行子集,输出契约载荷(@photo/contracts events.ts)。
import type { AdDeletedPayload, AdUpdatedPayload } from '@photo/contracts';
import type { Prisma } from '@photo/db-cn';
/** 载荷构建所需列子集(业务侧 select 不必拉全列)。 */
export type AdRow = Pick<
Prisma.adsGetPayload<Prisma.adsDefaultArgs>,
| 'id'
| 'imageObjectKey'
| 'linkUrl'
| 'enabled'
| 'sortOrder'
| 'albumIds'
| 'createdAt'
| 'entityVersion'
>;
/**
* ad.updated 载荷:ads_ov 投影重建所需的**全量快照**——每次管理面变更整行
* 替换(slot.updated 同款)。albumIds null=全局投放;enabled=false 由消费侧
* 按「删投影行」处理(无墓碑下线)。JSON 列读取值(null | 数组)→ 载荷可空
* 语义:null 归一保持 null(写入侧已保证仅 null 或 string[])。
*/
export function buildAdUpdatedPayload(ad: AdRow): AdUpdatedPayload {
return {
adId: ad.id,
imageObjectKey: ad.imageObjectKey,
linkUrl: ad.linkUrl,
enabled: ad.enabled,
sortOrder: ad.sortOrder,
albumIds: (ad.albumIds as string[] | null) ?? null,
createdAt: ad.createdAt.toISOString(),
entityVersion: ad.entityVersion,
};
}
/** ad.deleted 墓碑载荷(entityVersion=删除时刻终值=末次版本+1)。 */
export function buildAdDeletedPayload(
adId: string,
entityVersion: number,
): AdDeletedPayload {
return { adId, entityVersion };
}
+65 -8
View File
@@ -1,20 +1,77 @@
// services/api/src/ads/ads.controller.ts —— /admin/ads 端点(ads-carousel)。
// 方法名与契约 operationId 一一对应(tag [ads])。控制器级 SessionGuard:
// 端点全需认证(契约 bearerAuth);admin 判定在服务层(AuthUser.role,
// 403 AUTH_FORBIDDEN——同 embed-slots 口径)。
// Task 6 仅注册 uploads 端点;list/create/update/remove(listAds/createAd/
// updateAd/removeAd)Task 7 再加——路由占位(501)会被 e2e 误触,不做。
import { Body, Controller, Post, Req, UseGuards } from '@nestjs/common';
// 方法名与契约 operationId 一一对应(tag [ads]:listAdminAds/createAd/
// updateAd/deleteAd/initAdUpload)。控制器级 SessionGuard:端点全需认证
// (契约 bearerAuth);admin 判定在服务层(AuthUser.role,403
// AUTH_FORBIDDEN——同 embed-slots 口径)。
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
Patch,
Post,
Req,
UseGuards,
} from '@nestjs/common';
import { userOf } from '../albums/albums.controller';
import { AuthenticatedRequest, SessionGuard } from '../auth/session.guard';
import { AdsService, type AdUploadInitResponse } from './ads.service';
import { AdUploadInitRequestDto } from './dto';
import {
AdsService,
type AdminAdItem,
type AdminAdListResponse,
type AdUploadInitResponse,
} from './ads.service';
import {
AdCreateRequestDto,
AdUpdateRequestDto,
AdUploadInitRequestDto,
} from './dto';
@Controller('admin/ads')
@UseGuards(SessionGuard)
export class AdsController {
constructor(private readonly adsService: AdsService) {}
/** 广告位列表(全量:sortOrder 升序、并列 createdAt 升序;imageUrl 现签)。 */
@Get()
listAdminAds(
@Req() req: AuthenticatedRequest,
): Promise<AdminAdListResponse> {
return this.adsService.list(userOf(req));
}
/** 创建广告位(staging 核验 → 版本化终键 v1 → outbox ad.updated v1)。 */
@Post()
createAd(
@Body() dto: AdCreateRequestDto,
@Req() req: AuthenticatedRequest,
): Promise<AdminAdItem> {
return this.adsService.create(userOf(req), dto);
}
/** 部分更新(任一管理面字段递增 entityVersion 并重发整行快照;停用 high)。 */
@Patch(':id')
updateAd(
@Param('id') id: string,
@Body() dto: AdUpdateRequestDto,
@Req() req: AuthenticatedRequest,
): Promise<AdminAdItem> {
return this.adsService.update(userOf(req), id, dto);
}
/** 硬删除(ad.deleted 墓碑 high,entityVersion=末+1;重复删除 404)。 */
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
deleteAd(
@Param('id') id: string,
@Req() req: AuthenticatedRequest,
): Promise<void> {
return this.adsService.remove(userOf(req), id);
}
/** 广告图直传初始化(契约 201:staging 键 + 临时直传凭证)。 */
@Post('uploads')
initUpload(
+3 -3
View File
@@ -1,8 +1,8 @@
// services/api/src/ads/ —— 广告位管理模块(ads-carousel Task 6,仅 cn 入口
// services/api/src/ads/ —— 广告位管理模块(ads-carousel,仅 cn 入口
// 装配:广告是 cn 权威实体,ov 侧只承接 ads_ov 投影与公开读端点)。
// UploadsModule 取 STORAGE_PORT(storage.provider.ts env 工厂;模块已 export,
// 同 EmbedModule 之后的装配口径);PrismaModule 为 @Global,Task 7 CRUD 落地
// 时直接注入。
// 同 EmbedModule 之后的装配口径);PrismaModule 为 @Global,CRUD(Task 7)
// 直接注入 PrismaService。
import { Module } from '@nestjs/common';
import { UploadsModule } from '../uploads/uploads.module';
import { AdsController } from './ads.controller';
+449 -5
View File
@@ -1,6 +1,6 @@
// services/api/src/ads/ads.service.ts —— 广告位管理核心逻辑(ads-carousel)。
// 契约权威:packages/contracts/openapi.yaml tags [ads]。本任务(Task 6)先落
// initUpload + requireAdmin;list/create/update/remove Task 7 实现。要点:
// 契约权威:packages/contracts/openapi.yaml tags [ads]。Task 6 落 initUpload;
// Task 7 落 list/create/update/remove。要点:
// · admin-only(AuthUser.role,同 embed 口径):非 admin 403 AUTH_FORBIDDEN;
// · 无会话概念(契约 AdUploadInitRequest 描述):init 只 presign、不落任何
// DB 行——重复转存/防覆盖由创建端 copyIfAbsent 禁止覆盖语义承担;
@@ -12,27 +12,109 @@
// (键含自引用键段,无行可回填);扩展名复用 uploads/support.extOfFilename
// 白名单归一(jpeg→jpg);
// · presignPut 参数序:(objectKey, bytes, contentType, ttlSeconds)——
// StoragePort 接口权威;TTL 惰性读 SIGNED_URL_TTL_SECONDS(同上传域)。
// 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)
// → ads.insert → outbox ad.updated v1(albumId 恒空串信封约定)→ audit
// ad.create }。copy 在事务内先于 insert(红线顺序):copy 后事务失败=孤儿
// 终键对象,OSS 生命周期兜底(banner 同款语义);
// · update:requireAdmin → 空请求体 400(embed 更新口径)→ 换图三字段
// (imageStagingKey/imageBytes/imageContentType)必须同时提供,缺一 400
// (服务层裁决)→ 事务内条件更新(WHERE entityVersion=读取版本,embed
// 乐观重试模式:并发败方整事务重跑,保证换图终键 v{N+1} 与行版本对齐)
// → outbox ad.updated(停用迁移 true→false priority='high',ov 侧尽快删
// 投影行;其余 normal)+ audit ad.update(metadata.fields=提交字段名);
// · remove:requireAdmin → 事务内条件删除(deleteMany WHERE entityVersion=
// 读取版本,重跑保墓碑版本=末+1)+ outbox ad.deleted(priority='high',
// 载荷 {adId, entityVersion=末+1})+ audit ad.delete。行不存在(含重复
// 删除)404 NOT_FOUND——硬删除语义,无恢复端点;
// · list:requireAdmin → findMany orderBy [{sortOrder:'asc'},{createdAt:'asc'}]
// (契约全量不分页,运营量级 <20)→ imageUrl 现签 GET(TTL 惰性读)。
import { HttpStatus, Inject, Injectable } from '@nestjs/common';
import { AD_UPLOAD_MAX_BYTES, ErrorCodes } from '@photo/contracts';
import type { components } from '@photo/contracts';
import { adsStagingObjectKey, type StoragePort } from '@photo/storage';
import {
StorageError,
adObjectKey,
adsStagingObjectKey,
type StoragePort,
} from '@photo/storage';
import type { Prisma } from '@photo/db-cn';
import { ApiException } from '../api-exception';
import type { AuthUser } from '../auth/session.guard';
import { validationFailed } from '../albums/support';
import { PrismaService } from '../prisma/prisma.service';
import { STORAGE_PORT } from '../uploads/storage.provider';
import {
cuidLikeId,
extOfFilename,
extOfObjectKey,
signedUrlTtlSeconds,
uploadVerifyFailed,
} from '../uploads/support';
import type { AdUploadInitRequestDto } from './dto';
import {
buildAdDeletedPayload,
buildAdUpdatedPayload,
} from './ad-payload';
import type {
AdCreateRequestDto,
AdUpdateRequestDto,
AdUploadInitRequestDto,
} from './dto';
export type AdUploadInitResponse = components['schemas']['AdUploadInitResponse'];
export type AdminAdItem = components['schemas']['AdminAdItem'];
export type AdminAdListResponse = components['schemas']['AdminAdListResponse'];
/** staging/终键扩展名合法集(init 扩展名白名单的归一值域)。 */
const AD_IMAGE_EXTENSIONS = new Set(['jpg', 'png', 'heic']);
/**
* updateAd 审计 metadata.fields 的字段名序列(契约 AdUpdateRequest 属性序,
* 同 embed UPDATE_FIELD_ORDER 约定:固定顺序保证同形请求同形审计;换图三
* 字段合并记作 'image'——一次逻辑变更)。
*/
const UPDATE_FIELD_ORDER = [
'title',
'linkUrl',
'enabled',
'sortOrder',
'albumIds',
'image',
] as const;
function adNotFound(): ApiException {
return new ApiException(
ErrorCodes.NOT_FOUND,
'广告位不存在',
HttpStatus.NOT_FOUND,
);
}
function internalError(): ApiException {
return new ApiException(
ErrorCodes.INTERNAL_ERROR,
'Internal server error',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
/** head 三核后的 staging 申明值(ext 供终键派生)。 */
interface StagingImage {
key: string;
bytes: number;
contentType: string;
ext: string;
}
type AdsRow = Prisma.adsGetPayload<Prisma.adsDefaultArgs>;
@Injectable()
export class AdsService {
constructor(
private readonly prisma: PrismaService,
@Inject(STORAGE_PORT) private readonly storage: StoragePort,
) {}
@@ -73,4 +155,366 @@ export class AdsService {
);
return { objectKey, credential };
}
/**
* 广告位列表(契约 listAdminAds):全量不分页,sortOrder 升序、并列
* createdAt 升序(@@index([sortOrder, createdAt]));imageUrl 现签 GET,
* 过期后重拉本列表刷新(契约语义)。
*/
async list(user: AuthUser): Promise<AdminAdListResponse> {
this.requireAdmin(user);
const rows = await this.prisma.ads.findMany({
orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }],
});
const ttl = signedUrlTtlSeconds();
const items = await Promise.all(
rows.map(async (row) => ({
id: row.id,
title: row.title,
imageObjectKey: row.imageObjectKey,
imageUrl: await this.storage.presignGet(row.imageObjectKey, ttl),
linkUrl: row.linkUrl,
enabled: row.enabled,
sortOrder: row.sortOrder,
albumIds: (row.albumIds as string[] | null) ?? null,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
})),
);
return { items };
}
/**
* 创建广告位(契约 createAd 201):相册定向校验(事务外,count 比对)→
* staging head 三核(assets complete 同口径)→ 事务三写(ads + outbox
* ad.updated v1 + audit ad.create)。adId 应用侧生成(终键含自引用键段,
* 一次 insert 成形,uploads 同款);copyIfAbsent 在事务内先于 insert
* (红线顺序)——copy 后事务失败=孤儿终键对象,OSS 生命周期兜底(banner
* 同款语义)。
*/
async create(
user: AuthUser,
dto: AdCreateRequestDto,
): Promise<AdminAdItem> {
this.requireAdmin(user);
const albumIds = await this.normalizeAlbumIds(
this.prisma,
dto.albumIds ?? null,
);
const staging = await this.verifyStaging(
dto.imageStagingKey,
dto.imageBytes,
dto.imageContentType,
);
const adId = await this.prisma.$transaction(async (tx) => {
const id = cuidLikeId();
const finalKey = adObjectKey(id, 1, staging.ext);
await this.copyWithVerify(staging.key, finalKey, staging.bytes);
const row = await tx.ads.create({
data: {
id,
title: dto.title ?? null,
imageObjectKey: finalKey,
linkUrl: dto.linkUrl,
sortOrder: dto.sortOrder ?? 0,
// null → SQL NULL(全局投放;sync-inbox ads_ov 投影同款双转型:
// 运行时可空 Json 列接受 null=DbNull 语义,类型面 InputJsonValue
// 不含 null 故经 unknown 中转)。
albumIds: albumIds as unknown as Prisma.InputJsonValue,
},
});
await tx.outbox_events.create({
data: {
type: 'ad.updated',
// ad.* 信封约定:albumId 恒空串(绑定关系在载荷 albumIds,设计 §4)。
albumId: '',
entityVersion: row.entityVersion,
payload: buildAdUpdatedPayload(
row,
) as unknown as Prisma.InputJsonValue,
},
});
await tx.audit_logs.create({
data: {
actorId: user.id,
action: 'ad.create',
resourceId: id,
},
});
return id;
});
return this.loadItem(adId);
}
/**
* 部分更新(契约 updateAd):仅提交的字段被修改,空请求体 400(embed 更新
* 口径)。albumIds:未提供=不动,null/[]=转全局,数组=去重整体替换(存在
* 性校验在事务内)。换图:imageStagingKey/imageBytes/imageContentType 三者
* 必须同时提供(缺一 400,服务层裁决),staging head 三核(事务外)后转存
* 版本化终键 v{N+1}(事务内 copy 先于行更新——同 create 红线顺序;旧对象
* 不回收,OSS 生命周期兜底)。条件更新 WHERE entityVersion=读取版本(embed
* 乐观重试模式):并发败方整事务重跑,保证终键版本段与行 entityVersion
* 严格对齐。事务内写 outbox ad.updated(载荷=更新后全量快照;停用迁移
* true→false 走 high 优先级,ov 侧尽快删投影行)+ audit(metadata.fields)。
*/
async update(
user: AuthUser,
adId: string,
dto: AdUpdateRequestDto,
): Promise<AdminAdItem> {
this.requireAdmin(user);
const provided = UPDATE_FIELD_ORDER.filter(
(key) =>
key !== 'image'
? dto[key] !== undefined
: dto.imageStagingKey !== undefined ||
dto.imageBytes !== undefined ||
dto.imageContentType !== undefined,
) as string[];
if (provided.length === 0) {
throw validationFailed('请求体至少需要包含一个待更新字段');
}
// 换图三字段同时性:任一出现即要求三件齐备(契约 AdUpdateRequest 描述)。
const imageFields = [
dto.imageStagingKey,
dto.imageBytes,
dto.imageContentType,
];
const providedCount = imageFields.filter((v) => v !== undefined).length;
if (providedCount !== 0 && providedCount !== 3) {
throw validationFailed(
'换图须同时提供 imageStagingKey/imageBytes/imageContentType',
);
}
const staging: StagingImage | null =
providedCount === 3
? await this.verifyStaging(
dto.imageStagingKey!,
dto.imageBytes!,
dto.imageContentType!,
)
: null;
let outcome: 'done' | 'retry' = 'retry';
for (let attempt = 0; attempt < 3 && outcome === 'retry'; attempt++) {
outcome = await this.prisma.$transaction(async (tx) => {
const row = await tx.ads.findUnique({ where: { id: adId } });
if (row === null) {
throw adNotFound();
}
let nextAlbumIds: string[] | null | undefined;
if (dto.albumIds !== undefined) {
nextAlbumIds = await this.normalizeAlbumIds(tx, dto.albumIds);
}
const nextVersion = row.entityVersion + 1;
let nextImageKey: string | undefined;
if (staging !== null) {
nextImageKey = adObjectKey(adId, nextVersion, staging.ext);
await this.copyWithVerify(staging.key, nextImageKey, staging.bytes);
}
const claimed = await tx.ads.updateMany({
where: { id: adId, entityVersion: row.entityVersion },
data: {
title: dto.title,
linkUrl: dto.linkUrl,
enabled: dto.enabled,
sortOrder: dto.sortOrder,
// undefined=未提交不动;null=转全局(SQL NULL,转型同上)。
albumIds:
nextAlbumIds === undefined
? undefined
: (nextAlbumIds as unknown as Prisma.InputJsonValue),
imageObjectKey: nextImageKey,
entityVersion: { increment: 1 },
},
});
if (claimed.count === 0) {
return 'retry' as const;
}
const updated = await tx.ads.findUniqueOrThrow({
where: { id: adId },
});
await tx.outbox_events.create({
data: {
type: 'ad.updated',
albumId: '',
entityVersion: updated.entityVersion,
priority:
dto.enabled === false && row.enabled === true
? 'high'
: 'normal',
payload: buildAdUpdatedPayload(
updated,
) as unknown as Prisma.InputJsonValue,
},
});
await tx.audit_logs.create({
data: {
actorId: user.id,
action: 'ad.update',
resourceId: adId,
metadata: { fields: provided },
},
});
return 'done' as const;
});
}
if (outcome !== 'done') {
throw internalError();
}
return this.loadItem(adId);
}
/**
* 硬删除(契约 deleteAd 204):行删 + outbox ad.deleted 墓碑
* (entityVersion=末+1,priority='high'——ov 侧删 ads_ov 投影行且不被旧
* 事件复活)+ audit ad.delete。条件删除 WHERE entityVersion=读取版本(重跑
* 保墓碑版本=末+1);行不存在(含重复删除)404 NOT_FOUND。
*/
async remove(user: AuthUser, adId: string): Promise<void> {
this.requireAdmin(user);
let outcome: 'done' | 'retry' = 'retry';
for (let attempt = 0; attempt < 3 && outcome === 'retry'; attempt++) {
outcome = await this.prisma.$transaction(async (tx) => {
const row = await tx.ads.findUnique({ where: { id: adId } });
if (row === null) {
// 不存在与已删除同码 404(硬删除语义,重复删除 404)。
throw adNotFound();
}
const claimed = await tx.ads.deleteMany({
where: { id: adId, entityVersion: row.entityVersion },
});
if (claimed.count === 0) {
return 'retry' as const;
}
await tx.outbox_events.create({
data: {
type: 'ad.deleted',
albumId: '',
entityVersion: row.entityVersion + 1,
priority: 'high',
payload: buildAdDeletedPayload(
adId,
row.entityVersion + 1,
) as unknown as Prisma.InputJsonValue,
},
});
await tx.audit_logs.create({
data: {
actorId: user.id,
action: 'ad.delete',
resourceId: adId,
},
});
return 'done' as const;
});
}
if (outcome !== 'done') {
throw internalError();
}
}
/** 组装 AdminAdItem 响应(imageUrl 现签 GET,TTL 惰性读取)。 */
private async loadItem(adId: string): Promise<AdminAdItem> {
const row = await this.prisma.ads.findUnique({ where: { id: adId } });
// 事务提交与回读之间被并发硬删:与不存在同码 404(而非 5xx)。
if (row === null) {
throw adNotFound();
}
return this.toItem(row);
}
private async toItem(row: AdsRow): Promise<AdminAdItem> {
return {
id: row.id,
title: row.title,
imageObjectKey: row.imageObjectKey,
imageUrl: await this.storage.presignGet(
row.imageObjectKey,
signedUrlTtlSeconds(),
),
linkUrl: row.linkUrl,
enabled: row.enabled,
sortOrder: row.sortOrder,
albumIds: (row.albumIds as string[] | null) ?? null,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
/**
* albumIds 归一与存在性校验:null/[](含全重复)→ null(全局投放);非空
* 去重后 count albums(id in + status != 'deleted'),不等 → 400
* VALIDATION_FAILED(相册删除不级联清理广告,容失效见设计 §2.1——仅拦截
* 创建/更新时刻的显式绑定)。
*/
private async normalizeAlbumIds(
client: PrismaService | Prisma.TransactionClient,
albumIds: string[] | null,
): Promise<string[] | null> {
if (albumIds === null || albumIds.length === 0) {
return null;
}
const ids = [...new Set(albumIds)];
const count = await client.albums.count({
where: { id: { in: ids }, status: { not: 'deleted' } },
});
if (count !== ids.length) {
throw validationFailed('albumIds 含不存在或已删除的相册');
}
return ids;
}
/**
* staging 对象三核(assets complete 同口径):存在性 / bytes / contentType
* 与申明一致,任一不符 400 UPLOAD_VERIFY_FAILED(失败是请求级错误,可重新
* init+PUT 后重试)。扩展名自 staging 键尾派生(init 扩展名白名单归一值)
* 并校验合法集,供版本化终键派生。
*/
private async verifyStaging(
key: string,
bytes: number,
contentType: string,
): Promise<StagingImage> {
const ext = extOfObjectKey(key);
if (ext === null || !AD_IMAGE_EXTENSIONS.has(ext)) {
throw validationFailed('imageStagingKey 扩展名不接受(jpg/png/heic)');
}
const head = await this.storage.head(key);
if (head === null) {
throw uploadVerifyFailed('广告图对象不存在或尚未上传');
}
if (head.bytes !== bytes) {
throw uploadVerifyFailed(
`字节数与申明不符(expected=${bytes}, actual=${head.bytes})`,
);
}
if (head.contentType !== contentType) {
throw uploadVerifyFailed(
`Content-Type 与申明不符(expected=${contentType}, actual=${head.contentType})`,
);
}
return { key, bytes, contentType, ext };
}
/** copyIfAbsent 包装:StorageError(UPLOAD_VERIFY_FAILED) → 同码 400 业务异常。 */
private async copyWithVerify(
src: string,
dest: string,
expectedBytes: number,
): Promise<void> {
try {
await this.storage.copyIfAbsent(src, dest, expectedBytes);
} catch (err) {
if (err instanceof StorageError) {
throw uploadVerifyFailed(err.message);
}
throw err;
}
}
}
+5 -2
View File
@@ -2,7 +2,7 @@
// 校验约束对齐契约 AdCreateRequest / AdUpdateRequest / AdUploadInitRequest:
// · linkUrl:^https?:// 前缀 + 2048 上限(契约 pattern/maxLength);
// · title 1..120(VarChar(120) 后台备注名);sortOrder ≥0;
// · albumIds:数组元素 ≤64;未提供或空数组=全局投放(落库归一 null,
// · albumIds:数组元素 ≤64;未提供/null/空数组=全局投放(落库归一 null,
// 元素存在性校验在服务层事务内);更新侧 null=转全局(@IsOptional 对
// undefined/null 均放行且 null 保留在 DTO 上——服务层以 !== undefined
// 区分「未提交」与「显式 null」,同 embed albumId 口径);
@@ -74,11 +74,14 @@ export class AdCreateRequestDto {
@Min(0)
sortOrder?: number;
/** null 视同缺省/空(JSON null 在 @IsOptional 下可达并保留在 DTO 上——
* embed settings 先例);未提供/null/空数组均归一为全局投放(服务层落库
* 归一 null)。 */
@IsOptional()
@IsArray()
@IsString({ each: true })
@MaxLength(64, { each: true })
albumIds?: string[];
albumIds?: string[] | null;
}
export class AdUpdateRequestDto {