- 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
253 lines
8.3 KiB
TypeScript
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;
|
|
}
|
|
} |