feat(catalog): add product catalog APIs

This commit is contained in:
lai_hong
2026-09-13 11:17:53 +08:00
parent 63c0013076
commit 59e398fcf9
15 changed files with 655 additions and 68 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) {
+58 -3
View File
@@ -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;
}
+4 -3
View File
@@ -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()
+54 -11
View File
@@ -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) }),
};
}
}