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>
128 lines
4.6 KiB
TypeScript
128 lines
4.6 KiB
TypeScript
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<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 不清 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<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}`);
|
||
}
|
||
}
|
||
}
|