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