feat(api): /admin/ads/uploads 广告图 staging 预签名(2MB/白名单/admin-only)
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -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<AdUploadInitResponse> {
|
||||||
|
return this.adsService.initUpload(userOf(req), dto);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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<AdUploadInitResponse> {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ import { AlbumsModule } from './albums/albums.module';
|
|||||||
import { AdminModule } from './admin/admin.module';
|
import { AdminModule } from './admin/admin.module';
|
||||||
import { AllExceptionsFilter } from './all-exceptions.filter';
|
import { AllExceptionsFilter } from './all-exceptions.filter';
|
||||||
import { AuthModule } from './auth/auth.module';
|
import { AuthModule } from './auth/auth.module';
|
||||||
|
import { AdsModule } from './ads/ads.module';
|
||||||
import { EmbedModule } from './embed/embed.module';
|
import { EmbedModule } from './embed/embed.module';
|
||||||
import { HealthController } from './health.controller';
|
import { HealthController } from './health.controller';
|
||||||
import { PhotosModule } from './photos/photos.module';
|
import { PhotosModule } from './photos/photos.module';
|
||||||
@@ -49,6 +50,9 @@ import { createLoggerOptions } from './shared/logger-options';
|
|||||||
AdminModule,
|
AdminModule,
|
||||||
// 展示位管理域(P4b Task 2:/admin/embed-slots,slot-state 事件源)。
|
// 展示位管理域(P4b Task 2:/admin/embed-slots,slot-state 事件源)。
|
||||||
EmbedModule,
|
EmbedModule,
|
||||||
|
// 广告位管理域(ads-carousel Task 6 起:/admin/ads/uploads 直传初始化;
|
||||||
|
// CRUD Task 7——ad.* 事件源,仅 cn 入口装配)。
|
||||||
|
AdsModule,
|
||||||
// 内部同步域(P2 Task 3:/internal/sync/bootstrap|reconcile,HMAC 鉴权,
|
// 内部同步域(P2 Task 3:/internal/sync/bootstrap|reconcile,HMAC 鉴权,
|
||||||
// 仅 cn 入口装配——cn 是内容同步的源端)。
|
// 仅 cn 入口装配——cn 是内容同步的源端)。
|
||||||
SyncModule,
|
SyncModule,
|
||||||
|
|||||||
@@ -16,14 +16,31 @@
|
|||||||
// ⑤ ad.deleted 墓碑 v4(high 优先级)→ 删行,账本收敛 v4。
|
// ⑤ ad.deleted 墓碑 v4(high 优先级)→ 删行,账本收敛 v4。
|
||||||
// ④ 在 ⑤ 之前执行:stale 重放的断言基线是「行处于 v3 态」,墓碑(v4)之后
|
// ④ 在 ⑤ 之前执行:stale 重放的断言基线是「行处于 v3 态」,墓碑(v4)之后
|
||||||
// 无法回到该基线(此后 v3 亦为 stale)。
|
// 无法回到该基线(此后 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 { NestFactory } from '@nestjs/core';
|
||||||
import { type NestExpressApplication } from '@nestjs/platform-express';
|
import { type NestExpressApplication } from '@nestjs/platform-express';
|
||||||
|
import request from 'supertest';
|
||||||
import { PrismaClient } from '@photo/db-cn';
|
import { PrismaClient } from '@photo/db-cn';
|
||||||
import type { Prisma } from '@photo/db-cn';
|
import type { Prisma } from '@photo/db-cn';
|
||||||
import { PrismaClient as OvPrismaClient } from '@photo/db-ov';
|
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 { AppCnModule } from '../src/app-cn.module';
|
||||||
import { AppOvModule } from '../src/app-ov.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 { configureApp } from '../src/shared/configure-app';
|
||||||
import { ContractWsAdapter } from '../src/realtime/contract-ws.adapter';
|
import { ContractWsAdapter } from '../src/realtime/contract-ws.adapter';
|
||||||
import { resetDb } from './reset';
|
import { resetDb } from './reset';
|
||||||
@@ -33,6 +50,8 @@ import { OutboxSender } from '../../sync/src/sender';
|
|||||||
jest.setTimeout(120_000);
|
jest.setTimeout(120_000);
|
||||||
|
|
||||||
const SYNC_KEY = process.env.SYNC_INTERNAL_KEY ?? 'dev-sync-secret-change-me';
|
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) 内)。 */
|
/** 本 spec 全程操作的单个广告位 ID(链条用例共享;VarChar(32) 内)。 */
|
||||||
const AD_ID = 'ads-e2e-0001';
|
const AD_ID = 'ads-e2e-0001';
|
||||||
@@ -54,10 +73,14 @@ interface AdsOvRow {
|
|||||||
describe('ads sync projection (e2e)', () => {
|
describe('ads sync projection (e2e)', () => {
|
||||||
let cnApp: NestExpressApplication;
|
let cnApp: NestExpressApplication;
|
||||||
let ovApp: NestExpressApplication;
|
let ovApp: NestExpressApplication;
|
||||||
|
let cnBase: string;
|
||||||
let ovBase: string;
|
let ovBase: string;
|
||||||
let prismaCn: PrismaClient;
|
let prismaCn: PrismaClient;
|
||||||
let prismaOv: OvPrismaClient;
|
let prismaOv: OvPrismaClient;
|
||||||
let sender: OutboxSender;
|
let sender: OutboxSender;
|
||||||
|
let storage: StoragePort;
|
||||||
|
let adminToken: string;
|
||||||
|
let staffToken: string;
|
||||||
|
|
||||||
// outbox 行 occurredAt 基准 + 单调序号:保证发送器取批顺序(occurredAt
|
// outbox 行 occurredAt 基准 + 单调序号:保证发送器取批顺序(occurredAt
|
||||||
// 升序)与用例推进顺序一致——④ 的迟到 v2 必须晚于 ③ 的 v3 被推送。
|
// 升序)与用例推进顺序一致——④ 的迟到 v2 必须晚于 ③ 的 v3 被推送。
|
||||||
@@ -149,12 +172,14 @@ describe('ads sync projection (e2e)', () => {
|
|||||||
await resetDb();
|
await resetDb();
|
||||||
await resetOvDb();
|
await resetOvDb();
|
||||||
|
|
||||||
// cn 入口仅作骨架就位(本 spec 不走 cn HTTP;outbox 行直插 cn 库)。
|
// cn 入口仅作骨架就位(①-⑤ 不走 cn HTTP;outbox 行直插 cn 库)——Task 6
|
||||||
|
// 追加段起复用同一实例走 /v1/admin/ads/uploads。
|
||||||
cnApp = await NestFactory.create<NestExpressApplication>(AppCnModule, {
|
cnApp = await NestFactory.create<NestExpressApplication>(AppCnModule, {
|
||||||
rawBody: true,
|
rawBody: true,
|
||||||
});
|
});
|
||||||
configureApp(cnApp);
|
configureApp(cnApp);
|
||||||
await cnApp.listen(0);
|
await cnApp.listen(0);
|
||||||
|
cnBase = await cnApp.getUrl();
|
||||||
|
|
||||||
ovApp = await NestFactory.create<NestExpressApplication>(AppOvModule, {
|
ovApp = await NestFactory.create<NestExpressApplication>(AppOvModule, {
|
||||||
rawBody: true,
|
rawBody: true,
|
||||||
@@ -171,6 +196,35 @@ describe('ads sync projection (e2e)', () => {
|
|||||||
ovApiUrl: ovBase,
|
ovApiUrl: ovBase,
|
||||||
internalKey: SYNC_KEY,
|
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 () => {
|
afterAll(async () => {
|
||||||
@@ -268,4 +322,99 @@ describe('ads sync projection (e2e)', () => {
|
|||||||
).toBeNull();
|
).toBeNull();
|
||||||
await expectAppliedVersion(4);
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user