diff --git a/services/api/src/ads/ads.controller.ts b/services/api/src/ads/ads.controller.ts new file mode 100644 index 0000000..ba4c113 --- /dev/null +++ b/services/api/src/ads/ads.controller.ts @@ -0,0 +1,26 @@ +// 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'; +import { userOf } from '../albums/albums.controller'; +import { AuthenticatedRequest, SessionGuard } from '../auth/session.guard'; +import { AdsService, type AdUploadInitResponse } from './ads.service'; +import { AdUploadInitRequestDto } from './dto'; + +@Controller('admin/ads') +@UseGuards(SessionGuard) +export class AdsController { + constructor(private readonly adsService: AdsService) {} + + /** 广告图直传初始化(契约 201:staging 键 + 临时直传凭证)。 */ + @Post('uploads') + initUpload( + @Body() dto: AdUploadInitRequestDto, + @Req() req: AuthenticatedRequest, + ): Promise { + return this.adsService.initUpload(userOf(req), dto); + } +} diff --git a/services/api/src/ads/ads.module.ts b/services/api/src/ads/ads.module.ts new file mode 100644 index 0000000..59e6f30 --- /dev/null +++ b/services/api/src/ads/ads.module.ts @@ -0,0 +1,16 @@ +// services/api/src/ads/ —— 广告位管理模块(ads-carousel Task 6,仅 cn 入口 +// 装配:广告是 cn 权威实体,ov 侧只承接 ads_ov 投影与公开读端点)。 +// UploadsModule 取 STORAGE_PORT(storage.provider.ts env 工厂;模块已 export, +// 同 EmbedModule 之后的装配口径);PrismaModule 为 @Global,Task 7 CRUD 落地 +// 时直接注入。 +import { Module } from '@nestjs/common'; +import { UploadsModule } from '../uploads/uploads.module'; +import { AdsController } from './ads.controller'; +import { AdsService } from './ads.service'; + +@Module({ + imports: [UploadsModule], + controllers: [AdsController], + providers: [AdsService], +}) +export class AdsModule {} diff --git a/services/api/src/ads/ads.service.ts b/services/api/src/ads/ads.service.ts new file mode 100644 index 0000000..49de2d3 --- /dev/null +++ b/services/api/src/ads/ads.service.ts @@ -0,0 +1,76 @@ +// 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 实现。要点: +// · admin-only(AuthUser.role,同 embed 口径):非 admin 403 AUTH_FORBIDDEN; +// · 无会话概念(契约 AdUploadInitRequest 描述):init 只 presign、不落任何 +// DB 行——重复转存/防覆盖由创建端 copyIfAbsent 禁止覆盖语义承担; +// · 大小限额 AD_UPLOAD_MAX_BYTES=2MB(@photo/contracts 常量,独立于照片域 +// UPLOAD_MAX_BYTES 的 50MB,设计 §3):服务层裁决(报文含限额值),DTO +// 不设 @Max——限额唯一裁决点在此(同 UploadInitRequestDto 约定); +// · staging 键 adsStagingObjectKey(cuid, ext)(@photo/storage 权威布局 +// photo-live/{env}/ads/staging/{cuid}.{ext});cuidLikeId 应用侧生成 +// (键含自引用键段,无行可回填);扩展名复用 uploads/support.extOfFilename +// 白名单归一(jpeg→jpg); +// · presignPut 参数序:(objectKey, bytes, contentType, ttlSeconds)—— +// StoragePort 接口权威;TTL 惰性读 SIGNED_URL_TTL_SECONDS(同上传域)。 +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 { ApiException } from '../api-exception'; +import type { AuthUser } from '../auth/session.guard'; +import { validationFailed } from '../albums/support'; +import { STORAGE_PORT } from '../uploads/storage.provider'; +import { + cuidLikeId, + extOfFilename, + signedUrlTtlSeconds, +} from '../uploads/support'; +import type { AdUploadInitRequestDto } from './dto'; + +export type AdUploadInitResponse = components['schemas']['AdUploadInitResponse']; + +@Injectable() +export class AdsService { + constructor( + @Inject(STORAGE_PORT) private readonly storage: StoragePort, + ) {} + + /** 仅公司 admin 可管理广告位(契约 ads 管理端点全集 403 语义)。 */ + private requireAdmin(user: AuthUser): void { + if (user.role !== 'admin') { + throw new ApiException( + ErrorCodes.AUTH_FORBIDDEN, + '仅管理员可管理广告位', + HttpStatus.FORBIDDEN, + ); + } + } + + /** + * 广告图直传初始化(契约 initAdUpload 201):admin 校验 → 限额 → 扩展名 + * 白名单 → staging 键派生 → presign。幂等由创建端 copyIfAbsent 承担 + * (每次 init 新键,无会话行)。 + */ + async initUpload( + user: AuthUser, + dto: AdUploadInitRequestDto, + ): Promise { + this.requireAdmin(user); + if (dto.bytes > AD_UPLOAD_MAX_BYTES) { + throw validationFailed(`广告图超过大小限额(limit=${AD_UPLOAD_MAX_BYTES})`); + } + const ext = extOfFilename(dto.filename); + if (ext === null) { + throw validationFailed('文件扩展名不接受(jpg/jpeg/png/heic)'); + } + const objectKey = adsStagingObjectKey(cuidLikeId(), ext); + const credential = await this.storage.presignPut( + objectKey, + dto.bytes, + dto.contentType, + signedUrlTtlSeconds(), + ); + return { objectKey, credential }; + } +} diff --git a/services/api/src/ads/dto.ts b/services/api/src/ads/dto.ts new file mode 100644 index 0000000..5943be3 --- /dev/null +++ b/services/api/src/ads/dto.ts @@ -0,0 +1,126 @@ +// services/api/src/ads/dto.ts —— 广告位管理端点入参 DTO(ads-carousel)。 +// 校验约束对齐契约 AdCreateRequest / AdUpdateRequest / AdUploadInitRequest: +// · linkUrl:^https?:// 前缀 + 2048 上限(契约 pattern/maxLength); +// · title 1..120(VarChar(120) 后台备注名);sortOrder ≥0; +// · albumIds:数组元素 ≤64;未提供或空数组=全局投放(落库归一 null, +// 元素存在性校验在服务层事务内);更新侧 null=转全局(@IsOptional 对 +// undefined/null 均放行且 null 保留在 DTO 上——服务层以 !== undefined +// 区分「未提交」与「显式 null」,同 embed albumId 口径); +// · imageStagingKey/imageBytes/imageContentType:创建必填、更新可选 +// (换图三字段须同时提供,服务层裁决); +// · 大小限额与 filename 扩展名白名单是服务层校验(同 UploadInitRequestDto +// 约定:限额报文需含限额值、扩展名经 extOfFilename 归一后派生对象键); +// contentType 与照片直传共用白名单 UPLOAD_CONTENT_TYPES(image/heic +// 暂定,G2 真机验证)。 +import { + IsArray, + IsBoolean, + IsIn, + IsInt, + IsNotEmpty, + IsOptional, + IsString, + Matches, + MaxLength, + Min, +} from 'class-validator'; +import { + UPLOAD_CONTENT_TYPES, + type UploadContentType, +} from '../uploads/support'; + +/** 契约 linkUrl 校验(AdCreateRequest/AdUpdateRequest 同一 pattern)。 */ +const LINK_URL_PATTERN = /^https?:\/\//; + +export class AdUploadInitRequestDto { + @IsString() + @IsNotEmpty() + @MaxLength(255) + filename!: string; + + @IsInt() + @Min(1) + bytes!: number; + + @IsIn(UPLOAD_CONTENT_TYPES) + contentType!: UploadContentType; +} + +export class AdCreateRequestDto { + @IsOptional() + @IsString() + @IsNotEmpty() + @MaxLength(120) + title?: string; + + @IsString() + @Matches(LINK_URL_PATTERN, { message: 'linkUrl 必须以 http(s):// 开头' }) + @MaxLength(2048) + linkUrl!: string; + + @IsString() + @IsNotEmpty() + imageStagingKey!: string; + + @IsInt() + @Min(1) + imageBytes!: number; + + @IsIn(UPLOAD_CONTENT_TYPES) + imageContentType!: UploadContentType; + + @IsOptional() + @IsInt() + @Min(0) + sortOrder?: number; + + @IsOptional() + @IsArray() + @IsString({ each: true }) + @MaxLength(64, { each: true }) + albumIds?: string[]; +} + +export class AdUpdateRequestDto { + @IsOptional() + @IsString() + @IsNotEmpty() + @MaxLength(120) + title?: string; + + @IsOptional() + @IsString() + @Matches(LINK_URL_PATTERN, { message: 'linkUrl 必须以 http(s):// 开头' }) + @MaxLength(2048) + linkUrl?: string; + + @IsOptional() + @IsBoolean() + enabled?: boolean; + + @IsOptional() + @IsInt() + @Min(0) + sortOrder?: number; + + /** null=转全局;数组=整体替换(服务层以 !== undefined 判定提交与否)。 */ + @IsOptional() + @IsArray() + @IsString({ each: true }) + @MaxLength(64, { each: true }) + albumIds?: string[] | null; + + @IsOptional() + @IsString() + @IsNotEmpty() + imageStagingKey?: string; + + @IsOptional() + @IsInt() + @Min(1) + imageBytes?: number; + + @IsOptional() + @IsIn(UPLOAD_CONTENT_TYPES) + imageContentType?: UploadContentType; +} diff --git a/services/api/src/app-cn.module.ts b/services/api/src/app-cn.module.ts index aa8f3f1..d58a399 100644 --- a/services/api/src/app-cn.module.ts +++ b/services/api/src/app-cn.module.ts @@ -18,6 +18,7 @@ import { AlbumsModule } from './albums/albums.module'; import { AdminModule } from './admin/admin.module'; import { AllExceptionsFilter } from './all-exceptions.filter'; import { AuthModule } from './auth/auth.module'; +import { AdsModule } from './ads/ads.module'; import { EmbedModule } from './embed/embed.module'; import { HealthController } from './health.controller'; import { PhotosModule } from './photos/photos.module'; @@ -49,6 +50,9 @@ import { createLoggerOptions } from './shared/logger-options'; AdminModule, // 展示位管理域(P4b Task 2:/admin/embed-slots,slot-state 事件源)。 EmbedModule, + // 广告位管理域(ads-carousel Task 6 起:/admin/ads/uploads 直传初始化; + // CRUD Task 7——ad.* 事件源,仅 cn 入口装配)。 + AdsModule, // 内部同步域(P2 Task 3:/internal/sync/bootstrap|reconcile,HMAC 鉴权, // 仅 cn 入口装配——cn 是内容同步的源端)。 SyncModule, diff --git a/services/api/test/ads.e2e-spec.ts b/services/api/test/ads.e2e-spec.ts index 821c9fb..5617166 100644 --- a/services/api/test/ads.e2e-spec.ts +++ b/services/api/test/ads.e2e-spec.ts @@ -16,14 +16,31 @@ // ⑤ ad.deleted 墓碑 v4(high 优先级)→ 删行,账本收敛 v4。 // ④ 在 ⑤ 之前执行:stale 重放的断言基线是「行处于 v3 态」,墓碑(v4)之后 // 无法回到该基线(此后 v3 亦为 stale)。 +// +// Task 6 追加段(cn HTTP):/v1/admin/ads/uploads 上传初始化—— +// ⑥ admin 201:staging 键形态 + 凭证真实可用(PUT 实写 MinIO 后 head 可核, +// 对照 uploads.e2e 的 presign 断言写法;无会话概念=不落任何 DB 行); +// ⑦ 非 admin 403;匿名 401(入参合法); +// ⑧ 超过 AD_UPLOAD_MAX_BYTES(2MB)400(服务层,报文含限额值); +// contentType 非白名单 400(DTO @IsIn);扩展名不接受 400(服务层)。 +import { randomBytes } from 'node:crypto'; import { NestFactory } from '@nestjs/core'; import { type NestExpressApplication } from '@nestjs/platform-express'; +import request from 'supertest'; import { PrismaClient } from '@photo/db-cn'; import type { Prisma } from '@photo/db-cn'; import { PrismaClient as OvPrismaClient } from '@photo/db-ov'; -import type { AdDeletedPayload, AdUpdatedPayload } from '@photo/contracts'; +import { + AD_UPLOAD_MAX_BYTES, + type AdDeletedPayload, + type AdUpdatedPayload, + ErrorCodes, +} from '@photo/contracts'; +import { createStorageFromEnv, type StoragePort } from '@photo/storage'; import { AppCnModule } from '../src/app-cn.module'; import { AppOvModule } from '../src/app-ov.module'; +import { seedAdmin } from '../src/scripts/seed-admin'; +import { hashSessionToken } from '../src/auth/session.guard'; import { configureApp } from '../src/shared/configure-app'; import { ContractWsAdapter } from '../src/realtime/contract-ws.adapter'; import { resetDb } from './reset'; @@ -33,6 +50,8 @@ import { OutboxSender } from '../../sync/src/sender'; jest.setTimeout(120_000); const SYNC_KEY = process.env.SYNC_INTERNAL_KEY ?? 'dev-sync-secret-change-me'; +const ADMIN_ACCOUNT = process.env.ADMIN_ACCOUNT ?? 'admin'; +const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD ?? 'admin-secret-change-me'; /** 本 spec 全程操作的单个广告位 ID(链条用例共享;VarChar(32) 内)。 */ const AD_ID = 'ads-e2e-0001'; @@ -54,10 +73,14 @@ interface AdsOvRow { describe('ads sync projection (e2e)', () => { let cnApp: NestExpressApplication; let ovApp: NestExpressApplication; + let cnBase: string; let ovBase: string; let prismaCn: PrismaClient; let prismaOv: OvPrismaClient; let sender: OutboxSender; + let storage: StoragePort; + let adminToken: string; + let staffToken: string; // outbox 行 occurredAt 基准 + 单调序号:保证发送器取批顺序(occurredAt // 升序)与用例推进顺序一致——④ 的迟到 v2 必须晚于 ③ 的 v3 被推送。 @@ -149,12 +172,14 @@ describe('ads sync projection (e2e)', () => { await resetDb(); await resetOvDb(); - // cn 入口仅作骨架就位(本 spec 不走 cn HTTP;outbox 行直插 cn 库)。 + // cn 入口仅作骨架就位(①-⑤ 不走 cn HTTP;outbox 行直插 cn 库)——Task 6 + // 追加段起复用同一实例走 /v1/admin/ads/uploads。 cnApp = await NestFactory.create(AppCnModule, { rawBody: true, }); configureApp(cnApp); await cnApp.listen(0); + cnBase = await cnApp.getUrl(); ovApp = await NestFactory.create(AppOvModule, { rawBody: true, @@ -171,6 +196,35 @@ describe('ads sync projection (e2e)', () => { ovApiUrl: ovBase, internalKey: SYNC_KEY, }); + storage = createStorageFromEnv(); + + // Task 6 追加段身份:admin(seedAdmin + 真实登录)与 staff 对照账号 + // (直插 users + sessions,embed.e2e 同款);匿名 = 不带 Authorization。 + await seedAdmin(prismaCn, ADMIN_ACCOUNT, ADMIN_PASSWORD); + const login = await request(cnBase).post('/v1/auth/password').send({ + account: ADMIN_ACCOUNT, + password: ADMIN_PASSWORD, + }); + expect(login.status).toBe(200); + adminToken = login.body.sessionToken as string; + const staff = await prismaCn.users.create({ + data: { + account: 'ads-staff', + role: 'staff', + passwordHash: 'x', + displayName: 'ads-staff', + }, + select: { id: true }, + }); + const staffSession = randomBytes(32).toString('hex'); + await prismaCn.sessions.create({ + data: { + userId: staff.id, + tokenHash: hashSessionToken(staffSession), + expiresAt: new Date(Date.now() + 3_600_000), + }, + }); + staffToken = staffSession; }); afterAll(async () => { @@ -268,4 +322,99 @@ describe('ads sync projection (e2e)', () => { ).toBeNull(); await expectAppliedVersion(4); }); + + // ── Task 6 追加段:/v1/admin/ads/uploads 上传初始化(cn HTTP,admin-only)── + + it('⑥ admin 初始化 → 201 staging 键 + 凭证(真实 PUT 实写 MinIO,head 可核)', async () => { + const res = await request(cnBase) + .post('/v1/admin/ads/uploads') + .set('Authorization', `Bearer ${adminToken}`) + .send({ filename: 'ad-banner.jpeg', bytes: 256, contentType: 'image/jpeg' }); + expect(res.status).toBe(201); + // 响应形状精确断言(契约 AdUploadInitResponse;无会话概念=无其他字段)。 + expect(Object.keys(res.body).sort()).toEqual(['credential', 'objectKey']); + expect(Object.keys(res.body.credential).sort()).toEqual( + ['expiresAt', 'headers', 'method', 'mode', 'url'], + ); + expect(res.body.credential).toMatchObject({ + mode: 'presigned', + method: 'PUT', + headers: { 'Content-Type': 'image/jpeg' }, + }); + expect(new Date(res.body.credential.expiresAt).getTime()).toBeGreaterThan( + Date.now(), + ); + // staging 键形态:photo-live/dev/ads/staging/{cuid 25 字符}.{ext};jpeg 归一 + // 为 jpg(扩展名白名单映射,同上传域口径)。 + expect(res.body.objectKey).toMatch( + /^photo-live\/dev\/ads\/staging\/c[0-9a-z]{24}\.jpg$/, + ); + + // 凭证真实可用:按凭证 PUT 后 head 可见精确字节数与类型(fetch,Node 22)。 + const put = await fetch(res.body.credential.url, { + method: res.body.credential.method, + headers: res.body.credential.headers, + body: new Uint8Array(Buffer.alloc(256, 7)), + }); + expect(put.ok).toBe(true); + expect(await storage.head(res.body.objectKey)).toMatchObject({ + bytes: 256, + contentType: 'image/jpeg', + }); + }); + + it('⑦ 非 admin 403;匿名 401(入参合法)', async () => { + const body = { filename: 'ad.png', bytes: 100, contentType: 'image/png' }; + const staff = await request(cnBase) + .post('/v1/admin/ads/uploads') + .set('Authorization', `Bearer ${staffToken}`) + .send(body); + expect(staff.status).toBe(403); + expect(staff.body.code).toBe(ErrorCodes.AUTH_FORBIDDEN); + + const anon = await request(cnBase).post('/v1/admin/ads/uploads').send(body); + expect(anon.status).toBe(401); + }); + + it('⑧ 超过 2MB / contentType 非白名单 / 扩展名不接受 → 400 VALIDATION_FAILED', async () => { + const post = (payload: object) => + request(cnBase) + .post('/v1/admin/ads/uploads') + .set('Authorization', `Bearer ${adminToken}`) + .send(payload); + + const over = await post({ + filename: 'big.jpg', + bytes: AD_UPLOAD_MAX_BYTES + 1, + contentType: 'image/jpeg', + }); + expect(over.status).toBe(400); + expect(over.body.code).toBe(ErrorCodes.VALIDATION_FAILED); + // 服务层限额报文含限额值(DTO 不设 @Max,限额唯一裁决点在服务层)。 + expect(over.body.message).toContain(String(AD_UPLOAD_MAX_BYTES)); + + const badType = await post({ + filename: 'a.gif', + bytes: 100, + contentType: 'image/gif', + }); + expect(badType.status).toBe(400); + expect(badType.body.code).toBe(ErrorCodes.VALIDATION_FAILED); + + const badExt = await post({ + filename: 'a.gif', + bytes: 100, + contentType: 'image/jpeg', + }); + expect(badExt.status).toBe(400); + expect(badExt.body.code).toBe(ErrorCodes.VALIDATION_FAILED); + + const noExt = await post({ + filename: 'noext', + bytes: 100, + contentType: 'image/jpeg', + }); + expect(noExt.status).toBe(400); + expect(noExt.body.code).toBe(ErrorCodes.VALIDATION_FAILED); + }); });