商品模板功能上传

This commit is contained in:
2026-07-06 15:29:27 +08:00
parent 80d967f2cf
commit f46904b47c
19 changed files with 1148 additions and 8 deletions
@@ -83,6 +83,6 @@ import type { ISmsProvider } from './sms/sms.interface';
TencentLbsProvider,
{ provide: MAP_PROVIDER, useExisting: TencentLbsProvider },
],
exports: [SMS_PROVIDER, SmsCodeStore, PAY_PROVIDER, DELIVERY_PROVIDER, WECHAT_PROVIDER, OSS_PROVIDER, MAP_PROVIDER, CourierModule],
exports: [SMS_PROVIDER, SmsCodeStore, PAY_PROVIDER, DELIVERY_PROVIDER, WECHAT_PROVIDER, OSS_PROVIDER, MAP_PROVIDER, TencentLbsProvider, CourierModule],
})
export class IntegrationsModule {}
@@ -0,0 +1,34 @@
import { Body, Controller, Get, Param, Post, Put, Query, UseGuards } from '@nestjs/common';
import { HqAuthGuard } from '../../common/guards/hq-auth.guard';
import { AdminProductDetailTemplatesService } from './admin-product-detail-templates.service';
import { AdminProductDetailTemplatesQueryDto } from './dto/admin-query.dto';
import {
CreateProductDetailTemplateDto,
UpdateProductDetailTemplateDto,
} from './dto/admin-mutate.dto';
@Controller('admin/product-detail-templates')
@UseGuards(HqAuthGuard)
export class AdminProductDetailTemplatesController {
constructor(private readonly service: AdminProductDetailTemplatesService) {}
@Get()
list(@Query() query: AdminProductDetailTemplatesQueryDto) {
return this.service.list(query);
}
@Get(':id')
detail(@Param('id') id: string) {
return this.service.detail(BigInt(id));
}
@Post()
create(@Body() dto: CreateProductDetailTemplateDto) {
return this.service.create(dto);
}
@Put(':id')
update(@Param('id') id: string, @Body() dto: UpdateProductDetailTemplateDto) {
return this.service.update(BigInt(id), dto);
}
}
@@ -0,0 +1,183 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../common/prisma/prisma.module';
import { serializeBigInt } from '../../common/decorators/current-user.decorator';
import type { AdminProductDetailTemplatesQueryDto } from './dto/admin-query.dto';
import type {
CreateProductDetailTemplateDto,
UpdateProductDetailTemplateDto,
} from './dto/admin-mutate.dto';
type TemplateRow = {
id: bigint;
code: string;
name: string;
description: string | null;
aromaType: string | null;
storyTitle: string | null;
storyText: string | null;
features: unknown;
detailImageUrls?: unknown;
suggestedDetailImageCount: number;
sortOrder: number;
status: string;
createdAt: Date;
updatedAt: Date;
};
const MAX_TEMPLATE_DETAIL_IMAGES = 20;
@Injectable()
export class AdminProductDetailTemplatesService {
constructor(private readonly prisma: PrismaService) {}
async list(query: AdminProductDetailTemplatesQueryDto) {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 20;
const where: Prisma.CommonProductDetailTemplateWhereInput = {};
if (query.name) where.name = { contains: query.name };
if (query.code) where.code = { contains: query.code };
if (query.status) {
where.status = query.status as Prisma.EnumDetailTemplateStatusFilter['equals'];
}
if (query.aromaType) {
where.aromaType = query.aromaType as Prisma.EnumAromaTypeFilter['equals'];
}
const [items, total] = await Promise.all([
this.prisma.commonProductDetailTemplate.findMany({
where,
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
skip: (page - 1) * pageSize,
take: pageSize,
}),
this.prisma.commonProductDetailTemplate.count({ where }),
]);
return serializeBigInt({
items: items.map((row) => this.format(row)),
total,
page,
pageSize,
});
}
async detail(id: bigint) {
const row = await this.prisma.commonProductDetailTemplate.findUnique({ where: { id } });
if (!row) throw new NotFoundException('详情模板不存在');
return serializeBigInt(this.format(row));
}
async create(dto: CreateProductDetailTemplateDto) {
const exists = await this.prisma.commonProductDetailTemplate.findUnique({
where: { code: dto.code },
});
if (exists) throw new BadRequestException('模板编码已存在');
const detailImageUrls = this.normalizeDetailImageUrls(dto.detailImageUrls);
const row = await this.prisma.commonProductDetailTemplate.create({
data: {
code: dto.code,
name: dto.name,
description: dto.description,
aromaType: dto.aromaType as Prisma.CommonProductDetailTemplateCreateInput['aromaType'],
storyTitle: dto.storyTitle,
storyText: dto.storyText,
features: this.normalizeFeatures(dto.features) as Prisma.InputJsonValue,
detailImageUrls: detailImageUrls as Prisma.InputJsonValue,
suggestedDetailImageCount:
detailImageUrls.length > 0 ? detailImageUrls.length : (dto.suggestedDetailImageCount ?? 1),
sortOrder: dto.sortOrder ?? 0,
status: (dto.status ?? 'ACTIVE') as Prisma.CommonProductDetailTemplateCreateInput['status'],
} as Prisma.CommonProductDetailTemplateCreateInput,
});
return serializeBigInt(this.format(row));
}
async update(id: bigint, dto: UpdateProductDetailTemplateDto) {
const existing = await this.prisma.commonProductDetailTemplate.findUnique({ where: { id } });
if (!existing) throw new NotFoundException('详情模板不存在');
if (dto.code && dto.code !== existing.code) {
const dup = await this.prisma.commonProductDetailTemplate.findUnique({ where: { code: dto.code } });
if (dup) throw new BadRequestException('模板编码已存在');
}
const data: Prisma.CommonProductDetailTemplateUpdateInput = {};
if (dto.code !== undefined) data.code = dto.code;
if (dto.name !== undefined) data.name = dto.name;
if (dto.description !== undefined) data.description = dto.description;
if (dto.aromaType !== undefined) {
data.aromaType = dto.aromaType as Prisma.CommonProductDetailTemplateUpdateInput['aromaType'];
}
if (dto.storyTitle !== undefined) data.storyTitle = dto.storyTitle;
if (dto.storyText !== undefined) data.storyText = dto.storyText;
if (dto.features !== undefined) {
data.features = this.normalizeFeatures(dto.features) as Prisma.InputJsonValue;
}
if (dto.detailImageUrls !== undefined) {
const detailImageUrls = this.normalizeDetailImageUrls(dto.detailImageUrls);
(data as Prisma.CommonProductDetailTemplateUpdateInput & { detailImageUrls?: Prisma.InputJsonValue }).detailImageUrls =
detailImageUrls as Prisma.InputJsonValue;
data.suggestedDetailImageCount =
detailImageUrls.length > 0
? detailImageUrls.length
: (dto.suggestedDetailImageCount ?? existing.suggestedDetailImageCount);
} else if (dto.suggestedDetailImageCount !== undefined) {
data.suggestedDetailImageCount = dto.suggestedDetailImageCount;
}
if (dto.sortOrder !== undefined) data.sortOrder = dto.sortOrder;
if (dto.status !== undefined) {
data.status = dto.status as Prisma.CommonProductDetailTemplateUpdateInput['status'];
}
const row = await this.prisma.commonProductDetailTemplate.update({ where: { id }, data });
return serializeBigInt(this.format(row));
}
private normalizeDetailImageUrls(urls?: string[]) {
if (!urls) return [];
const cleaned = urls.map((u) => u?.trim()).filter(Boolean);
if (cleaned.length > MAX_TEMPLATE_DETAIL_IMAGES) {
throw new BadRequestException(`详情图最多 ${MAX_TEMPLATE_DETAIL_IMAGES}`);
}
return cleaned;
}
private normalizeFeatures(features?: Array<{ icon: string; title: string; desc: string }>) {
if (!features) return [];
return features
.filter((f) => f.title?.trim() || f.desc?.trim())
.map((f) => ({
icon: f.icon?.trim() || 'star',
title: f.title?.trim() ?? '',
desc: f.desc?.trim() ?? '',
}));
}
private format(row: TemplateRow) {
const features = Array.isArray(row.features)
? (row.features as Array<{ icon: string; title: string; desc: string }>)
: [];
const detailImageUrls = Array.isArray(row.detailImageUrls)
? (row.detailImageUrls as string[]).filter(Boolean)
: [];
return {
id: row.id.toString(),
code: row.code,
name: row.name,
description: row.description,
aromaType: row.aromaType,
storyTitle: row.storyTitle,
storyText: row.storyText,
features,
detailImageUrls,
suggestedDetailImageCount: detailImageUrls.length || row.suggestedDetailImageCount,
sortOrder: row.sortOrder,
status: row.status,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
}
}
@@ -454,3 +454,108 @@ export class UpdateProductDto {
@IsObject()
detailContent?: Record<string, unknown>;
}
class ProductDetailFeatureInputDto {
@IsString()
icon: string;
@IsString()
title: string;
@IsString()
desc: string;
}
export class CreateProductDetailTemplateDto {
@IsString()
@IsNotEmpty()
code: string;
@IsString()
@IsNotEmpty()
name: string;
@IsOptional()
@IsString()
description?: string;
@IsOptional()
@IsIn(['QINGXIANG', 'JIANGXIANG', 'NONGXIANG'])
aromaType?: string;
@IsOptional()
@IsString()
storyTitle?: string;
@IsOptional()
@IsString()
storyText?: string;
@IsOptional()
@IsArray()
features?: ProductDetailFeatureInputDto[];
@IsOptional()
@IsArray()
@IsString({ each: true })
detailImageUrls?: string[];
@IsOptional()
@IsNumber()
suggestedDetailImageCount?: number;
@IsOptional()
@IsNumber()
sortOrder?: number;
@IsOptional()
@IsIn(['ACTIVE', 'DISABLED'])
status?: string;
}
export class UpdateProductDetailTemplateDto {
@IsOptional()
@IsString()
code?: string;
@IsOptional()
@IsString()
name?: string;
@IsOptional()
@IsString()
description?: string;
@IsOptional()
@IsIn(['QINGXIANG', 'JIANGXIANG', 'NONGXIANG', null])
aromaType?: string | null;
@IsOptional()
@IsString()
storyTitle?: string;
@IsOptional()
@IsString()
storyText?: string;
@IsOptional()
@IsArray()
features?: ProductDetailFeatureInputDto[];
@IsOptional()
@IsArray()
@IsString({ each: true })
detailImageUrls?: string[];
@IsOptional()
@IsNumber()
suggestedDetailImageCount?: number;
@IsOptional()
@IsNumber()
sortOrder?: number;
@IsOptional()
@IsIn(['ACTIVE', 'DISABLED'])
status?: string;
}
@@ -227,6 +227,24 @@ export class AdminProductsQueryDto extends PaginationQueryDto {
aromaType?: string;
}
export class AdminProductDetailTemplatesQueryDto extends PaginationQueryDto {
@IsOptional()
@IsString()
name?: string;
@IsOptional()
@IsString()
code?: string;
@IsOptional()
@IsString()
status?: string;
@IsOptional()
@IsString()
aromaType?: string;
}
export class AdminUserLogsQueryDto extends PaginationQueryDto {
@IsOptional()
@IsString()
@@ -31,6 +31,8 @@ import { CommonModule } from '../common/common.module';
import { IntegrationsModule } from '../../integrations/integrations.module';
import { AdminXiaofeixiaController } from './admin-xiaofeixia.controller';
import { AdminXiaofeixiaService } from './admin-xiaofeixia.service';
import { AdminProductDetailTemplatesController } from './admin-product-detail-templates.controller';
import { AdminProductDetailTemplatesService } from './admin-product-detail-templates.service';
@Module({
imports: [IamModule, TradeModule, BenefitModule, CommonModule, IntegrationsModule],
@@ -53,6 +55,7 @@ import { AdminXiaofeixiaService } from './admin-xiaofeixia.service';
AdminUserLogsController,
AdminTicketsController,
AdminXiaofeixiaController,
AdminProductDetailTemplatesController,
],
providers: [
AdminDashboardService,
@@ -69,6 +72,7 @@ import { AdminXiaofeixiaService } from './admin-xiaofeixia.service';
AdminUserLogsService,
AdminTicketsService,
AdminXiaofeixiaService,
AdminProductDetailTemplatesService,
SuperAdminGuard,
],
})