feat(catalog): add product catalog APIs
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, 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 {
|
||||
@@ -19,17 +19,72 @@ export class CreateProductDto {
|
||||
@Min(0)
|
||||
price!: number;
|
||||
|
||||
@ApiPropertyOptional({ description: '图片 URL 列表', type: [String] })
|
||||
@ApiPropertyOptional({ description: '划线原价(元)' })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber()
|
||||
@Min(0)
|
||||
originalPrice?: number;
|
||||
|
||||
@ApiProperty({ description: '生产工期,例如 3-5个工作日' })
|
||||
@IsString()
|
||||
leadTime!: string;
|
||||
|
||||
@ApiPropertyOptional({ description: '一句话卖点' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
subtitle?: string;
|
||||
|
||||
@ApiProperty({ description: '图片 URL 列表', type: [String] })
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
images?: string[];
|
||||
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()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
tags?: string[];
|
||||
|
||||
@ApiProperty({ description: '规格键值对数组' })
|
||||
@IsArray()
|
||||
specs!: [string, string][];
|
||||
|
||||
@ApiProperty({ description: '主题色 RGB' })
|
||||
@IsArray()
|
||||
@IsInt({ each: true })
|
||||
tone!: number[];
|
||||
|
||||
@ApiProperty({ description: '设计画布遮罩' })
|
||||
@IsObject()
|
||||
mask!: Record<string, unknown>;
|
||||
|
||||
@ApiPropertyOptional({ default: 0 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
sort?: number;
|
||||
|
||||
@ApiPropertyOptional({ enum: ProductStatus, default: ProductStatus.DRAFT })
|
||||
@IsOptional()
|
||||
@IsEnum(ProductStatus)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
|
||||
import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
|
||||
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 { ListProductsQueryDto } from './dto/list-products-query.dto';
|
||||
|
||||
@ApiTags('商品')
|
||||
@ApiBearerAuth()
|
||||
@@ -13,8 +14,8 @@ export class ProductsController {
|
||||
@Public()
|
||||
@Get()
|
||||
@ApiOperation({ summary: '在售商品列表' })
|
||||
list() {
|
||||
return this.productsService.list();
|
||||
list(@Query() query: ListProductsQueryDto) {
|
||||
return this.productsService.list(query);
|
||||
}
|
||||
|
||||
@Public()
|
||||
|
||||
@@ -1,27 +1,70 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ProductStatus } from '@prisma/client';
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma, Product, ProductStatus } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { CreateProductDto } from './dto/create-product.dto';
|
||||
import { ListProductsQueryDto } from './dto/list-products-query.dto';
|
||||
|
||||
@Injectable()
|
||||
export class ProductsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async list() {
|
||||
// TODO: 分页、按分类/状态筛选、价格区间
|
||||
return this.prisma.product.findMany({
|
||||
where: { status: ProductStatus.ON_SALE },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
async list(query: ListProductsQueryDto) {
|
||||
const { page = 1, pageSize = 20, categoryId, keyword } = query;
|
||||
const where: Prisma.ProductWhereInput = {
|
||||
status: ProductStatus.ON_SALE,
|
||||
...(categoryId ? { categoryId } : {}),
|
||||
...(keyword?.trim()
|
||||
? {
|
||||
OR: [
|
||||
{ name: { contains: keyword.trim(), mode: 'insensitive' } },
|
||||
{ subtitle: { contains: keyword.trim(), mode: 'insensitive' } },
|
||||
{ description: { contains: keyword.trim(), mode: 'insensitive' } },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
const [list, total] = await this.prisma.$transaction([
|
||||
this.prisma.product.findMany({
|
||||
where,
|
||||
orderBy: [{ sort: 'asc' }, { createdAt: 'desc' }],
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
this.prisma.product.count({ where }),
|
||||
]);
|
||||
return {
|
||||
list: list.map((product) => this.toDto(product)),
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
async findOne(id: string) {
|
||||
// TODO: 404 处理、浏览量统计
|
||||
return this.prisma.product.findUnique({ where: { id } });
|
||||
const product = await this.prisma.product.findFirst({
|
||||
where: { id, status: ProductStatus.ON_SALE },
|
||||
});
|
||||
if (!product) throw new NotFoundException('商品不存在或已下架');
|
||||
return this.toDto(product);
|
||||
}
|
||||
|
||||
async create(dto: CreateProductDto) {
|
||||
// TODO: 校验 categoryId 存在、图片归属
|
||||
return this.prisma.product.create({ data: dto });
|
||||
return this.prisma.product.create({
|
||||
data: {
|
||||
...dto,
|
||||
specs: dto.specs as Prisma.InputJsonValue,
|
||||
mask: dto.mask as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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