feat(deploy): production deployment setup and fixes

- Debian-based api image (bookworm-slim), docker/debian mirrors, prisma
  binaryTargets for openssl 3.0
- nginx: admin SPA under /admin, TLS via acme.sh (ZeroSSL) + auto-renewal
  cron, http->https redirect
- prisma: add origin_goods.delisted migration, sync missing schema
  (good_image/tag_font_color/good_tags), fix users.createdAt Timestamptz
- api: CORS wildcard reflection, helmet CORP cross-origin, price
  backfill in persistProductDetail, categoryIcon ancestor fallback,
  mediaByColor per-color gallery in public goods detail
- admin: /admin base path (vite + router)
- import-data.mjs: udt_name casting, serial sequence advance fix
This commit is contained in:
yeuimu
2026-08-26 14:23:09 +08:00
parent be0b90e68f
commit 6c61a4e871
982 changed files with 74156 additions and 179393 deletions
+60 -60
View File
@@ -1,61 +1,61 @@
import { ApiProperty } from '@nestjs/swagger';
import {
ArrayMinSize,
IsArray,
IsInt,
IsOptional,
Min,
ValidateNested,
} from 'class-validator';
import { Type } from 'class-transformer';
export class BatchCreateItemDto {
@ApiProperty()
@IsInt()
@Min(1)
originGoodId!: number;
@ApiProperty({ required: false })
@IsOptional()
@IsInt()
@Min(0)
priority?: number;
}
export class BatchCreateGoodDto {
@ApiProperty({ type: [BatchCreateItemDto] })
@IsArray()
@ArrayMinSize(1)
@ValidateNested({ each: true })
@Type(() => BatchCreateItemDto)
items!: BatchCreateItemDto[];
@ApiProperty()
@IsInt()
@Min(1)
countryId!: number;
@ApiProperty()
@IsInt()
@Min(1)
categoryId!: number;
@ApiProperty({ required: false, nullable: true, type: [Number] })
@IsOptional()
@IsArray()
@IsInt({ each: true })
@Min(1, { each: true })
tagIds?: number[];
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsInt()
@Min(1)
positionId?: number;
@ApiProperty({ required: false, default: 0 })
@IsOptional()
@IsInt()
@Min(0)
defaultPriority?: number;
import { ApiProperty } from '@nestjs/swagger';
import {
ArrayMinSize,
IsArray,
IsInt,
IsOptional,
Min,
ValidateNested,
} from 'class-validator';
import { Type } from 'class-transformer';
export class BatchCreateItemDto {
@ApiProperty()
@IsInt()
@Min(1)
originGoodId!: number;
@ApiProperty({ required: false })
@IsOptional()
@IsInt()
@Min(0)
priority?: number;
}
export class BatchCreateGoodDto {
@ApiProperty({ type: [BatchCreateItemDto] })
@IsArray()
@ArrayMinSize(1)
@ValidateNested({ each: true })
@Type(() => BatchCreateItemDto)
items!: BatchCreateItemDto[];
@ApiProperty()
@IsInt()
@Min(1)
countryId!: number;
@ApiProperty()
@IsInt()
@Min(1)
categoryId!: number;
@ApiProperty({ required: false, nullable: true, type: [Number] })
@IsOptional()
@IsArray()
@IsInt({ each: true })
@Min(1, { each: true })
tagIds?: number[];
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsInt()
@Min(1)
positionId?: number;
@ApiProperty({ required: false, default: 0 })
@IsOptional()
@IsInt()
@Min(0)
defaultPriority?: number;
}
+30 -30
View File
@@ -1,30 +1,30 @@
import { ApiProperty } from '@nestjs/swagger';
import {
ArrayMinSize,
IsArray,
IsInt,
Min,
ValidateNested,
} from 'class-validator';
import { Type } from 'class-transformer';
export class PriorityItemDto {
@ApiProperty()
@IsInt()
@Min(1)
id!: number;
@ApiProperty()
@IsInt()
@Min(0)
priority!: number;
}
export class BatchPriorityDto {
@ApiProperty({ type: [PriorityItemDto] })
@IsArray()
@ArrayMinSize(1)
@ValidateNested({ each: true })
@Type(() => PriorityItemDto)
items!: PriorityItemDto[];
}
import { ApiProperty } from '@nestjs/swagger';
import {
ArrayMinSize,
IsArray,
IsInt,
Min,
ValidateNested,
} from 'class-validator';
import { Type } from 'class-transformer';
export class PriorityItemDto {
@ApiProperty()
@IsInt()
@Min(1)
id!: number;
@ApiProperty()
@IsInt()
@Min(0)
priority!: number;
}
export class BatchPriorityDto {
@ApiProperty({ type: [PriorityItemDto] })
@IsArray()
@ArrayMinSize(1)
@ValidateNested({ each: true })
@Type(() => PriorityItemDto)
items!: PriorityItemDto[];
}
+56 -56
View File
@@ -1,57 +1,57 @@
import { ApiProperty } from '@nestjs/swagger';
import {
ArrayMinSize,
IsArray,
IsInt,
IsNotEmpty,
IsOptional,
IsString,
Min,
} from 'class-validator';
export class CreateGoodDto {
@ApiProperty()
@IsString()
@IsNotEmpty()
goodName!: string;
@ApiProperty()
@IsInt()
@Min(1)
originGoodId!: number;
@ApiProperty()
@IsInt()
@Min(1)
countryId!: number;
@ApiProperty()
@IsInt()
@Min(1)
categoryId!: number;
@ApiProperty({ required: false, nullable: true, type: [Number] })
@IsOptional()
@IsArray()
@ArrayMinSize(1)
@IsInt({ each: true })
@Min(1, { each: true })
tagIds?: number[];
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsInt()
@Min(1)
positionId?: number;
@ApiProperty({ required: false, default: 0 })
@IsOptional()
@IsInt()
@Min(0)
goodPriority?: number;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsString()
goodImage?: string;
import { ApiProperty } from '@nestjs/swagger';
import {
ArrayMinSize,
IsArray,
IsInt,
IsNotEmpty,
IsOptional,
IsString,
Min,
} from 'class-validator';
export class CreateGoodDto {
@ApiProperty()
@IsString()
@IsNotEmpty()
goodName!: string;
@ApiProperty()
@IsInt()
@Min(1)
originGoodId!: number;
@ApiProperty()
@IsInt()
@Min(1)
countryId!: number;
@ApiProperty()
@IsInt()
@Min(1)
categoryId!: number;
@ApiProperty({ required: false, nullable: true, type: [Number] })
@IsOptional()
@IsArray()
@ArrayMinSize(1)
@IsInt({ each: true })
@Min(1, { each: true })
tagIds?: number[];
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsInt()
@Min(1)
positionId?: number;
@ApiProperty({ required: false, default: 0 })
@IsOptional()
@IsInt()
@Min(0)
goodPriority?: number;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsString()
goodImage?: string;
}
+136 -136
View File
@@ -1,17 +1,17 @@
import { ApiProperty } from '@nestjs/swagger';
import type { Good as PrismaGood } from '@prisma/client';
export interface GoodRelations {
country?: { id: bigint; countryName: string; countryIcon: string | null } | null;
category?: { id: bigint; categoryName: string; categoryIcon: string | null } | null;
tag?: { id: bigint; tagName: string; tagColor: string | null; tagFontColor: string | null } | null;
position?: { id: bigint; indexVal: number } | null;
import { ApiProperty } from '@nestjs/swagger';
import type { Good as PrismaGood } from '@prisma/client';
export interface GoodRelations {
country?: { id: bigint; countryName: string; countryIcon: string | null } | null;
category?: { id: bigint; categoryName: string; categoryIcon: string | null } | null;
tag?: { id: bigint; tagName: string; tagColor: string | null; tagFontColor: string | null } | null;
position?: { id: bigint; indexVal: number } | null;
originGood?: {
id: bigint;
id: bigint;
sdsGoodId: string;
source: 'SDS' | 'CUSTOM';
goodName: string | null;
goodImage: string | null;
goodName: string | null;
goodImage: string | null;
goodPrice: unknown;
detail?: {
productCode: string | null;
@@ -31,67 +31,67 @@ export interface GoodRelations {
[key: string]: unknown;
}>;
_count?: { variants: number };
} | null;
goodTags?: { tag: { id: bigint; tagName: string; tagColor: string | null; tagFontColor: string | null } }[];
}
export class GoodDto {
@ApiProperty()
id!: string;
@ApiProperty()
goodName!: string;
@ApiProperty({ nullable: true })
goodImage!: string | null;
@ApiProperty()
goodPriority!: number;
@ApiProperty()
countryId!: string;
@ApiProperty()
categoryId!: string;
@ApiProperty({ nullable: true })
tagId!: string | null;
@ApiProperty({ nullable: true })
positionId!: string | null;
@ApiProperty()
originGoodId!: string;
@ApiProperty()
createdAt!: string;
@ApiProperty()
updatedAt!: string;
@ApiProperty({ required: false, nullable: true })
country?: { id: string; countryName: string; countryIcon: string | null } | null;
@ApiProperty({ required: false, nullable: true })
category?: { id: string; categoryName: string; categoryIcon: string | null } | null;
@ApiProperty({ required: false, nullable: true })
tag?: { id: string; tagName: string; tagColor: string | null; tagFontColor: string | null } | null;
@ApiProperty({ required: false, type: Array })
tags!: Array<{ id: string; tagName: string; tagColor: string | null; tagFontColor: string | null }>;
@ApiProperty({ required: false, nullable: true })
position?: { id: string; indexVal: number } | null;
@ApiProperty({ required: false, nullable: true })
} | null;
goodTags?: { tag: { id: bigint; tagName: string; tagColor: string | null; tagFontColor: string | null } }[];
}
export class GoodDto {
@ApiProperty()
id!: string;
@ApiProperty()
goodName!: string;
@ApiProperty({ nullable: true })
goodImage!: string | null;
@ApiProperty()
goodPriority!: number;
@ApiProperty()
countryId!: string;
@ApiProperty()
categoryId!: string;
@ApiProperty({ nullable: true })
tagId!: string | null;
@ApiProperty({ nullable: true })
positionId!: string | null;
@ApiProperty()
originGoodId!: string;
@ApiProperty()
createdAt!: string;
@ApiProperty()
updatedAt!: string;
@ApiProperty({ required: false, nullable: true })
country?: { id: string; countryName: string; countryIcon: string | null } | null;
@ApiProperty({ required: false, nullable: true })
category?: { id: string; categoryName: string; categoryIcon: string | null } | null;
@ApiProperty({ required: false, nullable: true })
tag?: { id: string; tagName: string; tagColor: string | null; tagFontColor: string | null } | null;
@ApiProperty({ required: false, type: Array })
tags!: Array<{ id: string; tagName: string; tagColor: string | null; tagFontColor: string | null }>;
@ApiProperty({ required: false, nullable: true })
position?: { id: string; indexVal: number } | null;
@ApiProperty({ required: false, nullable: true })
originGood?: {
id: string;
id: string;
sdsGoodId: string;
source: 'SDS' | 'CUSTOM';
isCustom: boolean;
goodName: string | null;
goodImage: string | null;
goodName: string | null;
goodImage: string | null;
goodPrice: string | null;
hasDetail: boolean;
detailSyncedAt: string | null;
@@ -99,72 +99,72 @@ export class GoodDto {
sizeRowCount: number;
packageRowCount: number;
productCode: string | null;
} | null;
static from(
good: PrismaGood,
rel: GoodRelations = {},
): GoodDto {
return {
id: good.id.toString(),
goodName: good.goodName,
goodImage: good.goodImage,
goodPriority: good.goodPriority,
countryId: good.countryId.toString(),
categoryId: good.categoryId.toString(),
tagId: good.tagId === null || good.tagId === undefined ? null : good.tagId.toString(),
positionId: good.positionId === null || good.positionId === undefined ? null : good.positionId.toString(),
originGoodId: good.originGoodId.toString(),
createdAt: good.createdAt.toISOString(),
updatedAt: good.updatedAt.toISOString(),
country: rel.country
? {
id: rel.country.id.toString(),
countryName: rel.country.countryName,
countryIcon: rel.country.countryIcon,
}
: null,
category: rel.category
? {
id: rel.category.id.toString(),
categoryName: rel.category.categoryName,
categoryIcon: rel.category.categoryIcon,
}
: null,
tag: rel.tag
? {
id: rel.tag.id.toString(),
tagName: rel.tag.tagName,
tagColor: rel.tag.tagColor,
tagFontColor: rel.tag.tagFontColor,
}
: null,
tags: rel.goodTags
? rel.goodTags.map((gt) => ({
id: gt.tag.id.toString(),
tagName: gt.tag.tagName,
tagColor: gt.tag.tagColor,
tagFontColor: gt.tag.tagFontColor,
}))
: [],
position: rel.position
? {
id: rel.position.id.toString(),
indexVal: rel.position.indexVal,
}
: null,
} | null;
static from(
good: PrismaGood,
rel: GoodRelations = {},
): GoodDto {
return {
id: good.id.toString(),
goodName: good.goodName,
goodImage: good.goodImage,
goodPriority: good.goodPriority,
countryId: good.countryId.toString(),
categoryId: good.categoryId.toString(),
tagId: good.tagId === null || good.tagId === undefined ? null : good.tagId.toString(),
positionId: good.positionId === null || good.positionId === undefined ? null : good.positionId.toString(),
originGoodId: good.originGoodId.toString(),
createdAt: good.createdAt.toISOString(),
updatedAt: good.updatedAt.toISOString(),
country: rel.country
? {
id: rel.country.id.toString(),
countryName: rel.country.countryName,
countryIcon: rel.country.countryIcon,
}
: null,
category: rel.category
? {
id: rel.category.id.toString(),
categoryName: rel.category.categoryName,
categoryIcon: rel.category.categoryIcon,
}
: null,
tag: rel.tag
? {
id: rel.tag.id.toString(),
tagName: rel.tag.tagName,
tagColor: rel.tag.tagColor,
tagFontColor: rel.tag.tagFontColor,
}
: null,
tags: rel.goodTags
? rel.goodTags.map((gt) => ({
id: gt.tag.id.toString(),
tagName: gt.tag.tagName,
tagColor: gt.tag.tagColor,
tagFontColor: gt.tag.tagFontColor,
}))
: [],
position: rel.position
? {
id: rel.position.id.toString(),
indexVal: rel.position.indexVal,
}
: null,
originGood: rel.originGood
? {
id: rel.originGood.id.toString(),
id: rel.originGood.id.toString(),
sdsGoodId: rel.originGood.sdsGoodId,
source: rel.originGood.source,
isCustom: rel.originGood.source === 'CUSTOM',
goodName: rel.originGood.goodName,
goodImage: rel.originGood.goodImage,
goodPrice:
rel.originGood.goodPrice === null ||
rel.originGood.goodPrice === undefined
? null
goodName: rel.originGood.goodName,
goodImage: rel.originGood.goodImage,
goodPrice:
rel.originGood.goodPrice === null ||
rel.originGood.goodPrice === undefined
? null
: (rel.originGood.goodPrice as { toString(): string }).toString(),
hasDetail: Boolean(rel.originGood.detail),
detailSyncedAt: rel.originGood.detail?.syncedAt.toISOString() ?? null,
@@ -173,7 +173,7 @@ export class GoodDto {
packageRowCount: GoodDto.jsonRows(rel.originGood.detail?.packageSpecs),
productCode: rel.originGood.detail?.productCode ?? null,
}
: null,
: null,
};
}
@@ -207,10 +207,10 @@ export class GoodDetailDto extends GoodDto {
};
}
}
export interface PaginatedGoods {
items: GoodDto[];
total: number;
page: number;
pageSize: number;
export interface PaginatedGoods {
items: GoodDto[];
total: number;
page: number;
pageSize: number;
}
+58 -58
View File
@@ -1,58 +1,58 @@
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
IsInt,
IsOptional,
IsString,
Max,
Min,
} from 'class-validator';
export class QueryGoodDto {
@ApiProperty({ required: false, default: 1 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page: number = 1;
@ApiProperty({ required: false, default: 20 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(200)
pageSize: number = 20;
@ApiProperty({ required: false })
@IsOptional()
@Type(() => Number)
@IsInt()
countryId?: number;
@ApiProperty({
required: false,
description: 'Includes all descendants of this category recursively',
})
@IsOptional()
@Type(() => Number)
@IsInt()
categoryId?: number;
@ApiProperty({ required: false })
@IsOptional()
@Type(() => Number)
@IsInt()
tagId?: number;
@ApiProperty({ required: false })
@IsOptional()
@Type(() => Number)
@IsInt()
positionId?: number;
@ApiProperty({ required: false })
@IsOptional()
@IsString()
keyword?: string;
}
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
IsInt,
IsOptional,
IsString,
Max,
Min,
} from 'class-validator';
export class QueryGoodDto {
@ApiProperty({ required: false, default: 1 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page: number = 1;
@ApiProperty({ required: false, default: 20 })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(200)
pageSize: number = 20;
@ApiProperty({ required: false })
@IsOptional()
@Type(() => Number)
@IsInt()
countryId?: number;
@ApiProperty({
required: false,
description: 'Includes all descendants of this category recursively',
})
@IsOptional()
@Type(() => Number)
@IsInt()
categoryId?: number;
@ApiProperty({ required: false })
@IsOptional()
@Type(() => Number)
@IsInt()
tagId?: number;
@ApiProperty({ required: false })
@IsOptional()
@Type(() => Number)
@IsInt()
positionId?: number;
@ApiProperty({ required: false })
@IsOptional()
@IsString()
keyword?: string;
}
+56 -56
View File
@@ -1,57 +1,57 @@
import { ApiProperty } from '@nestjs/swagger';
import {
IsArray,
IsInt,
IsOptional,
IsString,
Min,
} from 'class-validator';
export class UpdateGoodDto {
@ApiProperty({ required: false })
@IsOptional()
@IsString()
goodName?: string;
@ApiProperty({ required: false })
@IsOptional()
@IsInt()
@Min(1)
originGoodId?: number;
@ApiProperty({ required: false })
@IsOptional()
@IsInt()
@Min(1)
countryId?: number;
@ApiProperty({ required: false })
@IsOptional()
@IsInt()
@Min(1)
categoryId?: number;
@ApiProperty({ required: false, nullable: true, type: [Number] })
@IsOptional()
@IsArray()
@IsInt({ each: true })
@Min(1, { each: true })
tagIds?: number[];
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsInt()
@Min(1)
positionId?: number | null;
@ApiProperty({ required: false })
@IsOptional()
@IsInt()
@Min(0)
goodPriority?: number;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsString()
goodImage?: string | null;
import { ApiProperty } from '@nestjs/swagger';
import {
IsArray,
IsInt,
IsOptional,
IsString,
Min,
} from 'class-validator';
export class UpdateGoodDto {
@ApiProperty({ required: false })
@IsOptional()
@IsString()
goodName?: string;
@ApiProperty({ required: false })
@IsOptional()
@IsInt()
@Min(1)
originGoodId?: number;
@ApiProperty({ required: false })
@IsOptional()
@IsInt()
@Min(1)
countryId?: number;
@ApiProperty({ required: false })
@IsOptional()
@IsInt()
@Min(1)
categoryId?: number;
@ApiProperty({ required: false, nullable: true, type: [Number] })
@IsOptional()
@IsArray()
@IsInt({ each: true })
@Min(1, { each: true })
tagIds?: number[];
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsInt()
@Min(1)
positionId?: number | null;
@ApiProperty({ required: false })
@IsOptional()
@IsInt()
@Min(0)
goodPriority?: number;
@ApiProperty({ required: false, nullable: true })
@IsOptional()
@IsString()
goodImage?: string | null;
}
+48 -48
View File
@@ -1,59 +1,59 @@
import {
Body,
Controller,
Delete,
Get,
Param,
ParseIntPipe,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import {
ApiBearerAuth,
ApiOperation,
ApiTags,
} from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { GoodsService } from './goods.service';
import { CreateGoodDto } from './dto/create-good.dto';
import { UpdateGoodDto } from './dto/update-good.dto';
import { QueryGoodDto } from './dto/query-good.dto';
import { BatchCreateGoodDto } from './dto/batch-create-good.dto';
import {
Body,
Controller,
Delete,
Get,
Param,
ParseIntPipe,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import {
ApiBearerAuth,
ApiOperation,
ApiTags,
} from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { GoodsService } from './goods.service';
import { CreateGoodDto } from './dto/create-good.dto';
import { UpdateGoodDto } from './dto/update-good.dto';
import { QueryGoodDto } from './dto/query-good.dto';
import { BatchCreateGoodDto } from './dto/batch-create-good.dto';
import { BatchPriorityDto } from './dto/batch-priority.dto';
import {
CreateCustomGoodDto,
UpdateCustomGoodContentDto,
} from './dto/custom-good.dto';
@ApiTags('goods')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('goods')
export class GoodsController {
constructor(private readonly service: GoodsService) {}
@Get()
@ApiOperation({ summary: 'List goods with filters & pagination' })
findAll(@Query() query: QueryGoodDto) {
return this.service.findAll(query);
}
@ApiTags('goods')
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Controller('goods')
export class GoodsController {
constructor(private readonly service: GoodsService) {}
@Get()
@ApiOperation({ summary: 'List goods with filters & pagination' })
findAll(@Query() query: QueryGoodDto) {
return this.service.findAll(query);
}
@Post()
@ApiOperation({ summary: 'Create a good' })
create(@Body() dto: CreateGoodDto) {
return this.service.create(dto);
}
@ApiOperation({ summary: 'Create a good' })
create(@Body() dto: CreateGoodDto) {
return this.service.create(dto);
}
@Patch('batch-priority')
@ApiOperation({ summary: 'Batch update good priorities (transaction)' })
batchPriority(@Body() dto: BatchPriorityDto) {
return this.service.batchUpdatePriority(dto);
}
@ApiOperation({ summary: 'Batch update good priorities (transaction)' })
batchPriority(@Body() dto: BatchPriorityDto) {
return this.service.batchUpdatePriority(dto);
}
@Post('batch')
@ApiOperation({ summary: 'Batch create goods from origin goods (transaction)' })
@ApiOperation({ summary: 'Batch create goods from origin goods (transaction)' })
batchCreate(@Body() dto: BatchCreateGoodDto) {
return this.service.batchCreate(dto);
}
+7 -7
View File
@@ -1,12 +1,12 @@
import { Module } from '@nestjs/common';
import { GoodsController } from './goods.controller';
import { Module } from '@nestjs/common';
import { GoodsController } from './goods.controller';
import { GoodsService } from './goods.service';
import { SyncModule } from '../sync/sync.module';
@Module({
imports: [SyncModule],
controllers: [GoodsController],
providers: [GoodsService],
exports: [GoodsService],
})
export class GoodsModule {}
controllers: [GoodsController],
providers: [GoodsService],
exports: [GoodsService],
})
export class GoodsModule {}
+212 -212
View File
@@ -1,28 +1,28 @@
import { Test } from '@nestjs/testing';
import {
BadRequestException,
NotFoundException,
} from '@nestjs/common';
import { GoodsService } from './goods.service';
import { Test } from '@nestjs/testing';
import {
BadRequestException,
NotFoundException,
} from '@nestjs/common';
import { GoodsService } from './goods.service';
import { PrismaService } from '../prisma/prisma.service';
import { SyncService } from '../sync/sync.service';
describe('GoodsService', () => {
let service: GoodsService;
let prisma: PrismaService;
const stamp = Date.now();
// Fixtures
let countryId: bigint;
let country2Id: bigint;
let categoryId: bigint;
let childCategoryId: bigint;
let tagId: bigint;
let positionId: bigint;
let originGoodIds: bigint[] = [];
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
describe('GoodsService', () => {
let service: GoodsService;
let prisma: PrismaService;
const stamp = Date.now();
// Fixtures
let countryId: bigint;
let country2Id: bigint;
let categoryId: bigint;
let childCategoryId: bigint;
let tagId: bigint;
let positionId: bigint;
let originGoodIds: bigint[] = [];
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
providers: [
GoodsService,
PrismaService,
@@ -31,92 +31,92 @@ describe('GoodsService', () => {
useValue: { queueProductDetailSync: jest.fn() },
},
],
}).compile();
service = moduleRef.get(GoodsService);
prisma = moduleRef.get(PrismaService);
await prisma.onModuleInit();
const country = await prisma.country.create({
data: { countryName: `Goods Country ${stamp}` },
});
countryId = country.id;
const country2 = await prisma.country.create({
data: { countryName: `Goods Country 2 ${stamp}` },
});
country2Id = country2.id;
const cat = await prisma.category.create({
data: { categoryName: `Goods Cat ${stamp}` },
});
categoryId = cat.id;
const child = await prisma.category.create({
data: { categoryName: `Goods Child ${stamp}`, parentCategoryId: cat.id },
});
childCategoryId = child.id;
const tag = await prisma.tag.create({
data: { tagName: `Goods Tag ${stamp}`, tagColor: '#00FF00' },
});
tagId = tag.id;
const pos = await prisma.position.create({
data: { indexVal: 1, countryId, categoryId },
});
positionId = pos.id;
const originGoods = await Promise.all(
Array.from({ length: 5 }).map((_, i) =>
prisma.originGood.create({
data: {
sdsGoodId: `sds-goods-${stamp}-${i}`,
goodName: `Goods Origin ${stamp} ${i}`,
},
}),
),
);
originGoodIds = originGoods.map((og) => og.id);
});
afterAll(async () => {
// Wipe all goods first so origin_goods/category can be removed.
await prisma.good.deleteMany({
where: { goodName: { contains: `Goods Test ${stamp}` } },
});
await prisma.good.deleteMany({
where: { goodName: { contains: `Origin ${stamp}` } },
});
await prisma.originGood.deleteMany({
where: { id: { in: originGoodIds } },
});
await prisma.position.delete({ where: { id: positionId } });
await prisma.tag.delete({ where: { id: tagId } });
await prisma.category.delete({ where: { id: childCategoryId } });
await prisma.category.delete({ where: { id: categoryId } });
await prisma.country.delete({ where: { id: countryId } });
await prisma.country.delete({ where: { id: country2Id } });
await prisma.onModuleDestroy();
});
it('should be defined', () => {
expect(service).toBeDefined();
});
}).compile();
service = moduleRef.get(GoodsService);
prisma = moduleRef.get(PrismaService);
await prisma.onModuleInit();
const country = await prisma.country.create({
data: { countryName: `Goods Country ${stamp}` },
});
countryId = country.id;
const country2 = await prisma.country.create({
data: { countryName: `Goods Country 2 ${stamp}` },
});
country2Id = country2.id;
const cat = await prisma.category.create({
data: { categoryName: `Goods Cat ${stamp}` },
});
categoryId = cat.id;
const child = await prisma.category.create({
data: { categoryName: `Goods Child ${stamp}`, parentCategoryId: cat.id },
});
childCategoryId = child.id;
const tag = await prisma.tag.create({
data: { tagName: `Goods Tag ${stamp}`, tagColor: '#00FF00' },
});
tagId = tag.id;
const pos = await prisma.position.create({
data: { indexVal: 1, countryId, categoryId },
});
positionId = pos.id;
const originGoods = await Promise.all(
Array.from({ length: 5 }).map((_, i) =>
prisma.originGood.create({
data: {
sdsGoodId: `sds-goods-${stamp}-${i}`,
goodName: `Goods Origin ${stamp} ${i}`,
},
}),
),
);
originGoodIds = originGoods.map((og) => og.id);
});
afterAll(async () => {
// Wipe all goods first so origin_goods/category can be removed.
await prisma.good.deleteMany({
where: { goodName: { contains: `Goods Test ${stamp}` } },
});
await prisma.good.deleteMany({
where: { goodName: { contains: `Origin ${stamp}` } },
});
await prisma.originGood.deleteMany({
where: { id: { in: originGoodIds } },
});
await prisma.position.delete({ where: { id: positionId } });
await prisma.tag.delete({ where: { id: tagId } });
await prisma.category.delete({ where: { id: childCategoryId } });
await prisma.category.delete({ where: { id: categoryId } });
await prisma.country.delete({ where: { id: countryId } });
await prisma.country.delete({ where: { id: country2Id } });
await prisma.onModuleDestroy();
});
it('should be defined', () => {
expect(service).toBeDefined();
});
it('creates and reads back a good', async () => {
const created = await service.create({
goodName: `Goods Test ${stamp} basic`,
originGoodId: Number(originGoodIds[0]),
countryId: Number(countryId),
categoryId: Number(categoryId),
tagIds: [Number(tagId)],
positionId: Number(positionId),
goodPriority: 3,
});
expect(created.id).toBeTruthy();
expect(created.country?.countryName).toBeTruthy();
expect(created.tags.some((t) => t.tagColor === '#00FF00')).toBe(true);
const fetched = await service.findOne(BigInt(created.id));
expect(fetched.goodName).toBe(`Goods Test ${stamp} basic`);
const created = await service.create({
goodName: `Goods Test ${stamp} basic`,
originGoodId: Number(originGoodIds[0]),
countryId: Number(countryId),
categoryId: Number(categoryId),
tagIds: [Number(tagId)],
positionId: Number(positionId),
goodPriority: 3,
});
expect(created.id).toBeTruthy();
expect(created.country?.countryName).toBeTruthy();
expect(created.tags.some((t) => t.tagColor === '#00FF00')).toBe(true);
const fetched = await service.findOne(BigInt(created.id));
expect(fetched.goodName).toBe(`Goods Test ${stamp} basic`);
});
it('creates, edits, and removes a fully editable custom good', async () => {
@@ -163,107 +163,107 @@ describe('GoodsService', () => {
prisma.originGood.findUnique({ where: { id: customOriginId } }),
).resolves.toBeNull();
});
it('filters by countryId, tagId, positionId and keyword', async () => {
const result = await service.findAll({
page: 1,
pageSize: 20,
countryId: Number(countryId),
tagId: Number(tagId),
keyword: `Goods Test ${stamp}`,
});
expect(result.items.length).toBeGreaterThan(0);
expect(result.items.every((g) => g.countryId === countryId.toString())).toBe(true);
expect(
result.items.every((g) => g.tags.some((t) => t.id === tagId.toString())),
).toBe(true);
});
it('categoryId filter includes descendants recursively', async () => {
const inChild = await service.create({
goodName: `Goods Test ${stamp} child`,
originGoodId: Number(originGoodIds[1]),
countryId: Number(countryId),
categoryId: Number(childCategoryId),
});
const result = await service.findAll({
page: 1,
pageSize: 20,
categoryId: Number(categoryId),
keyword: `Goods Test ${stamp}`,
});
const ids = result.items.map((g) => g.id);
expect(ids).toContain(inChild.id);
});
it('batch update priority is atomic', async () => {
const created = await service.create({
goodName: `Goods Test ${stamp} prio`,
originGoodId: Number(originGoodIds[2]),
countryId: Number(countryId),
categoryId: Number(categoryId),
});
const result = await service.batchUpdatePriority({
items: [{ id: Number(created.id), priority: 42 }],
});
expect(result.count).toBe(1);
const after = await service.findOne(BigInt(created.id));
expect(after.goodPriority).toBe(42);
});
it('batch create creates all rows or none', async () => {
// Count of goods whose originGoodId is one of the two fixture ids,
// so we are independent of goodName (which batch derives from origin).
const before = await service.findAll({
page: 1,
pageSize: 100,
keyword: `Goods Origin ${stamp}`,
});
const created = await service.batchCreate({
countryId: Number(countryId),
categoryId: Number(categoryId),
defaultPriority: 1,
items: [
{ originGoodId: Number(originGoodIds[3]) },
{ originGoodId: Number(originGoodIds[4]) },
],
});
expect(created.length).toBe(2);
const after = await service.findAll({
page: 1,
pageSize: 100,
keyword: `Goods Origin ${stamp}`,
});
expect(after.total).toBe(before.total + 2);
});
it('batch create rolls back on failure', async () => {
const before = await service.findAll({
page: 1,
pageSize: 100,
keyword: `Goods Origin ${stamp}`,
});
await expect(
service.batchCreate({
countryId: Number(countryId),
categoryId: Number(categoryId),
items: [
{ originGoodId: Number(originGoodIds[0]) },
{ originGoodId: 99999999 }, // missing -> failure
],
}),
).rejects.toBeInstanceOf(BadRequestException);
const after = await service.findAll({
page: 1,
pageSize: 100,
keyword: `Goods Origin ${stamp}`,
});
expect(after.total).toBe(before.total);
});
it('throws NotFoundException for unknown id', async () => {
await expect(service.findOne(BigInt(99999999))).rejects.toBeInstanceOf(
NotFoundException,
);
});
});
it('filters by countryId, tagId, positionId and keyword', async () => {
const result = await service.findAll({
page: 1,
pageSize: 20,
countryId: Number(countryId),
tagId: Number(tagId),
keyword: `Goods Test ${stamp}`,
});
expect(result.items.length).toBeGreaterThan(0);
expect(result.items.every((g) => g.countryId === countryId.toString())).toBe(true);
expect(
result.items.every((g) => g.tags.some((t) => t.id === tagId.toString())),
).toBe(true);
});
it('categoryId filter includes descendants recursively', async () => {
const inChild = await service.create({
goodName: `Goods Test ${stamp} child`,
originGoodId: Number(originGoodIds[1]),
countryId: Number(countryId),
categoryId: Number(childCategoryId),
});
const result = await service.findAll({
page: 1,
pageSize: 20,
categoryId: Number(categoryId),
keyword: `Goods Test ${stamp}`,
});
const ids = result.items.map((g) => g.id);
expect(ids).toContain(inChild.id);
});
it('batch update priority is atomic', async () => {
const created = await service.create({
goodName: `Goods Test ${stamp} prio`,
originGoodId: Number(originGoodIds[2]),
countryId: Number(countryId),
categoryId: Number(categoryId),
});
const result = await service.batchUpdatePriority({
items: [{ id: Number(created.id), priority: 42 }],
});
expect(result.count).toBe(1);
const after = await service.findOne(BigInt(created.id));
expect(after.goodPriority).toBe(42);
});
it('batch create creates all rows or none', async () => {
// Count of goods whose originGoodId is one of the two fixture ids,
// so we are independent of goodName (which batch derives from origin).
const before = await service.findAll({
page: 1,
pageSize: 100,
keyword: `Goods Origin ${stamp}`,
});
const created = await service.batchCreate({
countryId: Number(countryId),
categoryId: Number(categoryId),
defaultPriority: 1,
items: [
{ originGoodId: Number(originGoodIds[3]) },
{ originGoodId: Number(originGoodIds[4]) },
],
});
expect(created.length).toBe(2);
const after = await service.findAll({
page: 1,
pageSize: 100,
keyword: `Goods Origin ${stamp}`,
});
expect(after.total).toBe(before.total + 2);
});
it('batch create rolls back on failure', async () => {
const before = await service.findAll({
page: 1,
pageSize: 100,
keyword: `Goods Origin ${stamp}`,
});
await expect(
service.batchCreate({
countryId: Number(countryId),
categoryId: Number(categoryId),
items: [
{ originGoodId: Number(originGoodIds[0]) },
{ originGoodId: 99999999 }, // missing -> failure
],
}),
).rejects.toBeInstanceOf(BadRequestException);
const after = await service.findAll({
page: 1,
pageSize: 100,
keyword: `Goods Origin ${stamp}`,
});
expect(after.total).toBe(before.total);
});
it('throws NotFoundException for unknown id', async () => {
await expect(service.findOne(BigInt(99999999))).rejects.toBeInstanceOf(
NotFoundException,
);
});
});
+286 -286
View File
@@ -1,14 +1,14 @@
import { Prisma } from '@prisma/client';
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { CreateGoodDto } from './dto/create-good.dto';
import { UpdateGoodDto } from './dto/update-good.dto';
import { QueryGoodDto } from './dto/query-good.dto';
import { BatchCreateGoodDto } from './dto/batch-create-good.dto';
import { Prisma } from '@prisma/client';
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { CreateGoodDto } from './dto/create-good.dto';
import { UpdateGoodDto } from './dto/update-good.dto';
import { QueryGoodDto } from './dto/query-good.dto';
import { BatchCreateGoodDto } from './dto/batch-create-good.dto';
import { BatchPriorityDto } from './dto/batch-priority.dto';
import { GoodDetailDto, GoodDto, PaginatedGoods } from './dto/good.dto';
import { SyncService } from '../sync/sync.service';
@@ -19,12 +19,12 @@ import {
CustomGoodVariantDto,
UpdateCustomGoodContentDto,
} from './dto/custom-good.dto';
const GOOD_INCLUDE = {
country: true,
category: true,
tag: true,
position: true,
const GOOD_INCLUDE = {
country: true,
category: true,
tag: true,
position: true,
originGood: {
include: {
detail: true,
@@ -32,105 +32,105 @@ const GOOD_INCLUDE = {
_count: { select: { variants: true } },
},
},
goodTags: { include: { tag: true } },
} satisfies Prisma.GoodInclude;
@Injectable()
export class GoodsService {
goodTags: { include: { tag: true } },
} satisfies Prisma.GoodInclude;
@Injectable()
export class GoodsService {
constructor(
private readonly prisma: PrismaService,
private readonly syncService: SyncService,
) {}
async findAll(query: QueryGoodDto): Promise<PaginatedGoods> {
const { page, pageSize, countryId, categoryId, tagId, positionId, keyword } = query;
const where: Prisma.GoodWhereInput = {};
if (countryId !== undefined) where.countryId = BigInt(countryId);
if (tagId !== undefined) where.goodTags = { some: { tagId: BigInt(tagId) } };
if (positionId !== undefined) where.positionId = BigInt(positionId);
if (keyword) {
where.goodName = { contains: keyword, mode: 'insensitive' };
}
if (categoryId !== undefined) {
const ids = await this.collectCategoryDescendants(BigInt(categoryId));
where.categoryId = { in: ids };
}
const [total, rows] = await this.prisma.$transaction([
this.prisma.good.count({ where }),
this.prisma.good.findMany({
where,
include: GOOD_INCLUDE,
orderBy: [{ goodPriority: 'desc' }, { createdAt: 'desc' }],
skip: (page - 1) * pageSize,
take: pageSize,
}),
]);
return {
items: rows.map((g) => GoodDto.from(g, {
country: g.country,
category: g.category,
tag: g.tag,
position: g.position,
originGood: g.originGood,
goodTags: g.goodTags,
})),
total,
page,
pageSize,
};
}
async findAll(query: QueryGoodDto): Promise<PaginatedGoods> {
const { page, pageSize, countryId, categoryId, tagId, positionId, keyword } = query;
const where: Prisma.GoodWhereInput = {};
if (countryId !== undefined) where.countryId = BigInt(countryId);
if (tagId !== undefined) where.goodTags = { some: { tagId: BigInt(tagId) } };
if (positionId !== undefined) where.positionId = BigInt(positionId);
if (keyword) {
where.goodName = { contains: keyword, mode: 'insensitive' };
}
if (categoryId !== undefined) {
const ids = await this.collectCategoryDescendants(BigInt(categoryId));
where.categoryId = { in: ids };
}
const [total, rows] = await this.prisma.$transaction([
this.prisma.good.count({ where }),
this.prisma.good.findMany({
where,
include: GOOD_INCLUDE,
orderBy: [{ goodPriority: 'desc' }, { createdAt: 'desc' }],
skip: (page - 1) * pageSize,
take: pageSize,
}),
]);
return {
items: rows.map((g) => GoodDto.from(g, {
country: g.country,
category: g.category,
tag: g.tag,
position: g.position,
originGood: g.originGood,
goodTags: g.goodTags,
})),
total,
page,
pageSize,
};
}
async findOne(id: bigint): Promise<GoodDetailDto> {
const good = await this.prisma.good.findUnique({
where: { id },
include: GOOD_INCLUDE,
});
if (!good) throw new NotFoundException(`Good ${id} not found`);
const good = await this.prisma.good.findUnique({
where: { id },
include: GOOD_INCLUDE,
});
if (!good) throw new NotFoundException(`Good ${id} not found`);
return GoodDetailDto.fromGood(good, {
country: good.country,
category: good.category,
tag: good.tag,
position: good.position,
originGood: good.originGood,
goodTags: good.goodTags,
});
}
country: good.country,
category: good.category,
tag: good.tag,
position: good.position,
originGood: good.originGood,
goodTags: good.goodTags,
});
}
async create(dto: CreateGoodDto): Promise<GoodDto> {
await this.ensureReferences(dto);
const result = await this.prisma.$transaction(async (tx) => {
const created = await tx.good.create({
data: {
goodName: dto.goodName,
goodImage: dto.goodImage,
originGoodId: BigInt(dto.originGoodId),
countryId: BigInt(dto.countryId),
categoryId: BigInt(dto.categoryId),
positionId: dto.positionId === undefined ? null : BigInt(dto.positionId),
goodPriority: dto.goodPriority ?? 0,
},
});
if (dto.tagIds && dto.tagIds.length > 0) {
await tx.goodTag.createMany({
data: dto.tagIds.map((tagId) => ({
goodId: created.id,
tagId: BigInt(tagId),
})),
});
}
const result = await tx.good.findUniqueOrThrow({
where: { id: created.id },
include: GOOD_INCLUDE,
});
const created = await tx.good.create({
data: {
goodName: dto.goodName,
goodImage: dto.goodImage,
originGoodId: BigInt(dto.originGoodId),
countryId: BigInt(dto.countryId),
categoryId: BigInt(dto.categoryId),
positionId: dto.positionId === undefined ? null : BigInt(dto.positionId),
goodPriority: dto.goodPriority ?? 0,
},
});
if (dto.tagIds && dto.tagIds.length > 0) {
await tx.goodTag.createMany({
data: dto.tagIds.map((tagId) => ({
goodId: created.id,
tagId: BigInt(tagId),
})),
});
}
const result = await tx.good.findUniqueOrThrow({
where: { id: created.id },
include: GOOD_INCLUDE,
});
return GoodDto.from(result, {
country: result.country,
category: result.category,
tag: result.tag,
position: result.position,
originGood: result.originGood,
goodTags: result.goodTags,
country: result.country,
category: result.category,
tag: result.tag,
position: result.position,
originGood: result.originGood,
goodTags: result.goodTags,
});
});
if (
@@ -243,61 +243,61 @@ export class GoodsService {
});
return this.findOne(id);
}
async update(id: bigint, dto: UpdateGoodDto): Promise<GoodDto> {
await this.findOne(id);
const data: Prisma.GoodUpdateInput = {};
if (dto.goodName !== undefined) data.goodName = dto.goodName;
if (dto.originGoodId !== undefined) {
await this.ensureOriginGood(dto.originGoodId);
data.originGood = { connect: { id: BigInt(dto.originGoodId) } };
}
if (dto.countryId !== undefined) {
await this.ensureCountry(dto.countryId);
data.country = { connect: { id: BigInt(dto.countryId) } };
}
if (dto.categoryId !== undefined) {
await this.ensureCategory(dto.categoryId);
data.category = { connect: { id: BigInt(dto.categoryId) } };
}
if (dto.positionId !== undefined) {
data.position =
dto.positionId === null
? { disconnect: true }
: { connect: { id: BigInt(dto.positionId) } };
}
if (dto.goodPriority !== undefined) data.goodPriority = dto.goodPriority;
if (dto.goodImage !== undefined) data.goodImage = dto.goodImage;
if (dto.tagIds !== undefined) {
for (const tagId of dto.tagIds) {
await this.ensureTag(tagId);
}
}
async update(id: bigint, dto: UpdateGoodDto): Promise<GoodDto> {
await this.findOne(id);
const data: Prisma.GoodUpdateInput = {};
if (dto.goodName !== undefined) data.goodName = dto.goodName;
if (dto.originGoodId !== undefined) {
await this.ensureOriginGood(dto.originGoodId);
data.originGood = { connect: { id: BigInt(dto.originGoodId) } };
}
if (dto.countryId !== undefined) {
await this.ensureCountry(dto.countryId);
data.country = { connect: { id: BigInt(dto.countryId) } };
}
if (dto.categoryId !== undefined) {
await this.ensureCategory(dto.categoryId);
data.category = { connect: { id: BigInt(dto.categoryId) } };
}
if (dto.positionId !== undefined) {
data.position =
dto.positionId === null
? { disconnect: true }
: { connect: { id: BigInt(dto.positionId) } };
}
if (dto.goodPriority !== undefined) data.goodPriority = dto.goodPriority;
if (dto.goodImage !== undefined) data.goodImage = dto.goodImage;
if (dto.tagIds !== undefined) {
for (const tagId of dto.tagIds) {
await this.ensureTag(tagId);
}
}
const result = await this.prisma.$transaction(async (tx) => {
if (dto.tagIds !== undefined) {
await tx.goodTag.deleteMany({ where: { goodId: id } });
if (dto.tagIds.length > 0) {
await tx.goodTag.createMany({
data: dto.tagIds.map((tagId) => ({
goodId: id,
tagId: BigInt(tagId),
})),
});
}
}
const updated = await tx.good.update({
where: { id },
data,
include: GOOD_INCLUDE,
});
if (dto.tagIds !== undefined) {
await tx.goodTag.deleteMany({ where: { goodId: id } });
if (dto.tagIds.length > 0) {
await tx.goodTag.createMany({
data: dto.tagIds.map((tagId) => ({
goodId: id,
tagId: BigInt(tagId),
})),
});
}
}
const updated = await tx.good.update({
where: { id },
data,
include: GOOD_INCLUDE,
});
return GoodDto.from(updated, {
country: updated.country,
category: updated.category,
tag: updated.tag,
position: updated.position,
originGood: updated.originGood,
goodTags: updated.goodTags,
country: updated.country,
category: updated.category,
tag: updated.tag,
position: updated.position,
originGood: updated.originGood,
goodTags: updated.goodTags,
});
});
if (
@@ -308,8 +308,8 @@ export class GoodsService {
this.syncService.queueProductDetailSync(result.originGood.sdsGoodId);
}
return result;
}
}
async remove(id: bigint): Promise<{ id: string }> {
const good = await this.prisma.good.findUnique({
where: { id },
@@ -327,80 +327,80 @@ export class GoodsService {
}
}
});
return { id: id.toString() };
}
/**
* Updates priorities in a single transaction; either all rows update
* or none do.
*/
async batchUpdatePriority(dto: BatchPriorityDto): Promise<{ count: number }> {
return { id: id.toString() };
}
/**
* Updates priorities in a single transaction; either all rows update
* or none do.
*/
async batchUpdatePriority(dto: BatchPriorityDto): Promise<{ count: number }> {
const result = await this.prisma.$transaction(async (tx) => {
for (const item of dto.items) {
await tx.good.update({
where: { id: BigInt(item.id) },
data: { goodPriority: item.priority },
});
}
for (const item of dto.items) {
await tx.good.update({
where: { id: BigInt(item.id) },
data: { goodPriority: item.priority },
});
}
return { count: dto.items.length };
});
return result;
}
/**
* Creates multiple goods atomically, sharing countryId/categoryId/tagIds/positionId
* and a default priority that may be overridden per item.
*/
async batchCreate(dto: BatchCreateGoodDto): Promise<GoodDto[]> {
const defaultPriority = dto.defaultPriority ?? 0;
if (dto.tagIds && dto.tagIds.length > 0) {
for (const tagId of dto.tagIds) {
await this.ensureTag(tagId);
}
}
}
/**
* Creates multiple goods atomically, sharing countryId/categoryId/tagIds/positionId
* and a default priority that may be overridden per item.
*/
async batchCreate(dto: BatchCreateGoodDto): Promise<GoodDto[]> {
const defaultPriority = dto.defaultPriority ?? 0;
if (dto.tagIds && dto.tagIds.length > 0) {
for (const tagId of dto.tagIds) {
await this.ensureTag(tagId);
}
}
const result = await this.prisma.$transaction(async (tx) => {
const created: GoodDto[] = [];
for (const item of dto.items) {
const og = await tx.originGood.findUnique({
where: { id: BigInt(item.originGoodId) },
});
if (!og) {
throw new BadRequestException(
`Origin good ${item.originGoodId} not found`,
);
}
const row = await tx.good.create({
data: {
goodName: og.goodName ?? `Origin Good ${og.sdsGoodId}`,
goodImage: og.goodImage,
originGoodId: og.id,
countryId: BigInt(dto.countryId),
categoryId: BigInt(dto.categoryId),
positionId: dto.positionId === undefined ? null : BigInt(dto.positionId),
goodPriority: item.priority ?? defaultPriority,
},
});
if (dto.tagIds && dto.tagIds.length > 0) {
await tx.goodTag.createMany({
data: dto.tagIds.map((tagId) => ({
goodId: row.id,
tagId: BigInt(tagId),
})),
});
}
const result = await tx.good.findUniqueOrThrow({
where: { id: row.id },
include: GOOD_INCLUDE,
});
created.push(GoodDto.from(result, {
country: result.country,
category: result.category,
tag: result.tag,
position: result.position,
originGood: result.originGood,
goodTags: result.goodTags,
}));
}
const created: GoodDto[] = [];
for (const item of dto.items) {
const og = await tx.originGood.findUnique({
where: { id: BigInt(item.originGoodId) },
});
if (!og) {
throw new BadRequestException(
`Origin good ${item.originGoodId} not found`,
);
}
const row = await tx.good.create({
data: {
goodName: og.goodName ?? `Origin Good ${og.sdsGoodId}`,
goodImage: og.goodImage,
originGoodId: og.id,
countryId: BigInt(dto.countryId),
categoryId: BigInt(dto.categoryId),
positionId: dto.positionId === undefined ? null : BigInt(dto.positionId),
goodPriority: item.priority ?? defaultPriority,
},
});
if (dto.tagIds && dto.tagIds.length > 0) {
await tx.goodTag.createMany({
data: dto.tagIds.map((tagId) => ({
goodId: row.id,
tagId: BigInt(tagId),
})),
});
}
const result = await tx.good.findUniqueOrThrow({
where: { id: row.id },
include: GOOD_INCLUDE,
});
created.push(GoodDto.from(result, {
country: result.country,
category: result.category,
tag: result.tag,
position: result.position,
originGood: result.originGood,
goodTags: result.goodTags,
}));
}
return created;
});
for (const goodId of new Set(
@@ -416,64 +416,64 @@ export class GoodsService {
this.syncService.queueProductDetailSync(goodId);
}
return result;
}
/**
* Walk the category tree and return the requested id + all of its
* descendants. We use a level-by-level BFS to keep the queries small
* for the typical tree sizes we expect.
*/
private async collectCategoryDescendants(rootId: bigint): Promise<bigint[]> {
const ids: bigint[] = [rootId];
let frontier: bigint[] = [rootId];
while (frontier.length > 0) {
const children = await this.prisma.category.findMany({
where: { parentCategoryId: { in: frontier } },
select: { id: true },
});
if (children.length === 0) break;
const childIds = children.map((c) => c.id);
ids.push(...childIds);
frontier = childIds;
}
return ids;
}
private async ensureOriginGood(id: number) {
const og = await this.prisma.originGood.findUnique({
where: { id: BigInt(id) },
});
if (!og) throw new BadRequestException(`Origin good ${id} not found`);
}
private async ensureCountry(id: number) {
const c = await this.prisma.country.findUnique({ where: { id: BigInt(id) } });
if (!c) throw new BadRequestException(`Country ${id} not found`);
}
private async ensureCategory(id: number) {
const c = await this.prisma.category.findUnique({ where: { id: BigInt(id) } });
if (!c) throw new BadRequestException(`Category ${id} not found`);
}
}
/**
* Walk the category tree and return the requested id + all of its
* descendants. We use a level-by-level BFS to keep the queries small
* for the typical tree sizes we expect.
*/
private async collectCategoryDescendants(rootId: bigint): Promise<bigint[]> {
const ids: bigint[] = [rootId];
let frontier: bigint[] = [rootId];
while (frontier.length > 0) {
const children = await this.prisma.category.findMany({
where: { parentCategoryId: { in: frontier } },
select: { id: true },
});
if (children.length === 0) break;
const childIds = children.map((c) => c.id);
ids.push(...childIds);
frontier = childIds;
}
return ids;
}
private async ensureOriginGood(id: number) {
const og = await this.prisma.originGood.findUnique({
where: { id: BigInt(id) },
});
if (!og) throw new BadRequestException(`Origin good ${id} not found`);
}
private async ensureCountry(id: number) {
const c = await this.prisma.country.findUnique({ where: { id: BigInt(id) } });
if (!c) throw new BadRequestException(`Country ${id} not found`);
}
private async ensureCategory(id: number) {
const c = await this.prisma.category.findUnique({ where: { id: BigInt(id) } });
if (!c) throw new BadRequestException(`Category ${id} not found`);
}
private async ensureTag(id: number) {
const t = await this.prisma.tag.findUnique({ where: { id: BigInt(id) } });
if (!t) throw new BadRequestException(`Tag ${id} not found`);
}
const t = await this.prisma.tag.findUnique({ where: { id: BigInt(id) } });
if (!t) throw new BadRequestException(`Tag ${id} not found`);
}
private async ensureReferences(dto: CreateGoodDto) {
await this.ensureOriginGood(dto.originGoodId);
await this.ensureCountry(dto.countryId);
await this.ensureCategory(dto.categoryId);
if (dto.tagIds && dto.tagIds.length > 0) {
for (const tagId of dto.tagIds) {
await this.ensureTag(tagId);
}
}
if (dto.positionId !== undefined) {
const p = await this.prisma.position.findUnique({ where: { id: BigInt(dto.positionId) } });
if (!p) throw new BadRequestException(`Position ${dto.positionId} not found`);
}
await this.ensureOriginGood(dto.originGoodId);
await this.ensureCountry(dto.countryId);
await this.ensureCategory(dto.categoryId);
if (dto.tagIds && dto.tagIds.length > 0) {
for (const tagId of dto.tagIds) {
await this.ensureTag(tagId);
}
}
if (dto.positionId !== undefined) {
const p = await this.prisma.position.findUnique({ where: { id: BigInt(dto.positionId) } });
if (!p) throw new BadRequestException(`Position ${dto.positionId} not found`);
}
}
private async ensurePosition(id: number) {