feat(api): /admin/ads/uploads 广告图 staging 预签名(2MB/白名单/admin-only)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-28 23:34:11 +08:00
parent 4db90b0e9e
commit 121afadf1e
6 changed files with 399 additions and 2 deletions
+26
View File
@@ -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);
}
}
+16
View File
@@ -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 {}
+76
View File
@@ -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 };
}
}
+126
View File
@@ -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;
}
+4
View File
@@ -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,