From 4979091d2c23d5e3f89a5c998a3e28616b2504af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Mon, 28 Sep 2026 23:57:43 +0800 Subject: [PATCH] =?UTF-8?q?feat(api):=20=E5=B9=BF=E5=91=8A=E4=BD=8D=20CRUD?= =?UTF-8?q?=E2=80=94=E2=80=94outbox=20ad.*=20=E4=BA=8B=E4=BB=B6/audit/?= =?UTF-8?q?=E5=81=9C=E7=94=A8=E9=AB=98=E4=BC=98=E5=85=88=E7=BA=A7/?= =?UTF-8?q?=E7=89=88=E6=9C=AC=E5=8C=96=E6=8D=A2=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- services/api/src/ads/ad-payload.ts | 49 +++ services/api/src/ads/ads.controller.ts | 73 +++- services/api/src/ads/ads.module.ts | 6 +- services/api/src/ads/ads.service.ts | 454 +++++++++++++++++++++- services/api/src/ads/dto.ts | 7 +- services/api/test/ads.e2e-spec.ts | 515 ++++++++++++++++++++++++- 6 files changed, 1079 insertions(+), 25 deletions(-) create mode 100644 services/api/src/ads/ad-payload.ts diff --git a/services/api/src/ads/ad-payload.ts b/services/api/src/ads/ad-payload.ts new file mode 100644 index 0000000..22132f3 --- /dev/null +++ b/services/api/src/ads/ad-payload.ts @@ -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, + | '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 }; +} diff --git a/services/api/src/ads/ads.controller.ts b/services/api/src/ads/ads.controller.ts index ba4c113..de8c9b2 100644 --- a/services/api/src/ads/ads.controller.ts +++ b/services/api/src/ads/ads.controller.ts @@ -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 { + return this.adsService.list(userOf(req)); + } + + /** 创建广告位(staging 核验 → 版本化终键 v1 → outbox ad.updated v1)。 */ + @Post() + createAd( + @Body() dto: AdCreateRequestDto, + @Req() req: AuthenticatedRequest, + ): Promise { + return this.adsService.create(userOf(req), dto); + } + + /** 部分更新(任一管理面字段递增 entityVersion 并重发整行快照;停用 high)。 */ + @Patch(':id') + updateAd( + @Param('id') id: string, + @Body() dto: AdUpdateRequestDto, + @Req() req: AuthenticatedRequest, + ): Promise { + 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 { + return this.adsService.remove(userOf(req), id); + } + /** 广告图直传初始化(契约 201:staging 键 + 临时直传凭证)。 */ @Post('uploads') initUpload( diff --git a/services/api/src/ads/ads.module.ts b/services/api/src/ads/ads.module.ts index 59e6f30..7f17f91 100644 --- a/services/api/src/ads/ads.module.ts +++ b/services/api/src/ads/ads.module.ts @@ -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'; diff --git a/services/api/src/ads/ads.service.ts b/services/api/src/ads/ads.service.ts index 49de2d3..d981670 100644 --- a/services/api/src/ads/ads.service.ts +++ b/services/api/src/ads/ads.service.ts @@ -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; @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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + try { + await this.storage.copyIfAbsent(src, dest, expectedBytes); + } catch (err) { + if (err instanceof StorageError) { + throw uploadVerifyFailed(err.message); + } + throw err; + } + } } diff --git a/services/api/src/ads/dto.ts b/services/api/src/ads/dto.ts index 5943be3..d7446ba 100644 --- a/services/api/src/ads/dto.ts +++ b/services/api/src/ads/dto.ts @@ -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 { diff --git a/services/api/test/ads.e2e-spec.ts b/services/api/test/ads.e2e-spec.ts index 5617166..1f4b31a 100644 --- a/services/api/test/ads.e2e-spec.ts +++ b/services/api/test/ads.e2e-spec.ts @@ -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 { 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 = {}, + ): Promise> { + 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, + ): Promise { + const row = await prismaCn.ads.findUniqueOrThrow({ where: { id: adId } }); + expect(row).toMatchObject(expected); + } + + /** 直种相册行(albumIds 存在性校验标的;ads 域只读 id,无需翻译行)。 */ + async function seedAdAlbum(alias: string): Promise { + 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 { + 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,'); + 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); + }); });