feat(r2): 地址与设计清单完整 CRUD(事务/归属校验/状态机)

addresses:
- 补 PATCH /:id、DELETE /:id(契约 §4 全路由齐备)
- 默认地址唯一性事务:create/update/setDefault 先清旧默认再写入
- 首个地址自动设为默认;删除默认地址事务内补偿最新一条
- 归属校验统一:不存在 404,非本人 403

design-list:
- 补 PATCH /:id、DELETE /:id、POST /batch-delete(宽松语义返回 deleted 数)
- items 强制恰好 1 个元素(一条设计=一条清单,阶段0 决策#1)
- designData 白名单 7 键放行、单条 ≤1MB 校验(400)
- 单向状态机 DRAFT→SUBMITTED→PROCESSING→DONE,回退/跳级 400
- PATCH 部分更新语义,不传 items 不清 designData(R4 wordcloud 保护)

infra:
- main.ts: JSON body 上限 100KB→2MB,使契约 1MB 设计数据可达(>2MB 返回 413)
- exceptions filter: body-parser entity.too.large 映射 413
- api-contract-v1.md §5 补实现说明(非契约变更)

验证:nest build 通过;本地 3091 实例 + mock 登录实测 35 项全过
(CRUD/默认地址补偿/越权 403/404/状态机/1MB 边界/413/未登录 401)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-09-12 02:37:53 +08:00
co-authored by Claude
parent 01a1891988
commit 0a3c8e834d
11 changed files with 406 additions and 29 deletions
+5
View File
@@ -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
+28 -3
View File
@@ -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);
}
}
+71 -5
View File
@@ -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,存在但非本人 403api-contract-v1 §4 */
private async getOwnedAddress(userId: string, id: string): Promise<Address> {
const address = await this.prisma.address.findUnique({ where: { id } });
if (!address) throw new NotFoundException('地址不存在');
if (address.userId !== userId) throw new ForbiddenException('无权操作该地址');
return address;
}
}
+46
View File
@@ -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;
}
@@ -34,6 +34,14 @@ export class AllExceptionsFilter implements ExceptionFilter {
? res
: ((res as Record<string, unknown>).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) {
+32 -7
View File
@@ -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);
}
}
+102 -7
View File
@@ -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 §51MB */
const DESIGN_DATA_MAX_BYTES = 1024 * 1024;
/** 状态机:单向推进(阶段0 决策#6),前端只允许提交 DRAFT→SUBMITTED */
const STATUS_TRANSITIONS: Record<DesignListStatus, DesignListStatus[]> = {
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 不清 designDataR4 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<DesignList> {
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<T extends { designData?: Record<string, unknown> }>(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}`);
}
}
}
@@ -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[];
}
+56 -6
View File
@@ -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 = 一条后端 DesignListitems 固定 1 个元素;
* 决策#3productIcon 不入库,由客户端按 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<string, unknown>;
}
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<string, unknown>[];
@ArrayMinSize(1)
@ArrayMaxSize(1)
@ValidateNested({ each: true })
@Type(() => DesignListEntryDto)
items!: DesignListEntryDto[];
}
@@ -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;
}
+6 -1
View File
@@ -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<void> {
const app = await NestFactory.create(AppModule);
// 设计清单 designData 契约上限 1MBapi-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'] });