merge: integrate catalog into order payment flow
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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) }),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user