Files
inkreach-official-website/apps/api/src/public/public.service.ts
T
yeuimu 1e07c62fc8 feat(admin): country drag-sort + always-available member detail sync
- Country 加 sort_order(默认 0 保持 id 序);PATCH /countries/sort 批量保存
  顺序(对齐 /tags/sort 模式);findAll 与公开 /public/countries 均按
  sortOrder 排序
- CountriesView 表格改为可拖拽行列表:拖动松开即全量保存新顺序,失败回滚
- 商品编辑弹窗成员展开面板:同步详情按钮常驻(已同步显示 重新同步详情),
  不再只在未同步态出现
- goods.service.spec 的 FamilyRecomputeService mock 补齐 syncFamilyTags 等
  方法(全量并行时其他套件的扫名归族会把本套件夹具收进族,create/update
  会调用到,mock 缺方法导致偶发 TypeError)
- api 164/164、admin typecheck+22/22+构建全绿
2026-08-28 20:00:56 +08:00

798 lines
31 KiB
TypeScript

import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Category as PrismaCategory, Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import {
PublicHomeGoodsQueryDto,
PublicQueryGoodDto,
PublicTagFilterDto,
} 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';
import {
PublicGoodDetailDto,
PublicTagGroupFilterDto,
} from './dto/public-good-detail.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: {
include: {
detail: true,
variants: { orderBy: [{ sortOrder: 'asc' as const }, { id: 'asc' as const }] },
family: {
select: {
id: true,
familyCode: true,
familyName: true,
sizeChart: true,
packageSpecs: true,
priceMatrix: true,
},
},
},
},
mergedOriginGoods: {
orderBy: { createdAt: 'asc' as const },
include: {
originGood: {
include: {
detail: true,
variants: { orderBy: [{ sortOrder: 'asc' as const }, { id: 'asc' as const }] },
},
},
},
},
goodTags: { include: { tag: { include: { tagGroup: true } } } },
} satisfies Prisma.GoodInclude;
type PublicGoodRow = Prisma.GoodGetPayload<{ include: typeof PUBLIC_GOOD_INCLUDE }>;
@Injectable()
export class PublicService {
constructor(private readonly prisma: PrismaService) {}
async getCategoriesTree(countryId?: string): Promise<PublicCategoryNodeDto[]> {
const goodsWhere: Prisma.GoodWhereInput = {
familyId: { not: null },
originGood: { delisted: false },
...(countryId ? { countryId: BigInt(countryId) } : {}),
};
const leafCategories = await this.prisma.category.findMany({
where: { goods: { some: goodsWhere } },
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
? await this.prisma.category.findMany({
where: { id: { in: [...ancestorIds] } },
orderBy: { id: 'asc' },
})
: [];
const allRows = [...leafCategories, ...ancestorRows].filter(
(row, index, rows) => rows.findIndex((item) => item.id === row.id) === index,
);
allRows.sort((a, b) => Number(a.id - b.id));
const directCounts = await this.prisma.good.groupBy({
by: ['categoryId'],
where: goodsWhere,
_count: { _all: true },
});
return this.buildTree(
allRows,
new Map(directCounts.map((row) => [row.categoryId, row._count._all])),
);
}
async getCountries(): Promise<PublicCountryDto[]> {
const rows = await this.prisma.country.findMany({
where: { goods: { some: { familyId: { not: null }, originGood: { delisted: false } } } },
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
});
return rows.map(PublicCountryDto.from);
}
async getTags(): Promise<PublicTagDto[]> {
const rows = await this.prisma.tag.findMany({
where: {
goodTags: {
some: { good: { familyId: { not: null }, originGood: { delisted: false } } },
},
},
orderBy: [
{ tagGroup: { sortOrder: 'asc' } },
{ sortOrder: 'asc' },
{ id: 'asc' },
],
include: { tagGroup: true },
});
return rows.map(PublicTagDto.from);
}
async getTagGroups(countryId?: string): Promise<PublicTagGroupFilterDto[]> {
const goodWhere: Prisma.GoodWhereInput = {
familyId: { not: null },
originGood: { delisted: false },
...(countryId ? { countryId: BigInt(countryId) } : {}),
};
const rows = await this.prisma.tagGroup.findMany({
where: { tags: { some: { goodTags: { some: { good: goodWhere } } } } },
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
include: {
tags: {
where: { goodTags: { some: { good: goodWhere } } },
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
include: {
_count: { select: { goodTags: { where: { good: goodWhere } } } },
},
},
},
});
return rows.map((group) => ({
...PublicTagGroupDto.from(group),
tags: group.tags.map((tag) => ({
id: tag.id.toString(),
tagName: tag.tagName,
tagColor: tag.tagColor,
tagFontColor: tag.tagFontColor,
sortOrder: tag.sortOrder,
productCount: tag._count.goodTags,
})),
}));
}
async getGoods(query: PublicQueryGoodDto): Promise<PublicPaginatedGoods> {
// 无族商品(自定义)不进公开列表:只认族
const where: Prisma.GoodWhereInput = {
familyId: { not: null },
originGood: { delisted: false },
};
if (query.countryId) where.countryId = BigInt(query.countryId);
if (query.keyword) where.goodName = { contains: query.keyword, mode: 'insensitive' };
if (query.categoryId) {
where.categoryId = { in: await this.collectCategoryDescendants(BigInt(query.categoryId)) };
}
const tagFilters = await this.buildTagGroupFilters(query.tags ?? []);
if (tagFilters.length) where.AND = tagFilters;
const minPrice = this.parsePrice(query.minPrice, 'minPrice');
const maxPrice = this.parsePrice(query.maxPrice, 'maxPrice');
if (minPrice !== null && maxPrice !== null && minPrice > maxPrice) {
throw new BadRequestException('minPrice 不能大于 maxPrice');
}
if (minPrice !== null || maxPrice !== null) {
where.originGood = {
delisted: false,
goodPrice: {
...(minPrice !== null ? { gte: minPrice } : {}),
...(maxPrice !== null ? { lte: maxPrice } : {}),
},
};
}
const orderBy: Prisma.GoodOrderByWithRelationInput[] =
query.sort === 'PRICE_ASC'
? [{ originGood: { goodPrice: 'asc' } }, { id: 'asc' }]
: query.sort === 'PRICE_DESC'
? [{ originGood: { goodPrice: 'desc' } }, { id: 'asc' }]
: query.sort === 'NEWEST'
? [{ createdAt: 'desc' }, { id: 'asc' }]
: [
{ goodPriority: 'desc' },
{ position: { indexVal: 'asc' } },
{ createdAt: 'desc' },
{ id: 'asc' },
];
// 契约族化:一族对外只暴露一条(代表行=排序第一条,goodId=族ID);
// 无族 Good(自定义商品)各自成一条。商品量级为百级,先取全量匹配
// 再内存分组、分页作用于分组结果 —— 若量级上万需改为物化族表查询。
const rows = await this.prisma.good.findMany({
where,
include: PUBLIC_GOOD_INCLUDE,
orderBy,
});
const grouped = new Map<string, PublicGoodRow[]>();
for (const good of rows) {
const key = good.familyId ? `f:${good.familyId}` : `g:${good.id}`;
const bucket = grouped.get(key);
if (bucket) bucket.push(good);
else grouped.set(key, [good]);
}
let items = [...grouped.values()].map((goods) => {
const rep = goods[0];
const dto = this.toPublicGood(rep);
// 列表价 = 族矩阵最低价("这个款之下有哪些价格"的起价);无矩阵回退链接价
const familyMin = this.familyMinPrice(rep);
if (rep.familyId && familyMin !== null) dto.price = familyMin;
return dto;
});
if (query.sort === 'PRICE_ASC' || query.sort === 'PRICE_DESC') {
// 分组后的最终价(族最低价)重排,null 价沉底
const num = (v: string | null) => (v === null ? Number.POSITIVE_INFINITY : Number(v));
items = items.sort((a, b) =>
query.sort === 'PRICE_ASC' ? num(a.price) - num(b.price) : num(b.price) - num(a.price),
);
}
const total = items.length;
const start = (query.page - 1) * query.pageSize;
return {
items: items.slice(start, start + query.pageSize),
total,
page: query.page,
pageSize: query.pageSize,
};
}
/** 族物化矩阵的最低价;无族/无矩阵返回 null */
private familyMinPrice(good: PublicGoodRow): string | null {
const matrix = good.originGood.family?.priceMatrix as
| { rows?: Array<{ price: string }> }
| null
| undefined;
const prices = (matrix?.rows ?? []).map((r) => Number(r.price)).filter((n) => Number.isFinite(n));
return prices.length ? String(Math.min(...prices)) : null;
}
/**
* 详情寻址契约(族化后):goodId 即族 ID;无族商品(自定义)不对外暴露。
* 族不存在、或族下没有任何在售商品配置(未配置/已下架)→ 404。
*/
async getGood(goodId: string): Promise<PublicGoodDetailDto> {
const notFound = () =>
new NotFoundException({ message: '不存在商品', error: 'PRODUCT_NOT_FOUND' });
if (!/^\d+$/.test(goodId)) throw notFound();
const detail = await this.getGoodByFamilyId(BigInt(goodId));
if (!detail) throw notFound();
return detail;
}
/** 族视角详情:代表 Good 提供公共字段(名称/主图/国家/分类),变体取全体成员并集 */
private async getGoodByFamilyId(familyId: bigint): Promise<PublicGoodDetailDto | null> {
const [family, goods] = await Promise.all([
this.prisma.productFamily.findUnique({ where: { id: familyId } }),
this.prisma.good.findMany({
where: { familyId, originGood: { delisted: false } },
include: PUBLIC_GOOD_INCLUDE,
orderBy: [{ goodPriority: 'desc' }, { createdAt: 'desc' }, { id: 'asc' }],
}),
]);
// 族不存在、或族下没有任何在售商品配置(未配置/已下架)→ 走回退路径
if (!family || goods.length === 0) return null;
const rep = goods[0];
const members = await this.prisma.originGood.findMany({
where: { familyId, delisted: false },
orderBy: { id: 'asc' },
select: {
id: true,
detail: true,
variants: { orderBy: [{ sortOrder: 'asc' as const }, { id: 'asc' as const }] },
},
});
const familyVariants = members.flatMap((m) =>
m.variants.map((variant) => ({ originGoodId: m.id, variant })),
);
const familyDetails = members
.filter((m) => m.id !== rep.originGoodId)
.map((m) => m.detail);
const dto = this.toPublicGoodDetail(rep, familyVariants, familyDetails);
// 尺码表/包装规格以族物化并集为准(款级公共数据),空并集回退主链接合并结果
if (process.env.PUBLIC_DETAIL_FROM_FAMILY !== 'false') {
dto.sizeChart = (family.sizeChart as PublicGoodDetailDto['sizeChart']) ?? dto.sizeChart;
dto.packageSpecs =
(family.packageSpecs as PublicGoodDetailDto['packageSpecs']) ?? dto.packageSpecs;
}
dto.category.categoryIcon = await this.resolveCategoryIcon(rep.category);
return dto;
}
async getHomeGoods(query: PublicHomeGoodsQueryDto): Promise<PublicGoodDto[]> {
const rows = await this.prisma.good.findMany({
where: {
positionId: { not: null },
familyId: { not: null },
originGood: { delisted: false },
...(query.countryId ? { countryId: BigInt(query.countryId) } : {}),
},
include: PUBLIC_GOOD_INCLUDE,
orderBy: [
{ position: { indexVal: 'asc' } },
{ goodPriority: 'desc' },
{ id: 'asc' },
],
take: query.limit,
});
// 首页同样按族去重(同族多条位置配置只保留排序最前一条),再截取 limit
const seen = new Set<string>();
const items: PublicGoodDto[] = [];
for (const good of rows) {
const key = good.familyId ? `f:${good.familyId}` : `g:${good.id}`;
if (seen.has(key)) continue;
seen.add(key);
const dto = this.toPublicGood(good);
const familyMin = this.familyMinPrice(good);
if (good.familyId && familyMin !== null) dto.price = familyMin;
items.push(dto);
}
return items.slice(0, query.limit);
}
private toPublicGood(good: PublicGoodRow): PublicGoodDto {
const formatGroup = (group: { id: bigint; groupName: string; sortOrder: number } | null) =>
group
? { id: group.id.toString(), groupName: group.groupName, sortOrder: group.sortOrder }
: null;
return {
// 有族 → 族ID(对外契约);无族(自定义商品)→ sdsGoodId
goodId: good.familyId ? good.familyId.toString() : good.originGood.sdsGoodId,
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(({ tag }) => ({
id: tag.id.toString(),
tagName: tag.tagName,
tagColor: tag.tagColor,
tagFontColor: tag.tagFontColor,
group: formatGroup(tag.tagGroup),
})),
position: good.position
? { id: good.position.id.toString(), indexVal: good.position.indexVal }
: null,
image: good.goodImage ?? good.originGood.goodImage,
price: good.originGood.goodPrice?.toString() ?? null,
createdAt: good.createdAt.toISOString(),
};
}
/** Group distinct variant images by color so the frontend can switch media per color.
* Only color-specific photos are included (main / result / detail images);
* design-layer素材图 and the product-level blank garment photo are excluded
* because they are not per-color gallery photos. */
private groupImagesByColor(
variants: Array<PublicGoodRow['originGood']['variants'][number]>,
): Array<{ colorId: string | null; colorName: string | null; colorHex: string | null; images: string[] }> {
const groups = new Map<string, {
colorId: string | null;
colorName: string | null;
colorHex: string | null;
images: string[];
}>();
for (const variant of variants) {
const key = variant.colorId ?? `variant:${variant.sdsVariantId}`;
let group = groups.get(key);
if (!group) {
group = {
colorId: variant.colorId,
colorName: variant.colorName,
colorHex: variant.colorHex,
images: [],
};
groups.set(key, group);
}
const design = (variant.designData ?? {}) as {
detailImgUrls?: Array<{ imageUrl?: unknown }>;
prototypeResultGroups?: Array<{ resultImage?: unknown }>;
};
const urls: unknown[] = [
variant.imageUrl,
...(design.prototypeResultGroups ?? []).map((item) => item?.resultImage),
...(design.detailImgUrls ?? []).map((image) => image?.imageUrl),
];
for (const url of urls) {
const value = typeof url === 'string' ? url.trim() : '';
if (value && !group.images.includes(value)) {
group.images.push(value);
}
}
}
return [...groups.values()];
}
/** Leaf categories often have no icon upstream; fall back to the nearest ancestor that has one. */
private async resolveCategoryIcon(category: PublicGoodRow['category']): Promise<string | null> {
if (category.categoryIcon) return category.categoryIcon;
let cursor = category.parentCategoryId;
for (let depth = 0; cursor !== null && depth < 10; depth++) {
const parent = await this.prisma.category.findUnique({
where: { id: cursor },
select: { categoryIcon: true, parentCategoryId: true },
});
if (!parent) break;
if (parent.categoryIcon) return parent.categoryIcon;
cursor = parent.parentCategoryId;
}
return null;
}
private toPublicGoodDetail(
good: PublicGoodRow,
familyVariants: Array<{
originGoodId: bigint;
variant: PublicGoodRow['originGood']['variants'][number];
}> = [],
/** 族成员 detail(款级公共规格并集来源之一;族视角详情传入,链接视角为空) */
familyDetails: Array<PublicGoodRow['originGood']['detail']> = [],
): PublicGoodDetailDto {
const base = this.toPublicGood(good);
const detail = good.originGood.detail;
// Detail specs filled from secondaries (族成员 + 旧副源) for sizes/options
// the primary does not have.
const secondaryDetails = [
...familyDetails,
...good.mergedOriginGoods.map((m) => m.originGood.detail),
].filter((d): d is NonNullable<typeof d> => Boolean(d));
// 变体并集:主链接 ∪ 族成员(新机制)∪ 旧副源(过渡期);
// 同一链接可能既是族成员又挂旧副源,先按 `${originGoodId}:${sdsVariantId}` 去重,
// 再按 color+size 去重(先到先得),避免站点出现重复的尺码×颜色行。
const seen = new Set<string>(['']);
const dedupe = (originGoodId: bigint, variant: PublicGoodRow['originGood']['variants'][number]) => {
const key = `${originGoodId}:${variant.sdsVariantId}`;
if (seen.has(key)) return null;
seen.add(key);
return variant;
};
good.originGood.variants.forEach((v) => dedupe(good.originGoodId, v));
const unionVariants = [
...good.originGood.variants,
...familyVariants
.map(({ originGoodId, variant }) => dedupe(originGoodId, variant))
.filter((v): v is PublicGoodRow['originGood']['variants'][number] => v !== null),
...good.mergedOriginGoods.flatMap((m) =>
m.originGood.variants
.map((variant) => dedupe(m.originGoodId, variant))
.filter((v): v is PublicGoodRow['originGood']['variants'][number] => v !== null),
),
];
const allVariants = this.dedupeVariants(unionVariants);
return {
...base,
productCode: detail?.productCode ?? null,
englishName: detail?.englishName ?? null,
productionCycleHours: detail?.productionCycleHours ?? null,
minWeightG: detail?.minWeightG?.toString() ?? null,
details: {
reminder: detail?.reminder ?? null,
productionProcess: detail?.productionProcess ?? null,
materialDescription: detail?.materialDescription ?? null,
productPerformance: detail?.productPerformance ?? null,
applicableScenarios: detail?.applicableScenarios ?? null,
washingInstructions: detail?.washingInstructions ?? null,
specialDescription: detail?.specialDescription ?? null,
designExplanation: detail?.designExplanation ?? null,
designArea: detail?.designArea ?? null,
pictureRequest: detail?.pictureRequest ?? null,
},
media: this.mergeMedia(detail, secondaryDetails),
mediaByColor: this.groupImagesByColor(allVariants),
options: this.mergeOptions(detail, secondaryDetails),
sizeChart: this.mergeRowsByKey(detail, secondaryDetails, 'sizeChart'),
packageSpecs: this.mergeRowsByKey(detail, secondaryDetails, 'packageSpecs'),
variants: allVariants.map((variant) => ({
id: variant.sdsVariantId,
sku: variant.sku,
sizeId: variant.sizeId,
sizeName: variant.sizeName,
colorId: variant.colorId,
colorName: variant.colorName,
colorHex: variant.colorHex,
imageUrl: variant.imageUrl,
price: variant.price?.toString() ?? null,
originalPrice: variant.originalPrice?.toString() ?? null,
weightG: variant.weightG?.toString() ?? null,
boxLengthCm: variant.boxLengthCm?.toString() ?? null,
boxWidthCm: variant.boxWidthCm?.toString() ?? null,
boxHeightCm: variant.boxHeightCm?.toString() ?? null,
enabled: variant.enabled,
sortOrder: variant.sortOrder,
})),
detailSyncedAt: detail?.syncedAt.toISOString() ?? null,
...this.familyBlock(good),
};
}
/**
* 族块(设计 D4:零新增公开端点):默认输出;仅当 PUBLIC_DETAIL_FROM_FAMILY
* 显式设为 'false' 时关闭(应急回退开关)。数据全部来自 ProductFamily 的物化 JSON。
*/
private familyBlock(
good: PublicGoodRow,
): Pick<PublicGoodDetailDto, 'family'> | Record<string, never> {
if (process.env.PUBLIC_DETAIL_FROM_FAMILY === 'false') return {};
const family = good.originGood.family;
if (!family || !family.priceMatrix) return {};
const matrix = family.priceMatrix as {
sizes: Array<{ key: string; name: string | null }>;
colors: Array<{ key: string; name: string | null; hex: string | null; imageUrl: string | null }>;
printCounts: string[];
crafts: string[];
logistics: string[];
rows: Array<{ price: string }>;
};
const prices = matrix.rows.map((r) => Number(r.price)).filter((n) => Number.isFinite(n));
return {
family: {
familyId: family.id.toString(),
familyCode: family.familyCode,
familyName: family.familyName,
sizes: matrix.sizes,
colors: matrix.colors,
printCounts: matrix.printCounts ?? [],
crafts: matrix.crafts,
logistics: matrix.logistics,
sizeChart: (family.sizeChart as Record<string, unknown> | null) ?? null,
packageSpecs: (family.packageSpecs as Record<string, unknown> | null) ?? null,
priceMatrix: matrix as unknown as Record<string, unknown>,
minPrice: prices.length ? String(Math.min(...prices)) : null,
},
};
}
/** Norm key for variant/dedupe matching: case-insensitive, trimmed. */
private static normName(value: string | null | undefined): string {
return (value ?? '').trim().toLowerCase();
}
/** Dedupe variants by color+size (case-insensitive); first (primary) wins. */
private dedupeVariants(variants: PublicGoodRow['originGood']['variants']) {
const seen = new Set<string>();
const result: PublicGoodRow['originGood']['variants'] = [];
for (const variant of variants) {
const key = `${PublicService.normName(variant.colorName)}|${PublicService.normName(variant.sizeName)}`;
if (seen.has(key)) continue;
seen.add(key);
result.push(variant);
}
return result;
}
/**
* Merge a `{ rows: [...] }` spec (sizeChart / packageSpecs) across primary
* and secondary details: primary rows win, rows for sizes the primary
* lacks are appended from secondaries in order.
*/
private mergeRowsByKey(
primary: PublicGoodRow['originGood']['detail'],
secondaries: NonNullable<PublicGoodRow['originGood']['detail']>[],
field: 'sizeChart' | 'packageSpecs',
): Record<string, unknown> | null {
const primaryRows = (primary?.[field] as { rows?: Array<Record<string, unknown>> } | null)?.rows;
if (!Array.isArray(primaryRows) && secondaries.length === 0) return null;
const rows: Array<Record<string, unknown>> = Array.isArray(primaryRows) ? [...primaryRows] : [];
const seen = new Set(rows.map((r) => PublicService.normName(String(r?.sizeName ?? ''))));
for (const sec of secondaries) {
const secRows = (sec[field] as { rows?: Array<Record<string, unknown>> } | null)?.rows;
if (!Array.isArray(secRows)) continue;
for (const row of secRows) {
const key = PublicService.normName(String(row?.sizeName ?? ''));
if (seen.has(key)) continue;
seen.add(key);
rows.push(row);
}
}
if (rows.length === 0) return null;
return { ...(primary?.[field] as Record<string, unknown> | null ?? {}), rows };
}
/**
* Merge options across details: sizes/colors are unioned by name,
* primary entries win and secondary-only ones are appended.
*/
private mergeOptions(
primary: PublicGoodRow['originGood']['detail'],
secondaries: NonNullable<PublicGoodRow['originGood']['detail']>[],
): Record<string, unknown> | null {
const primaryOptions = primary?.options as
| { sizes?: Array<Record<string, unknown>>; colors?: Array<Record<string, unknown>> }
| null;
if (!primaryOptions && secondaries.length === 0) return null;
const merged: Record<string, unknown> = { ...(primaryOptions ?? {}) };
for (const listKey of ['sizes', 'colors'] as const) {
const base = Array.isArray(primaryOptions?.[listKey])
? [...(primaryOptions![listKey] as Array<Record<string, unknown>>)]
: null;
if (!base && secondaries.length === 0) continue;
const rows = base ?? [];
const seen = new Set(rows.map((r) => PublicService.normName(String(r?.name ?? ''))));
for (const sec of secondaries) {
const secOptions = sec.options as
| { sizes?: Array<Record<string, unknown>>; colors?: Array<Record<string, unknown>> }
| null;
const secRows = secOptions?.[listKey];
if (!Array.isArray(secRows)) continue;
for (const row of secRows) {
const key = PublicService.normName(String(row?.name ?? ''));
if (seen.has(key)) continue;
seen.add(key);
rows.push(row);
}
}
merged[listKey] = rows;
}
return merged;
}
/**
* Merge the gallery `media` across details: primary images first,
* secondary-only image URLs appended (URL-deduped). `primaryImageUrl`
* stays the primary's. Image entries keep their original shape
* (`{id,url,sortOrder}` objects or plain strings).
*/
private mergeMedia(
primary: PublicGoodRow['originGood']['detail'],
secondaries: NonNullable<PublicGoodRow['originGood']['detail']>[],
): Record<string, unknown> | null {
const imageUrl = (img: unknown): string | null => {
if (typeof img === 'string') return img;
if (img && typeof img === 'object' && typeof (img as { url?: unknown }).url === 'string') {
return (img as { url: string }).url;
}
return null;
};
const primaryMedia = (primary?.media as Record<string, unknown> | null) ?? null;
const primaryImages = Array.isArray(primaryMedia?.images)
? (primaryMedia!.images as unknown[])
: null;
if (!primaryImages && secondaries.length === 0) return null;
const images: unknown[] = primaryImages ? [...primaryImages] : [];
const seen = new Set(images.map(imageUrl).filter((u): u is string => Boolean(u)));
for (const sec of secondaries) {
const secMedia = sec.media as { images?: unknown } | null;
if (!Array.isArray(secMedia?.images)) continue;
for (const img of secMedia!.images as unknown[]) {
const url = imageUrl(img);
if (!url || seen.has(url)) continue;
seen.add(url);
images.push(img);
}
}
if (images.length === 0) return null;
return { ...(primaryMedia ?? {}), images };
}
private async buildTagGroupFilters(
selectedGroups: PublicTagFilterDto[],
): Promise<Prisma.GoodWhereInput[]> {
const selections = selectedGroups.flatMap((group) => {
if (!/^\d+$/.test(group.tagGroupId) || !Array.isArray(group.tagIds)) {
throw new BadRequestException(
'tags 每个元素必须包含合法的 tagGroupId 和 tagIds',
);
}
return group.tagIds.map((tagId) => {
if (!/^\d+$/.test(tagId)) {
throw new BadRequestException('tagIds 必须全部为数字字符串');
}
return { tagGroupId: group.tagGroupId, tagId };
});
});
const uniqueTagIds = [...new Set(selections.map((item) => item.tagId))];
const selected = uniqueTagIds.length
? await this.prisma.tag.findMany({
where: { id: { in: uniqueTagIds.map((id) => BigInt(id)) } },
select: { id: true, tagGroupId: true },
})
: [];
if (selected.length !== uniqueTagIds.length) {
throw new BadRequestException('包含不存在的标签 ID');
}
const actualGroups = new Map(
selected.map((tag) => [
tag.id.toString(),
tag.tagGroupId?.toString() ?? null,
]),
);
for (const selection of selections) {
if (actualGroups.get(selection.tagId) !== selection.tagGroupId) {
throw new BadRequestException(
`标签 ${selection.tagId} 不属于标签组 ${selection.tagGroupId}`,
);
}
}
const byGroup = new Map<string, bigint[]>();
for (const selection of selections) {
const key = selection.tagGroupId;
const ids = byGroup.get(key) ?? [];
const id = BigInt(selection.tagId);
if (!ids.includes(id)) ids.push(id);
byGroup.set(key, ids);
}
return [...byGroup.values()].map((ids) => ({
goodTags: { some: { tagId: { in: ids } } },
}));
}
private parsePrice(value: string | undefined, field: string): number | null {
if (value === undefined || value === '') return null;
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed < 0) {
throw new BadRequestException(`${field} 必须是大于等于 0 的金额`);
}
return parsed;
}
private async collectCategoryDescendants(rootId: bigint): Promise<bigint[]> {
const ids: bigint[] = [rootId];
let frontier: bigint[] = [rootId];
while (frontier.length) {
const children = await this.prisma.category.findMany({
where: { parentCategoryId: { in: frontier } },
select: { id: true },
});
if (!children.length) break;
frontier = children.map((child) => child.id);
ids.push(...frontier);
}
return ids;
}
private buildTree(
rows: PrismaCategory[],
directCounts: Map<bigint, number>,
): PublicCategoryNodeDto[] {
const byId = new Map<bigint, PublicCategoryNodeDto>();
for (const row of rows) {
const node = PublicCategoryNodeDto.from(row, []);
node.productCount = directCounts.get(row.id) ?? 0;
byId.set(row.id, node);
}
const roots: PublicCategoryNodeDto[] = [];
for (const row of rows) {
const node = byId.get(row.id)!;
const parent = row.parentCategoryId === null ? null : byId.get(row.parentCategoryId);
if (parent) parent.children.push(node);
else roots.push(node);
}
const total = (node: PublicCategoryNodeDto): number => {
node.productCount += node.children.reduce((sum, child) => sum + total(child), 0);
return node.productCount;
};
roots.forEach(total);
return roots;
}
}