diff --git a/docs/api-contract-v1.md b/docs/api-contract-v1.md
index 0da33ca..fbd6ff5 100644
--- a/docs/api-contract-v1.md
+++ b/docs/api-contract-v1.md
@@ -179,6 +179,11 @@
> 贴纸图 `src` 必须为 COS 持久 URL(禁止 `wxfile://`/`tmp`)、保留 `wordcloud` 分组、
> 保留 `category.mask`;后端该 JSON 白名单须放行 `version/background/wordcloud/rotation/zIndex`。
>`items` 为服务端 JSON,需做结构白名单与大小校验(单条 ≤ 1MB)。
+>
+> **实现补充(R2,非契约变更)**:服务端 JSON body 传输上限为 **2MB**(`main.ts`,Nest 默认 100KB
+> 会使 1MB 业务限制不可达)。三层边界:≤1MB 正常受理;1MB~2MB 由 design-list service 返回
+> 400「单条设计数据超过 1MB 上限」;>2MB 返回 413「请求体过大」。文件上传(R4 multipart)
+> 不走此限制,沿用各模块独立校验(如底图 ≤10MB)。
### PATCH /api/design-list/:id
diff --git a/src/addresses/addresses.controller.ts b/src/addresses/addresses.controller.ts
index 0210422..a076cc4 100644
--- a/src/addresses/addresses.controller.ts
+++ b/src/addresses/addresses.controller.ts
@@ -1,8 +1,17 @@
-import { Body, Controller, Get, Param, Patch, Post } from '@nestjs/common';
+import {
+ Body,
+ Controller,
+ Delete,
+ Get,
+ Param,
+ Patch,
+ Post,
+} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CurrentUser, JwtPayload } from '../common/decorators/current-user.decorator';
import { AddressesService } from './addresses.service';
import { CreateAddressDto } from './dto/create-address.dto';
+import { UpdateAddressDto } from './dto/update-address.dto';
@ApiTags('收货地址')
@ApiBearerAuth()
@@ -11,13 +20,13 @@ export class AddressesController {
constructor(private readonly addressesService: AddressesService) {}
@Get()
- @ApiOperation({ summary: '我的收货地址列表' })
+ @ApiOperation({ summary: '我的收货地址列表(默认地址在前)' })
list(@CurrentUser() user: JwtPayload) {
return this.addressesService.listByUser(user.sub);
}
@Post()
- @ApiOperation({ summary: '新增收货地址' })
+ @ApiOperation({ summary: '新增收货地址(首个地址自动设为默认)' })
create(@CurrentUser() user: JwtPayload, @Body() dto: CreateAddressDto) {
return this.addressesService.create(user.sub, dto);
}
@@ -27,4 +36,20 @@ export class AddressesController {
setDefault(@CurrentUser() user: JwtPayload, @Param('id') id: string) {
return this.addressesService.setDefault(user.sub, id);
}
+
+ @Patch(':id')
+ @ApiOperation({ summary: '更新本人地址(isDefault 走默认地址事务)' })
+ update(
+ @CurrentUser() user: JwtPayload,
+ @Param('id') id: string,
+ @Body() dto: UpdateAddressDto,
+ ) {
+ return this.addressesService.update(user.sub, id, dto);
+ }
+
+ @Delete(':id')
+ @ApiOperation({ summary: '删除本人地址(删默认地址时自动补偿新默认)' })
+ remove(@CurrentUser() user: JwtPayload, @Param('id') id: string) {
+ return this.addressesService.remove(user.sub, id);
+ }
}
diff --git a/src/addresses/addresses.service.ts b/src/addresses/addresses.service.ts
index 6220c07..78e57cf 100644
--- a/src/addresses/addresses.service.ts
+++ b/src/addresses/addresses.service.ts
@@ -1,6 +1,8 @@
-import { Injectable } from '@nestjs/common';
+import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
+import { Address, Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { CreateAddressDto } from './dto/create-address.dto';
+import { UpdateAddressDto } from './dto/update-address.dto';
@Injectable()
export class AddressesService {
@@ -14,12 +16,76 @@ export class AddressesService {
}
async create(userId: string, dto: CreateAddressDto) {
- // TODO: 若 isDefault,需先把该用户其它地址置为非默认(事务)
- return this.prisma.address.create({ data: { ...dto, userId } });
+ return this.prisma.$transaction(async (tx) => {
+ // 首个地址强制为默认(列表非空时默认地址始终存在)
+ const count = await tx.address.count({ where: { userId } });
+ const isDefault = count === 0 ? true : (dto.isDefault ?? false);
+ if (isDefault) {
+ // 事务内先清旧默认再写入,保证默认地址唯一
+ await tx.address.updateMany({
+ where: { userId, isDefault: true },
+ data: { isDefault: false },
+ });
+ }
+ return tx.address.create({
+ data: { ...dto, isDefault, userId },
+ });
+ });
+ }
+
+ async update(userId: string, id: string, dto: UpdateAddressDto) {
+ const existing = await this.getOwnedAddress(userId, id);
+ return this.prisma.$transaction(async (tx) => {
+ if (dto.isDefault === true) {
+ await tx.address.updateMany({
+ where: { userId, isDefault: true },
+ data: { isDefault: false },
+ });
+ }
+ return tx.address.update({ where: { id: existing.id }, data: dto });
+ });
}
async setDefault(userId: string, id: string) {
- // TODO: 事务内先清旧默认再设新默认
- return this.prisma.address.update({ where: { id }, data: { isDefault: true } });
+ const existing = await this.getOwnedAddress(userId, id);
+ return this.prisma.$transaction(async (tx) => {
+ await tx.address.updateMany({
+ where: { userId, isDefault: true },
+ data: { isDefault: false },
+ });
+ return tx.address.update({
+ where: { id: existing.id },
+ data: { isDefault: true },
+ });
+ });
+ }
+
+ async remove(userId: string, id: string) {
+ const existing = await this.getOwnedAddress(userId, id);
+ await this.prisma.$transaction(async (tx) => {
+ await tx.address.delete({ where: { id: existing.id } });
+ // 删除的是默认地址:补偿把最新一条设为默认,避免列表无默认
+ if (existing.isDefault) {
+ const next = await tx.address.findFirst({
+ where: { userId },
+ orderBy: { createdAt: 'desc' },
+ });
+ if (next) {
+ await tx.address.update({
+ where: { id: next.id },
+ data: { isDefault: true },
+ });
+ }
+ }
+ });
+ return null;
+ }
+
+ /** 归属校验:不存在 404,存在但非本人 403(api-contract-v1 §4) */
+ private async getOwnedAddress(userId: string, id: string): Promise
{
+ const address = await this.prisma.address.findUnique({ where: { id } });
+ if (!address) throw new NotFoundException('地址不存在');
+ if (address.userId !== userId) throw new ForbiddenException('无权操作该地址');
+ return address;
}
}
diff --git a/src/addresses/dto/update-address.dto.ts b/src/addresses/dto/update-address.dto.ts
new file mode 100644
index 0000000..f5f068d
--- /dev/null
+++ b/src/addresses/dto/update-address.dto.ts
@@ -0,0 +1,46 @@
+import { ApiPropertyOptional } from '@nestjs/swagger';
+import { IsBoolean, IsNotEmpty, IsOptional, IsString } from 'class-validator';
+
+/** PATCH /api/addresses/:id:全字段可选,至少传一个字段才有意义 */
+export class UpdateAddressDto {
+ @ApiPropertyOptional()
+ @IsOptional()
+ @IsString()
+ @IsNotEmpty()
+ name?: string;
+
+ @ApiPropertyOptional()
+ @IsOptional()
+ @IsString()
+ @IsNotEmpty()
+ phone?: string;
+
+ @ApiPropertyOptional()
+ @IsOptional()
+ @IsString()
+ @IsNotEmpty()
+ province?: string;
+
+ @ApiPropertyOptional()
+ @IsOptional()
+ @IsString()
+ @IsNotEmpty()
+ city?: string;
+
+ @ApiPropertyOptional()
+ @IsOptional()
+ @IsString()
+ @IsNotEmpty()
+ district?: string;
+
+ @ApiPropertyOptional()
+ @IsOptional()
+ @IsString()
+ @IsNotEmpty()
+ detail?: string;
+
+ @ApiPropertyOptional()
+ @IsOptional()
+ @IsBoolean()
+ isDefault?: boolean;
+}
diff --git a/src/common/filters/all-exceptions.filter.ts b/src/common/filters/all-exceptions.filter.ts
index 0dfa1bf..517416e 100644
--- a/src/common/filters/all-exceptions.filter.ts
+++ b/src/common/filters/all-exceptions.filter.ts
@@ -34,6 +34,14 @@ export class AllExceptionsFilter implements ExceptionFilter {
? res
: ((res as Record).message as string | string[] | undefined)?.toString() ??
exception.message;
+ } else if (
+ exception instanceof Error &&
+ (exception as Error & { type?: string }).type === 'entity.too.large'
+ ) {
+ // body-parser 请求体超限(main.ts 限制 2MB)
+ status = HttpStatus.PAYLOAD_TOO_LARGE;
+ code = 413;
+ message = '请求体过大';
} else if (exception instanceof Prisma.PrismaClientKnownRequestError) {
// 常见 Prisma 错误码映射
switch (exception.code) {
diff --git a/src/design-list/design-list.controller.ts b/src/design-list/design-list.controller.ts
index b1a5c39..01ea520 100644
--- a/src/design-list/design-list.controller.ts
+++ b/src/design-list/design-list.controller.ts
@@ -1,30 +1,55 @@
-import { Body, Controller, Get, Param, Post } from '@nestjs/common';
+import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Param, Patch, Post } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CurrentUser, JwtPayload } from '../common/decorators/current-user.decorator';
import { DesignListService } from './design-list.service';
+import { BatchDeleteDesignListDto } from './dto/batch-delete-design-list.dto';
import { CreateDesignListDto } from './dto/create-design-list.dto';
+import { UpdateDesignListDto } from './dto/update-design-list.dto';
@ApiTags('设计清单')
@ApiBearerAuth()
-@Controller('design-lists')
+@Controller('design-list')
export class DesignListController {
constructor(private readonly designListService: DesignListService) {}
@Get()
- @ApiOperation({ summary: '我的设计清单列表' })
+ @ApiOperation({ summary: '我的设计清单(createdAt 倒序,不分页)' })
list(@CurrentUser() user: JwtPayload) {
return this.designListService.listByUser(user.sub);
}
@Get(':id')
- @ApiOperation({ summary: '设计清单详情' })
- findOne(@Param('id') id: string) {
- return this.designListService.findOne(id);
+ @ApiOperation({ summary: '查询单条本人清单' })
+ findOne(@CurrentUser() user: JwtPayload, @Param('id') id: string) {
+ return this.designListService.findOne(user.sub, id);
}
@Post()
- @ApiOperation({ summary: '创建设计清单' })
+ @ApiOperation({ summary: '创建清单(一条设计一条清单,items 固定 1 个元素)' })
create(@CurrentUser() user: JwtPayload, @Body() dto: CreateDesignListDto) {
return this.designListService.create(user.sub, dto);
}
+
+ @Patch(':id')
+ @ApiOperation({ summary: '更新本人清单(title/items/状态迁移,单向状态机)' })
+ update(
+ @CurrentUser() user: JwtPayload,
+ @Param('id') id: string,
+ @Body() dto: UpdateDesignListDto,
+ ) {
+ return this.designListService.update(user.sub, id, dto);
+ }
+
+ @Delete(':id')
+ @ApiOperation({ summary: '删除本人清单' })
+ remove(@CurrentUser() user: JwtPayload, @Param('id') id: string) {
+ return this.designListService.remove(user.sub, id);
+ }
+
+ @Post('batch-delete')
+ @HttpCode(HttpStatus.OK)
+ @ApiOperation({ summary: '批量删除本人清单,返回实际删除数' })
+ batchDelete(@CurrentUser() user: JwtPayload, @Body() dto: BatchDeleteDesignListDto) {
+ return this.designListService.batchDelete(user.sub, dto);
+ }
}
diff --git a/src/design-list/design-list.service.ts b/src/design-list/design-list.service.ts
index 3574b72..b214de5 100644
--- a/src/design-list/design-list.service.ts
+++ b/src/design-list/design-list.service.ts
@@ -1,7 +1,32 @@
-import { Injectable } from '@nestjs/common';
-import { Prisma } from '@prisma/client';
+import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
+import { DesignList, DesignListStatus, Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
+import { BatchDeleteDesignListDto } from './dto/batch-delete-design-list.dto';
import { CreateDesignListDto } from './dto/create-design-list.dto';
+import { UpdateDesignListDto } from './dto/update-design-list.dto';
+
+/** designData 结构白名单(wechat_wc/docs/design-data-contract-v1.md §2 冻结字段) */
+const DESIGN_DATA_ALLOWED_KEYS = new Set([
+ 'version',
+ 'category',
+ 'background',
+ 'wordcloud',
+ 'stickers',
+ // 兼容旧版字段(契约明确保留)
+ 'imageSrc',
+ 'imagePos',
+]);
+
+/** 单条设计数据上限(api-contract-v1 §5:1MB) */
+const DESIGN_DATA_MAX_BYTES = 1024 * 1024;
+
+/** 状态机:单向推进(阶段0 决策#6),前端只允许提交 DRAFT→SUBMITTED */
+const STATUS_TRANSITIONS: Record = {
+ DRAFT: [DesignListStatus.SUBMITTED],
+ SUBMITTED: [DesignListStatus.PROCESSING],
+ PROCESSING: [DesignListStatus.DONE],
+ DONE: [],
+};
@Injectable()
export class DesignListService {
@@ -14,19 +39,89 @@ export class DesignListService {
});
}
- async findOne(id: string) {
- // TODO: 权限校验(仅本人可查)
- return this.prisma.designList.findUnique({ where: { id } });
+ async findOne(userId: string, id: string) {
+ const list = await this.getOwnedList(userId, id);
+ return list;
}
async create(userId: string, dto: CreateDesignListDto) {
- // TODO: 校验 items 结构、关联商品 SKU
+ const items = dto.items.map((it) => this.validateDesignData(it));
return this.prisma.designList.create({
data: {
title: dto.title,
- items: (dto.items ?? []) as Prisma.InputJsonValue,
+ items: this.toJson(items),
userId,
},
});
}
+
+ async update(userId: string, id: string, dto: UpdateDesignListDto) {
+ const existing = await this.getOwnedList(userId, id);
+
+ // 部分更新语义:只覆盖显式传入的字段,不传 items 不清 designData(R4 wordcloud 分组保护)
+ const data: Prisma.DesignListUpdateInput = {};
+ if (dto.title !== undefined) data.title = dto.title;
+ if (dto.items !== undefined) {
+ data.items = this.toJson(dto.items.map((it) => this.validateDesignData(it)));
+ }
+ if (dto.status !== undefined && dto.status !== existing.status) {
+ this.assertTransition(existing.status, dto.status);
+ data.status = dto.status;
+ }
+
+ return this.prisma.designList.update({ where: { id: existing.id }, data });
+ }
+
+ async remove(userId: string, id: string) {
+ const existing = await this.getOwnedList(userId, id);
+ await this.prisma.designList.delete({ where: { id: existing.id } });
+ return null;
+ }
+
+ /** 批量删除:一次事务只删本人清单,返回实际删除数(部分 id 无效不报错) */
+ async batchDelete(userId: string, dto: BatchDeleteDesignListDto) {
+ const result = await this.prisma.designList.deleteMany({
+ where: { id: { in: dto.ids }, userId },
+ });
+ return { deleted: result.count };
+ }
+
+ /** DTO 实例 → 纯 JSON(剥离 class 元数据与 undefined 字段,满足 Prisma InputJsonValue) */
+ private toJson(items: unknown): Prisma.InputJsonValue {
+ return JSON.parse(JSON.stringify(items)) as Prisma.InputJsonValue;
+ }
+
+ /** 归属校验:不存在 404,存在但非本人 403(与 addresses 模块一致) */
+ private async getOwnedList(userId: string, id: string): Promise {
+ const list = await this.prisma.designList.findUnique({ where: { id } });
+ if (!list) throw new NotFoundException('设计清单不存在');
+ if (list.userId !== userId) throw new ForbiddenException('无权操作该设计清单');
+ return list;
+ }
+
+ /** 白名单 + 大小校验;只校验不修改业务 JSON(契约:服务端不改内容) */
+ private validateDesignData }>(item: T): T {
+ if (item.designData === undefined) return item;
+
+ const unknownKeys = Object.keys(item.designData).filter(
+ (k) => !DESIGN_DATA_ALLOWED_KEYS.has(k),
+ );
+ if (unknownKeys.length > 0) {
+ throw new BadRequestException(
+ `designData 包含不支持的字段: ${unknownKeys.join(', ')}`,
+ );
+ }
+
+ const size = Buffer.byteLength(JSON.stringify(item.designData), 'utf8');
+ if (size > DESIGN_DATA_MAX_BYTES) {
+ throw new BadRequestException('单条设计数据超过 1MB 上限');
+ }
+ return item;
+ }
+
+ private assertTransition(from: DesignListStatus, to: DesignListStatus) {
+ if (!STATUS_TRANSITIONS[from].includes(to)) {
+ throw new BadRequestException(`状态不允许从 ${from} 迁移到 ${to}`);
+ }
+ }
}
diff --git a/src/design-list/dto/batch-delete-design-list.dto.ts b/src/design-list/dto/batch-delete-design-list.dto.ts
new file mode 100644
index 0000000..d78ee41
--- /dev/null
+++ b/src/design-list/dto/batch-delete-design-list.dto.ts
@@ -0,0 +1,13 @@
+import { ApiProperty } from '@nestjs/swagger';
+import { ArrayMaxSize, ArrayNotEmpty, IsArray, IsString, IsNotEmpty } from 'class-validator';
+
+/** POST /api/design-list/batch-delete 请求体(api-contract-v1 §5) */
+export class BatchDeleteDesignListDto {
+ @ApiProperty({ type: [String] })
+ @IsArray()
+ @ArrayNotEmpty()
+ @ArrayMaxSize(50)
+ @IsString({ each: true })
+ @IsNotEmpty({ each: true })
+ ids!: string[];
+}
diff --git a/src/design-list/dto/create-design-list.dto.ts b/src/design-list/dto/create-design-list.dto.ts
index 5bf80f8..10dde61 100644
--- a/src/design-list/dto/create-design-list.dto.ts
+++ b/src/design-list/dto/create-design-list.dto.ts
@@ -1,14 +1,64 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
-import { IsArray, IsObject, IsOptional, IsString } from 'class-validator';
+import { Type } from 'class-transformer';
+import {
+ ArrayMaxSize,
+ ArrayMinSize,
+ IsArray,
+ IsNotEmpty,
+ IsNumber,
+ IsObject,
+ IsOptional,
+ IsString,
+ Max,
+ Min,
+ ValidateNested,
+} from 'class-validator';
+
+/**
+ * 清单条目(api-contract-v1 §5)。
+ * 阶段0 决策#1:一条前端 DesignItem = 一条后端 DesignList,items 固定 1 个元素;
+ * 决策#3:productIcon 不入库,由客户端按 productId 推导。
+ */
+export class DesignListEntryDto {
+ @ApiProperty()
+ @IsString()
+ @IsNotEmpty()
+ productId!: string;
+
+ @ApiProperty()
+ @IsString()
+ @IsNotEmpty()
+ productName!: string;
+
+ @ApiProperty()
+ @IsNumber()
+ @Min(0)
+ unitPrice!: number;
+
+ @ApiProperty()
+ @IsNumber()
+ @Min(1)
+ @Max(999)
+ count!: number;
+
+ /** 结构白名单与大小校验在 service 层做(设计数据契约 v1) */
+ @ApiPropertyOptional()
+ @IsOptional()
+ @IsObject()
+ designData?: Record;
+}
export class CreateDesignListDto {
- @ApiProperty({ description: '清单标题' })
+ @ApiProperty({ description: '清单标题;前端传 productName(阶段0 决策#4)' })
@IsString()
+ @IsNotEmpty()
title!: string;
- @ApiPropertyOptional({ description: '定制项(结构化 JSON)', example: [{ sku: 'tshirt', color: 'black' }] })
- @IsOptional()
+ @ApiProperty({ type: [DesignListEntryDto] })
@IsArray()
- @IsObject({ each: true })
- items?: Record[];
+ @ArrayMinSize(1)
+ @ArrayMaxSize(1)
+ @ValidateNested({ each: true })
+ @Type(() => DesignListEntryDto)
+ items!: DesignListEntryDto[];
}
diff --git a/src/design-list/dto/update-design-list.dto.ts b/src/design-list/dto/update-design-list.dto.ts
new file mode 100644
index 0000000..75b6b95
--- /dev/null
+++ b/src/design-list/dto/update-design-list.dto.ts
@@ -0,0 +1,39 @@
+import { ApiPropertyOptional } from '@nestjs/swagger';
+import { Type } from 'class-transformer';
+import {
+ ArrayMaxSize,
+ ArrayMinSize,
+ IsArray,
+ IsEnum,
+ IsNotEmpty,
+ IsOptional,
+ IsString,
+ MaxLength,
+ ValidateNested,
+} from 'class-validator';
+import { DesignListStatus } from '@prisma/client';
+import { DesignListEntryDto } from './create-design-list.dto';
+
+export class UpdateDesignListDto {
+ @ApiPropertyOptional()
+ @IsOptional()
+ @IsString()
+ @IsNotEmpty()
+ title?: string;
+
+ @ApiPropertyOptional({ type: [DesignListEntryDto] })
+ @IsOptional()
+ @IsArray()
+ @ArrayMinSize(1)
+ @ArrayMaxSize(1)
+ @ValidateNested({ each: true })
+ @Type(() => DesignListEntryDto)
+ items?: DesignListEntryDto[];
+
+ /** 状态迁移由 service 层做单向状态机校验(DRAFT→SUBMITTED→PROCESSING→DONE) */
+ @ApiPropertyOptional({ enum: DesignListStatus })
+ @IsOptional()
+ @IsEnum(DesignListStatus)
+ @MaxLength(20)
+ status?: DesignListStatus;
+}
diff --git a/src/main.ts b/src/main.ts
index df1d9d9..f3a393f 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -2,12 +2,17 @@ import { ValidationPipe } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { NestFactory } from '@nestjs/core';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
+import * as express from 'express';
import { AppModule } from './app.module';
import { AllExceptionsFilter } from './common/filters/all-exceptions.filter';
import { TransformInterceptor } from './common/interceptors/transform.interceptor';
async function bootstrap(): Promise {
- const app = await NestFactory.create(AppModule);
+ // 设计清单 designData 契约上限 1MB(api-contract-v1 §5),
+ // body 限制放宽到 2MB,超 1MB 的业务校验在 design-list service 返回 400
+ const app = await NestFactory.create(AppModule, { bodyParser: false });
+ app.use(express.json({ limit: '2mb' }));
+ app.use(express.urlencoded({ extended: true, limit: '2mb' }));
const config = app.get(ConfigService);
app.setGlobalPrefix('api', { exclude: ['health'] });