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:
@@ -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 };
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// services/api/test/ads.e2e-spec.ts —— 广告位 ov 消费链 e2e(ads-carousel
|
||||
// Task 5)。聚焦「事件→投影」直断:CRUD 端点 Task 7 才有,故手工以
|
||||
// AdUpdatedPayload/AdDeletedPayload 构造 cn outbox_events 行(albumId 恒空串
|
||||
// ——契约信封约定,绑定关系在载荷 albumIds),经生产 OutboxSender 真实 HTTP
|
||||
// 推送到测试内起的 ov 入口,断言 ads_ov 副本与 sync_state 版本账本——与生产
|
||||
// 链路逐字节同源(embed.e2e-spec 模式:AppOvModule + drainSender)。
|
||||
// services/api/test/ads.e2e-spec.ts —— 广告位域 e2e(ads-carousel)。
|
||||
// Task 5 段聚焦「事件→投影」直断:手工以 AdUpdatedPayload/AdDeletedPayload
|
||||
// 构造 cn outbox_events 行(albumId 恒空串——契约信封约定,绑定关系在载荷
|
||||
// albumIds),经生产 OutboxSender 真实 HTTP 推送到测试内起的 ov 入口,断言
|
||||
// ads_ov 副本与 sync_state 版本账本——与生产链路逐字节同源(embed.e2e-spec
|
||||
// 模式:AppOvModule + drainSender)。
|
||||
//
|
||||
// 覆盖(5 用例,链条递进共享同一 adId):
|
||||
// ① ad.updated 快照(enabled=true, albumIds=null)→ 副本行全字段 + 版本
|
||||
@@ -23,6 +23,27 @@
|
||||
// ⑦ 非 admin 403;匿名 401(入参合法);
|
||||
// ⑧ 超过 AD_UPLOAD_MAX_BYTES(2MB)400(服务层,报文含限额值);
|
||||
// contentType 非白名单 400(DTO @IsIn);扩展名不接受 400(服务层)。
|
||||
//
|
||||
// Task 7 追加段(cn HTTP CRUD + outbox + audit + 同步贯通):
|
||||
// ⑨ 创建:真图直传 → POST /admin/ads → 201:行就位(albumIds 未提交归一
|
||||
// null)、outbox {type:'ad.updated', albumId:'', entityVersion:1}(载荷=
|
||||
// 全量快照)、audit ad.create、imageUrl 为现签 URL(GET 回真实字节)、
|
||||
// 终键 v1 对象 head 可见;
|
||||
// ⑩ albumIds 定向:绑定两相册(含重复元素去重)→ 载荷回传数组;传 []/
|
||||
// null 归一 null;绑定不存在的相册 400 VALIDATION_FAILED;
|
||||
// ⑪ linkUrl 非法(javascript:/data:、超长 2048+)400(DTO pattern/
|
||||
// maxLength);
|
||||
// ⑫ PATCH:title/enabled/sortOrder/albumIds 各自 bump entityVersion+1;
|
||||
// 停用(true→false)outbox priority='high',常规更新 normal;空请求体
|
||||
// 400;albumIds 缺省不碰(null=转全局、数组=整体替换);不存在的 ad 404;
|
||||
// ⑬ PATCH 换图:三字段缺一 400(服务层同时性校验);齐备(新 staging 真
|
||||
// 图)→ 终键 v{N+1} 新对象(head 可见新字节/类型);旧版本对象不回收;
|
||||
// ⑭ DELETE:行删 + outbox {type:'ad.deleted', priority:'high',
|
||||
// entityVersion=末+1}(载荷={adId, entityVersion})+ audit ad.delete;
|
||||
// 再删 404(NOT_FOUND);
|
||||
// ⑮ 权限:非 admin 全端点 403(list/create/patch/delete);匿名 401;
|
||||
// ⑯ 同步贯通(衔接 Task 5 的 prismaOv 断言):创建→drainSender→ads_ov 行
|
||||
// 就位;停用→drainSender→行删;删除→drainSender→行删(账本收敛墓碑版)。
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { type NestExpressApplication } from '@nestjs/platform-express';
|
||||
@@ -70,7 +91,7 @@ interface AdsOvRow {
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
describe('ads sync projection (e2e)', () => {
|
||||
describe('ads domain (e2e)', () => {
|
||||
let cnApp: NestExpressApplication;
|
||||
let ovApp: NestExpressApplication;
|
||||
let cnBase: string;
|
||||
@@ -90,6 +111,14 @@ describe('ads sync projection (e2e)', () => {
|
||||
/** ③ 落位的 v3 快照(④ stale 断言的基线)。 */
|
||||
let v3Snapshot: AdUpdatedPayload;
|
||||
|
||||
// ── Task 7 追加段共用状态 ──
|
||||
|
||||
/** ⑨ 创建、⑫-⑭ 顺次推进的 CRUD 链条 ad(版本链 v1..v7 → 墓碑 v8)。 */
|
||||
let crudAdId = '';
|
||||
/** ⑩/⑫/⑯ 定向投放用的两个直种相册。 */
|
||||
let adAlbum1 = '';
|
||||
let adAlbum2 = '';
|
||||
|
||||
/** 排空发送器:循环 runOnce 直至无未投递事件(embed.e2e 同款实现)。 */
|
||||
async function drainSender(): Promise<void> {
|
||||
for (let round = 0; round < 20; round++) {
|
||||
@@ -167,6 +196,93 @@ describe('ads sync projection (e2e)', () => {
|
||||
expect(state?.appliedVersion).toBe(v);
|
||||
}
|
||||
|
||||
// ── Task 7 追加段共用工具 ──
|
||||
|
||||
/**
|
||||
* 真实直传广告图到 staging(init → PUT MinIO,Task 6 的 ⑥ 同款链路抽出的
|
||||
* helper):返回创建/换图请求所需的申明三元组。
|
||||
*/
|
||||
async function uploadAdImage(
|
||||
bytes: number,
|
||||
contentType: 'image/jpeg' | 'image/png' = 'image/jpeg',
|
||||
filename = 'ad-banner.jpg',
|
||||
): Promise<{
|
||||
imageStagingKey: string;
|
||||
imageBytes: number;
|
||||
imageContentType: string;
|
||||
}> {
|
||||
const init = await request(cnBase)
|
||||
.post('/v1/admin/ads/uploads')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.send({ filename, bytes, contentType });
|
||||
expect(init.status).toBe(201);
|
||||
const put = await fetch(init.body.credential.url, {
|
||||
method: init.body.credential.method,
|
||||
headers: init.body.credential.headers,
|
||||
body: new Uint8Array(Buffer.alloc(bytes, 9)),
|
||||
});
|
||||
expect(put.ok).toBe(true);
|
||||
return {
|
||||
imageStagingKey: init.body.objectKey,
|
||||
imageBytes: bytes,
|
||||
imageContentType: contentType,
|
||||
};
|
||||
}
|
||||
|
||||
/** 组装合法创建请求体(真实 staging 图,字段可覆盖)。 */
|
||||
async function adCreateBody(
|
||||
overrides: Record<string, unknown> = {},
|
||||
): Promise<Record<string, unknown>> {
|
||||
return {
|
||||
linkUrl: 'https://sponsor.example/landing',
|
||||
...(await uploadAdImage(200)),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** 该 ad 的 outbox 事件行(payload.adId 过滤,occurredAt 升序)。 */
|
||||
async function adOutboxEvents(
|
||||
adId: string,
|
||||
type: 'ad.updated' | 'ad.deleted',
|
||||
) {
|
||||
const rows = await prismaCn.outbox_events.findMany({
|
||||
where: { type },
|
||||
orderBy: { occurredAt: 'asc' },
|
||||
});
|
||||
return rows.filter((r) => (r.payload as { adId?: string }).adId === adId);
|
||||
}
|
||||
|
||||
/** 断言 cn ads 行匹配(部分字段 toMatchObject)。 */
|
||||
async function expectAdRow(
|
||||
adId: string,
|
||||
expected: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const row = await prismaCn.ads.findUniqueOrThrow({ where: { id: adId } });
|
||||
expect(row).toMatchObject(expected);
|
||||
}
|
||||
|
||||
/** 直种相册行(albumIds 存在性校验标的;ads 域只读 id,无需翻译行)。 */
|
||||
async function seedAdAlbum(alias: string): Promise<string> {
|
||||
const album = await prismaCn.albums.create({
|
||||
data: {
|
||||
publicToken: `adalb-${alias}-${randomBytes(6).toString('hex')}`,
|
||||
ownerId: 'ads-e2e-owner',
|
||||
startAt: new Date('2026-10-01T00:00:00.000Z'),
|
||||
defaultLocale: 'zh-CN',
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
return album.id;
|
||||
}
|
||||
|
||||
/** 断言 ov 版本账本收敛在指定版本(任意 adId)。 */
|
||||
async function expectOvAppliedVersion(adId: string, v: number): Promise<void> {
|
||||
const state = await prismaOv.sync_state.findUnique({
|
||||
where: { entityType_entityId: { entityType: 'ad', entityId: adId } },
|
||||
});
|
||||
expect(state?.appliedVersion).toBe(v);
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
expect(SYNC_KEY).not.toBe('');
|
||||
await resetDb();
|
||||
@@ -417,4 +533,389 @@ describe('ads sync projection (e2e)', () => {
|
||||
expect(noExt.status).toBe(400);
|
||||
expect(noExt.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
|
||||
});
|
||||
|
||||
// ── Task 7 追加段:/v1/admin/ads CRUD(cn HTTP + outbox + audit)──
|
||||
|
||||
it('⑨ 创建:真图直传 → POST /admin/ads → 201 行/outbox v1/audit/imageUrl 现签', async () => {
|
||||
const image = await uploadAdImage(200);
|
||||
const res = await request(cnBase)
|
||||
.post('/v1/admin/ads')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.send({
|
||||
title: '首页轮播',
|
||||
linkUrl: 'https://sponsor.example/launch',
|
||||
sortOrder: 3,
|
||||
...image,
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
// 响应形状精确(契约 AdminAdItem 全字段,无多余键)。
|
||||
expect(Object.keys(res.body).sort()).toEqual([
|
||||
'albumIds',
|
||||
'createdAt',
|
||||
'enabled',
|
||||
'id',
|
||||
'imageObjectKey',
|
||||
'imageUrl',
|
||||
'linkUrl',
|
||||
'sortOrder',
|
||||
'title',
|
||||
'updatedAt',
|
||||
]);
|
||||
crudAdId = res.body.id;
|
||||
expect(res.body).toMatchObject({
|
||||
id: crudAdId,
|
||||
title: '首页轮播',
|
||||
linkUrl: 'https://sponsor.example/launch',
|
||||
enabled: true,
|
||||
sortOrder: 3,
|
||||
// albumIds 未提交 → 全局投放(落库归一 null)。
|
||||
albumIds: null,
|
||||
});
|
||||
|
||||
// cn 行就位:版本化终键 v1、albumIds SQL NULL、entityVersion=1。
|
||||
const row = await prismaCn.ads.findUnique({ where: { id: crudAdId } });
|
||||
expect(row).not.toBeNull();
|
||||
expect(row).toMatchObject({
|
||||
title: '首页轮播',
|
||||
imageObjectKey: `photo-live/dev/ads/${crudAdId}/v1.jpg`,
|
||||
linkUrl: 'https://sponsor.example/launch',
|
||||
enabled: true,
|
||||
sortOrder: 3,
|
||||
entityVersion: 1,
|
||||
});
|
||||
expect(row?.albumIds).toBeNull();
|
||||
expect(res.body.imageObjectKey).toBe(row?.imageObjectKey);
|
||||
|
||||
// imageUrl 为现签 URL:GET 回真实对象(200 字节)。
|
||||
const img = await fetch(res.body.imageUrl);
|
||||
expect(img.ok).toBe(true);
|
||||
expect(Number(img.headers.get('content-length'))).toBe(200);
|
||||
|
||||
// 终键对象可见(staging → v1 受控转存)。
|
||||
expect(await storage.head(row!.imageObjectKey)).toMatchObject({
|
||||
bytes: 200,
|
||||
contentType: 'image/jpeg',
|
||||
});
|
||||
|
||||
// outbox:ad.updated v1(albumId 恒空串信封约定),载荷=全量快照。
|
||||
const events = await adOutboxEvents(crudAdId, 'ad.updated');
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]).toMatchObject({
|
||||
albumId: '',
|
||||
entityVersion: 1,
|
||||
priority: 'normal',
|
||||
});
|
||||
expect(events[0].payload).toEqual({
|
||||
adId: crudAdId,
|
||||
imageObjectKey: row!.imageObjectKey,
|
||||
linkUrl: 'https://sponsor.example/launch',
|
||||
enabled: true,
|
||||
sortOrder: 3,
|
||||
albumIds: null,
|
||||
createdAt: row!.createdAt.toISOString(),
|
||||
entityVersion: 1,
|
||||
});
|
||||
|
||||
// audit:ad.create。
|
||||
expect(
|
||||
await prismaCn.audit_logs.findFirst({
|
||||
where: { action: 'ad.create', resourceId: crudAdId },
|
||||
}),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it('⑩ albumIds 定向:两相册去重回传;[]/null 归一 null;不存在相册 400', async () => {
|
||||
adAlbum1 = await seedAdAlbum('one');
|
||||
adAlbum2 = await seedAdAlbum('two');
|
||||
|
||||
const targeted = await request(cnBase)
|
||||
.post('/v1/admin/ads')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.send(await adCreateBody({ albumIds: [adAlbum1, adAlbum2, adAlbum1] }));
|
||||
expect(targeted.status).toBe(201);
|
||||
// 重复元素去重后回传。
|
||||
expect(targeted.body.albumIds).toEqual([adAlbum1, adAlbum2]);
|
||||
const targetedEvents = await adOutboxEvents(targeted.body.id, 'ad.updated');
|
||||
expect(targetedEvents).toHaveLength(1);
|
||||
expect(
|
||||
(targetedEvents[0].payload as { albumIds: unknown }).albumIds,
|
||||
).toEqual([adAlbum1, adAlbum2]);
|
||||
|
||||
const emptyArr = await request(cnBase)
|
||||
.post('/v1/admin/ads')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.send(await adCreateBody({ albumIds: [] }));
|
||||
expect(emptyArr.status).toBe(201);
|
||||
expect(emptyArr.body.albumIds).toBeNull();
|
||||
expect(
|
||||
(await prismaCn.ads.findUnique({ where: { id: emptyArr.body.id } }))
|
||||
?.albumIds,
|
||||
).toBeNull();
|
||||
|
||||
// JSON null 在 @IsOptional 下可达(embed settings 先例):视同缺省=全局。
|
||||
const nullIds = await request(cnBase)
|
||||
.post('/v1/admin/ads')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.send(await adCreateBody({ albumIds: null }));
|
||||
expect(nullIds.status).toBe(201);
|
||||
expect(nullIds.body.albumIds).toBeNull();
|
||||
|
||||
const missing = await request(cnBase)
|
||||
.post('/v1/admin/ads')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.send(await adCreateBody({ albumIds: ['no-such-album'] }));
|
||||
expect(missing.status).toBe(400);
|
||||
expect(missing.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
|
||||
});
|
||||
|
||||
it('⑪ linkUrl 非法(javascript:/data:、超长)→ 400 VALIDATION_FAILED', async () => {
|
||||
const image = await uploadAdImage(64);
|
||||
const post = (linkUrl: string) =>
|
||||
request(cnBase)
|
||||
.post('/v1/admin/ads')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.send({ linkUrl, ...image });
|
||||
|
||||
const js = await post('javascript:alert(1)');
|
||||
expect(js.status).toBe(400);
|
||||
expect(js.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
|
||||
|
||||
const dataUri = await post('data:text/html,<script></script>');
|
||||
expect(dataUri.status).toBe(400);
|
||||
expect(dataUri.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
|
||||
|
||||
const long = await post(`https://example.com/${'x'.repeat(2100)}`);
|
||||
expect(long.status).toBe(400);
|
||||
expect(long.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
|
||||
});
|
||||
|
||||
it('⑫ PATCH:title/enabled/sortOrder/albumIds 各自 bump+1;停用 high;空体 400;缺省不碰', async () => {
|
||||
const patch = (body: object) =>
|
||||
request(cnBase)
|
||||
.patch(`/v1/admin/ads/${crudAdId}`)
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.send(body);
|
||||
|
||||
// 空请求体(无任何待更新字段)→ 400(对照 embed 更新口径)。
|
||||
const empty = await patch({});
|
||||
expect(empty.status).toBe(400);
|
||||
expect(empty.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
|
||||
|
||||
// title:v1→2;albumIds 缺省不碰(仍 null)。
|
||||
const renamed = await patch({ title: '改名' });
|
||||
expect(renamed.status).toBe(200);
|
||||
expect(renamed.body.title).toBe('改名');
|
||||
expect(renamed.body.albumIds).toBeNull();
|
||||
await expectAdRow(crudAdId, { title: '改名', albumIds: null, entityVersion: 2 });
|
||||
|
||||
// 停用(true→false):v2→3,outbox priority='high'(ov 侧尽快删投影)。
|
||||
const disabled = await patch({ enabled: false });
|
||||
expect(disabled.status).toBe(200);
|
||||
expect(disabled.body.enabled).toBe(false);
|
||||
await expectAdRow(crudAdId, { enabled: false, entityVersion: 3 });
|
||||
let events = await adOutboxEvents(crudAdId, 'ad.updated');
|
||||
expect(events.at(-1)).toMatchObject({ entityVersion: 3, priority: 'high' });
|
||||
|
||||
// sortOrder(非停用迁移 → normal):v3→4。
|
||||
const sorted = await patch({ sortOrder: 7 });
|
||||
expect(sorted.status).toBe(200);
|
||||
expect(sorted.body.sortOrder).toBe(7);
|
||||
await expectAdRow(crudAdId, { sortOrder: 7, entityVersion: 4 });
|
||||
events = await adOutboxEvents(crudAdId, 'ad.updated');
|
||||
expect(events.at(-1)).toMatchObject({ entityVersion: 4, priority: 'normal' });
|
||||
|
||||
// albumIds 数组=整体替换:v4→5。
|
||||
const bound = await patch({ albumIds: [adAlbum1] });
|
||||
expect(bound.status).toBe(200);
|
||||
expect(bound.body.albumIds).toEqual([adAlbum1]);
|
||||
await expectAdRow(crudAdId, {
|
||||
albumIds: [adAlbum1],
|
||||
entityVersion: 5,
|
||||
});
|
||||
|
||||
// albumIds null=转全局:v5→6。
|
||||
const global = await patch({ albumIds: null });
|
||||
expect(global.status).toBe(200);
|
||||
expect(global.body.albumIds).toBeNull();
|
||||
await expectAdRow(crudAdId, { albumIds: null, entityVersion: 6 });
|
||||
|
||||
// 不存在的 ad → 404 NOT_FOUND。
|
||||
const absent = await request(cnBase)
|
||||
.patch('/v1/admin/ads/no-such-ad')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.send({ title: 'x' });
|
||||
expect(absent.status).toBe(404);
|
||||
expect(absent.body.code).toBe(ErrorCodes.NOT_FOUND);
|
||||
});
|
||||
|
||||
it('⑬ PATCH 换图:三字段缺一 400;齐备 → 终键 v{N+1} 新对象(旧对象不回收)', async () => {
|
||||
const patch = (body: object) =>
|
||||
request(cnBase)
|
||||
.patch(`/v1/admin/ads/${crudAdId}`)
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.send(body);
|
||||
|
||||
// 换图三字段必须同时提供(服务层裁决):仅 staging 键 / 缺 bytes / 缺类型。
|
||||
const onlyKey = await patch({
|
||||
imageStagingKey: 'photo-live/dev/ads/staging/cpartialstub0000000.jpg',
|
||||
});
|
||||
expect(onlyKey.status).toBe(400);
|
||||
expect(onlyKey.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
|
||||
|
||||
const image2 = await uploadAdImage(320, 'image/png', 'ad-banner-2.png');
|
||||
const noBytes = await patch({
|
||||
imageStagingKey: image2.imageStagingKey,
|
||||
imageContentType: 'image/png',
|
||||
});
|
||||
expect(noBytes.status).toBe(400);
|
||||
expect(noBytes.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
|
||||
|
||||
const noType = await patch({
|
||||
imageStagingKey: image2.imageStagingKey,
|
||||
imageBytes: 320,
|
||||
});
|
||||
expect(noType.status).toBe(400);
|
||||
expect(noType.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
|
||||
|
||||
// 换图成功:v6→7,终键 v7.png(扩展名随 staging 键),head 可见新字节/类型。
|
||||
const swap = await patch(image2);
|
||||
expect(swap.status).toBe(200);
|
||||
expect(swap.body.imageObjectKey).toBe(
|
||||
`photo-live/dev/ads/${crudAdId}/v7.png`,
|
||||
);
|
||||
await expectAdRow(crudAdId, {
|
||||
imageObjectKey: `photo-live/dev/ads/${crudAdId}/v7.png`,
|
||||
entityVersion: 7,
|
||||
});
|
||||
expect(
|
||||
await storage.head(`photo-live/dev/ads/${crudAdId}/v7.png`),
|
||||
).toMatchObject({ bytes: 320, contentType: 'image/png' });
|
||||
// 旧版本对象不回收(OSS 生命周期兜底,契约语义)。
|
||||
expect(
|
||||
await storage.head(`photo-live/dev/ads/${crudAdId}/v1.jpg`),
|
||||
).toMatchObject({ bytes: 200 });
|
||||
});
|
||||
|
||||
it('⑭ DELETE:行删 + 墓碑(末版本+1, high)+ audit;再删 404', async () => {
|
||||
const del = await request(cnBase)
|
||||
.delete(`/v1/admin/ads/${crudAdId}`)
|
||||
.set('Authorization', `Bearer ${adminToken}`);
|
||||
expect(del.status).toBe(204);
|
||||
expect(
|
||||
await prismaCn.ads.findUnique({ where: { id: crudAdId } }),
|
||||
).toBeNull();
|
||||
|
||||
const tombs = await adOutboxEvents(crudAdId, 'ad.deleted');
|
||||
expect(tombs).toHaveLength(1);
|
||||
expect(tombs[0]).toMatchObject({
|
||||
albumId: '',
|
||||
entityVersion: 8,
|
||||
priority: 'high',
|
||||
});
|
||||
expect(tombs[0].payload).toEqual({ adId: crudAdId, entityVersion: 8 });
|
||||
expect(
|
||||
await prismaCn.audit_logs.findFirst({
|
||||
where: { action: 'ad.delete', resourceId: crudAdId },
|
||||
}),
|
||||
).not.toBeNull();
|
||||
|
||||
// 重复删除 → 404(硬删除语义,无恢复端点)。
|
||||
const again = await request(cnBase)
|
||||
.delete(`/v1/admin/ads/${crudAdId}`)
|
||||
.set('Authorization', `Bearer ${adminToken}`);
|
||||
expect(again.status).toBe(404);
|
||||
expect(again.body.code).toBe(ErrorCodes.NOT_FOUND);
|
||||
});
|
||||
|
||||
it('⑮ 权限:非 admin 全端点 403;匿名 401(list/create/patch/delete)', async () => {
|
||||
const createBody = {
|
||||
linkUrl: 'https://x.example/',
|
||||
imageStagingKey: 'photo-live/dev/ads/staging/cstub.jpg',
|
||||
imageBytes: 1,
|
||||
imageContentType: 'image/jpeg',
|
||||
};
|
||||
|
||||
const staffList = await request(cnBase)
|
||||
.get('/v1/admin/ads')
|
||||
.set('Authorization', `Bearer ${staffToken}`);
|
||||
expect(staffList.status).toBe(403);
|
||||
expect(staffList.body.code).toBe(ErrorCodes.AUTH_FORBIDDEN);
|
||||
|
||||
const staffCreate = await request(cnBase)
|
||||
.post('/v1/admin/ads')
|
||||
.set('Authorization', `Bearer ${staffToken}`)
|
||||
.send(createBody);
|
||||
expect(staffCreate.status).toBe(403);
|
||||
expect(staffCreate.body.code).toBe(ErrorCodes.AUTH_FORBIDDEN);
|
||||
|
||||
const staffPatch = await request(cnBase)
|
||||
.patch(`/v1/admin/ads/${crudAdId}`)
|
||||
.set('Authorization', `Bearer ${staffToken}`)
|
||||
.send({ title: 'x' });
|
||||
expect(staffPatch.status).toBe(403);
|
||||
expect(staffPatch.body.code).toBe(ErrorCodes.AUTH_FORBIDDEN);
|
||||
|
||||
const staffDelete = await request(cnBase)
|
||||
.delete(`/v1/admin/ads/${crudAdId}`)
|
||||
.set('Authorization', `Bearer ${staffToken}`);
|
||||
expect(staffDelete.status).toBe(403);
|
||||
expect(staffDelete.body.code).toBe(ErrorCodes.AUTH_FORBIDDEN);
|
||||
|
||||
const anonList = await request(cnBase).get('/v1/admin/ads');
|
||||
expect(anonList.status).toBe(401);
|
||||
const anonCreate = await request(cnBase)
|
||||
.post('/v1/admin/ads')
|
||||
.send(createBody);
|
||||
expect(anonCreate.status).toBe(401);
|
||||
const anonPatch = await request(cnBase)
|
||||
.patch(`/v1/admin/ads/${crudAdId}`)
|
||||
.send({ title: 'x' });
|
||||
expect(anonPatch.status).toBe(401);
|
||||
const anonDelete = await request(cnBase).delete(`/v1/admin/ads/${crudAdId}`);
|
||||
expect(anonDelete.status).toBe(401);
|
||||
});
|
||||
|
||||
it('⑯ 同步贯通:创建→drain→ads_ov 行就位;停用→drain→行删;删除→drain→行删', async () => {
|
||||
const created = await request(cnBase)
|
||||
.post('/v1/admin/ads')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.send(await adCreateBody({ albumIds: [adAlbum1], sortOrder: 12 }));
|
||||
expect(created.status).toBe(201);
|
||||
const adId = created.body.id as string;
|
||||
|
||||
await drainSender();
|
||||
const replica = await prismaOv.ads_ov.findUnique({ where: { adId } });
|
||||
expect(replica).toMatchObject({
|
||||
adId,
|
||||
imageObjectKey: created.body.imageObjectKey,
|
||||
linkUrl: 'https://sponsor.example/landing',
|
||||
sortOrder: 12,
|
||||
albumIds: [adAlbum1],
|
||||
entityVersion: 1,
|
||||
updatedAt: expect.any(Date),
|
||||
});
|
||||
|
||||
// 停用 → ov 删投影行(disabled=无投影,设计 §2.2/§4),账本收敛 v2。
|
||||
const off = await request(cnBase)
|
||||
.patch(`/v1/admin/ads/${adId}`)
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.send({ enabled: false });
|
||||
expect(off.status).toBe(200);
|
||||
await drainSender();
|
||||
expect(
|
||||
await prismaOv.ads_ov.findUnique({ where: { adId } }),
|
||||
).toBeNull();
|
||||
await expectOvAppliedVersion(adId, 2);
|
||||
|
||||
// 删除 → 墓碑 v3:行保持删除(不被旧事件复活),账本收敛墓碑版。
|
||||
const del = await request(cnBase)
|
||||
.delete(`/v1/admin/ads/${adId}`)
|
||||
.set('Authorization', `Bearer ${adminToken}`);
|
||||
expect(del.status).toBe(204);
|
||||
await drainSender();
|
||||
expect(
|
||||
await prismaOv.ads_ov.findUnique({ where: { adId } }),
|
||||
).toBeNull();
|
||||
await expectOvAppliedVersion(adId, 3);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user