import { Injectable, NotFoundException } from '@nestjs/common'; import { Category as PrismaCategory, Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { PublicQueryGoodDto } from './dto/public-query-good.dto'; import { PublicCategoryNodeDto } from './dto/public-category.dto'; import { PublicCountryDto } from './dto/public-country.dto'; import { PublicTagDto } from './dto/public-tag.dto'; import { PublicTagGroupDto } from './dto/public-tag-group.dto'; import { PublicGoodDto } from './dto/public-good.dto'; export interface PublicPaginatedGoods { items: PublicGoodDto[]; total: number; page: number; pageSize: number; } const PUBLIC_GOOD_INCLUDE = { country: true, category: true, tag: { include: { tagGroup: true } }, position: true, originGood: true, goodTags: { include: { tag: { include: { tagGroup: true } } } }, } satisfies Prisma.GoodInclude; @Injectable() export class PublicService { constructor(private readonly prisma: PrismaService) {} async getCategoriesTree(): Promise { const leafCategories = await this.prisma.category.findMany({ where: { goods: { some: {} } }, orderBy: { id: 'asc' }, }); const ancestorIds = new Set(); for (const leaf of leafCategories) { let cursor: bigint | null = leaf.parentCategoryId; while (cursor !== null && !ancestorIds.has(cursor)) { ancestorIds.add(cursor); const parent = await this.prisma.category.findUnique({ where: { id: cursor }, select: { id: true, parentCategoryId: true }, }); if (!parent) break; cursor = parent.parentCategoryId; } } const ancestorRows = ancestorIds.size > 0 ? await this.prisma.category.findMany({ where: { id: { in: [...ancestorIds] } }, orderBy: { id: 'asc' }, }) : []; const allRows = [...leafCategories, ...ancestorRows].filter( (row, idx, arr) => arr.findIndex((r) => r.id === row.id) === idx, ); allRows.sort((a, b) => Number(a.id - b.id)); return this.buildTree(allRows); } async getCountries(): Promise { const rows = await this.prisma.country.findMany({ where: { goods: { some: {} } }, orderBy: { id: 'asc' }, }); return rows.map(PublicCountryDto.from); } async getTags(): Promise { const rows = await this.prisma.tag.findMany({ where: { goodTags: { some: {} } }, orderBy: [ { tagGroup: { sortOrder: 'asc' } }, { sortOrder: 'asc' }, { id: 'asc' }, ], include: { tagGroup: true }, }); return rows.map(PublicTagDto.from); } async getTagGroups(): Promise { const rows = await this.prisma.tagGroup.findMany({ where: { tags: { some: { goodTags: { some: {} } } } }, orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }], }); return rows.map(PublicTagGroupDto.from); } async getGoods(query: PublicQueryGoodDto): Promise { const where: Prisma.GoodWhereInput = {}; if (query.countryId !== undefined) where.countryId = BigInt(query.countryId); if (query.tagIds) { const ids = query.tagIds .split(',') .map((s) => s.trim()) .filter(Boolean) .map((s) => BigInt(s)); if (ids.length > 0) { // AND logic: 商品必须同时具备所有选中的 tag where.AND = ids.map((id) => ({ goodTags: { some: { tagId: id } } })); } } if (query.keyword) { where.goodName = { contains: query.keyword, mode: 'insensitive' }; } if (query.categoryId !== undefined) { const ids = await this.collectCategoryDescendants(BigInt(query.categoryId)); where.categoryId = { in: ids }; } const [total, rows] = await this.prisma.$transaction([ this.prisma.good.count({ where }), this.prisma.good.findMany({ where, include: PUBLIC_GOOD_INCLUDE, // Server-side primary sort; PublicGoodDto retains original indexes // for stable pagination but the final ORDER BY is mirrored below. orderBy: [ { goodPriority: 'desc' }, { position: { indexVal: 'asc' } }, { createdAt: 'desc' }, ], skip: (query.page - 1) * query.pageSize, take: query.pageSize, }), ]); return { items: rows.map((g) => this.toPublicGood(g)), total, page: query.page, pageSize: query.pageSize, }; } async getGood(id: bigint): Promise { const good = await this.prisma.good.findUnique({ where: { id }, include: PUBLIC_GOOD_INCLUDE, }); if (!good) throw new NotFoundException(`Good ${id} not found`); return this.toPublicGood(good); } private toPublicGood(good: { id: bigint; goodName: string; goodImage: string | null; goodPriority: number; country: { id: bigint; countryName: string; countryIcon: string | null }; category: { id: bigint; categoryName: string; categoryIcon: string | null }; tag: { id: bigint; tagName: string; tagColor: string | null; tagFontColor: string | null; tagGroup: { id: bigint; groupName: string; sortOrder: number } | null } | null; position: { id: bigint; indexVal: number } | null; originGood: { goodImage: string | null; goodPrice: { toString(): string } | null; } | null; goodTags: { tag: { id: bigint; tagName: string; tagColor: string | null; tagFontColor: string | null; tagGroup: { id: bigint; groupName: string; sortOrder: number } | null } }[]; createdAt: Date; }): PublicGoodDto { const formatGroup = (g: { id: bigint; groupName: string; sortOrder: number } | null) => g ? { id: g.id.toString(), groupName: g.groupName, sortOrder: g.sortOrder, } : null; return { id: good.id.toString(), goodName: good.goodName, goodPriority: good.goodPriority, country: { id: good.country.id.toString(), countryName: good.country.countryName, countryIcon: good.country.countryIcon, }, category: { id: good.category.id.toString(), categoryName: good.category.categoryName, categoryIcon: good.category.categoryIcon, }, tag: good.tag ? { id: good.tag.id.toString(), tagName: good.tag.tagName, tagColor: good.tag.tagColor, tagFontColor: good.tag.tagFontColor, group: formatGroup(good.tag.tagGroup), } : null, tags: good.goodTags.map((gt) => ({ id: gt.tag.id.toString(), tagName: gt.tag.tagName, tagColor: gt.tag.tagColor, tagFontColor: gt.tag.tagFontColor, group: formatGroup(gt.tag.tagGroup), })), position: good.position ? { id: good.position.id.toString(), indexVal: good.position.indexVal, } : null, image: good.goodImage ?? good.originGood?.goodImage ?? null, price: good.originGood?.goodPrice === null || good.originGood?.goodPrice === undefined ? null : good.originGood.goodPrice.toString(), createdAt: good.createdAt.toISOString(), }; } private async collectCategoryDescendants(rootId: bigint): Promise { 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 buildTree( rows: PrismaCategory[], ): PublicCategoryNodeDto[] { const byId = new Map(); for (const row of rows) { byId.set(row.id, PublicCategoryNodeDto.from(row, [])); } const roots: PublicCategoryNodeDto[] = []; for (const row of rows) { const node = byId.get(row.id)!; if (row.parentCategoryId === null) { roots.push(node); } else { const parent = byId.get(row.parentCategoryId); if (parent) parent.children.push(node); else roots.push(node); } } return roots; } }