Files
inkreach-official-website/apps/api/src/public/public.service.ts
T
yeuimu 7e04877bb6 chore: migrate to pnpm workspaces monorepo with Turborepo
- Restructure directories: apps/api, apps/admin, apps/website
- Add root pnpm-workspace.yaml, turbo.json, .prettierrc, .gitignore
- Rename packages to @inkreach/api, @inkreach/admin, @inkreach/website
- Add shared packages: packages/tsconfig, packages/shared-types
- Add pnpm.onlyBuiltDependencies for native builds
- Update docs: README.md, structs.md
- All three projects build successfully
2026-07-11 16:54:05 +08:00

253 lines
8.3 KiB
TypeScript

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<PublicCategoryNodeDto[]> {
const leafCategories = await this.prisma.category.findMany({
where: { goods: { some: {} } },
orderBy: { id: 'asc' },
});
const ancestorIds = new Set<bigint>();
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<PublicCountryDto[]> {
const rows = await this.prisma.country.findMany({
where: { goods: { some: {} } },
orderBy: { id: 'asc' },
});
return rows.map(PublicCountryDto.from);
}
async getTags(): Promise<PublicTagDto[]> {
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<PublicTagGroupDto[]> {
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<PublicPaginatedGoods> {
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<PublicGoodDto> {
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<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 buildTree(
rows: PrismaCategory[],
): PublicCategoryNodeDto[] {
const byId = new Map<bigint, PublicCategoryNodeDto>();
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;
}
}