feat(public): family-first contract — goodId=familyId, strict 5-dim price matrix
公开契约族化(前端只需知道款号/族): - GET /public/goods 一族一条(goodId=族ID,price=族起价,分页作用于分组后); 无族商品(自定义)不进任何公开端点(列表/首页/分类树/标签统计) - GET /public/goods/:goodId 仅认族 ID;公共字段取代表 Good,变体=全体成员并集, 尺码表/包装规格=族物化并集;旧 SDS 链接 ID 寻址 404 - priceMatrix 严格五维:尺码×颜色×印花数量×工艺×物流;维度来源改为链接级 标签(人工接管按人工标签),弃用原始 craftLabel;CUSTOM 成员尊重显式标签 - 名称派生补裸「单面/双面」写法(直喷双面→双面印花+直喷,18 条存量链接修复) - family_price_overrides 加 print_count 列(五键唯一),PUT/DELETE/校验五键化 - admin 编辑弹窗矩阵消费适配(SKU 列直读 printCount,成员格子三维匹配, 改价 payload 带 printCount) - 存量 339 族已全量重算;api 162/162、admin 22/22、双端构建绿
This commit is contained in:
@@ -65,7 +65,7 @@ export class PublicGoodDetailDto extends PublicGoodDto {
|
||||
required: false,
|
||||
nullable: true,
|
||||
type: Object,
|
||||
description: '产品族块:并集尺码表/包装规则 + 五维价格矩阵(尺码×颜色×工艺×物流)',
|
||||
description: '产品族块:并集尺码表/包装规则 + 五维价格矩阵(尺码×颜色×印花数量×工艺×物流)',
|
||||
})
|
||||
family?: {
|
||||
familyId: string;
|
||||
@@ -73,6 +73,7 @@ export class PublicGoodDetailDto extends PublicGoodDto {
|
||||
familyName: string;
|
||||
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[];
|
||||
sizeChart: Record<string, unknown> | null;
|
||||
|
||||
@@ -94,8 +94,8 @@ describe('PublicService family block (PUBLIC_DETAIL_FROM_FAMILY)', () => {
|
||||
|
||||
it('默认(未设开关):输出族块', async () => {
|
||||
delete process.env.PUBLIC_DETAIL_FROM_FAMILY
|
||||
const detail = await service.getGood(sdsGoodId);
|
||||
expect(detail.goodId).toBe(sdsGoodId);
|
||||
const detail = await service.getGood(familyId.toString());
|
||||
expect(detail.goodId).toBe(familyId.toString());
|
||||
expect(detail.family).toBeTruthy();
|
||||
expect(detail.family!.familyCode).toBe(`PF${stamp}`);
|
||||
expect(detail.family!.minPrice).toBe('25');
|
||||
@@ -105,12 +105,12 @@ describe('PublicService family block (PUBLIC_DETAIL_FROM_FAMILY)', () => {
|
||||
|
||||
it('显式关闭(false):响应完全不含 family 键(应急回退)', async () => {
|
||||
process.env.PUBLIC_DETAIL_FROM_FAMILY = 'false';
|
||||
const detail = await service.getGood(sdsGoodId);
|
||||
expect(detail.goodId).toBe(sdsGoodId);
|
||||
const detail = await service.getGood(familyId.toString());
|
||||
expect(detail.goodId).toBe(familyId.toString());
|
||||
expect('family' in detail).toBe(false);
|
||||
});
|
||||
|
||||
it('族变体并集:任何族成员链接的 sdsGoodId 均命中同一商品且变体含全体成员', async () => {
|
||||
it('族变体并集:族ID 命中商品且变体含全体成员;成员 sdsGoodId 不再可寻址', async () => {
|
||||
process.env.PUBLIC_DETAIL_FROM_FAMILY = 'true';
|
||||
// 再加一个同族成员(不同仓库段)
|
||||
const og2 = await prisma.originGood.create({
|
||||
@@ -136,18 +136,20 @@ describe('PublicService family block (PUBLIC_DETAIL_FROM_FAMILY)', () => {
|
||||
},
|
||||
});
|
||||
|
||||
// 用成员链接(非主链接)的 sdsGoodId 访问 → 命中同一商品(响应 goodId 仍为主链接)
|
||||
const detail = await service.getGood(`pubfam-m-${stamp}`);
|
||||
expect(detail.goodId).toBe(sdsGoodId);
|
||||
// 族 ID 是唯一公开键:成员变体并入同一详情
|
||||
const detail = await service.getGood(familyId.toString());
|
||||
expect(detail.goodId).toBe(familyId.toString());
|
||||
const skus = detail.variants.map((v) => v.sku);
|
||||
expect(skus).toContain(`PF-${stamp}-S`);
|
||||
expect(skus).toContain(`PF-${stamp}-M`); // 族成员变体并集
|
||||
// 成员链接的 sdsGoodId 不再可寻址
|
||||
await expect(service.getGood(`pubfam-m-${stamp}`)).rejects.toThrow();
|
||||
// 清理:把成员移出族避免影响其他用例
|
||||
await prisma.originGoodVariant.deleteMany({ where: { originGoodId: og2.id } });
|
||||
await prisma.originGood.update({ where: { id: og2.id }, data: { familyId: null } });
|
||||
});
|
||||
|
||||
it('无族商品:开关开启也不含 family 键', async () => {
|
||||
it('无族商品:公开端点不可见(列表不含 / 详情 404)', async () => {
|
||||
process.env.PUBLIC_DETAIL_FROM_FAMILY = 'true';
|
||||
const og2 = await prisma.originGood.create({
|
||||
data: {
|
||||
@@ -166,8 +168,13 @@ describe('PublicService family block (PUBLIC_DETAIL_FROM_FAMILY)', () => {
|
||||
},
|
||||
});
|
||||
createdGoodIds.push(good2.id);
|
||||
const detail = await service.getGood(`pubfam-2-${stamp}`);
|
||||
expect('family' in detail).toBe(false);
|
||||
const list = await service.getGoods({
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
countryId: createdCountryIds[0].toString(),
|
||||
});
|
||||
expect(list.items.map((i) => i.goodName)).not.toContain(`无族商品-${stamp}`);
|
||||
await expect(service.getGood(`pubfam-2-${stamp}`)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -13,50 +13,50 @@ import {
|
||||
ApiTags,
|
||||
getSchemaPath,
|
||||
} from '@nestjs/swagger';
|
||||
import { PublicService } from './public.service';
|
||||
import { PublicService } from './public.service';
|
||||
import {
|
||||
PublicCountryQueryDto,
|
||||
PublicHomeGoodsQueryDto,
|
||||
PublicQueryGoodDto,
|
||||
PublicTagFilterDto,
|
||||
} from './dto/public-query-good.dto';
|
||||
import { PublicTagDto } from './dto/public-tag.dto';
|
||||
import { PublicTagDto } from './dto/public-tag.dto';
|
||||
import { PublicGoodDetailDto, PublicTagGroupFilterDto } from './dto/public-good-detail.dto';
|
||||
import { PublicGoodDto } from './dto/public-good.dto';
|
||||
|
||||
|
||||
@ApiTags('public')
|
||||
@ApiExtraModels(PublicTagFilterDto)
|
||||
@Controller('public')
|
||||
export class PublicController {
|
||||
constructor(private readonly service: PublicService) {}
|
||||
|
||||
export class PublicController {
|
||||
constructor(private readonly service: PublicService) {}
|
||||
|
||||
@Get('categories')
|
||||
@ApiOperation({ summary: '获取商品分类树;countryId 不传时返回全部国家' })
|
||||
getCategories(@Query() query: PublicCountryQueryDto) {
|
||||
return this.service.getCategoriesTree(query.countryId);
|
||||
}
|
||||
|
||||
@Get('countries')
|
||||
@ApiOperation({ summary: 'Public list of countries that have goods' })
|
||||
getCountries() {
|
||||
return this.service.getCountries();
|
||||
}
|
||||
|
||||
@Get('tags')
|
||||
@ApiOperation({ summary: 'Public list of tags that have goods' })
|
||||
getTags(): Promise<PublicTagDto[]> {
|
||||
return this.service.getTags();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Get('countries')
|
||||
@ApiOperation({ summary: 'Public list of countries that have goods' })
|
||||
getCountries() {
|
||||
return this.service.getCountries();
|
||||
}
|
||||
|
||||
@Get('tags')
|
||||
@ApiOperation({ summary: 'Public list of tags that have goods' })
|
||||
getTags(): Promise<PublicTagDto[]> {
|
||||
return this.service.getTags();
|
||||
}
|
||||
|
||||
@Get('tag-groups')
|
||||
@ApiOperation({ summary: '获取标签组及标签筛选项;countryId 不传时返回全部国家' })
|
||||
@ApiOkResponse({ type: [PublicTagGroupFilterDto] })
|
||||
getTagGroups(@Query() query: PublicCountryQueryDto): Promise<PublicTagGroupFilterDto[]> {
|
||||
return this.service.getTagGroups(query.countryId);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Get('goods')
|
||||
@ApiOperation({ summary: '分页获取商品' })
|
||||
@ApiOperation({ summary: '分页获取商品(族化契约:一族一条,goodId=族ID;无族商品不返回)' })
|
||||
@ApiQuery({
|
||||
name: 'tags',
|
||||
required: false,
|
||||
@@ -80,8 +80,8 @@ export class PublicController {
|
||||
}
|
||||
|
||||
@Get('goods/:goodId')
|
||||
@ApiOperation({ summary: '获取商品完整详情' })
|
||||
@ApiParam({ name: 'goodId', type: String, example: '168746' })
|
||||
@ApiOperation({ summary: '获取商品完整详情(goodId = 族 ID)' })
|
||||
@ApiParam({ name: 'goodId', type: String, example: '758', description: '产品族 ID' })
|
||||
@ApiOkResponse({ type: PublicGoodDetailDto })
|
||||
getGood(@Param('goodId') goodId: string): Promise<PublicGoodDetailDto> {
|
||||
return this.service.getGood(goodId);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { PublicService } from './public.service';
|
||||
import { FamilyRecomputeService } from '../product-families/family-recompute.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
describe('PublicService', () => {
|
||||
@@ -15,6 +16,7 @@ describe('PublicService', () => {
|
||||
let filterGroupIds: bigint[] = [];
|
||||
let filterTagIds: bigint[] = [];
|
||||
let originGoodId: bigint;
|
||||
let familyId: bigint;
|
||||
let goodIds: bigint[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
@@ -57,6 +59,16 @@ describe('PublicService', () => {
|
||||
});
|
||||
originGoodId = og.id;
|
||||
|
||||
// 公开契约族化:商品必须挂族才对外可见
|
||||
const family = await prisma.productFamily.create({
|
||||
data: { familyName: `Pub Family ${stamp}`, primaryOriginGoodId: og.id },
|
||||
});
|
||||
familyId = family.id;
|
||||
await prisma.originGood.update({
|
||||
where: { id: og.id },
|
||||
data: { familyId: family.id },
|
||||
});
|
||||
|
||||
// Seed 3 goods:
|
||||
// high priority + position.indexVal=1
|
||||
// mid priority + position.indexVal=5
|
||||
@@ -72,6 +84,7 @@ describe('PublicService', () => {
|
||||
data: {
|
||||
goodName: `Pub High ${stamp}`,
|
||||
originGoodId,
|
||||
familyId: family.id,
|
||||
countryId,
|
||||
categoryId,
|
||||
goodPriority: 10,
|
||||
@@ -82,6 +95,7 @@ describe('PublicService', () => {
|
||||
data: {
|
||||
goodName: `Pub Mid ${stamp}`,
|
||||
originGoodId,
|
||||
familyId: family.id,
|
||||
countryId,
|
||||
categoryId,
|
||||
goodPriority: 5,
|
||||
@@ -92,6 +106,7 @@ describe('PublicService', () => {
|
||||
data: {
|
||||
goodName: `Pub NoPos ${stamp}`,
|
||||
originGoodId,
|
||||
familyId: family.id,
|
||||
countryId,
|
||||
categoryId,
|
||||
tagId,
|
||||
@@ -143,6 +158,8 @@ describe('PublicService', () => {
|
||||
price: 38,
|
||||
},
|
||||
});
|
||||
// 物化族矩阵(公开详情 family 块依赖 priceMatrix 已重算)
|
||||
await new FamilyRecomputeService(prisma).recomputeFamily(family.id);
|
||||
|
||||
// Seed a good in `otherCategory` so the "onlyHaveGoods" filter
|
||||
// returns more than one category.
|
||||
@@ -150,6 +167,7 @@ describe('PublicService', () => {
|
||||
data: {
|
||||
goodName: `Pub Other ${stamp}`,
|
||||
originGoodId,
|
||||
familyId: family.id,
|
||||
countryId,
|
||||
categoryId: otherCategoryId,
|
||||
goodPriority: 1,
|
||||
@@ -162,6 +180,7 @@ describe('PublicService', () => {
|
||||
data: {
|
||||
goodName: `Pub ChildGood ${stamp}`,
|
||||
originGoodId,
|
||||
familyId: family.id,
|
||||
countryId,
|
||||
categoryId: childCategoryId,
|
||||
goodPriority: 0,
|
||||
@@ -176,6 +195,8 @@ describe('PublicService', () => {
|
||||
await prisma.good.deleteMany({
|
||||
where: { goodName: { contains: `Pub ` } },
|
||||
});
|
||||
// Good.familyId / OriginGood.familyId 均为 SetNull,先删商品再删族
|
||||
await prisma.productFamily.deleteMany({ where: { id: familyId } });
|
||||
await prisma.position.deleteMany({
|
||||
where: { countryId },
|
||||
});
|
||||
@@ -224,7 +245,7 @@ describe('PublicService', () => {
|
||||
categoryId: categoryId.toString(), // includes child
|
||||
keyword: `Pub `,
|
||||
});
|
||||
expect(filtered.total).toBeGreaterThanOrEqual(4); // High, Mid, NoPos, ChildGood
|
||||
expect(filtered.total).toBe(1); // 族化后:同族 4 条在售 Good(High/Mid/NoPos/Child)= 1 个款
|
||||
expect(filtered.items.every((g) => g.country.id === countryId.toString())).toBe(true);
|
||||
});
|
||||
|
||||
@@ -254,9 +275,8 @@ describe('PublicService', () => {
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(sameGroup.items.map((item) => item.goodName)).toEqual(
|
||||
expect.arrayContaining([`Pub High ${stamp}`, `Pub Mid ${stamp}`]),
|
||||
);
|
||||
// 族化后命中族内多条 Good 仍只出代表行(High 优先级最高)
|
||||
expect(sameGroup.items.map((item) => item.goodName)).toEqual([`Pub High ${stamp}`]);
|
||||
|
||||
const acrossGroups = await service.getGoods({
|
||||
page: 1,
|
||||
@@ -293,20 +313,22 @@ describe('PublicService', () => {
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('returns the SDS product id as the public product id', async () => {
|
||||
it('returns the family id as the public product id (一族多条 Good 只出一条)', async () => {
|
||||
const result = await service.getGoods({
|
||||
page: 1,
|
||||
pageSize: 1,
|
||||
pageSize: 50,
|
||||
countryId: countryId.toString(),
|
||||
keyword: `Pub High ${stamp}`,
|
||||
keyword: `Pub `,
|
||||
});
|
||||
|
||||
// 该族下 5 条 Good(High/Mid/NoPos/Other/Child)→ 列表仅 1 条,goodId=族ID
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(result.items[0].goodId).toBe(`pub-sds-${stamp}`);
|
||||
expect(result.items[0].goodId).toBe(familyId.toString());
|
||||
expect(result.items[0].goodId).not.toBe(goodIds[0].toString());
|
||||
expect(result.items[0].goodName).toBe(`Pub High ${stamp}`); // 代表行 = 排序第一条
|
||||
});
|
||||
|
||||
it('returns custom goods through the same public product contract', async () => {
|
||||
it('custom goods (无族) are not visible on public endpoints', async () => {
|
||||
const customPublicId = `custom-public-${stamp}`;
|
||||
const origin = await prisma.originGood.create({
|
||||
data: {
|
||||
@@ -326,17 +348,22 @@ describe('PublicService', () => {
|
||||
},
|
||||
});
|
||||
try {
|
||||
const detail = await service.getGood(customPublicId);
|
||||
expect(detail.goodId).toBe(customPublicId);
|
||||
expect(detail.goodName).toBe(`Pub Custom ${stamp}`);
|
||||
expect(detail.productCode).toBe(`CUSTOM-${stamp}`);
|
||||
const list = await service.getGoods({
|
||||
page: 1,
|
||||
pageSize: 50,
|
||||
countryId: countryId.toString(),
|
||||
keyword: `Pub Custom`,
|
||||
});
|
||||
expect(list.items).toHaveLength(0);
|
||||
// sdsGoodId 不再是公开寻址键:非数字直接 404
|
||||
await expect(service.getGood(customPublicId)).rejects.toBeInstanceOf(NotFoundException);
|
||||
} finally {
|
||||
await prisma.good.delete({ where: { id: good.id } });
|
||||
await prisma.originGood.delete({ where: { id: origin.id } });
|
||||
}
|
||||
});
|
||||
|
||||
it('getGood returns detail and 404 for unknown id', async () => {
|
||||
it('getGood returns family detail by family id and 404 for unknown id', async () => {
|
||||
const first = await service.getGoods({
|
||||
page: 1,
|
||||
pageSize: 1,
|
||||
@@ -344,14 +371,17 @@ describe('PublicService', () => {
|
||||
keyword: `Pub `,
|
||||
});
|
||||
expect(first.items.length).toBe(1);
|
||||
const detail = await service.getGood(`pub-sds-${stamp}`);
|
||||
const detail = await service.getGood(familyId.toString());
|
||||
expect(detail.goodId).toBe(first.items[0].goodId);
|
||||
expect(detail.productCode).toBe('OZ10827003');
|
||||
expect(detail.details.productionProcess).toBe('白墨烫画');
|
||||
expect((detail.sizeChart?.rows as unknown[])).toHaveLength(1);
|
||||
expect((detail.packageSpecs?.rows as unknown[])).toHaveLength(1);
|
||||
expect(detail.variants).toHaveLength(1);
|
||||
expect(detail.family?.familyId).toBe(familyId.toString());
|
||||
|
||||
// 旧 sdsGoodId 寻址不再可达(族 ID 是唯一公开键)
|
||||
await expect(service.getGood(`pub-sds-${stamp}`)).rejects.toBeInstanceOf(NotFoundException);
|
||||
await expect(service.getGood('99999999')).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
@@ -382,7 +412,7 @@ describe('PublicService', () => {
|
||||
});
|
||||
|
||||
describe('merged secondary origin goods', () => {
|
||||
it('resolves a good by secondary sdsGoodId with merged variants', async () => {
|
||||
it('family members union variants and media (secondary link joins the family)', async () => {
|
||||
const secondary = await prisma.originGood.create({
|
||||
data: { sdsGoodId: `pub-secondary-${stamp}`, goodName: `Pub Secondary ${stamp}` },
|
||||
});
|
||||
@@ -396,20 +426,24 @@ describe('PublicService', () => {
|
||||
imageUrl: 'http://img/black-sec',
|
||||
},
|
||||
});
|
||||
// Attach as secondary source of the highest-priority fixture good.
|
||||
await prisma.goodOriginGood.create({
|
||||
data: { goodId: goodIds[0], originGoodId: secondary.id },
|
||||
// 副链归入主 fixture 的族(族机制替代旧 good_origin_goods 关联)
|
||||
await prisma.originGood.update({
|
||||
where: { id: secondary.id },
|
||||
data: { familyId },
|
||||
});
|
||||
|
||||
try {
|
||||
const detail = await service.getGood(`pub-secondary-${stamp}`);
|
||||
expect(detail.goodId).toBe(`pub-sds-${stamp}`); // 对外 goodId 仍是主源
|
||||
const detail = await service.getGood(familyId.toString());
|
||||
expect(detail.goodId).toBe(familyId.toString()); // 对外 goodId 恒为族ID
|
||||
expect(detail.variants.length).toBeGreaterThanOrEqual(2);
|
||||
const black = detail.mediaByColor.find((g) => g.colorName === '黑色');
|
||||
expect(black).toBeTruthy();
|
||||
expect(black!.images).toContain('http://img/black-sec');
|
||||
// sdsGoodId 不是公开键:副链 ID 无法寻址
|
||||
await expect(service.getGood(`pub-secondary-${stamp}`)).rejects.toBeInstanceOf(
|
||||
NotFoundException,
|
||||
);
|
||||
} finally {
|
||||
await prisma.goodOriginGood.deleteMany({ where: { originGoodId: secondary.id } });
|
||||
await prisma.originGoodVariant.delete({ where: { id: secVariant.id } }).catch(() => undefined);
|
||||
await prisma.originGood.delete({ where: { id: secondary.id } }).catch(() => undefined);
|
||||
}
|
||||
@@ -456,20 +490,26 @@ describe('PublicService', () => {
|
||||
media: { images: [{ id: 'i1', url: 'http://img/pri-a', sortOrder: 0 }, { id: 'i9', url: 'http://img/sec-x', sortOrder: 0 }], primaryImageUrl: 'http://img/pri-a' },
|
||||
},
|
||||
});
|
||||
// 主副链同族 + 一条官网 Good(族化契约:Good 挂族才公开)
|
||||
const mergeFamily = await prisma.productFamily.create({
|
||||
data: { familyName: `Pub Merge Family ${stamp2}`, primaryOriginGoodId: primaryOg.id },
|
||||
});
|
||||
await prisma.originGood.updateMany({
|
||||
where: { id: { in: [primaryOg.id, secondaryOg.id] } },
|
||||
data: { familyId: mergeFamily.id },
|
||||
});
|
||||
const mergedGood = await prisma.good.create({
|
||||
data: {
|
||||
goodName: `Pub Merged ${stamp2}`,
|
||||
originGoodId: primaryOg.id,
|
||||
familyId: mergeFamily.id,
|
||||
countryId,
|
||||
categoryId,
|
||||
},
|
||||
});
|
||||
await prisma.goodOriginGood.create({
|
||||
data: { goodId: mergedGood.id, originGoodId: secondaryOg.id },
|
||||
});
|
||||
|
||||
try {
|
||||
const detail = await service.getGood(`pub-pri-${stamp2}`);
|
||||
const detail = await service.getGood(mergeFamily.id.toString());
|
||||
// Variants: 3 unique color+size combos (case-insensitive); duplicate
|
||||
// Black|S from the secondary deduped.
|
||||
expect(
|
||||
@@ -496,8 +536,8 @@ describe('PublicService', () => {
|
||||
]);
|
||||
expect(media.primaryImageUrl).toBe('http://img/pri-a');
|
||||
} finally {
|
||||
await prisma.goodOriginGood.deleteMany({ where: { goodId: mergedGood.id } });
|
||||
await prisma.good.delete({ where: { id: mergedGood.id } });
|
||||
await prisma.productFamily.delete({ where: { id: mergeFamily.id } });
|
||||
await prisma.originGoodVariant.deleteMany({ where: { originGoodId: { in: [primaryOg.id, secondaryOg.id] } } });
|
||||
await prisma.originGoodDetail.deleteMany({ where: { originGoodId: { in: [primaryOg.id, secondaryOg.id] } } });
|
||||
await prisma.originGood.delete({ where: { id: primaryOg.id } });
|
||||
|
||||
@@ -66,6 +66,7 @@ export class PublicService {
|
||||
|
||||
async getCategoriesTree(countryId?: string): Promise<PublicCategoryNodeDto[]> {
|
||||
const goodsWhere: Prisma.GoodWhereInput = {
|
||||
familyId: { not: null },
|
||||
originGood: { delisted: false },
|
||||
...(countryId ? { countryId: BigInt(countryId) } : {}),
|
||||
};
|
||||
@@ -109,7 +110,7 @@ export class PublicService {
|
||||
|
||||
async getCountries(): Promise<PublicCountryDto[]> {
|
||||
const rows = await this.prisma.country.findMany({
|
||||
where: { goods: { some: { originGood: { delisted: false } } } },
|
||||
where: { goods: { some: { familyId: { not: null }, originGood: { delisted: false } } } },
|
||||
orderBy: { id: 'asc' },
|
||||
});
|
||||
return rows.map(PublicCountryDto.from);
|
||||
@@ -117,7 +118,11 @@ export class PublicService {
|
||||
|
||||
async getTags(): Promise<PublicTagDto[]> {
|
||||
const rows = await this.prisma.tag.findMany({
|
||||
where: { goodTags: { some: { good: { originGood: { delisted: false } } } } },
|
||||
where: {
|
||||
goodTags: {
|
||||
some: { good: { familyId: { not: null }, originGood: { delisted: false } } },
|
||||
},
|
||||
},
|
||||
orderBy: [
|
||||
{ tagGroup: { sortOrder: 'asc' } },
|
||||
{ sortOrder: 'asc' },
|
||||
@@ -130,6 +135,7 @@ export class PublicService {
|
||||
|
||||
async getTagGroups(countryId?: string): Promise<PublicTagGroupFilterDto[]> {
|
||||
const goodWhere: Prisma.GoodWhereInput = {
|
||||
familyId: { not: null },
|
||||
originGood: { delisted: false },
|
||||
...(countryId ? { countryId: BigInt(countryId) } : {}),
|
||||
};
|
||||
@@ -160,7 +166,11 @@ export class PublicService {
|
||||
}
|
||||
|
||||
async getGoods(query: PublicQueryGoodDto): Promise<PublicPaginatedGoods> {
|
||||
const where: Prisma.GoodWhereInput = { originGood: { delisted: false } };
|
||||
// 无族商品(自定义)不进公开列表:只认族
|
||||
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) {
|
||||
@@ -199,65 +209,107 @@ export class PublicService {
|
||||
{ id: 'asc' },
|
||||
];
|
||||
|
||||
const [total, rows] = await this.prisma.$transaction([
|
||||
this.prisma.good.count({ where }),
|
||||
this.prisma.good.findMany({
|
||||
where,
|
||||
include: PUBLIC_GOOD_INCLUDE,
|
||||
orderBy,
|
||||
skip: (query.page - 1) * query.pageSize,
|
||||
take: query.pageSize,
|
||||
}),
|
||||
]);
|
||||
// 契约族化:一族对外只暴露一条(代表行=排序第一条,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: rows.map((good) => this.toPublicGood(good)),
|
||||
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 good = await this.prisma.good.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ originGood: { sdsGoodId: goodId, delisted: false } },
|
||||
// 族内任何成员链接均可命中同一商品(替代旧副源关联的可达性语义)
|
||||
{ family: { originGoods: { some: { sdsGoodId: goodId, delisted: false } } } },
|
||||
// 历史副源关联(good_origin_goods)只读保留,仍可命中
|
||||
{
|
||||
mergedOriginGoods: {
|
||||
some: { originGood: { sdsGoodId: goodId, delisted: false } },
|
||||
},
|
||||
},
|
||||
],
|
||||
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 }] },
|
||||
},
|
||||
include: PUBLIC_GOOD_INCLUDE,
|
||||
orderBy: [{ goodPriority: 'desc' }, { id: 'asc' }],
|
||||
});
|
||||
if (!good) {
|
||||
throw new NotFoundException({ message: '不存在商品', error: 'PRODUCT_NOT_FOUND' });
|
||||
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;
|
||||
}
|
||||
// 族机制(新):变体并集 = 主链接 ∪ 族成员 ∪ 旧副源(过渡期),按 (链接, 变体) 去重
|
||||
let familyVariants: Array<{
|
||||
originGoodId: bigint;
|
||||
variant: PublicGoodRow['originGood']['variants'][number];
|
||||
}> = [];
|
||||
const familyId = good.originGood.family?.id;
|
||||
if (familyId) {
|
||||
const members = await this.prisma.originGood.findMany({
|
||||
where: { familyId, delisted: false },
|
||||
select: {
|
||||
id: true,
|
||||
variants: { orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }] },
|
||||
},
|
||||
});
|
||||
familyVariants = members
|
||||
.filter((m) => m.id !== good.originGoodId)
|
||||
.flatMap((m) => m.variants.map((variant) => ({ originGoodId: m.id, variant })));
|
||||
}
|
||||
const dto = this.toPublicGoodDetail(good, familyVariants);
|
||||
dto.category.categoryIcon = await this.resolveCategoryIcon(good.category);
|
||||
dto.category.categoryIcon = await this.resolveCategoryIcon(rep.category);
|
||||
return dto;
|
||||
}
|
||||
|
||||
@@ -265,6 +317,7 @@ export class PublicService {
|
||||
const rows = await this.prisma.good.findMany({
|
||||
where: {
|
||||
positionId: { not: null },
|
||||
familyId: { not: null },
|
||||
originGood: { delisted: false },
|
||||
...(query.countryId ? { countryId: BigInt(query.countryId) } : {}),
|
||||
},
|
||||
@@ -276,7 +329,19 @@ export class PublicService {
|
||||
],
|
||||
take: query.limit,
|
||||
});
|
||||
return rows.map((good) => this.toPublicGood(good));
|
||||
// 首页同样按族去重(同族多条位置配置只保留排序最前一条),再截取 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 {
|
||||
@@ -285,7 +350,8 @@ export class PublicService {
|
||||
? { id: group.id.toString(), groupName: group.groupName, sortOrder: group.sortOrder }
|
||||
: null;
|
||||
return {
|
||||
goodId: good.originGood.sdsGoodId,
|
||||
// 有族 → 族ID(对外契约);无族(自定义商品)→ sdsGoodId
|
||||
goodId: good.familyId ? good.familyId.toString() : good.originGood.sdsGoodId,
|
||||
goodName: good.goodName,
|
||||
goodPriority: good.goodPriority,
|
||||
country: {
|
||||
@@ -389,14 +455,17 @@ export class PublicService {
|
||||
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 = good.mergedOriginGoods
|
||||
.map((m) => m.originGood.detail)
|
||||
.filter((d): d is NonNullable<typeof d> => Boolean(d));
|
||||
// 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 去重(先到先得),避免站点出现重复的尺码×颜色行。
|
||||
@@ -479,6 +548,7 @@ export class PublicService {
|
||||
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 }>;
|
||||
@@ -491,6 +561,7 @@ export class PublicService {
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user