Files
wxmp_backend/src/design-list/design-list.service.ts
T
lhmin0604andClaude 0a3c8e834d 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>
2026-09-12 02:37:53 +08:00

128 lines
4.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 {
constructor(private readonly prisma: PrismaService) {}
async listByUser(userId: string) {
return this.prisma.designList.findMany({
where: { userId },
orderBy: { createdAt: 'desc' },
});
}
async findOne(userId: string, id: string) {
const list = await this.getOwnedList(userId, id);
return list;
}
async create(userId: string, dto: CreateDesignListDto) {
const items = dto.items.map((it) => this.validateDesignData(it));
return this.prisma.designList.create({
data: {
title: dto.title,
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}`);
}
}
}