merge: integrate catalog into order payment flow

This commit is contained in:
2026-09-13 18:18:14 +08:00
15 changed files with 626 additions and 150 deletions
+1 -2
View File
@@ -6,9 +6,8 @@ import { CreateCategoryDto } from './dto/create-category.dto';
export class CategoriesService {
constructor(private readonly prisma: PrismaService) {}
// TODO: 树形结构组装、缓存
async findAll() {
return this.prisma.category.findMany({ orderBy: { sort: 'asc' } });
return this.prisma.category.findMany({ orderBy: [{ sort: 'asc' }, { createdAt: 'asc' }] });
}
async create(dto: CreateCategoryDto) {
+42 -41
View File
@@ -1,6 +1,6 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsArray, IsEnum, IsNumber, IsObject, IsOptional, IsString, Min } from 'class-validator';
import { IsArray, IsEnum, IsInt, IsNumber, IsObject, IsOptional, IsString, Min } from 'class-validator';
import { ProductStatus } from '@prisma/client';
export class CreateProductDto {
@@ -26,21 +26,39 @@ export class CreateProductDto {
@Min(0)
originalPrice?: number;
@ApiPropertyOptional({ default: '7-10 个工作日' })
@IsOptional()
@ApiProperty({ description: '生产工期,例如 3-5个工作日' })
@IsString()
leadTime?: string;
leadTime!: string;
@ApiPropertyOptional()
@ApiPropertyOptional({ description: '一句话卖点' })
@IsOptional()
@IsString()
subtitle?: string;
@ApiPropertyOptional({ type: [Number] })
@IsOptional()
@ApiProperty({ description: '图片 URL 列表', type: [String] })
@IsArray()
@IsNumber({}, { each: true })
tone?: number[];
@IsString({ each: true })
images!: string[];
@ApiPropertyOptional({ description: '商品图标 URL' })
@IsOptional()
@IsString()
iconImg?: string;
@ApiPropertyOptional({ description: '描述' })
@IsOptional()
@IsString()
description?: string;
@ApiPropertyOptional({ description: '商品故事' })
@IsOptional()
@IsString()
story?: string;
@ApiPropertyOptional({ description: '使用场景' })
@IsOptional()
@IsString()
scene?: string;
@ApiPropertyOptional({ type: [String] })
@IsOptional()
@@ -48,41 +66,24 @@ export class CreateProductDto {
@IsString({ each: true })
tags?: string[];
@ApiPropertyOptional({ type: Object })
@IsOptional()
@IsObject()
specs?: Record<string, unknown>;
@ApiPropertyOptional({ type: Object })
@IsOptional()
@IsObject()
mask?: Record<string, unknown>;
@ApiPropertyOptional()
@IsOptional()
@IsString()
story?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
scene?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
iconImg?: string;
@ApiPropertyOptional({ description: '图片 URL 列表', type: [String] })
@IsOptional()
@ApiProperty({ description: '规格键值对数组' })
@IsArray()
@IsString({ each: true })
images?: string[];
specs!: [string, string][];
@ApiPropertyOptional({ description: '描述' })
@ApiProperty({ description: '主题色 RGB' })
@IsArray()
@IsInt({ each: true })
tone!: number[];
@ApiProperty({ description: '设计画布遮罩' })
@IsObject()
mask!: Record<string, unknown>;
@ApiPropertyOptional({ default: 0 })
@IsOptional()
@IsString()
description?: string;
@Type(() => Number)
@IsInt()
sort?: number;
@ApiPropertyOptional({ enum: ProductStatus, default: ProductStatus.DRAFT })
@IsOptional()
@@ -0,0 +1,30 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
export class ListProductsQueryDto {
@ApiPropertyOptional({ default: 1, minimum: 1 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page = 1;
@ApiPropertyOptional({ default: 20, minimum: 1, maximum: 100 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
pageSize = 20;
@ApiPropertyOptional({ description: '分类 ID' })
@IsOptional()
@IsString()
categoryId?: string;
@ApiPropertyOptional({ description: '商品名称或描述关键词' })
@IsOptional()
@IsString()
keyword?: string;
}
+2 -2
View File
@@ -3,7 +3,7 @@ import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { Public } from '../common/decorators/public.decorator';
import { ProductsService } from './products.service';
import { CreateProductDto } from './dto/create-product.dto';
import { ProductQueryDto } from './dto/product-query.dto';
import { ListProductsQueryDto } from './dto/list-products-query.dto';
@ApiTags('商品')
@ApiBearerAuth()
@@ -14,7 +14,7 @@ export class ProductsController {
@Public()
@Get()
@ApiOperation({ summary: '在售商品列表' })
list(@Query() query: ProductQueryDto) {
list(@Query() query: ListProductsQueryDto) {
return this.productsService.list(query);
}
+35 -45
View File
@@ -1,80 +1,70 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { Product, ProductStatus } from '@prisma/client';
import { Prisma, Product, ProductStatus } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { CreateProductDto } from './dto/create-product.dto';
import { ProductQueryDto } from './dto/product-query.dto';
type ProductResponse = Omit<Product, 'price' | 'originalPrice'> & {
price: number;
originalPrice?: number;
};
function serialize(product: Product): ProductResponse {
return {
...product,
price: Number(product.price),
originalPrice: product.originalPrice == null ? undefined : Number(product.originalPrice),
};
}
import { ListProductsQueryDto } from './dto/list-products-query.dto';
@Injectable()
export class ProductsService {
constructor(private readonly prisma: PrismaService) {}
async list(query: ProductQueryDto) {
const where = {
async list(query: ListProductsQueryDto) {
const { page = 1, pageSize = 20, categoryId, keyword } = query;
const where: Prisma.ProductWhereInput = {
status: ProductStatus.ON_SALE,
...(query.categoryId ? { categoryId: query.categoryId } : {}),
...(query.keyword
...(categoryId ? { categoryId } : {}),
...(keyword?.trim()
? {
OR: [
{ name: { contains: query.keyword, mode: 'insensitive' as const } },
{ description: { contains: query.keyword, mode: 'insensitive' as const } },
{ name: { contains: keyword.trim(), mode: 'insensitive' } },
{ subtitle: { contains: keyword.trim(), mode: 'insensitive' } },
{ description: { contains: keyword.trim(), mode: 'insensitive' } },
],
}
: {}),
};
const [rows, total] = await this.prisma.$transaction([
const [list, total] = await this.prisma.$transaction([
this.prisma.product.findMany({
where,
orderBy: [{ sort: 'asc' }, { createdAt: 'desc' }],
skip: (query.page - 1) * query.pageSize,
take: query.pageSize,
skip: (page - 1) * pageSize,
take: pageSize,
}),
this.prisma.product.count({ where }),
]);
return { list: rows.map(serialize), total, page: query.page, pageSize: query.pageSize };
return {
list: list.map((product) => this.toDto(product)),
total,
page,
pageSize,
};
}
async findOne(id: string) {
const product = await this.prisma.product.findFirst({
where: { id, status: ProductStatus.ON_SALE },
});
if (!product) throw new NotFoundException('商品不存在');
return serialize(product);
if (!product) throw new NotFoundException('商品不存在或已下架');
return this.toDto(product);
}
async create(dto: CreateProductDto) {
const product = await this.prisma.product.create({
// TODO: 校验 categoryId 存在、图片归属
return this.prisma.product.create({
data: {
name: dto.name,
categoryId: dto.categoryId,
price: dto.price,
originalPrice: dto.originalPrice,
leadTime: dto.leadTime,
subtitle: dto.subtitle,
tone: dto.tone ?? [],
tags: dto.tags ?? [],
specs: dto.specs as any,
mask: dto.mask as any,
story: dto.story,
scene: dto.scene,
iconImg: dto.iconImg,
images: dto.images ?? [],
description: dto.description,
status: dto.status,
...dto,
specs: dto.specs as Prisma.InputJsonValue,
mask: dto.mask as Prisma.InputJsonValue,
},
});
return serialize(product);
}
private toDto(product: Product) {
const { originalPrice, ...rest } = product;
return {
...rest,
price: Number(product.price),
...(originalPrice === null ? {} : { originalPrice: Number(originalPrice) }),
};
}
}