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
+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}`);
}
}
}