diff --git a/apps/api/prisma/backfill-product-families.ts b/apps/api/prisma/backfill-product-families.ts index c787373..abc3c31 100644 --- a/apps/api/prisma/backfill-product-families.ts +++ b/apps/api/prisma/backfill-product-families.ts @@ -9,6 +9,7 @@ * 后台等价入口:POST /product-families/organize(「整理」按钮)。 */ import { PrismaService } from '../src/prisma/prisma.service'; +import { PublicCacheService } from '../src/public/public-cache.service'; import { FamilyRecomputeService } from '../src/product-families/family-recompute.service'; import { ProductFamiliesService } from '../src/product-families/product-families.service'; import { OrganizeService } from '../src/product-families/organize.service'; @@ -16,9 +17,9 @@ import { OrganizeService } from '../src/product-families/organize.service'; async function main() { const prisma = new PrismaService(); await prisma.onModuleInit(); - const recompute = new FamilyRecomputeService(prisma); + const recompute = new FamilyRecomputeService(prisma, new PublicCacheService()); const families = new ProductFamiliesService(prisma, recompute); - const organize = new OrganizeService(prisma, recompute, families); + const organize = new OrganizeService(prisma, recompute, families, new PublicCacheService()); const result = await organize.organize(); console.log( diff --git a/apps/api/prisma/fix-pure-sku-good-names.ts b/apps/api/prisma/fix-pure-sku-good-names.ts index 8e9a06a..2fc44fd 100644 --- a/apps/api/prisma/fix-pure-sku-good-names.ts +++ b/apps/api/prisma/fix-pure-sku-good-names.ts @@ -8,6 +8,7 @@ * origin_goods.good_name 为 SDS 纯镜像(每小时同步覆盖),本脚本只改 goods.good_name。 */ import { PrismaService } from '../src/prisma/prisma.service'; +import { PublicCacheService } from '../src/public/public-cache.service'; import { SyncService } from '../src/sync/sync.service'; import { FamilyRecomputeService } from '../src/product-families/family-recompute.service'; import { GoodsService } from '../src/goods/goods.service'; @@ -16,12 +17,13 @@ async function main() { const dryRun = !process.argv.includes('--apply'); const prisma = new PrismaService(); await prisma.onModuleInit(); - const recompute = new FamilyRecomputeService(prisma); + const recompute = new FamilyRecomputeService(prisma, new PublicCacheService()); // 回填路径不触发详情同步,SyncService 仅作占位依赖 const goods = new GoodsService( prisma, { queueProductDetailSync: async () => undefined } as unknown as SyncService, recompute, + new PublicCacheService(), ); const result = await goods.backfillPureSkuGoodNames({ dryRun }); diff --git a/apps/api/prisma/recompute-all-families.ts b/apps/api/prisma/recompute-all-families.ts index 77c7f62..b5876e3 100644 --- a/apps/api/prisma/recompute-all-families.ts +++ b/apps/api/prisma/recompute-all-families.ts @@ -7,12 +7,13 @@ * 运行:pnpm --filter @inkreach/api recompute:families */ import { PrismaService } from '../src/prisma/prisma.service'; +import { PublicCacheService } from '../src/public/public-cache.service'; import { FamilyRecomputeService } from '../src/product-families/family-recompute.service'; async function main() { const prisma = new PrismaService(); await prisma.onModuleInit(); - const recompute = new FamilyRecomputeService(prisma); + const recompute = new FamilyRecomputeService(prisma, new PublicCacheService()); const families = await prisma.productFamily.findMany({ select: { id: true }, diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 7d958bb..0507876 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -15,6 +15,7 @@ import { ProductFamiliesModule } from './product-families/product-families.modul import { GoodsModule } from './goods/goods.module'; import { SyncModule } from './sync/sync.module'; import { PublicModule } from './public/public.module'; +import { PublicCacheModule } from './public/public-cache.module'; import { UploadModule } from './upload/upload.module'; @Module({ @@ -31,6 +32,8 @@ import { UploadModule } from './upload/upload.module'; }, ]), PrismaModule, + // public 读路径缓存(分域版本失效,@Global 供各写路径注入 bump 入口) + PublicCacheModule, AuthModule, CountriesModule, CategoriesModule, diff --git a/apps/api/src/categories/categories.service.spec.ts b/apps/api/src/categories/categories.service.spec.ts index e1fe0e0..1c1962c 100644 --- a/apps/api/src/categories/categories.service.spec.ts +++ b/apps/api/src/categories/categories.service.spec.ts @@ -1,3 +1,4 @@ +import { PublicCacheService } from '../public/public-cache.service'; import { Test } from '@nestjs/testing'; import { BadRequestException, @@ -13,7 +14,7 @@ describe('CategoriesService', () => { beforeAll(async () => { const moduleRef = await Test.createTestingModule({ - providers: [CategoriesService, PrismaService], + providers: [CategoriesService, PrismaService, PublicCacheService], }).compile(); service = moduleRef.get(CategoriesService); prisma = moduleRef.get(PrismaService); diff --git a/apps/api/src/categories/categories.service.ts b/apps/api/src/categories/categories.service.ts index 984917c..b148b74 100644 --- a/apps/api/src/categories/categories.service.ts +++ b/apps/api/src/categories/categories.service.ts @@ -5,13 +5,17 @@ import { NotFoundException, } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; +import { PublicCacheService } from '../public/public-cache.service'; import { CreateCategoryDto } from './dto/create-category.dto'; import { UpdateCategoryDto } from './dto/update-category.dto'; import { CategoryNodeDto } from './dto/category-node.dto'; @Injectable() export class CategoriesService { - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + private readonly publicCache: PublicCacheService, + ) {} async findAll(): Promise { const all = await this.prisma.category.findMany({ @@ -37,7 +41,7 @@ export class CategoriesService { // Validate the parent exists to produce a clean 404 instead of FK error. await this.findOne(BigInt(dto.parentCategoryId)); } - return this.prisma.category.create({ + const created = await this.prisma.category.create({ data: { categoryName: dto.categoryName, categoryIcon: dto.categoryIcon ?? null, @@ -47,6 +51,8 @@ export class CategoriesService { : BigInt(dto.parentCategoryId), }, }); + this.publicCache.bump('meta'); + return created; } async update(id: bigint, dto: UpdateCategoryDto) { @@ -66,7 +72,9 @@ export class CategoriesService { ? { disconnect: true } : { connect: { id: BigInt(dto.parentCategoryId) } }; } - return this.prisma.category.update({ where: { id }, data }); + const updated = await this.prisma.category.update({ where: { id }, data }); + this.publicCache.bump('meta'); + return updated; } async remove(id: bigint) { @@ -80,7 +88,9 @@ export class CategoriesService { ); } try { - return await this.prisma.category.delete({ where: { id } }); + const removed = await this.prisma.category.delete({ where: { id } }); + this.publicCache.bump('meta'); + return removed; } catch (err) { if (this.isForeignKeyViolation(err)) { throw new BadRequestException( diff --git a/apps/api/src/countries/countries.service.spec.ts b/apps/api/src/countries/countries.service.spec.ts index dfc9a53..b81e852 100644 --- a/apps/api/src/countries/countries.service.spec.ts +++ b/apps/api/src/countries/countries.service.spec.ts @@ -1,3 +1,4 @@ +import { PublicCacheService } from '../public/public-cache.service'; import { Test } from '@nestjs/testing'; import { BadRequestException, @@ -14,7 +15,7 @@ describe('CountriesService', () => { beforeAll(async () => { const moduleRef = await Test.createTestingModule({ - providers: [CountriesService, PrismaService], + providers: [CountriesService, PrismaService, PublicCacheService], }).compile(); service = moduleRef.get(CountriesService); prisma = moduleRef.get(PrismaService); diff --git a/apps/api/src/countries/countries.service.ts b/apps/api/src/countries/countries.service.ts index 8d71d0b..2235feb 100644 --- a/apps/api/src/countries/countries.service.ts +++ b/apps/api/src/countries/countries.service.ts @@ -6,13 +6,17 @@ import { NotFoundException, } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; +import { PublicCacheService } from '../public/public-cache.service'; import { CreateCountryDto } from './dto/create-country.dto'; import { UpdateCountryDto } from './dto/update-country.dto'; import { ReorderCountriesDto } from './dto/reorder-countries.dto'; @Injectable() export class CountriesService { - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + private readonly publicCache: PublicCacheService, + ) {} findAll() { return this.prisma.country.findMany({ @@ -30,6 +34,7 @@ export class CountriesService { }), ), ); + this.publicCache.bump('meta'); return this.findAll(); } @@ -43,12 +48,14 @@ export class CountriesService { async create(dto: CreateCountryDto) { try { - return await this.prisma.country.create({ + const created = await this.prisma.country.create({ data: { countryName: dto.countryName, countryIcon: dto.countryIcon ?? null, }, }); + this.publicCache.bump('meta'); + return created; } catch (err) { if ( err instanceof Prisma.PrismaClientKnownRequestError && @@ -63,13 +70,15 @@ export class CountriesService { async update(id: bigint, dto: UpdateCountryDto) { await this.findOne(id); try { - return await this.prisma.country.update({ + const updated = await this.prisma.country.update({ where: { id }, data: { countryName: dto.countryName, countryIcon: dto.countryIcon === undefined ? undefined : dto.countryIcon, }, }); + this.publicCache.bump('meta'); + return updated; } catch (err) { if ( err instanceof Prisma.PrismaClientKnownRequestError && @@ -84,7 +93,9 @@ export class CountriesService { async remove(id: bigint) { await this.findOne(id); try { - return await this.prisma.country.delete({ where: { id } }); + const removed = await this.prisma.country.delete({ where: { id } }); + this.publicCache.bump('meta'); + return removed; } catch (err) { if (this.isForeignKeyViolation(err)) { throw new BadRequestException( diff --git a/apps/api/src/goods/goods.service.spec.ts b/apps/api/src/goods/goods.service.spec.ts index c2119af..de864bc 100644 --- a/apps/api/src/goods/goods.service.spec.ts +++ b/apps/api/src/goods/goods.service.spec.ts @@ -1,3 +1,4 @@ +import { PublicCacheService } from '../public/public-cache.service'; import { Test } from '@nestjs/testing'; import { BadRequestException, @@ -30,6 +31,7 @@ describe('GoodsService', () => { beforeAll(async () => { const moduleRef = await Test.createTestingModule({ providers: [ + PublicCacheService, GoodsService, PrismaService, { diff --git a/apps/api/src/goods/goods.service.ts b/apps/api/src/goods/goods.service.ts index b5ea7ce..9efaefa 100644 --- a/apps/api/src/goods/goods.service.ts +++ b/apps/api/src/goods/goods.service.ts @@ -14,6 +14,7 @@ import { BatchPriorityDto } from './dto/batch-priority.dto'; import { GoodDetailDto, GoodDto, PaginatedGoods } from './dto/good.dto'; import { SyncService } from '../sync/sync.service'; import { FamilyRecomputeService } from '../product-families/family-recompute.service'; +import { PublicCacheService } from '../public/public-cache.service'; import { isAutoTagGroupName } from '../product-families/auto-tag-rules'; import { randomUUID } from 'crypto'; import { @@ -120,6 +121,7 @@ export class GoodsService { private readonly prisma: PrismaService, private readonly syncService: SyncService, private readonly familyRecompute: FamilyRecomputeService, + private readonly publicCache: PublicCacheService, ) {} async findAll(query: QueryGoodDto): Promise { @@ -240,6 +242,7 @@ export class GoodsService { // 建商品后把链接有效标签镜像到该商品(纯聚合,不派生——派生在整理流程) await this.familyRecompute.mirrorLinkTagsToGoods(BigInt(result.originGoodId)); } + this.publicCache.bump('goods'); if ( result.originGood?.source === 'SDS' && result.originGood.sdsGoodId && @@ -314,6 +317,7 @@ export class GoodsService { } return good.id; }); + this.publicCache.bump('goods'); if (family) this.familyRecompute.enqueue(family.id); return this.findOne(goodId); } @@ -369,6 +373,7 @@ export class GoodsService { await tx.good.update({ where: { id }, data: goodData }); } }); + this.publicCache.bump('goods'); return this.findOne(id); } @@ -495,6 +500,7 @@ export class GoodsService { // 归族联动后重算矩阵(成员变化 → 自动重算,结构化聚合) await this.familyRecompute.recomputeFamily(familyIdForTags); } + this.publicCache.bump('goods'); if ( result.originGood?.source === 'SDS' && result.originGood.sdsGoodId && @@ -522,6 +528,7 @@ export class GoodsService { } } }); + this.publicCache.bump('goods'); return { id: id.toString() }; } @@ -539,6 +546,7 @@ export class GoodsService { } return { count: dto.items.length }; }); + this.publicCache.bump('goods'); return result; } @@ -634,6 +642,7 @@ export class GoodsService { )) { this.syncService.queueProductDetailSync(goodId); } + this.publicCache.bump('goods'); return result; } diff --git a/apps/api/src/origin-goods/origin-goods.service.spec.ts b/apps/api/src/origin-goods/origin-goods.service.spec.ts index c417ccc..2740f9d 100644 --- a/apps/api/src/origin-goods/origin-goods.service.spec.ts +++ b/apps/api/src/origin-goods/origin-goods.service.spec.ts @@ -1,3 +1,4 @@ +import { PublicCacheService } from '../public/public-cache.service'; import { Test } from '@nestjs/testing'; import { OriginGoodsService } from './origin-goods.service'; import { FamilyRecomputeService } from '../product-families/family-recompute.service'; @@ -15,7 +16,7 @@ describe('OriginGoodsService', () => { beforeAll(async () => { const moduleRef = await Test.createTestingModule({ - providers: [OriginGoodsService, FamilyRecomputeService, ProductFamiliesService, OrganizeService, PrismaService], + providers: [OriginGoodsService, FamilyRecomputeService, ProductFamiliesService, OrganizeService, PrismaService, PublicCacheService], }).compile(); service = moduleRef.get(OriginGoodsService); organize = moduleRef.get(OrganizeService); diff --git a/apps/api/src/origin-goods/origin-goods.service.ts b/apps/api/src/origin-goods/origin-goods.service.ts index f35a95b..ed58b87 100644 --- a/apps/api/src/origin-goods/origin-goods.service.ts +++ b/apps/api/src/origin-goods/origin-goods.service.ts @@ -8,6 +8,7 @@ import { PrismaService } from '../prisma/prisma.service'; import { FamilyRecomputeService } from '../product-families/family-recompute.service'; import { OrganizeService } from '../product-families/organize.service'; import { ProductFamiliesService } from '../product-families/product-families.service'; +import { PublicCacheService } from '../public/public-cache.service'; import { QueryOriginGoodDto } from './dto/query-origin-good.dto'; /** 链接标签(origin_good_tags 行,含人工/派生标记) */ @@ -97,6 +98,7 @@ export class OriginGoodsService { private readonly familyRecompute: FamilyRecomputeService, private readonly organize: OrganizeService, private readonly families: ProductFamiliesService, + private readonly publicCache: PublicCacheService, ) {} /** 链接当前标签(含 manual 标记) */ @@ -171,6 +173,9 @@ export class OriginGoodsService { this.prisma.originGood.update({ where: { id }, data: { tagsManual: true } }), ]); await this.familyRecompute.mirrorLinkTagsToGoods(id); + // 标签镜像改变 good_tags(meta 域的标签组过滤);无族链接挂靠失败时 + // 后续 recompute 不会执行,此处显式 bump 兜住全部路径(含 matrix 归因) + this.publicCache.bump('meta', 'goods', 'matrix'); // 人工改标签 → 归因维度可能变化,自动重算族矩阵(有族才重算) const ogFull = await this.prisma.originGood.findUnique({ where: { id }, @@ -205,6 +210,7 @@ export class OriginGoodsService { ]); // 派生集中在整理服务(解析去运行时化);「恢复自动」本身是显式人工动作 await this.organize.deriveTagsForOg(id); + this.publicCache.bump('meta', 'goods', 'matrix'); if (og.familyId) await this.familyRecompute.recomputeFamily(og.familyId); return this.getTags(id); } diff --git a/apps/api/src/positions/positions.service.spec.ts b/apps/api/src/positions/positions.service.spec.ts index 565c005..848f046 100644 --- a/apps/api/src/positions/positions.service.spec.ts +++ b/apps/api/src/positions/positions.service.spec.ts @@ -1,3 +1,4 @@ +import { PublicCacheService } from '../public/public-cache.service'; import { Test } from '@nestjs/testing'; import { NotFoundException } from '@nestjs/common'; import { PositionsService } from './positions.service'; @@ -12,7 +13,7 @@ describe('PositionsService', () => { beforeAll(async () => { const moduleRef = await Test.createTestingModule({ - providers: [PositionsService, PrismaService], + providers: [PositionsService, PrismaService, PublicCacheService], }).compile(); service = moduleRef.get(PositionsService); prisma = moduleRef.get(PrismaService); diff --git a/apps/api/src/positions/positions.service.ts b/apps/api/src/positions/positions.service.ts index 3de3076..43335da 100644 --- a/apps/api/src/positions/positions.service.ts +++ b/apps/api/src/positions/positions.service.ts @@ -3,12 +3,16 @@ import { NotFoundException, } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; +import { PublicCacheService } from '../public/public-cache.service'; import { CreatePositionDto } from './dto/create-position.dto'; import { UpdatePositionDto } from './dto/update-position.dto'; @Injectable() export class PositionsService { - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + private readonly publicCache: PublicCacheService, + ) {} findAll(filters?: { countryId?: bigint; categoryId?: bigint }) { const where: { countryId?: bigint; categoryId?: bigint } = {}; @@ -37,7 +41,7 @@ export class PositionsService { if (dto.categoryId !== undefined) { await this.ensureCategory(dto.categoryId); } - return this.prisma.position.create({ + const created = await this.prisma.position.create({ data: { indexVal: dto.indexVal, countryId: dto.countryId === undefined ? null : BigInt(dto.countryId), @@ -45,6 +49,8 @@ export class PositionsService { }, include: { country: true, category: true }, }); + this.publicCache.bump('goods'); + return created; } async update(id: bigint, dto: UpdatePositionDto) { @@ -55,7 +61,7 @@ export class PositionsService { if (dto.categoryId !== undefined && dto.categoryId !== null) { await this.ensureCategory(dto.categoryId); } - return this.prisma.position.update({ + const updated = await this.prisma.position.update({ where: { id }, data: { indexVal: dto.indexVal, @@ -74,11 +80,15 @@ export class PositionsService { }, include: { country: true, category: true }, }); + this.publicCache.bump('goods'); + return updated; } async remove(id: bigint) { await this.findOne(id); - return this.prisma.position.delete({ where: { id } }); + const removed = await this.prisma.position.delete({ where: { id } }); + this.publicCache.bump('goods'); + return removed; } private async ensureCountry(id: number) { diff --git a/apps/api/src/product-families/family-recompute.service.spec.ts b/apps/api/src/product-families/family-recompute.service.spec.ts index efd7cbb..adaf261 100644 --- a/apps/api/src/product-families/family-recompute.service.spec.ts +++ b/apps/api/src/product-families/family-recompute.service.spec.ts @@ -1,3 +1,4 @@ +import { PublicCacheService } from '../public/public-cache.service'; import { Test } from '@nestjs/testing'; import { Prisma } from '@prisma/client'; import { @@ -103,13 +104,14 @@ describe('FamilyRecomputeService', () => { beforeAll(async () => { const moduleRef = await Test.createTestingModule({ - providers: [FamilyRecomputeService, PrismaService], + providers: [FamilyRecomputeService, PrismaService, PublicCacheService], }).compile(); service = moduleRef.get(FamilyRecomputeService); organize = new OrganizeService( moduleRef.get(PrismaService), service, new ProductFamiliesService(moduleRef.get(PrismaService), service), + moduleRef.get(PublicCacheService), ); prisma = moduleRef.get(PrismaService); await prisma.onModuleInit(); diff --git a/apps/api/src/product-families/family-recompute.service.ts b/apps/api/src/product-families/family-recompute.service.ts index c3b3ad0..675fcb2 100644 --- a/apps/api/src/product-families/family-recompute.service.ts +++ b/apps/api/src/product-families/family-recompute.service.ts @@ -1,6 +1,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; +import { PublicCacheService } from '../public/public-cache.service'; import { isAutoTagGroupName } from './auto-tag-rules'; /** @@ -326,7 +327,10 @@ export class FamilyRecomputeService { private readonly logger = new Logger(FamilyRecomputeService.name); private readonly pending = new Map>(); - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + private readonly publicCache: PublicCacheService, + ) {} /** 进程内去重的异步重算入口(同步钩子用) */ enqueue(familyId: bigint): void { @@ -405,7 +409,9 @@ export class FamilyRecomputeService { data: { stale: true }, }); } - + // 族物化 JSON(矩阵/尺码表)与成员口径变化 → public 列表价/详情族块失效。 + // 人工接管族(autoManaged=false)虽未重写矩阵,但其成员/详情已变,goods 域同样失效。 + this.publicCache.bump('goods', 'matrix'); } /** 把链接的有效标签镜像到其名下商品(good 标签 = 链接标签 ∪ 非自动组既有标签) */ async mirrorLinkTagsToGoods(ogId: bigint): Promise { diff --git a/apps/api/src/product-families/organize.service.spec.ts b/apps/api/src/product-families/organize.service.spec.ts index 7440ef7..08db007 100644 --- a/apps/api/src/product-families/organize.service.spec.ts +++ b/apps/api/src/product-families/organize.service.spec.ts @@ -1,3 +1,4 @@ +import { PublicCacheService } from '../public/public-cache.service'; import { Test } from '@nestjs/testing'; import { FamilyRecomputeService } from './family-recompute.service'; import { ProductFamiliesService } from './product-families.service'; @@ -29,7 +30,7 @@ describe('链接级标签:派生 / 人工接管 / 商品镜像', () => { beforeAll(async () => { const moduleRef = await Test.createTestingModule({ - providers: [FamilyRecomputeService, ProductFamiliesService, OrganizeService, OriginGoodsService, PrismaService], + providers: [FamilyRecomputeService, ProductFamiliesService, OrganizeService, OriginGoodsService, PrismaService, PublicCacheService], }).compile(); recompute = moduleRef.get(FamilyRecomputeService); organize = moduleRef.get(OrganizeService); diff --git a/apps/api/src/product-families/organize.service.ts b/apps/api/src/product-families/organize.service.ts index bbe897a..da7ef3e 100644 --- a/apps/api/src/product-families/organize.service.ts +++ b/apps/api/src/product-families/organize.service.ts @@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; import { FamilyRecomputeService } from './family-recompute.service'; import { ProductFamiliesService } from './product-families.service'; +import { PublicCacheService } from '../public/public-cache.service'; import { parseOriginName } from './origin-name.parser'; import { DERIVED_TAG_GROUP_SPECS, @@ -28,6 +29,7 @@ export class OrganizeService { private readonly prisma: PrismaService, private readonly recompute: FamilyRecomputeService, private readonly families: ProductFamiliesService, + private readonly publicCache: PublicCacheService, ) {} async organize() { @@ -52,6 +54,9 @@ export class OrganizeService { familiesRecomputed: families.length, }; this.logger.log(`organize done: ${JSON.stringify(result)}`); + // 整理是全量标签/归族/矩阵重写动作,三域全失效(末尾逐族重算只覆盖 goods+matrix, + // 标签镜像与标签组创建落在 meta 域) + this.publicCache.bump('meta', 'goods', 'matrix'); return result; } @@ -190,6 +195,8 @@ export class OrganizeService { await this.prisma.originGood.update({ where: { id: og.id }, data: next }); parsed += 1; } + // 结构化标签列是 CUSTOM 成员的矩阵归因来源(下次重算生效),保守失效 matrix+goods + this.publicCache.bump('goods', 'matrix'); return { parsed, unparsable }; } @@ -220,6 +227,8 @@ export class OrganizeService { for (const fid of touchedFamilies) { await this.recompute.recomputeFamily(fid); } + // 标签镜像(good_tags)落 meta 域;散链接无族不会被重算覆盖,入口统一失效 + this.publicCache.bump('meta', 'goods', 'matrix'); return { linksUpdated, goodsUpdated, familiesRecomputed: touchedFamilies.length }; } @@ -339,6 +348,7 @@ export class OrganizeService { ]); goodsUpdated += 1; } + this.publicCache.bump('meta', 'goods'); return { goodsUpdated, linksUpdated }; } @@ -375,6 +385,7 @@ export class OrganizeService { linksUpdated = 1; } await this.recompute.mirrorLinkTagsToGoods(og.id); + this.publicCache.bump('meta', 'goods'); return { linksUpdated }; } diff --git a/apps/api/src/product-families/product-families.service.spec.ts b/apps/api/src/product-families/product-families.service.spec.ts index c926eaa..14af212 100644 --- a/apps/api/src/product-families/product-families.service.spec.ts +++ b/apps/api/src/product-families/product-families.service.spec.ts @@ -1,3 +1,4 @@ +import { PublicCacheService } from '../public/public-cache.service'; import { Test } from '@nestjs/testing'; import { BadRequestException, NotFoundException } from '@nestjs/common'; import { Prisma } from '@prisma/client'; @@ -28,13 +29,14 @@ describe('ProductFamiliesService', () => { beforeAll(async () => { const moduleRef = await Test.createTestingModule({ - providers: [ProductFamiliesService, FamilyRecomputeService, PrismaService], + providers: [ProductFamiliesService, FamilyRecomputeService, PrismaService, PublicCacheService], }).compile(); service = moduleRef.get(ProductFamiliesService); organize = new OrganizeService( moduleRef.get(PrismaService), moduleRef.get(FamilyRecomputeService), service, + moduleRef.get(PublicCacheService), ); prisma = moduleRef.get(PrismaService); await prisma.onModuleInit(); diff --git a/apps/api/src/public/public-cache.invalidation.spec.ts b/apps/api/src/public/public-cache.invalidation.spec.ts new file mode 100644 index 0000000..37ac281 --- /dev/null +++ b/apps/api/src/public/public-cache.invalidation.spec.ts @@ -0,0 +1,283 @@ +import { Test } from '@nestjs/testing'; +import { PrismaService } from '../prisma/prisma.service'; +import { PublicCacheService } from './public-cache.service'; +import { PublicService } from './public.service'; +import { CategoriesService } from '../categories/categories.service'; +import { CountriesService } from '../countries/countries.service'; +import { TagsService } from '../tags/tags.service'; +import { TagGroupsService } from '../tag-groups/tag-groups.service'; +import { PositionsService } from '../positions/positions.service'; +import { GoodsService } from '../goods/goods.service'; +import { SyncService } from '../sync/sync.service'; +import { SdsClientService } from '../sync/sds-client.service'; +import { FamilyRecomputeService } from '../product-families/family-recompute.service'; +import { ProductFamiliesService } from '../product-families/product-families.service'; +import { OrganizeService } from '../product-families/organize.service'; +import { OriginGoodsService } from '../origin-goods/origin-goods.service'; + +/** + * public 缓存失效链路(集成): + * 1. 读端点命中缓存(同筛选翻页/重复详情不再查库); + * 2. 「TTL 未到但写已发生 → 前台立即可见」端到端; + * 3. 每条写路径 bump 断言(全局约束 §2:等待 TTL 过期不是失效手段)。 + * 夹具自包含:全部天然键带运行时间戳,不依赖库内既有数据。 + */ +describe('public 缓存失效链路', () => { + const stamp = Date.now(); + let prisma: PrismaService; + let cache: PublicCacheService; + let service: PublicService; + let categoriesService: CategoriesService; + let countriesService: CountriesService; + let tagsService: TagsService; + let tagGroupsService: TagGroupsService; + let positionsService: PositionsService; + let goodsService: GoodsService; + let syncService: SyncService; + + let countryId: bigint; + let categoryId: bigint; + let tagId: bigint; + let tagGroupId: bigint; + let positionId: bigint; + let originGoodId: bigint; + let familyId: bigint; + let goodId: bigint; + let sdsGoodId: string; + let sdsCategoryId: string; + + beforeAll(async () => { + const sdsMock: Partial = { + // 返回库内全部 SDS 分类 + 逻辑:保证 syncCategories 不误删并行套件的夹具 + fetchCategoryTree: async () => { + const rows = await prisma.category.findMany({ + where: { sdsCategoryId: { not: null } }, + select: { sdsCategoryId: true, categoryName: true }, + }); + return rows.map((r) => ({ id: r.sdsCategoryId!, name: r.categoryName })); + }, + fetchProductsPage: async () => ({ items: [] }), + fetchProductDetail: async (goodId: string | number) => ({ id: goodId }), + }; + const moduleRef = await Test.createTestingModule({ + providers: [ + PublicService, + CategoriesService, + CountriesService, + TagsService, + TagGroupsService, + PositionsService, + GoodsService, + SyncService, + FamilyRecomputeService, + ProductFamiliesService, + OrganizeService, + OriginGoodsService, + { provide: SdsClientService, useValue: sdsMock }, + PrismaService, + PublicCacheService, + ], + }).compile(); + service = moduleRef.get(PublicService); + cache = moduleRef.get(PublicCacheService); + categoriesService = moduleRef.get(CategoriesService); + countriesService = moduleRef.get(CountriesService); + tagsService = moduleRef.get(TagsService); + tagGroupsService = moduleRef.get(TagGroupsService); + positionsService = moduleRef.get(PositionsService); + goodsService = moduleRef.get(GoodsService); + syncService = moduleRef.get(SyncService); + prisma = moduleRef.get(PrismaService); + await prisma.onModuleInit(); + + const country = await prisma.country.create({ + data: { countryName: `失效测试国家 ${stamp}` }, + }); + countryId = country.id; + sdsCategoryId = `inv-sds-cat-${stamp}`; + const category = await prisma.category.create({ + data: { categoryName: `失效测试分类 ${stamp}`, sdsCategoryId }, + }); + categoryId = category.id; + const tagGroup = await prisma.tagGroup.create({ + data: { groupName: `失效测试标签组 ${stamp}` }, + }); + tagGroupId = tagGroup.id; + const tag = await prisma.tag.create({ + data: { tagName: `失效测试标签 ${stamp}`, tagGroupId }, + }); + tagId = tag.id; + const position = await prisma.position.create({ + data: { indexVal: 1, countryId, categoryId }, + }); + positionId = position.id; + + sdsGoodId = `inv-sds-good-${stamp}`; + const og = await prisma.originGood.create({ + data: { sdsGoodId, goodName: `失效测试链接 ${stamp}`, goodImage: 'http://img' }, + }); + originGoodId = og.id; + const family = await prisma.productFamily.create({ + data: { familyName: `失效测试族 ${stamp}`, primaryOriginGoodId: og.id }, + }); + familyId = family.id; + await prisma.originGood.update({ + where: { id: og.id }, + data: { familyId }, + }); + const good = await prisma.good.create({ + data: { + goodName: `失效测试商品 ${stamp}`, + originGoodId, + familyId, + countryId, + categoryId, + goodPriority: 5, + }, + }); + goodId = good.id; + }, 30_000); + + afterAll(async () => { + await prisma.good.deleteMany({ where: { id: goodId } }); + await prisma.originGoodTag.deleteMany({ where: { originGoodId } }); + await prisma.originGoodVariant.deleteMany({ where: { originGoodId } }); + await prisma.originGoodDetail.deleteMany({ where: { originGoodId } }); + await prisma.originGood.deleteMany({ where: { id: originGoodId } }); + await prisma.familyPriceOverride.deleteMany({ where: { familyId } }); + await prisma.productFamily.deleteMany({ where: { id: familyId } }); + await prisma.position.deleteMany({ where: { id: positionId } }); + await prisma.tag.deleteMany({ where: { id: tagId } }); + await prisma.tagGroup.deleteMany({ where: { id: tagGroupId } }); + await prisma.category.deleteMany({ + where: { OR: [{ id: categoryId }, { sdsCategoryId: { startsWith: `inv-sds-cat-${stamp}` } }] }, + }); + await prisma.country.deleteMany({ where: { id: countryId } }); + await prisma.$disconnect(); + }); + + it('列表读缓存:同筛选重复请求与翻页共享一份缓存,只查一次库', async () => { + const findManySpy = jest.spyOn(prisma.good, 'findMany'); + const query = { + page: 1, + pageSize: 1, + countryId: countryId.toString(), + keyword: `失效测试商品 ${stamp}`, + sort: 'DEFAULT' as const, + }; + const first = await service.getGoods(query); + const second = await service.getGoods(query); + const page2 = await service.getGoods({ ...query, page: 2 }); + expect(first.items).toHaveLength(1); + expect(first.items[0].goodId).toBe(familyId.toString()); + expect(second.items).toEqual(first.items); + expect(page2.items).toHaveLength(0); // total=1,第二页为空,但仍命中同一份缓存 + expect(findManySpy).toHaveBeenCalledTimes(1); + findManySpy.mockRestore(); + }); + + it('详情读缓存:重复请求不再查族表', async () => { + const familySpy = jest.spyOn(prisma.productFamily, 'findUnique'); + const first = await service.getGood(familyId.toString()); + const second = await service.getGood(familyId.toString()); + expect(first.goodId).toBe(familyId.toString()); + expect(second).toEqual(first); + expect(familySpy).toHaveBeenCalledTimes(1); + familySpy.mockRestore(); + }); + + it('TTL 未到但分类改名 → 分类树立即返回新名(写后即失效,端到端)', async () => { + const oldName = `失效测试分类 ${stamp}`; + const newName = `失效后分类 ${stamp}`; + const categorySpy = jest.spyOn(prisma.category, 'findMany'); + const before = await service.getCategoriesTree(); + const flatBefore = JSON.stringify(before); + expect(flatBefore).toContain(oldName); + const callsAfterWarm = categorySpy.mock.calls.length; + + await categoriesService.update(categoryId, { categoryName: newName }); + + const after = await service.getCategoriesTree(); + expect(JSON.stringify(after)).toContain(newName); + expect(JSON.stringify(after)).not.toContain(oldName); + // 旧值来自缓存则不会再查库;失效正确时应产生新的分类查询 + expect(categorySpy.mock.calls.length).toBeGreaterThan(callsAfterWarm); + categorySpy.mockRestore(); + }); + + it('分类写路径 bump meta(Countries/Tags/TagGroups)', async () => { + const meta0 = cache.version('meta'); + const goods0 = cache.version('goods'); + await countriesService.update(countryId, { + countryName: `失效测试国家改 ${stamp}`, + countryIcon: null, + }); + expect(cache.version('meta')).toBe(meta0 + 1); + + await tagsService.update(tagId, { tagName: `失效测试标签改 ${stamp}` }); + expect(cache.version('meta')).toBe(meta0 + 2); + + await tagGroupsService.update(tagGroupId, { groupName: `失效测试标签组改 ${stamp}` }); + expect(cache.version('meta')).toBe(meta0 + 3); + expect(cache.version('goods')).toBe(goods0); // meta 写路径不误伤 goods 域 + }); + + it('positions/goods 写路径 bump goods', async () => { + const goods0 = cache.version('goods'); + const meta0 = cache.version('meta'); + await positionsService.update(positionId, { indexVal: 9 }); + expect(cache.version('goods')).toBe(goods0 + 1); + + await goodsService.update(goodId, { goodName: `失效测试商品改 ${stamp}` }); + // update 内部联动 recomputeFamily(goods+matrix)后再显式 bump goods + expect(cache.version('goods')).toBeGreaterThanOrEqual(goods0 + 2); + expect(cache.version('meta')).toBe(meta0); + }); + + it('族重算 recomputeFamily bump goods+matrix', async () => { + const goods0 = cache.version('goods'); + const matrix0 = cache.version('matrix'); + const recompute = new FamilyRecomputeService(prisma, cache); + await recompute.recomputeFamily(familyId); + // goods 用 ≥:goods.update 等前置操作可能触发异步详情同步(fire-and-forget + // persistProductDetail 也会 bump goods),不与本断言强耦合;matrix 仅重算写 + expect(cache.version('goods')).toBeGreaterThanOrEqual(goods0 + 1); + expect(cache.version('matrix')).toBe(matrix0 + 1); + }); + + it('链接人工改标签 bump meta+goods+matrix(含无族路径兜底)', async () => { + const meta0 = cache.version('meta'); + const goods0 = cache.version('goods'); + const matrix0 = cache.version('matrix'); + const originGoodsService = new OriginGoodsService( + prisma, + new FamilyRecomputeService(prisma, cache), + new OrganizeService(prisma, new FamilyRecomputeService(prisma, cache), new ProductFamiliesService(prisma, new FamilyRecomputeService(prisma, cache)), cache), + new ProductFamiliesService(prisma, new FamilyRecomputeService(prisma, cache)), + cache, + ); + await originGoodsService.updateTags(originGoodId, []); + expect(cache.version('meta')).toBeGreaterThanOrEqual(meta0 + 1); + expect(cache.version('goods')).toBeGreaterThanOrEqual(goods0 + 1); + expect(cache.version('matrix')).toBeGreaterThanOrEqual(matrix0 + 1); + }); + + it('同步写路径:syncCategories bump meta+goods,syncProducts bump goods,persistProductDetail bump goods', async () => { + const meta0 = cache.version('meta'); + const goods0 = cache.version('goods'); + await syncService.syncCategories(); + expect(cache.version('meta')).toBe(meta0 + 1); + expect(cache.version('goods')).toBe(goods0 + 1); + + await syncService.syncProducts(); + expect(cache.version('goods')).toBe(goods0 + 2); + + const goods1 = cache.version('goods'); + // persistProductDetail 是私有方法,直调以断言挂钩(上游最小对象,mapper 全容忍) + await (syncService as unknown as { persistProductDetail: (id: bigint, up: unknown) => Promise }).persistProductDetail( + originGoodId, + { id: sdsGoodId }, + ); + expect(cache.version('goods')).toBeGreaterThanOrEqual(goods1 + 1); + }, 60_000); +}); diff --git a/apps/api/src/public/public-cache.module.ts b/apps/api/src/public/public-cache.module.ts new file mode 100644 index 0000000..704fb97 --- /dev/null +++ b/apps/api/src/public/public-cache.module.ts @@ -0,0 +1,16 @@ +import { Global, Module } from '@nestjs/common'; +import { PublicCacheService } from './public-cache.service'; + +/** + * Global public 缓存模块,导出 {@link PublicCacheService}。 + * + * 与 PrismaModule 同款 @Global 模式:public 读路径与各 admin/sync 写路径 + * 都要注入失效入口(bump),逐一 import 太啰嗦。单进程部署下进程内即全局; + * 扩容多副本时缓存需换共享存储(见整改计划 P1-3 扩展阶梯)。 + */ +@Global() +@Module({ + providers: [PublicCacheService], + exports: [PublicCacheService], +}) +export class PublicCacheModule {} diff --git a/apps/api/src/public/public-cache.service.spec.ts b/apps/api/src/public/public-cache.service.spec.ts new file mode 100644 index 0000000..49ff4eb --- /dev/null +++ b/apps/api/src/public/public-cache.service.spec.ts @@ -0,0 +1,165 @@ +import { PublicCacheDomain, PublicCacheService } from './public-cache.service'; + +describe('PublicCacheService', () => { + let cache: PublicCacheService; + + beforeEach(() => { + cache = new PublicCacheService(); + delete process.env.PUBLIC_CACHE_DISABLED; + delete process.env.PUBLIC_CACHE_TTL_MS; + }); + + afterAll(() => { + delete process.env.PUBLIC_CACHE_DISABLED; + delete process.env.PUBLIC_CACHE_TTL_MS; + }); + + it('命中:相同 key 第二次调用不再执行 loader', async () => { + const loader = jest.fn(async () => ({ value: 1 })); + const first = await cache.wrap('k', ['goods'], loader); + const second = await cache.wrap('k', ['goods'], loader); + expect(first).toEqual({ value: 1 }); + expect(second).toEqual({ value: 1 }); + expect(loader).toHaveBeenCalledTimes(1); + }); + + it('不同依赖域组合的相同 key 互不串缓存', async () => { + const a = await cache.wrap('k', ['goods'], async () => 'A'); + const b = await cache.wrap('k', ['goods', 'matrix'], async () => 'B'); + expect(a).toBe('A'); + expect(b).toBe('B'); + }); + + it('TTL 过期后重新执行 loader(TTL 只是兜底,不是失效手段)', async () => { + jest.useFakeTimers(); + jest.setSystemTime(1_700_000_000_000); + try { + process.env.PUBLIC_CACHE_TTL_MS = '1000'; + const loader = jest.fn(async () => ({ v: 1 })); + await cache.wrap('k', ['goods'], loader); + jest.setSystemTime(1_700_000_000_000 + 999); + await cache.wrap('k', ['goods'], loader); + expect(loader).toHaveBeenCalledTimes(1); + jest.setSystemTime(1_700_000_000_000 + 1001); + await cache.wrap('k', ['goods'], loader); + expect(loader).toHaveBeenCalledTimes(2); + } finally { + jest.useRealTimers(); + } + }); + + it('bump 域版本后,依赖该域的缓存立即作废', async () => { + const loader = jest.fn(async () => ({ v: 1 })); + await cache.wrap('list', ['goods', 'matrix'], loader); + cache.bump('goods'); + await cache.wrap('list', ['goods', 'matrix'], loader); + expect(loader).toHaveBeenCalledTimes(2); + }); + + it('bump 某域不影响不依赖该域的条目(域间隔离)', async () => { + const metaLoader = jest.fn(async () => 'meta-data'); + const goodsLoader = jest.fn(async () => 'goods-data'); + await cache.wrap('meta-key', ['meta'], metaLoader); + cache.bump('goods'); + await cache.wrap('meta-key', ['meta'], metaLoader); + expect(metaLoader).toHaveBeenCalledTimes(1); + await cache.wrap('goods-key', ['goods'], goodsLoader); + expect(goodsLoader).toHaveBeenCalledTimes(1); + }); + + it('跨域条目:任一依赖域 bump 都使其作废', async () => { + const loader = jest.fn(async () => 'x'); + await cache.wrap('combo', ['goods', 'matrix', 'meta'], loader); + cache.bump('matrix'); + await cache.wrap('combo', ['goods', 'matrix', 'meta'], loader); + expect(loader).toHaveBeenCalledTimes(2); + }); + + it('竞态防护:loader 执行期间发生 bump → 结果照常返回但不入缓存', async () => { + let releaseLoader!: (v: string) => void; + const loader = jest.fn( + () => + new Promise((resolve) => { + releaseLoader = resolve; + }), + ); + const inflight = cache.wrap('k', ['goods'], loader); + cache.bump('goods'); // 加载期间数据变了 + releaseLoader('stale-value'); + await expect(inflight).resolves.toBe('stale-value'); + + const loader2 = jest.fn(async () => 'fresh-value'); + await expect(cache.wrap('k', ['goods'], loader2)).resolves.toBe('fresh-value'); + expect(loader2).toHaveBeenCalledTimes(1); // 旧值没有写回缓存 + }); + + it('并发合并:并发 miss 只触发一次 loader(防击穿)', async () => { + let release!: () => void; + const loader = jest.fn( + () => + new Promise((resolve) => { + release = () => resolve(42); + }), + ); + const p1 = cache.wrap('hot', ['goods'], loader); + const p2 = cache.wrap('hot', ['goods'], loader); + const p3 = cache.wrap('hot', ['goods'], loader); + release(); + expect(await Promise.all([p1, p2, p3])).toEqual([42, 42, 42]); + expect(loader).toHaveBeenCalledTimes(1); + }); + + it('PUBLIC_CACHE_DISABLED=true 时直通 loader、不缓存', async () => { + process.env.PUBLIC_CACHE_DISABLED = 'true'; + const loader = jest.fn(async () => ({ v: 1 })); + await cache.wrap('k', ['goods'], loader); + await cache.wrap('k', ['goods'], loader); + expect(loader).toHaveBeenCalledTimes(2); + }); + + it('loader 抛错不缓存、不残留 pending', async () => { + const failing = jest.fn(async () => { + throw new Error('boom'); + }); + await expect(cache.wrap('k', ['goods'], failing)).rejects.toThrow('boom'); + const ok = jest.fn(async () => 'good'); + await expect(cache.wrap('k', ['goods'], ok)).resolves.toBe('good'); + }); + + it('条目上限:先清过期条目,仍超限则按插入序淘汰最旧的', async () => { + jest.useFakeTimers(); + jest.setSystemTime(1_700_000_000_000); + try { + process.env.PUBLIC_CACHE_TTL_MS = '1000'; + process.env.PUBLIC_CACHE_MAX_ENTRIES = '3'; + await cache.wrap('old', ['goods'], async () => 'old'); + jest.setSystemTime(1_700_000_000_000 + 2000); + // old 已过期;再写 4 条活跃条目,第一条应顺带清掉过期的 old + for (let i = 0; i < 4; i++) { + await cache.wrap(`k${i}`, ['goods'], async () => i); + } + const loader = jest.fn(async () => 'reloaded'); + // k0 应已被容量淘汰,重新加载;k3 仍命中 + await cache.wrap('k0', ['goods'], loader); + expect(loader).toHaveBeenCalledTimes(1); + const hit = jest.fn(async () => -1); + await cache.wrap('k3', ['goods'], hit); + expect(hit).toHaveBeenCalledTimes(0); + } finally { + delete process.env.PUBLIC_CACHE_MAX_ENTRIES; + jest.useRealTimers(); + } + }); + + it('version() 暴露域版本用于诊断,bump 递增', () => { + const before = cache.version('goods'); + cache.bump('goods'); + expect(cache.version('goods')).toBe(before + 1); + expect(cache.version('matrix')).toBe(before === 0 ? 0 : cache.version('matrix')); + }); + + it('未知域名直接抛错(编程错误早暴露)', async () => { + expect(() => cache.bump('nope' as PublicCacheDomain)).toThrow(); + await expect(cache.wrap('k', ['nope' as PublicCacheDomain], async () => 1)).rejects.toThrow(); + }); +}); diff --git a/apps/api/src/public/public-cache.service.ts b/apps/api/src/public/public-cache.service.ts new file mode 100644 index 0000000..d71fdf1 --- /dev/null +++ b/apps/api/src/public/public-cache.service.ts @@ -0,0 +1,171 @@ +import { Injectable, Logger } from '@nestjs/common'; + +/** + * public 读路径的进程内缓存(性能整改 P0-1,详见 + * docs/references/performance-review-public-port.md 与 + * plans/refactor/public-capacity-10k-refactor.md)。 + * + * 语义要点(全局约束 §2 的落地): + * - 分域版本号失效:写路径在事务提交成功后 bump 依赖域,依赖该域的缓存 + * 条目立即作废——「等 TTL 自然过期」只是内存回收兜底,不是失效手段; + * - 条目记录写入时全部依赖域的版本快照,读时逐一比对,跨域依赖 + * (列表同时依赖 goods+matrix+meta)天然联动失效; + * - loader 完成后复核版本:bump 发生在加载期间 → 结果照常返回但不入缓存, + * 杜绝「失效瞬间的并发读把旧值写回」竞态; + * - in-flight 合并:并发 miss 只触发一次 loader,防瞬时洪峰击穿缓存; + * - 单进程部署下进程内即全局缓存;扩容多副本时需换共享存储(计划 P1-3 阶梯 c)。 + * + * 环境开关:PUBLIC_CACHE_DISABLED=true 全直通(应急); + * PUBLIC_CACHE_TTL_MS / PUBLIC_CACHE_MAX_ENTRIES 可调(默认 10 分钟 / 512 条)。 + */ + +export const PUBLIC_CACHE_DOMAINS = ['meta', 'goods', 'matrix'] as const; +export type PublicCacheDomain = (typeof PUBLIC_CACHE_DOMAINS)[number]; + +const DEFAULT_TTL_MS = 10 * 60 * 1000; +const DEFAULT_MAX_ENTRIES = 512; + +interface CacheEntry { + value: unknown; + expiresAt: number; + /** 写入时各依赖域的版本快照——任一域 bump 即作废 */ + versions: ReadonlyMap; +} + +@Injectable() +export class PublicCacheService { + private readonly logger = new Logger(PublicCacheService.name); + private readonly versions = new Map( + PUBLIC_CACHE_DOMAINS.map((domain) => [domain, 0]), + ); + private readonly entries = new Map(); + private readonly pending = new Map>(); + + /** + * 读穿透封装:命中(未过期且全部依赖域版本未变)直接返回缓存值, + * 否则执行 loader 并缓存。domains 声明该值依赖的数据域—— + * 写路径 bump 其中任一域都会让本条目作废。 + */ + async wrap( + key: string, + domains: readonly PublicCacheDomain[], + loader: () => Promise, + ): Promise { + this.assertDomains(domains); + if (this.disabled()) return loader(); + + const storeKey = `${[...domains].sort().join('+')}|${key}`; + const hit = this.readHit(storeKey); + if (hit !== undefined) return hit as T; + + const inflight = this.pending.get(storeKey); + if (inflight) return inflight as Promise; + + const snapshot = this.snapshotVersions(domains); + const run: Promise = (async () => { + const value = await loader(); + if (this.versionsMatch(snapshot)) { + this.writeEntry(storeKey, value, snapshot); + } else { + this.logger.debug(`cache skip (version bumped during load): ${storeKey}`); + } + return value; + })(); + this.pending.set(storeKey, run as Promise); + // 失败与成功都要摘掉 pending,异常不得残留在途槽位 + void run.catch(() => undefined).finally(() => this.pending.delete(storeKey)); + return run; + } + + /** + * 失效入口:写事务提交成功后调用。递增域版本并清除依赖该域的条目 + * (版本号本身已保证正确性,清条目只为及时回收内存)。 + */ + bump(...domains: PublicCacheDomain[]): void { + this.assertDomains(domains); + for (const domain of domains) { + this.versions.set(domain, (this.versions.get(domain) ?? 0) + 1); + } + if (this.entries.size === 0) return; + for (const [key, entry] of this.entries) { + if (domains.some((domain) => entry.versions.has(domain))) { + this.entries.delete(key); + } + } + } + + /** 当前域版本(诊断/测试用) */ + version(domain: PublicCacheDomain): number { + this.assertDomains([domain]); + return this.versions.get(domain) ?? 0; + } + + private readHit(storeKey: string): unknown { + const entry = this.entries.get(storeKey); + if (!entry) return undefined; + if (Date.now() >= entry.expiresAt) { + this.entries.delete(storeKey); + return undefined; + } + if (!this.versionsMatch(entry.versions)) { + this.entries.delete(storeKey); + return undefined; + } + return entry.value; + } + + private versionsMatch(snapshot: ReadonlyMap): boolean { + for (const [domain, version] of snapshot) { + if ((this.versions.get(domain) ?? 0) !== version) return false; + } + return true; + } + + private snapshotVersions( + domains: readonly PublicCacheDomain[], + ): Map { + return new Map(domains.map((domain) => [domain, this.versions.get(domain) ?? 0])); + } + + private writeEntry( + storeKey: string, + value: unknown, + versions: ReadonlyMap, + ): void { + const maxEntries = this.maxEntries(); + if (this.entries.size >= maxEntries) { + const now = Date.now(); + for (const [key, entry] of this.entries) { + if (entry.expiresAt <= now) this.entries.delete(key); + } + while (this.entries.size >= maxEntries) { + const oldest = this.entries.keys().next().value; + if (oldest === undefined) break; + this.entries.delete(oldest); + } + } + this.entries.set(storeKey, { value, expiresAt: Date.now() + this.ttlMs(), versions }); + } + + private ttlMs(): number { + const parsed = Number(process.env.PUBLIC_CACHE_TTL_MS); + return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_TTL_MS; + } + + private maxEntries(): number { + const parsed = Number(process.env.PUBLIC_CACHE_MAX_ENTRIES); + return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_MAX_ENTRIES; + } + + private disabled(): boolean { + return process.env.PUBLIC_CACHE_DISABLED === 'true'; + } + + private assertDomains(domains: readonly PublicCacheDomain[]): void { + for (const domain of domains) { + if (!this.versions.has(domain)) { + throw new Error(`未知的 public 缓存域: ${String(domain)}`); + } + } + } +} diff --git a/apps/api/src/public/public-family-block.spec.ts b/apps/api/src/public/public-family-block.spec.ts index 87bdf47..382d143 100644 --- a/apps/api/src/public/public-family-block.spec.ts +++ b/apps/api/src/public/public-family-block.spec.ts @@ -1,3 +1,4 @@ +import { PublicCacheService } from './public-cache.service'; import { Test } from '@nestjs/testing'; import { Prisma } from '@prisma/client'; import { PublicService } from './public.service'; @@ -24,8 +25,11 @@ describe('PublicService family block (PUBLIC_DETAIL_FROM_FAMILY)', () => { let familyId = 0n; beforeAll(async () => { + // 本套件测数据语义(裸 prisma 改数后立即读公开端点),不走带 bump 的写路径—— + // 禁用 public 缓存;缓存命中/失效语义由 public-cache.invalidation.spec.ts 覆盖 + process.env.PUBLIC_CACHE_DISABLED = 'true'; const moduleRef = await Test.createTestingModule({ - providers: [PublicService, PrismaService], + providers: [PublicService, PrismaService, PublicCacheService], }).compile(); service = moduleRef.get(PublicService); prisma = moduleRef.get(PrismaService); @@ -71,11 +75,12 @@ describe('PublicService family block (PUBLIC_DETAIL_FROM_FAMILY)', () => { createdFamilyIds.push(family.id); await prisma.originGood.update({ where: { id: og.id }, data: { familyId: family.id } }); // 解析去运行时化:先整理派生标签,再重算(重算只聚合不解析) - const recomputeSvc = new FamilyRecomputeService(prisma); + const recomputeSvc = new FamilyRecomputeService(prisma, new PublicCacheService()); const organizeSvc = new OrganizeService( prisma, recomputeSvc, new ProductFamiliesService(prisma, recomputeSvc), + new PublicCacheService(), ); await organizeSvc.deriveFamilyTags(family.id); await recomputeSvc.recomputeFamily(family.id); @@ -196,13 +201,14 @@ describe('Good familyId derivation', () => { beforeAll(async () => { const moduleRef = await Test.createTestingModule({ - providers: [PrismaService], + providers: [PrismaService, PublicCacheService], }).compile(); prisma = moduleRef.get(PrismaService); await prisma.onModuleInit(); familiesService = new (require('../product-families/product-families.service').ProductFamiliesService)( prisma, - new FamilyRecomputeService(prisma), + new FamilyRecomputeService(prisma, new PublicCacheService()), + new PublicCacheService(), ); }); diff --git a/apps/api/src/public/public.service.spec.ts b/apps/api/src/public/public.service.spec.ts index 723663e..a13487b 100644 --- a/apps/api/src/public/public.service.spec.ts +++ b/apps/api/src/public/public.service.spec.ts @@ -1,874 +1,878 @@ -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', () => { - let service: PublicService; - let prisma: PrismaService; - const stamp = Date.now(); - let countryId: bigint; - let categoryId: bigint; - let childCategoryId: bigint; - let otherCategoryId: bigint; - let tagId: bigint; - let filterGroupIds: bigint[] = []; - let filterTagIds: bigint[] = []; - let originGoodId: bigint; - let familyId: bigint; - let goodIds: bigint[] = []; - - beforeAll(async () => { - const moduleRef = await Test.createTestingModule({ - providers: [PublicService, PrismaService], - }).compile(); - service = moduleRef.get(PublicService); - prisma = moduleRef.get(PrismaService); - await prisma.onModuleInit(); - - const country = await prisma.country.create({ - data: { countryName: `Pub Country ${stamp}` }, - }); - countryId = country.id; - - const cat = await prisma.category.create({ - data: { categoryName: `Pub Cat ${stamp}` }, - }); - categoryId = cat.id; - const child = await prisma.category.create({ - data: { categoryName: `Pub Child ${stamp}`, parentCategoryId: cat.id }, - }); - childCategoryId = child.id; - const otherCat = await prisma.category.create({ - data: { categoryName: `Pub Other ${stamp}` }, - }); - otherCategoryId = otherCat.id; - - const tag = await prisma.tag.create({ - data: { tagName: `Pub Tag ${stamp}`, tagColor: '#0000FF' }, - }); - tagId = tag.id; - - const og = await prisma.originGood.create({ - data: { - sdsGoodId: `pub-sds-${stamp}`, - goodName: `Origin ${stamp}`, - goodImage: 'http://img', - }, - }); - 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 - // no priority + no position (falls back to createdAt) - const pos1 = await prisma.position.create({ - data: { indexVal: 1, countryId, categoryId }, - }); - const pos2 = await prisma.position.create({ - data: { indexVal: 5, countryId, categoryId }, - }); - - const g1 = await prisma.good.create({ - data: { - goodName: `Pub High ${stamp}`, - originGoodId, - familyId: family.id, - countryId, - categoryId, - goodPriority: 10, - positionId: pos1.id, - }, - }); - const g2 = await prisma.good.create({ - data: { - goodName: `Pub Mid ${stamp}`, - originGoodId, - familyId: family.id, - countryId, - categoryId, - goodPriority: 5, - positionId: pos2.id, - }, - }); - const g3 = await prisma.good.create({ - data: { - goodName: `Pub NoPos ${stamp}`, - originGoodId, - familyId: family.id, - countryId, - categoryId, - tagId, - goodPriority: 1, - }, - }); - - // 自包含:链接级三组封闭词表标签 + 商品镜像 - // (getTagGroups「物流渠道/印花数量/印刷工艺」断言依赖,不再依赖库内其他数据) - const autoTagIds: bigint[] = []; - for (const [groupName, tagName] of [ - ['物流渠道', '包邮'], - ['印花数量', '单面印花'], - ['印刷工艺', '烫画'], - ] as const) { - const group = await prisma.tagGroup.upsert({ - where: { groupName }, - create: { groupName }, - update: {}, - }); - const t = await prisma.tag.upsert({ - where: { tagName }, - create: { tagName, tagGroupId: group.id }, - update: {}, - }); - autoTagIds.push(t.id); - } - await prisma.originGoodTag.createMany({ - data: autoTagIds.map((tagId) => ({ originGoodId: og.id, tagId })), - }); - await prisma.goodTag.createMany({ - data: [g1.id, g2.id, g3.id].flatMap((goodId) => - autoTagIds.map((tagId) => ({ goodId, tagId })), - ), - }); - goodIds = [g1.id, g2.id, g3.id]; - - const craftGroup = await prisma.tagGroup.create({ - data: { groupName: `Pub Craft ${stamp}`, sortOrder: 100 }, - }); - const materialGroup = await prisma.tagGroup.create({ - data: { groupName: `Pub Material ${stamp}`, sortOrder: 101 }, - }); - filterGroupIds = [craftGroup.id, materialGroup.id]; - const craftA = await prisma.tag.create({ - data: { tagName: `Pub Craft A ${stamp}`, tagGroupId: craftGroup.id }, - }); - const craftB = await prisma.tag.create({ - data: { tagName: `Pub Craft B ${stamp}`, tagGroupId: craftGroup.id }, - }); - const cotton = await prisma.tag.create({ - data: { tagName: `Pub Cotton ${stamp}`, tagGroupId: materialGroup.id }, - }); - filterTagIds = [craftA.id, craftB.id, cotton.id]; - await prisma.goodTag.createMany({ - data: [ - { goodId: g1.id, tagId: craftA.id }, - { goodId: g1.id, tagId: cotton.id }, - { goodId: g2.id, tagId: craftB.id }, - ], - }); - - await prisma.originGoodDetail.create({ - data: { - originGoodId, - productCode: 'OZ10827003', - productionProcess: '白墨烫画', - sizeChart: { columns: [], rows: [{ sizeId: 'size_0', sizeName: 'S', measurements: [] }] }, - packageSpecs: { rows: [{ sizeId: 'size_0', sizeName: 'S' }] }, - }, - }); - await prisma.originGoodVariant.create({ - data: { - originGoodId, - sdsVariantId: `pub-variant-${stamp}`, - sku: `OZ${stamp}`, - sizeName: 'S', - 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. - await prisma.good.create({ - data: { - goodName: `Pub Other ${stamp}`, - originGoodId, - familyId: family.id, - countryId, - categoryId: otherCategoryId, - goodPriority: 1, - }, - }); - - // And seed a good in the *child* category, to verify categoryId - // recursion. - await prisma.good.create({ - data: { - goodName: `Pub ChildGood ${stamp}`, - originGoodId, - familyId: family.id, - countryId, - categoryId: childCategoryId, - goodPriority: 0, - }, - }); - }); - - afterAll(async () => { - if (goodIds.length) { - await prisma.good.deleteMany({ where: { id: { in: goodIds } } }); - } - 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 }, - }); - await prisma.tag.delete({ where: { id: tagId } }); - await prisma.tag.deleteMany({ where: { id: { in: filterTagIds } } }); - await prisma.tagGroup.deleteMany({ where: { id: { in: filterGroupIds } } }); - await prisma.originGood.delete({ where: { id: originGoodId } }); - // Delete children before parent (FK self-relation is RESTRICT). - await prisma.category.delete({ where: { id: childCategoryId } }); - await prisma.category.delete({ where: { id: otherCategoryId } }); - await prisma.category.delete({ where: { id: categoryId } }); - await prisma.country.delete({ where: { id: countryId } }); - await prisma.onModuleDestroy(); - }); - - it('should be defined', () => { - expect(service).toBeDefined(); - }); - - it('getCategoriesTree returns only categories that have goods', async () => { - const tree = await service.getCategoriesTree(); - const allIds = new Set(); - const walk = (list: Array<{ id: string; children: Array<{ id: string }> }>) => { - for (const n of list) { - allIds.add(n.id); - walk(n.children as any); - } - }; - walk(tree as any); - // We seeded goods in `categoryId`, `childCategoryId`, `otherCategoryId`. - expect(allIds.has(categoryId.toString())).toBe(true); - expect(allIds.has(childCategoryId.toString())).toBe(true); - expect(allIds.has(otherCategoryId.toString())).toBe(true); - }); - - it('getCountries returns only countries that have goods', async () => { - const countries = await service.getCountries(); - expect(countries.find((c) => c.id === countryId.toString())).toBeDefined(); - }); - - it('filters by countryId, tagId, keyword and categoryId (recursively)', async () => { - const filtered = await service.getGoods({ - page: 1, - pageSize: 50, - countryId: countryId.toString(), - categoryId: categoryId.toString(), // includes child - keyword: `Pub `, - }); - expect(filtered.total).toBe(1); // 族化后:同族 4 条在售 Good(High/Mid/NoPos/Child)= 1 个款 - expect(filtered.items.every((g) => g.country.id === countryId.toString())).toBe(true); - }); - - it('sorts by priority DESC, position.indexVal ASC, createdAt DESC', async () => { - const result = await service.getGoods({ - page: 1, - pageSize: 50, - countryId: countryId.toString(), - keyword: `Pub `, - }); - const priorities = result.items.map((g) => g.goodPriority); - // First verify primary descending priority. - const sorted = [...priorities].sort((a, b) => b - a); - expect(priorities).toEqual(sorted); - }); - - it('uses OR within one tag group and AND across tag groups', async () => { - const sameGroup = await service.getGoods({ - page: 1, - pageSize: 50, - countryId: countryId.toString(), - keyword: `Pub `, - tags: [ - { - tagGroupId: filterGroupIds[0].toString(), - tagIds: filterTagIds.slice(0, 2).map(String), - }, - ], - }); - // 族化后命中族内多条 Good 仍只出代表行(High 优先级最高) - expect(sameGroup.items.map((item) => item.goodName)).toEqual([`Pub High ${stamp}`]); - - const acrossGroups = await service.getGoods({ - page: 1, - pageSize: 50, - countryId: countryId.toString(), - keyword: `Pub `, - tags: [ - { - tagGroupId: filterGroupIds[0].toString(), - tagIds: filterTagIds.slice(0, 2).map(String), - }, - { - tagGroupId: filterGroupIds[1].toString(), - tagIds: [filterTagIds[2].toString()], - }, - ], - }); - expect(acrossGroups.items.map((item) => item.goodName)).toContain(`Pub High ${stamp}`); - expect(acrossGroups.items.map((item) => item.goodName)).not.toContain(`Pub Mid ${stamp}`); - }); - - it('rejects a tag paired with the wrong tag group', async () => { - await expect( - service.getGoods({ - page: 1, - pageSize: 20, - tags: [ - { - tagGroupId: filterGroupIds[1].toString(), - tagIds: [filterTagIds[0].toString()], - }, - ], - }), - ).rejects.toBeInstanceOf(BadRequestException); - }); - - it('returns the family id as the public product id (一族多条 Good 只出一条)', async () => { - const result = await service.getGoods({ - page: 1, - pageSize: 50, - countryId: countryId.toString(), - keyword: `Pub `, - }); - - // 该族下 5 条 Good(High/Mid/NoPos/Other/Child)→ 列表仅 1 条,goodId=族ID - expect(result.items).toHaveLength(1); - 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('list price is the family price matrix minimum (SQL aggregate)', async () => { - // 自包含 fixture:CUSTOM 光板成员(craftLabel/logisticsLabel 是矩阵归因来源) - // + 单变体 38 元。SDS 成员无标签时矩阵为空(不解析名称,等整理补标签), - // 共享 fixture 族 therefore 无矩阵,无法覆盖该路径。 - const og = await prisma.originGood.create({ - data: { - source: 'CUSTOM', - sdsGoodId: `pub-matrix-${stamp}`, - goodName: `Pub Matrix ${stamp}`, - craftLabel: '不打印', - logisticsLabel: '包邮', - goodPrice: 50, // 无矩阵时的回退链接价 - }, - }); - const family = await prisma.productFamily.create({ - data: { familyName: `Pub Matrix Family ${stamp}`, primaryOriginGoodId: og.id }, - }); - await prisma.originGood.update({ - where: { id: og.id }, - data: { familyId: family.id }, - }); - await prisma.originGoodVariant.create({ - data: { - originGoodId: og.id, - sdsVariantId: `pub-matrix-v-${stamp}`, - sku: `PM${stamp}`, - sizeName: 'S', - price: 38, - }, - }); - const good = await prisma.good.create({ - data: { - goodName: `Pub Matrix Good ${stamp}`, - originGoodId: og.id, - familyId: family.id, - countryId, - categoryId, - }, - }); - try { - // 重算前:族无矩阵 → 回退链接价 - const before = await service.getGoods({ - page: 1, - pageSize: 50, - keyword: `Pub Matrix Good`, - }); - expect(before.items).toHaveLength(1); - expect(before.items[0].price).toBe('50'); - - await new FamilyRecomputeService(prisma).recomputeFamily(family.id); - const after = await service.getGoods({ - page: 1, - pageSize: 50, - keyword: `Pub Matrix Good`, - }); - expect(after.items).toHaveLength(1); - // 重算后:矩阵最低价 38 生效,不再是回退链接价 - expect(after.items[0].price).toBe('38'); - } finally { - await prisma.good.delete({ where: { id: good.id } }); - await prisma.originGoodVariant.deleteMany({ - where: { sdsVariantId: `pub-matrix-v-${stamp}` }, - }); - await prisma.productFamily.delete({ where: { id: family.id } }); - await prisma.originGood.delete({ where: { id: og.id } }); - } - }); - - it('custom goods (无族) are not visible on public endpoints', async () => { - const customPublicId = `custom-public-${stamp}`; - const origin = await prisma.originGood.create({ - data: { - source: 'CUSTOM', - sdsGoodId: customPublicId, - goodName: `Pub Custom ${stamp}`, - goodPrice: 42, - detail: { create: { productCode: `CUSTOM-${stamp}` } }, - }, - }); - const good = await prisma.good.create({ - data: { - originGoodId: origin.id, - countryId, - categoryId, - goodName: `Pub Custom ${stamp}`, - }, - }); - try { - 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 family detail by family id and 404 for unknown id', async () => { - const first = await service.getGoods({ - page: 1, - pageSize: 1, - countryId: countryId.toString(), - keyword: `Pub `, - }); - expect(first.items.length).toBe(1); - 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, - ); - }); - - it('home-goods dedupes by family (keeps the best-positioned good)', async () => { - const home = await service.getHomeGoods({ limit: 50 }); - // fixture 族的两条带位商品(High index=1 / Mid index=5)→ 只出一条代表行 - const ours = home.filter((h) => h.goodId === familyId.toString()); - expect(ours).toHaveLength(1); - expect(ours[0].goodName).toBe(`Pub High ${stamp}`); - }); - - it('getTags returns tags with their group info, sorted by group then order', async () => { - const tags = await service.getTags(); - expect(tags.length).toBeGreaterThan(0); - // Each tag in our seed (包邮/不包邮/...) should have a group - const grouped = tags.find((t) => t.tagName === '包邮'); - if (grouped) { - expect(grouped.group).not.toBeNull(); - expect(grouped.group!.groupName).toBe('物流渠道'); - } - }); - - it('getTagGroups returns only groups that have goods', async () => { - const groups = await service.getTagGroups(); - expect(groups.length).toBeGreaterThan(0); - const names = groups.map((g) => g.groupName); - // 链接级派生标签落地后,商品挂在 物流渠道/印刷工艺/印花数量 三组 - expect(names).toContain('物流渠道'); - expect(names).toContain('印花数量'); - expect(names).toContain('印刷工艺'); - // Sorted by sortOrder - const sortOrders = groups.map((g) => g.sortOrder); - expect([...sortOrders].sort((a, b) => a - b)).toEqual(sortOrders); - }); - - describe('merged secondary origin goods', () => { - 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}` }, - }); - const secVariant = await prisma.originGoodVariant.create({ - data: { - originGoodId: secondary.id, - sdsVariantId: `pub-var-sec-${stamp}`, - sku: `PUB-SEC-${stamp}`, - colorId: 'black', - colorName: '黑色', - imageUrl: 'http://img/black-sec', - }, - }); - // 副链归入主 fixture 的族(族机制替代旧 good_origin_goods 关联) - await prisma.originGood.update({ - where: { id: secondary.id }, - data: { familyId }, - }); - - try { - 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.originGoodVariant.delete({ where: { id: secVariant.id } }).catch(() => undefined); - await prisma.originGood.delete({ where: { id: secondary.id } }).catch(() => undefined); - } - }); - - it('dedupes variants by color+size (primary wins) and merges specs/options', async () => { - const stamp2 = `${stamp}-merge2`; - const primaryOg = await prisma.originGood.create({ - data: { sdsGoodId: `pub-pri-${stamp2}`, goodName: `Pub Pri ${stamp2}` }, - }); - const secondaryOg = await prisma.originGood.create({ - data: { sdsGoodId: `pub-sec-${stamp2}`, goodName: `Pub Sec ${stamp2}` }, - }); - // Primary: Black|S and Black|M, size chart S/M, package S, options S/M. - await prisma.originGoodVariant.createMany({ - data: [ - { originGoodId: primaryOg.id, sdsVariantId: `v1-${stamp2}`, sku: `SK1-${stamp2}`, colorName: 'Black', sizeName: 'S', price: 16 }, - { originGoodId: primaryOg.id, sdsVariantId: `v2-${stamp2}`, sku: `SK2-${stamp2}`, colorName: 'black', sizeName: 'M', price: 16 }, - ], - }); - await prisma.originGoodDetail.create({ - data: { - originGoodId: primaryOg.id, - sizeChart: { rows: [{ sizeName: 'S', measurements: [{ key: 'chest', cm: '94' }] }, { sizeName: 'M', measurements: [{ key: 'chest', cm: '100' }] }] }, - packageSpecs: { rows: [{ sizeName: 'S' }] }, - options: { sizes: [{ name: 'S' }, { name: 'M' }] }, - media: { images: [{ id: 'i1', url: 'http://img/pri-a', sortOrder: 0 }, { id: 'i2', url: 'http://img/pri-b', sortOrder: 1 }], primaryImageUrl: 'http://img/pri-a' }, - }, - }); - // Secondary: duplicate Black|S with a DIFFERENT price (must be dropped, - // primary wins), plus a unique color Kelly|S; specs add XXXL rows. - await prisma.originGoodVariant.createMany({ - data: [ - { originGoodId: secondaryOg.id, sdsVariantId: `v3-${stamp2}`, sku: `SK3-${stamp2}`, colorName: 'Black', sizeName: 'S', price: 20 }, - { originGoodId: secondaryOg.id, sdsVariantId: `v4-${stamp2}`, sku: `SK4-${stamp2}`, colorName: 'Kelly', sizeName: 'S', price: 22 }, - ], - }); - await prisma.originGoodDetail.create({ - data: { - originGoodId: secondaryOg.id, - sizeChart: { rows: [{ sizeName: 'XXXL', measurements: [{ key: 'chest', cm: '120' }] }] }, - packageSpecs: { rows: [{ sizeName: 'M' }, { sizeName: 'XXXL' }] }, - options: { sizes: [{ name: 'XXXL' }] }, - 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, - }, - }); - - try { - const detail = await service.getGood(mergeFamily.id.toString()); - // Variants: 3 unique color+size combos (case-insensitive); duplicate - // Black|S from the secondary deduped. - expect( - detail.variants.map((v) => `${v.colorName}/${v.sizeName}`.toLowerCase()).sort(), - ).toEqual(['black/m', 'black/s', 'kelly/s']); - const blackS = detail.variants.find((v) => v.colorName === 'Black' && v.sizeName === 'S'); - expect(blackS!.price).toBe('16'); // primary price wins over secondary 20 - // Size chart: S/M from primary, XXXL appended from secondary. - const chartSizes = (detail.sizeChart as any).rows.map((r: any) => r.sizeName).sort(); - expect(chartSizes).toEqual(['M', 'S', 'XXXL']); - // Package specs: S from primary, M/XXXL appended from secondary. - const pkgSizes = (detail.packageSpecs as any).rows.map((r: any) => r.sizeName).sort(); - expect(pkgSizes).toEqual(['M', 'S', 'XXXL']); - // Options: sizes unioned S/M + XXXL. - const optSizes = (detail.options as any).sizes.map((s: any) => s.name).sort(); - expect(optSizes).toEqual(['M', 'S', 'XXXL']); - // Media gallery: primary images first, secondary-only URL appended, - // duplicate URL (pri-a) kept once. Entries keep their object shape. - const media = detail.media as any; - expect(media.images.map((i: any) => i.url)).toEqual([ - 'http://img/pri-a', - 'http://img/pri-b', - 'http://img/sec-x', - ]); - expect(media.primaryImageUrl).toBe('http://img/pri-a'); - } finally { - 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 } }); - await prisma.originGood.delete({ where: { id: secondaryOg.id } }).catch(() => undefined); - } - }); - }); - - describe('getGoods tree-order sorting (国家→二级→款→priority)', () => { - // 结构: 国家A(sort=1)>MidA>LeafA1(sort=1, 2条goods)、LeafA2(sort=2);国家B(sort=2)>MidB>LeafB1 - // 期望默认顺序: A款1(priority desc) -> A款2 -> B款1;B 的 priority=99 也不能越级 - const stamp2 = `${stamp}-treeorder`; - const sdsA1 = `la1-${stamp2}`; - const sdsA2 = `la2-${stamp2}`; - const sdsB1 = `lb1-${stamp2}`; - const trash = { - goodIds: [] as bigint[], - familyIds: [] as bigint[], - originGoodIds: [] as bigint[], - categoryIds: [] as bigint[], - countryIds: [] as bigint[], - }; - let orderedFamilyIds: string[] = []; - - beforeAll(async () => { - const cA = await prisma.country.create({ - data: { countryName: `TreeOrder A ${stamp2}`, sortOrder: 1 }, - }); - const cB = await prisma.country.create({ - data: { countryName: `TreeOrder B ${stamp2}`, sortOrder: 2 }, - }); - trash.countryIds = [cA.id, cB.id]; - const midA = await prisma.category.create({ - data: { categoryName: `TreeOrder MidA ${stamp2}`, sdsCategoryId: `ma-${stamp2}`, sortOrder: 1 }, - }); - const leafA1 = await prisma.category.create({ - data: { categoryName: `TreeOrder LeafA1 ${stamp2}`, parentCategoryId: midA.id, sdsCategoryId: sdsA1, sortOrder: 1 }, - }); - const leafA2 = await prisma.category.create({ - data: { categoryName: `TreeOrder LeafA2 ${stamp2}`, parentCategoryId: midA.id, sdsCategoryId: sdsA2, sortOrder: 2 }, - }); - const midB = await prisma.category.create({ - data: { categoryName: `TreeOrder MidB ${stamp2}`, sdsCategoryId: `mb-${stamp2}`, sortOrder: 2 }, - }); - const leafB1 = await prisma.category.create({ - data: { categoryName: `TreeOrder LeafB1 ${stamp2}`, parentCategoryId: midB.id, sdsCategoryId: sdsB1, sortOrder: 1 }, - }); - trash.categoryIds = [leafA1.id, leafA2.id, leafB1.id, midA.id, midB.id]; - - const mk = async ( - countryId: bigint, - sdsCategoryId: string, - name: string, - priority: number, - ) => { - const og = await prisma.originGood.create({ - data: { sdsGoodId: `to-${name}-${stamp2}`, goodName: name, sdsCategoryId }, - }); - trash.originGoodIds.push(og.id); - const fam = await prisma.productFamily.create({ - data: { familyName: `to-fam-${name}-${stamp2}`, primaryOriginGoodId: og.id }, - }); - trash.familyIds.push(fam.id); - await prisma.originGood.update({ where: { id: og.id }, data: { familyId: fam.id } }); - const good = await prisma.good.create({ - data: { - goodName: `TO${stamp2}-${name}`, - originGoodId: og.id, - familyId: fam.id, - countryId, - categoryId: sdsCategoryId === sdsA1 ? leafA1.id : sdsCategoryId === sdsA2 ? leafA2.id : leafB1.id, - goodPriority: priority, - }, - }); - trash.goodIds.push(good.id); - return fam.id.toString(); - }; - - const a1Low = await mk(cA.id, sdsA1, 'A1Low', 1); - const a1High = await mk(cA.id, sdsA1, 'A1High', 9); - const a2 = await mk(cA.id, sdsA2, 'A2', 0); - const b1 = await mk(cB.id, sdsB1, 'B1', 99); - orderedFamilyIds = [a1High, a1Low, a2, b1]; - }); - - afterAll(async () => { - await prisma.good.deleteMany({ where: { id: { in: trash.goodIds } } }).catch(() => undefined); - await prisma.productFamily.deleteMany({ where: { id: { in: trash.familyIds } } }).catch(() => undefined); - await prisma.originGood.deleteMany({ where: { id: { in: trash.originGoodIds } } }).catch(() => undefined); - for (const id of trash.categoryIds) { - await prisma.category.delete({ where: { id } }).catch(() => undefined); - } - await prisma.country.deleteMany({ where: { id: { in: trash.countryIds } } }).catch(() => undefined); - }); - - it('DEFAULT: country > mid > leaf > priority (cross-country priority cannot jump the queue)', async () => { - const res = await service.getGoods({ - page: 1, - pageSize: 100, - keyword: `TO${stamp2}`, // 唯一前缀圈定本夹具 4 条,避免全库分页截断 - }); - expect(res.total).toBe(4); - const idx = res.items.map((i) => i.goodId); - const pos = orderedFamilyIds.map((id) => idx.indexOf(id)); - expect(pos.every((p) => p >= 0)).toBe(true); // 全部命中 - expect(pos).toEqual([...pos].sort((a, b) => a - b)); // 相对有序 - // 同款内 priority desc - expect(idx.indexOf(orderedFamilyIds[0])).toBeLessThan(idx.indexOf(orderedFamilyIds[1])); - // 款顺序:LeafA1 -> LeafA2 - expect(idx.indexOf(orderedFamilyIds[1])).toBeLessThan(idx.indexOf(orderedFamilyIds[2])); - // 国家/款顺序优先于 priority:B1(99) 不能排到 A2(0) 前面 - expect(idx.indexOf(orderedFamilyIds[2])).toBeLessThan(idx.indexOf(orderedFamilyIds[3])); - }); - }); - - describe('family representative row consistency (列表/首页代表行对齐详情)', () => { - // 同族两条 Good:同 priority=10,Low 的 id 更小/createdAt 更早/价格更低/位置更好, - // High 的 createdAt 更新。详情代表行规则 = priority desc → createdAt desc → id asc - // → 详情永远取 High;列表/首页必须与详情一致,而不是随排序参数漂移到 Low。 - const stamp3 = `${stamp}-rep`; - let repFamilyId: bigint; - let trash = { - goodIds: [] as bigint[], - positionIds: [] as bigint[], - originGoodIds: [] as bigint[], - }; - const repLowName = `Rep Low ${stamp3}`; - const repHighName = `Rep High ${stamp3}`; - - beforeAll(async () => { - const posLow = await prisma.position.create({ - data: { indexVal: 1, countryId, categoryId }, - }); - const posHigh = await prisma.position.create({ - data: { indexVal: 5, countryId, categoryId }, - }); - trash.positionIds = [posLow.id, posHigh.id]; - - const ogLow = await prisma.originGood.create({ - data: { sdsGoodId: `rep-low-${stamp3}`, goodName: repLowName, goodPrice: 10 }, - }); - const ogHigh = await prisma.originGood.create({ - data: { sdsGoodId: `rep-high-${stamp3}`, goodName: repHighName, goodPrice: 20 }, - }); - trash.originGoodIds = [ogLow.id, ogHigh.id]; - - const family = await prisma.productFamily.create({ - data: { familyName: `rep-fam-${stamp3}`, primaryOriginGoodId: ogLow.id }, - }); - repFamilyId = family.id; - await prisma.originGood.updateMany({ - where: { id: { in: [ogLow.id, ogHigh.id] } }, - data: { familyId: family.id }, - }); - - const gLow = await prisma.good.create({ - data: { - goodName: repLowName, - originGoodId: ogLow.id, - familyId: family.id, - countryId, - categoryId, - goodPriority: 10, - positionId: posLow.id, - createdAt: new Date(stamp), - }, - }); - const gHigh = await prisma.good.create({ - data: { - goodName: repHighName, - originGoodId: ogHigh.id, - familyId: family.id, - countryId, - categoryId, - goodPriority: 10, - positionId: posHigh.id, - createdAt: new Date(stamp + 60_000), - }, - }); - trash.goodIds = [gLow.id, gHigh.id]; - }); - - afterAll(async () => { - await prisma.good.deleteMany({ where: { id: { in: trash.goodIds } } }).catch(() => undefined); - await prisma.position - .deleteMany({ where: { id: { in: trash.positionIds } } }) - .catch(() => undefined); - await prisma.productFamily.delete({ where: { id: repFamilyId } }).catch(() => undefined); - await prisma.originGood - .deleteMany({ where: { id: { in: trash.originGoodIds } } }) - .catch(() => undefined); - }); - - it('DEFAULT 列表代表行与详情一致(priority 并列时取 createdAt 最新,而非 id 最小)', async () => { - const detail = await service.getGood(repFamilyId.toString()); - expect(detail.goodName).toBe(repHighName); - - const list = await service.getGoods({ - page: 1, - pageSize: 50, - keyword: 'Rep ', // 本文件夹具唯一前缀,圈定本族(goodName: Rep Low/High) - }); - const ours = list.items.filter((i) => i.goodId === repFamilyId.toString()); - expect(ours).toHaveLength(1); - expect(ours[0].goodName).toBe(detail.goodName); - }); - - it('PRICE_ASC 列表代表行不漂移到价格更低的成员', async () => { - const detail = await service.getGood(repFamilyId.toString()); - const list = await service.getGoods({ - page: 1, - pageSize: 50, - keyword: 'Rep ', - sort: 'PRICE_ASC', - }); - const ours = list.items.filter((i) => i.goodId === repFamilyId.toString()); - expect(ours).toHaveLength(1); - expect(ours[0].goodName).toBe(detail.goodName); - }); - - it('home-goods 代表行与详情一致(不取位置更好的成员)', async () => { - const detail = await service.getGood(repFamilyId.toString()); - const home = await service.getHomeGoods({ limit: 50, countryId: countryId.toString() }); - const ours = home.filter((h) => h.goodId === repFamilyId.toString()); - expect(ours).toHaveLength(1); - expect(ours[0].goodName).toBe(detail.goodName); - }); - }); -}); +import { PublicCacheService } from './public-cache.service'; +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', () => { + let service: PublicService; + let prisma: PrismaService; + const stamp = Date.now(); + let countryId: bigint; + let categoryId: bigint; + let childCategoryId: bigint; + let otherCategoryId: bigint; + let tagId: bigint; + let filterGroupIds: bigint[] = []; + let filterTagIds: bigint[] = []; + let originGoodId: bigint; + let familyId: bigint; + let goodIds: bigint[] = []; + + beforeAll(async () => { + // 本套件测数据语义(裸 prisma 改数后立即读公开端点),不走带 bump 的写路径—— + // 禁用 public 缓存;缓存命中/失效语义由 public-cache.invalidation.spec.ts 覆盖 + process.env.PUBLIC_CACHE_DISABLED = 'true'; + const moduleRef = await Test.createTestingModule({ + providers: [PublicService, PrismaService, PublicCacheService], + }).compile(); + service = moduleRef.get(PublicService); + prisma = moduleRef.get(PrismaService); + await prisma.onModuleInit(); + + const country = await prisma.country.create({ + data: { countryName: `Pub Country ${stamp}` }, + }); + countryId = country.id; + + const cat = await prisma.category.create({ + data: { categoryName: `Pub Cat ${stamp}` }, + }); + categoryId = cat.id; + const child = await prisma.category.create({ + data: { categoryName: `Pub Child ${stamp}`, parentCategoryId: cat.id }, + }); + childCategoryId = child.id; + const otherCat = await prisma.category.create({ + data: { categoryName: `Pub Other ${stamp}` }, + }); + otherCategoryId = otherCat.id; + + const tag = await prisma.tag.create({ + data: { tagName: `Pub Tag ${stamp}`, tagColor: '#0000FF' }, + }); + tagId = tag.id; + + const og = await prisma.originGood.create({ + data: { + sdsGoodId: `pub-sds-${stamp}`, + goodName: `Origin ${stamp}`, + goodImage: 'http://img', + }, + }); + 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 + // no priority + no position (falls back to createdAt) + const pos1 = await prisma.position.create({ + data: { indexVal: 1, countryId, categoryId }, + }); + const pos2 = await prisma.position.create({ + data: { indexVal: 5, countryId, categoryId }, + }); + + const g1 = await prisma.good.create({ + data: { + goodName: `Pub High ${stamp}`, + originGoodId, + familyId: family.id, + countryId, + categoryId, + goodPriority: 10, + positionId: pos1.id, + }, + }); + const g2 = await prisma.good.create({ + data: { + goodName: `Pub Mid ${stamp}`, + originGoodId, + familyId: family.id, + countryId, + categoryId, + goodPriority: 5, + positionId: pos2.id, + }, + }); + const g3 = await prisma.good.create({ + data: { + goodName: `Pub NoPos ${stamp}`, + originGoodId, + familyId: family.id, + countryId, + categoryId, + tagId, + goodPriority: 1, + }, + }); + + // 自包含:链接级三组封闭词表标签 + 商品镜像 + // (getTagGroups「物流渠道/印花数量/印刷工艺」断言依赖,不再依赖库内其他数据) + const autoTagIds: bigint[] = []; + for (const [groupName, tagName] of [ + ['物流渠道', '包邮'], + ['印花数量', '单面印花'], + ['印刷工艺', '烫画'], + ] as const) { + const group = await prisma.tagGroup.upsert({ + where: { groupName }, + create: { groupName }, + update: {}, + }); + const t = await prisma.tag.upsert({ + where: { tagName }, + create: { tagName, tagGroupId: group.id }, + update: {}, + }); + autoTagIds.push(t.id); + } + await prisma.originGoodTag.createMany({ + data: autoTagIds.map((tagId) => ({ originGoodId: og.id, tagId })), + }); + await prisma.goodTag.createMany({ + data: [g1.id, g2.id, g3.id].flatMap((goodId) => + autoTagIds.map((tagId) => ({ goodId, tagId })), + ), + }); + goodIds = [g1.id, g2.id, g3.id]; + + const craftGroup = await prisma.tagGroup.create({ + data: { groupName: `Pub Craft ${stamp}`, sortOrder: 100 }, + }); + const materialGroup = await prisma.tagGroup.create({ + data: { groupName: `Pub Material ${stamp}`, sortOrder: 101 }, + }); + filterGroupIds = [craftGroup.id, materialGroup.id]; + const craftA = await prisma.tag.create({ + data: { tagName: `Pub Craft A ${stamp}`, tagGroupId: craftGroup.id }, + }); + const craftB = await prisma.tag.create({ + data: { tagName: `Pub Craft B ${stamp}`, tagGroupId: craftGroup.id }, + }); + const cotton = await prisma.tag.create({ + data: { tagName: `Pub Cotton ${stamp}`, tagGroupId: materialGroup.id }, + }); + filterTagIds = [craftA.id, craftB.id, cotton.id]; + await prisma.goodTag.createMany({ + data: [ + { goodId: g1.id, tagId: craftA.id }, + { goodId: g1.id, tagId: cotton.id }, + { goodId: g2.id, tagId: craftB.id }, + ], + }); + + await prisma.originGoodDetail.create({ + data: { + originGoodId, + productCode: 'OZ10827003', + productionProcess: '白墨烫画', + sizeChart: { columns: [], rows: [{ sizeId: 'size_0', sizeName: 'S', measurements: [] }] }, + packageSpecs: { rows: [{ sizeId: 'size_0', sizeName: 'S' }] }, + }, + }); + await prisma.originGoodVariant.create({ + data: { + originGoodId, + sdsVariantId: `pub-variant-${stamp}`, + sku: `OZ${stamp}`, + sizeName: 'S', + price: 38, + }, + }); + // 物化族矩阵(公开详情 family 块依赖 priceMatrix 已重算) + await new FamilyRecomputeService(prisma, new PublicCacheService()).recomputeFamily(family.id); + + // Seed a good in `otherCategory` so the "onlyHaveGoods" filter + // returns more than one category. + await prisma.good.create({ + data: { + goodName: `Pub Other ${stamp}`, + originGoodId, + familyId: family.id, + countryId, + categoryId: otherCategoryId, + goodPriority: 1, + }, + }); + + // And seed a good in the *child* category, to verify categoryId + // recursion. + await prisma.good.create({ + data: { + goodName: `Pub ChildGood ${stamp}`, + originGoodId, + familyId: family.id, + countryId, + categoryId: childCategoryId, + goodPriority: 0, + }, + }); + }); + + afterAll(async () => { + if (goodIds.length) { + await prisma.good.deleteMany({ where: { id: { in: goodIds } } }); + } + 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 }, + }); + await prisma.tag.delete({ where: { id: tagId } }); + await prisma.tag.deleteMany({ where: { id: { in: filterTagIds } } }); + await prisma.tagGroup.deleteMany({ where: { id: { in: filterGroupIds } } }); + await prisma.originGood.delete({ where: { id: originGoodId } }); + // Delete children before parent (FK self-relation is RESTRICT). + await prisma.category.delete({ where: { id: childCategoryId } }); + await prisma.category.delete({ where: { id: otherCategoryId } }); + await prisma.category.delete({ where: { id: categoryId } }); + await prisma.country.delete({ where: { id: countryId } }); + await prisma.onModuleDestroy(); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + it('getCategoriesTree returns only categories that have goods', async () => { + const tree = await service.getCategoriesTree(); + const allIds = new Set(); + const walk = (list: Array<{ id: string; children: Array<{ id: string }> }>) => { + for (const n of list) { + allIds.add(n.id); + walk(n.children as any); + } + }; + walk(tree as any); + // We seeded goods in `categoryId`, `childCategoryId`, `otherCategoryId`. + expect(allIds.has(categoryId.toString())).toBe(true); + expect(allIds.has(childCategoryId.toString())).toBe(true); + expect(allIds.has(otherCategoryId.toString())).toBe(true); + }); + + it('getCountries returns only countries that have goods', async () => { + const countries = await service.getCountries(); + expect(countries.find((c) => c.id === countryId.toString())).toBeDefined(); + }); + + it('filters by countryId, tagId, keyword and categoryId (recursively)', async () => { + const filtered = await service.getGoods({ + page: 1, + pageSize: 50, + countryId: countryId.toString(), + categoryId: categoryId.toString(), // includes child + keyword: `Pub `, + }); + expect(filtered.total).toBe(1); // 族化后:同族 4 条在售 Good(High/Mid/NoPos/Child)= 1 个款 + expect(filtered.items.every((g) => g.country.id === countryId.toString())).toBe(true); + }); + + it('sorts by priority DESC, position.indexVal ASC, createdAt DESC', async () => { + const result = await service.getGoods({ + page: 1, + pageSize: 50, + countryId: countryId.toString(), + keyword: `Pub `, + }); + const priorities = result.items.map((g) => g.goodPriority); + // First verify primary descending priority. + const sorted = [...priorities].sort((a, b) => b - a); + expect(priorities).toEqual(sorted); + }); + + it('uses OR within one tag group and AND across tag groups', async () => { + const sameGroup = await service.getGoods({ + page: 1, + pageSize: 50, + countryId: countryId.toString(), + keyword: `Pub `, + tags: [ + { + tagGroupId: filterGroupIds[0].toString(), + tagIds: filterTagIds.slice(0, 2).map(String), + }, + ], + }); + // 族化后命中族内多条 Good 仍只出代表行(High 优先级最高) + expect(sameGroup.items.map((item) => item.goodName)).toEqual([`Pub High ${stamp}`]); + + const acrossGroups = await service.getGoods({ + page: 1, + pageSize: 50, + countryId: countryId.toString(), + keyword: `Pub `, + tags: [ + { + tagGroupId: filterGroupIds[0].toString(), + tagIds: filterTagIds.slice(0, 2).map(String), + }, + { + tagGroupId: filterGroupIds[1].toString(), + tagIds: [filterTagIds[2].toString()], + }, + ], + }); + expect(acrossGroups.items.map((item) => item.goodName)).toContain(`Pub High ${stamp}`); + expect(acrossGroups.items.map((item) => item.goodName)).not.toContain(`Pub Mid ${stamp}`); + }); + + it('rejects a tag paired with the wrong tag group', async () => { + await expect( + service.getGoods({ + page: 1, + pageSize: 20, + tags: [ + { + tagGroupId: filterGroupIds[1].toString(), + tagIds: [filterTagIds[0].toString()], + }, + ], + }), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('returns the family id as the public product id (一族多条 Good 只出一条)', async () => { + const result = await service.getGoods({ + page: 1, + pageSize: 50, + countryId: countryId.toString(), + keyword: `Pub `, + }); + + // 该族下 5 条 Good(High/Mid/NoPos/Other/Child)→ 列表仅 1 条,goodId=族ID + expect(result.items).toHaveLength(1); + 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('list price is the family price matrix minimum (SQL aggregate)', async () => { + // 自包含 fixture:CUSTOM 光板成员(craftLabel/logisticsLabel 是矩阵归因来源) + // + 单变体 38 元。SDS 成员无标签时矩阵为空(不解析名称,等整理补标签), + // 共享 fixture 族 therefore 无矩阵,无法覆盖该路径。 + const og = await prisma.originGood.create({ + data: { + source: 'CUSTOM', + sdsGoodId: `pub-matrix-${stamp}`, + goodName: `Pub Matrix ${stamp}`, + craftLabel: '不打印', + logisticsLabel: '包邮', + goodPrice: 50, // 无矩阵时的回退链接价 + }, + }); + const family = await prisma.productFamily.create({ + data: { familyName: `Pub Matrix Family ${stamp}`, primaryOriginGoodId: og.id }, + }); + await prisma.originGood.update({ + where: { id: og.id }, + data: { familyId: family.id }, + }); + await prisma.originGoodVariant.create({ + data: { + originGoodId: og.id, + sdsVariantId: `pub-matrix-v-${stamp}`, + sku: `PM${stamp}`, + sizeName: 'S', + price: 38, + }, + }); + const good = await prisma.good.create({ + data: { + goodName: `Pub Matrix Good ${stamp}`, + originGoodId: og.id, + familyId: family.id, + countryId, + categoryId, + }, + }); + try { + // 重算前:族无矩阵 → 回退链接价 + const before = await service.getGoods({ + page: 1, + pageSize: 50, + keyword: `Pub Matrix Good`, + }); + expect(before.items).toHaveLength(1); + expect(before.items[0].price).toBe('50'); + + await new FamilyRecomputeService(prisma, new PublicCacheService()).recomputeFamily(family.id); + const after = await service.getGoods({ + page: 1, + pageSize: 50, + keyword: `Pub Matrix Good`, + }); + expect(after.items).toHaveLength(1); + // 重算后:矩阵最低价 38 生效,不再是回退链接价 + expect(after.items[0].price).toBe('38'); + } finally { + await prisma.good.delete({ where: { id: good.id } }); + await prisma.originGoodVariant.deleteMany({ + where: { sdsVariantId: `pub-matrix-v-${stamp}` }, + }); + await prisma.productFamily.delete({ where: { id: family.id } }); + await prisma.originGood.delete({ where: { id: og.id } }); + } + }); + + it('custom goods (无族) are not visible on public endpoints', async () => { + const customPublicId = `custom-public-${stamp}`; + const origin = await prisma.originGood.create({ + data: { + source: 'CUSTOM', + sdsGoodId: customPublicId, + goodName: `Pub Custom ${stamp}`, + goodPrice: 42, + detail: { create: { productCode: `CUSTOM-${stamp}` } }, + }, + }); + const good = await prisma.good.create({ + data: { + originGoodId: origin.id, + countryId, + categoryId, + goodName: `Pub Custom ${stamp}`, + }, + }); + try { + 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 family detail by family id and 404 for unknown id', async () => { + const first = await service.getGoods({ + page: 1, + pageSize: 1, + countryId: countryId.toString(), + keyword: `Pub `, + }); + expect(first.items.length).toBe(1); + 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, + ); + }); + + it('home-goods dedupes by family (keeps the best-positioned good)', async () => { + const home = await service.getHomeGoods({ limit: 50 }); + // fixture 族的两条带位商品(High index=1 / Mid index=5)→ 只出一条代表行 + const ours = home.filter((h) => h.goodId === familyId.toString()); + expect(ours).toHaveLength(1); + expect(ours[0].goodName).toBe(`Pub High ${stamp}`); + }); + + it('getTags returns tags with their group info, sorted by group then order', async () => { + const tags = await service.getTags(); + expect(tags.length).toBeGreaterThan(0); + // Each tag in our seed (包邮/不包邮/...) should have a group + const grouped = tags.find((t) => t.tagName === '包邮'); + if (grouped) { + expect(grouped.group).not.toBeNull(); + expect(grouped.group!.groupName).toBe('物流渠道'); + } + }); + + it('getTagGroups returns only groups that have goods', async () => { + const groups = await service.getTagGroups(); + expect(groups.length).toBeGreaterThan(0); + const names = groups.map((g) => g.groupName); + // 链接级派生标签落地后,商品挂在 物流渠道/印刷工艺/印花数量 三组 + expect(names).toContain('物流渠道'); + expect(names).toContain('印花数量'); + expect(names).toContain('印刷工艺'); + // Sorted by sortOrder + const sortOrders = groups.map((g) => g.sortOrder); + expect([...sortOrders].sort((a, b) => a - b)).toEqual(sortOrders); + }); + + describe('merged secondary origin goods', () => { + 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}` }, + }); + const secVariant = await prisma.originGoodVariant.create({ + data: { + originGoodId: secondary.id, + sdsVariantId: `pub-var-sec-${stamp}`, + sku: `PUB-SEC-${stamp}`, + colorId: 'black', + colorName: '黑色', + imageUrl: 'http://img/black-sec', + }, + }); + // 副链归入主 fixture 的族(族机制替代旧 good_origin_goods 关联) + await prisma.originGood.update({ + where: { id: secondary.id }, + data: { familyId }, + }); + + try { + 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.originGoodVariant.delete({ where: { id: secVariant.id } }).catch(() => undefined); + await prisma.originGood.delete({ where: { id: secondary.id } }).catch(() => undefined); + } + }); + + it('dedupes variants by color+size (primary wins) and merges specs/options', async () => { + const stamp2 = `${stamp}-merge2`; + const primaryOg = await prisma.originGood.create({ + data: { sdsGoodId: `pub-pri-${stamp2}`, goodName: `Pub Pri ${stamp2}` }, + }); + const secondaryOg = await prisma.originGood.create({ + data: { sdsGoodId: `pub-sec-${stamp2}`, goodName: `Pub Sec ${stamp2}` }, + }); + // Primary: Black|S and Black|M, size chart S/M, package S, options S/M. + await prisma.originGoodVariant.createMany({ + data: [ + { originGoodId: primaryOg.id, sdsVariantId: `v1-${stamp2}`, sku: `SK1-${stamp2}`, colorName: 'Black', sizeName: 'S', price: 16 }, + { originGoodId: primaryOg.id, sdsVariantId: `v2-${stamp2}`, sku: `SK2-${stamp2}`, colorName: 'black', sizeName: 'M', price: 16 }, + ], + }); + await prisma.originGoodDetail.create({ + data: { + originGoodId: primaryOg.id, + sizeChart: { rows: [{ sizeName: 'S', measurements: [{ key: 'chest', cm: '94' }] }, { sizeName: 'M', measurements: [{ key: 'chest', cm: '100' }] }] }, + packageSpecs: { rows: [{ sizeName: 'S' }] }, + options: { sizes: [{ name: 'S' }, { name: 'M' }] }, + media: { images: [{ id: 'i1', url: 'http://img/pri-a', sortOrder: 0 }, { id: 'i2', url: 'http://img/pri-b', sortOrder: 1 }], primaryImageUrl: 'http://img/pri-a' }, + }, + }); + // Secondary: duplicate Black|S with a DIFFERENT price (must be dropped, + // primary wins), plus a unique color Kelly|S; specs add XXXL rows. + await prisma.originGoodVariant.createMany({ + data: [ + { originGoodId: secondaryOg.id, sdsVariantId: `v3-${stamp2}`, sku: `SK3-${stamp2}`, colorName: 'Black', sizeName: 'S', price: 20 }, + { originGoodId: secondaryOg.id, sdsVariantId: `v4-${stamp2}`, sku: `SK4-${stamp2}`, colorName: 'Kelly', sizeName: 'S', price: 22 }, + ], + }); + await prisma.originGoodDetail.create({ + data: { + originGoodId: secondaryOg.id, + sizeChart: { rows: [{ sizeName: 'XXXL', measurements: [{ key: 'chest', cm: '120' }] }] }, + packageSpecs: { rows: [{ sizeName: 'M' }, { sizeName: 'XXXL' }] }, + options: { sizes: [{ name: 'XXXL' }] }, + 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, + }, + }); + + try { + const detail = await service.getGood(mergeFamily.id.toString()); + // Variants: 3 unique color+size combos (case-insensitive); duplicate + // Black|S from the secondary deduped. + expect( + detail.variants.map((v) => `${v.colorName}/${v.sizeName}`.toLowerCase()).sort(), + ).toEqual(['black/m', 'black/s', 'kelly/s']); + const blackS = detail.variants.find((v) => v.colorName === 'Black' && v.sizeName === 'S'); + expect(blackS!.price).toBe('16'); // primary price wins over secondary 20 + // Size chart: S/M from primary, XXXL appended from secondary. + const chartSizes = (detail.sizeChart as any).rows.map((r: any) => r.sizeName).sort(); + expect(chartSizes).toEqual(['M', 'S', 'XXXL']); + // Package specs: S from primary, M/XXXL appended from secondary. + const pkgSizes = (detail.packageSpecs as any).rows.map((r: any) => r.sizeName).sort(); + expect(pkgSizes).toEqual(['M', 'S', 'XXXL']); + // Options: sizes unioned S/M + XXXL. + const optSizes = (detail.options as any).sizes.map((s: any) => s.name).sort(); + expect(optSizes).toEqual(['M', 'S', 'XXXL']); + // Media gallery: primary images first, secondary-only URL appended, + // duplicate URL (pri-a) kept once. Entries keep their object shape. + const media = detail.media as any; + expect(media.images.map((i: any) => i.url)).toEqual([ + 'http://img/pri-a', + 'http://img/pri-b', + 'http://img/sec-x', + ]); + expect(media.primaryImageUrl).toBe('http://img/pri-a'); + } finally { + 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 } }); + await prisma.originGood.delete({ where: { id: secondaryOg.id } }).catch(() => undefined); + } + }); + }); + + describe('getGoods tree-order sorting (国家→二级→款→priority)', () => { + // 结构: 国家A(sort=1)>MidA>LeafA1(sort=1, 2条goods)、LeafA2(sort=2);国家B(sort=2)>MidB>LeafB1 + // 期望默认顺序: A款1(priority desc) -> A款2 -> B款1;B 的 priority=99 也不能越级 + const stamp2 = `${stamp}-treeorder`; + const sdsA1 = `la1-${stamp2}`; + const sdsA2 = `la2-${stamp2}`; + const sdsB1 = `lb1-${stamp2}`; + const trash = { + goodIds: [] as bigint[], + familyIds: [] as bigint[], + originGoodIds: [] as bigint[], + categoryIds: [] as bigint[], + countryIds: [] as bigint[], + }; + let orderedFamilyIds: string[] = []; + + beforeAll(async () => { + const cA = await prisma.country.create({ + data: { countryName: `TreeOrder A ${stamp2}`, sortOrder: 1 }, + }); + const cB = await prisma.country.create({ + data: { countryName: `TreeOrder B ${stamp2}`, sortOrder: 2 }, + }); + trash.countryIds = [cA.id, cB.id]; + const midA = await prisma.category.create({ + data: { categoryName: `TreeOrder MidA ${stamp2}`, sdsCategoryId: `ma-${stamp2}`, sortOrder: 1 }, + }); + const leafA1 = await prisma.category.create({ + data: { categoryName: `TreeOrder LeafA1 ${stamp2}`, parentCategoryId: midA.id, sdsCategoryId: sdsA1, sortOrder: 1 }, + }); + const leafA2 = await prisma.category.create({ + data: { categoryName: `TreeOrder LeafA2 ${stamp2}`, parentCategoryId: midA.id, sdsCategoryId: sdsA2, sortOrder: 2 }, + }); + const midB = await prisma.category.create({ + data: { categoryName: `TreeOrder MidB ${stamp2}`, sdsCategoryId: `mb-${stamp2}`, sortOrder: 2 }, + }); + const leafB1 = await prisma.category.create({ + data: { categoryName: `TreeOrder LeafB1 ${stamp2}`, parentCategoryId: midB.id, sdsCategoryId: sdsB1, sortOrder: 1 }, + }); + trash.categoryIds = [leafA1.id, leafA2.id, leafB1.id, midA.id, midB.id]; + + const mk = async ( + countryId: bigint, + sdsCategoryId: string, + name: string, + priority: number, + ) => { + const og = await prisma.originGood.create({ + data: { sdsGoodId: `to-${name}-${stamp2}`, goodName: name, sdsCategoryId }, + }); + trash.originGoodIds.push(og.id); + const fam = await prisma.productFamily.create({ + data: { familyName: `to-fam-${name}-${stamp2}`, primaryOriginGoodId: og.id }, + }); + trash.familyIds.push(fam.id); + await prisma.originGood.update({ where: { id: og.id }, data: { familyId: fam.id } }); + const good = await prisma.good.create({ + data: { + goodName: `TO${stamp2}-${name}`, + originGoodId: og.id, + familyId: fam.id, + countryId, + categoryId: sdsCategoryId === sdsA1 ? leafA1.id : sdsCategoryId === sdsA2 ? leafA2.id : leafB1.id, + goodPriority: priority, + }, + }); + trash.goodIds.push(good.id); + return fam.id.toString(); + }; + + const a1Low = await mk(cA.id, sdsA1, 'A1Low', 1); + const a1High = await mk(cA.id, sdsA1, 'A1High', 9); + const a2 = await mk(cA.id, sdsA2, 'A2', 0); + const b1 = await mk(cB.id, sdsB1, 'B1', 99); + orderedFamilyIds = [a1High, a1Low, a2, b1]; + }); + + afterAll(async () => { + await prisma.good.deleteMany({ where: { id: { in: trash.goodIds } } }).catch(() => undefined); + await prisma.productFamily.deleteMany({ where: { id: { in: trash.familyIds } } }).catch(() => undefined); + await prisma.originGood.deleteMany({ where: { id: { in: trash.originGoodIds } } }).catch(() => undefined); + for (const id of trash.categoryIds) { + await prisma.category.delete({ where: { id } }).catch(() => undefined); + } + await prisma.country.deleteMany({ where: { id: { in: trash.countryIds } } }).catch(() => undefined); + }); + + it('DEFAULT: country > mid > leaf > priority (cross-country priority cannot jump the queue)', async () => { + const res = await service.getGoods({ + page: 1, + pageSize: 100, + keyword: `TO${stamp2}`, // 唯一前缀圈定本夹具 4 条,避免全库分页截断 + }); + expect(res.total).toBe(4); + const idx = res.items.map((i) => i.goodId); + const pos = orderedFamilyIds.map((id) => idx.indexOf(id)); + expect(pos.every((p) => p >= 0)).toBe(true); // 全部命中 + expect(pos).toEqual([...pos].sort((a, b) => a - b)); // 相对有序 + // 同款内 priority desc + expect(idx.indexOf(orderedFamilyIds[0])).toBeLessThan(idx.indexOf(orderedFamilyIds[1])); + // 款顺序:LeafA1 -> LeafA2 + expect(idx.indexOf(orderedFamilyIds[1])).toBeLessThan(idx.indexOf(orderedFamilyIds[2])); + // 国家/款顺序优先于 priority:B1(99) 不能排到 A2(0) 前面 + expect(idx.indexOf(orderedFamilyIds[2])).toBeLessThan(idx.indexOf(orderedFamilyIds[3])); + }); + }); + + describe('family representative row consistency (列表/首页代表行对齐详情)', () => { + // 同族两条 Good:同 priority=10,Low 的 id 更小/createdAt 更早/价格更低/位置更好, + // High 的 createdAt 更新。详情代表行规则 = priority desc → createdAt desc → id asc + // → 详情永远取 High;列表/首页必须与详情一致,而不是随排序参数漂移到 Low。 + const stamp3 = `${stamp}-rep`; + let repFamilyId: bigint; + let trash = { + goodIds: [] as bigint[], + positionIds: [] as bigint[], + originGoodIds: [] as bigint[], + }; + const repLowName = `Rep Low ${stamp3}`; + const repHighName = `Rep High ${stamp3}`; + + beforeAll(async () => { + const posLow = await prisma.position.create({ + data: { indexVal: 1, countryId, categoryId }, + }); + const posHigh = await prisma.position.create({ + data: { indexVal: 5, countryId, categoryId }, + }); + trash.positionIds = [posLow.id, posHigh.id]; + + const ogLow = await prisma.originGood.create({ + data: { sdsGoodId: `rep-low-${stamp3}`, goodName: repLowName, goodPrice: 10 }, + }); + const ogHigh = await prisma.originGood.create({ + data: { sdsGoodId: `rep-high-${stamp3}`, goodName: repHighName, goodPrice: 20 }, + }); + trash.originGoodIds = [ogLow.id, ogHigh.id]; + + const family = await prisma.productFamily.create({ + data: { familyName: `rep-fam-${stamp3}`, primaryOriginGoodId: ogLow.id }, + }); + repFamilyId = family.id; + await prisma.originGood.updateMany({ + where: { id: { in: [ogLow.id, ogHigh.id] } }, + data: { familyId: family.id }, + }); + + const gLow = await prisma.good.create({ + data: { + goodName: repLowName, + originGoodId: ogLow.id, + familyId: family.id, + countryId, + categoryId, + goodPriority: 10, + positionId: posLow.id, + createdAt: new Date(stamp), + }, + }); + const gHigh = await prisma.good.create({ + data: { + goodName: repHighName, + originGoodId: ogHigh.id, + familyId: family.id, + countryId, + categoryId, + goodPriority: 10, + positionId: posHigh.id, + createdAt: new Date(stamp + 60_000), + }, + }); + trash.goodIds = [gLow.id, gHigh.id]; + }); + + afterAll(async () => { + await prisma.good.deleteMany({ where: { id: { in: trash.goodIds } } }).catch(() => undefined); + await prisma.position + .deleteMany({ where: { id: { in: trash.positionIds } } }) + .catch(() => undefined); + await prisma.productFamily.delete({ where: { id: repFamilyId } }).catch(() => undefined); + await prisma.originGood + .deleteMany({ where: { id: { in: trash.originGoodIds } } }) + .catch(() => undefined); + }); + + it('DEFAULT 列表代表行与详情一致(priority 并列时取 createdAt 最新,而非 id 最小)', async () => { + const detail = await service.getGood(repFamilyId.toString()); + expect(detail.goodName).toBe(repHighName); + + const list = await service.getGoods({ + page: 1, + pageSize: 50, + keyword: 'Rep ', // 本文件夹具唯一前缀,圈定本族(goodName: Rep Low/High) + }); + const ours = list.items.filter((i) => i.goodId === repFamilyId.toString()); + expect(ours).toHaveLength(1); + expect(ours[0].goodName).toBe(detail.goodName); + }); + + it('PRICE_ASC 列表代表行不漂移到价格更低的成员', async () => { + const detail = await service.getGood(repFamilyId.toString()); + const list = await service.getGoods({ + page: 1, + pageSize: 50, + keyword: 'Rep ', + sort: 'PRICE_ASC', + }); + const ours = list.items.filter((i) => i.goodId === repFamilyId.toString()); + expect(ours).toHaveLength(1); + expect(ours[0].goodName).toBe(detail.goodName); + }); + + it('home-goods 代表行与详情一致(不取位置更好的成员)', async () => { + const detail = await service.getGood(repFamilyId.toString()); + const home = await service.getHomeGoods({ limit: 50, countryId: countryId.toString() }); + const ours = home.filter((h) => h.goodId === repFamilyId.toString()); + expect(ours).toHaveLength(1); + expect(ours[0].goodName).toBe(detail.goodName); + }); + }); +}); diff --git a/apps/api/src/public/public.service.ts b/apps/api/src/public/public.service.ts index e90d26d..efb6e50 100644 --- a/apps/api/src/public/public.service.ts +++ b/apps/api/src/public/public.service.ts @@ -1,6 +1,7 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { Category as PrismaCategory, Prisma } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; +import { PublicCacheService } from './public-cache.service'; import { PublicHomeGoodsQueryDto, PublicQueryGoodDto, @@ -86,9 +87,28 @@ interface TreeOrderMeta { @Injectable() export class PublicService { - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + private readonly cache: PublicCacheService, + ) {} + + /** + * 以下读端点统一走 PublicCacheService(性能整改 P0-1,TTL 只是内存回收 + * 兜底,数据新鲜度由写路径 bump 保证,见 public-cache.service.ts)。 + * 依赖域声明: + * - meta:分类/国家/标签组等低熵元数据(含 DEFAULT 排序的树序元数据) + * - goods:goods 行/关联展示数据(含 position) + * - matrix:族 price_matrix 物化 JSON(族最低价聚合) + * 列表/详情同时展示元数据名与族价格 → 三域并依赖,任一写路径 bump 即失效。 + */ async getCategoriesTree(countryId?: string): Promise { + return this.cache.wrap(`cat-tree:${countryId ?? 'all'}`, ['meta', 'goods'], () => + this.loadCategoriesTree(countryId), + ); + } + + private async loadCategoriesTree(countryId?: string): Promise { const goodsWhere: Prisma.GoodWhereInput = { familyId: { not: null }, originGood: { delisted: false }, @@ -133,6 +153,10 @@ export class PublicService { } async getCountries(): Promise { + return this.cache.wrap('countries', ['meta', 'goods'], () => this.loadCountries()); + } + + private async loadCountries(): Promise { const rows = await this.prisma.country.findMany({ where: { goods: { some: { familyId: { not: null }, originGood: { delisted: false } } } }, orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }], @@ -141,6 +165,10 @@ export class PublicService { } async getTags(): Promise { + return this.cache.wrap('tags', ['meta', 'goods'], () => this.loadTags()); + } + + private async loadTags(): Promise { const rows = await this.prisma.tag.findMany({ where: { goodTags: { @@ -158,6 +186,12 @@ export class PublicService { } async getTagGroups(countryId?: string): Promise { + return this.cache.wrap(`tag-groups:${countryId ?? 'all'}`, ['meta', 'goods'], () => + this.loadTagGroups(countryId), + ); + } + + private async loadTagGroups(countryId?: string): Promise { const goodWhere: Prisma.GoodWhereInput = { familyId: { not: null }, originGood: { delisted: false }, @@ -189,7 +223,44 @@ export class PublicService { })); } + /** + * 缓存键 = 筛选参数(不含 page/pageSize):缓存的是已排序已分组的全量 + * items,翻页在缓存命中后内存切片——所有页码共享同一份物化结果。 + */ + private goodsListCacheKey(query: PublicQueryGoodDto): string { + return [ + 'goods-list', + query.countryId ?? '', + query.keyword ?? '', + query.categoryId ?? '', + query.minPrice ?? '', + query.maxPrice ?? '', + query.sort ?? 'DEFAULT', + JSON.stringify(query.tags ?? []), + ].join('|'); + } + async getGoods(query: PublicQueryGoodDto): Promise { + // 缓存的是「已排序已分组的全量 items」;分页切片必须在缓存外按请求执行, + // 否则后续页会拿到第一页的切片(缓存值会被多个页码共享) + const materialized = await this.cache.wrap( + this.goodsListCacheKey(query), + ['goods', 'matrix', 'meta'], + () => this.loadGoodsMaterialized(query), + ); + const start = (query.page - 1) * query.pageSize; + return { + items: materialized.items.slice(start, start + query.pageSize), + total: materialized.total, + page: query.page, + pageSize: query.pageSize, + }; + } + + private async loadGoodsMaterialized(query: PublicQueryGoodDto): Promise<{ + items: PublicGoodDto[]; + total: number; + }> { // 无族商品(自定义)不进公开列表:只认族 const where: Prisma.GoodWhereInput = { familyId: { not: null }, @@ -270,21 +341,18 @@ export class PublicService { 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, - }; + return { items, total: items.length }; } /** * 款序元数据:countries.sort_order(一级)+ 新树二/三级 categories.sort_order * (款顺序,回填自排序表)。key 用 origin_goods.sds_category_id 关联商品→款。 */ - private async loadTreeOrderMeta(): Promise { + private loadTreeOrderMeta(): Promise { + return this.cache.wrap('tree-order-meta', ['meta'], () => this.queryTreeOrderMeta()); + } + + private async queryTreeOrderMeta(): Promise { const [countries, leaves] = await Promise.all([ this.prisma.country.findMany({ select: { id: true, sortOrder: true } }), this.prisma.$queryRaw< @@ -331,7 +399,11 @@ export class PublicService { * 会让每个商品都携带整份矩阵(实测全量 ~330ms);PG 端展开聚合只回传 * 每族一个数字。非数字/缺失 price 的行跳过,与旧内存版过滤语义一致。 */ - private async loadFamilyMinPrices(): Promise> { + private loadFamilyMinPrices(): Promise> { + return this.cache.wrap('family-min-prices', ['matrix'], () => this.queryFamilyMinPrices()); + } + + private async queryFamilyMinPrices(): Promise> { const rows = await this.prisma.$queryRaw< Array<{ family_id: bigint | string; min_price: Prisma.Decimal | null }> >` @@ -365,6 +437,12 @@ export class PublicService { /** 族视角详情:代表 Good 提供公共字段(名称/主图/国家/分类),变体取全体成员并集 */ private async getGoodByFamilyId(familyId: bigint): Promise { + return this.cache.wrap(`family-detail:${familyId.toString()}`, ['goods', 'matrix', 'meta'], () => + this.loadGoodByFamilyId(familyId), + ); + } + + private async loadGoodByFamilyId(familyId: bigint): Promise { const [family, goods] = await Promise.all([ this.prisma.productFamily.findUnique({ where: { id: familyId } }), this.prisma.good.findMany({ @@ -405,6 +483,14 @@ export class PublicService { } async getHomeGoods(query: PublicHomeGoodsQueryDto): Promise { + return this.cache.wrap( + `home:${query.countryId ?? 'all'}:${query.limit}`, + ['goods', 'matrix', 'meta'], + () => this.loadHomeGoods(query), + ); + } + + private async loadHomeGoods(query: PublicHomeGoodsQueryDto): Promise { const rows = await this.prisma.good.findMany({ where: { positionId: { not: null }, diff --git a/apps/api/src/sync/sync-family-hooks.spec.ts b/apps/api/src/sync/sync-family-hooks.spec.ts index bf9ed39..bfb3d12 100644 --- a/apps/api/src/sync/sync-family-hooks.spec.ts +++ b/apps/api/src/sync/sync-family-hooks.spec.ts @@ -1,3 +1,4 @@ +import { PublicCacheService } from '../public/public-cache.service'; import { Test } from '@nestjs/testing'; import { Prisma } from '@prisma/client'; import { SyncService } from './sync.service'; @@ -27,6 +28,7 @@ describe('SyncService family hooks', () => { beforeAll(async () => { const moduleRef = await Test.createTestingModule({ providers: [ + PublicCacheService, SyncService, { provide: SdsClientService, diff --git a/apps/api/src/sync/sync.service.spec.ts b/apps/api/src/sync/sync.service.spec.ts index 0e7944a..13fa498 100644 --- a/apps/api/src/sync/sync.service.spec.ts +++ b/apps/api/src/sync/sync.service.spec.ts @@ -1,3 +1,4 @@ +import { PublicCacheService } from '../public/public-cache.service'; import { Test } from '@nestjs/testing'; import { ConfigModule } from '@nestjs/config'; import { @@ -25,6 +26,7 @@ describe('SyncService', () => { const moduleRef = await Test.createTestingModule({ imports: [ConfigModule.forRoot({ isGlobal: true })], providers: [ + PublicCacheService, SyncService, { provide: SdsClientService, useValue: sdsMock }, { provide: FamilyRecomputeService, useValue: { enqueue: jest.fn() } }, @@ -295,7 +297,7 @@ describe('SyncService product detail scopes', () => { const familyRecompute = { enqueue: jest.fn(), } as unknown as FamilyRecomputeService; - const scopedService = new SyncService(prisma, sds, familyRecompute); + const scopedService = new SyncService(prisma, sds, familyRecompute, new PublicCacheService()); jest .spyOn(scopedService as any, 'persistProductDetail') .mockResolvedValue(undefined); diff --git a/apps/api/src/sync/sync.service.ts b/apps/api/src/sync/sync.service.ts index 1a75895..7b1f43c 100644 --- a/apps/api/src/sync/sync.service.ts +++ b/apps/api/src/sync/sync.service.ts @@ -15,6 +15,7 @@ import { } from './sds-client.service'; import { normalizeProductDetail } from './sds-product-detail.mapper'; import { FamilyRecomputeService } from '../product-families/family-recompute.service'; +import { PublicCacheService } from '../public/public-cache.service'; export interface CategorySyncResult { @@ -96,6 +97,7 @@ export class SyncService { private readonly prisma: PrismaService, private readonly sds: SdsClientService, private readonly familyRecompute: FamilyRecomputeService, + private readonly publicCache: PublicCacheService, ) {} /** @@ -298,6 +300,8 @@ export class SyncService { message: `inserted=${inserted} updated=${updated} total=${flat.length} staleDeleted=${deletedStale}`, }, }); + // 分类树/树序元数据变化 → public 缓存失效(全局约束 §2:不等 TTL) + this.publicCache.bump('meta', 'goods'); return { inserted, updated, total: flat.length, deletedStale }; } catch (err) { const message = err instanceof Error ? err.message : String(err); @@ -407,6 +411,8 @@ export class SyncService { message: `inserted=${inserted} updated=${updated} total=${total} delisted=${delistedCount} reactivated=${reactivatedCount} leafCategories=${leafRows.length}`, }, }); + // 链接镜像(名称/图/价/上下架)变化 → public 商品列表/详情失效 + this.publicCache.bump('goods'); return { inserted, updated, @@ -677,6 +683,8 @@ export class SyncService { }, }); }); + // 详情/变体/价格落库 → public 详情缓存失效;族矩阵变化由重算钩子 bump matrix + this.publicCache.bump('goods'); // 族成员的详情/变体变化 → 异步重算该族(进程内去重) await this.maybeEnqueueFamilyRecompute(originGoodId); } diff --git a/apps/api/src/tag-groups/tag-groups.service.spec.ts b/apps/api/src/tag-groups/tag-groups.service.spec.ts index c09ddef..31786fe 100644 --- a/apps/api/src/tag-groups/tag-groups.service.spec.ts +++ b/apps/api/src/tag-groups/tag-groups.service.spec.ts @@ -1,3 +1,4 @@ +import { PublicCacheService } from '../public/public-cache.service'; import { Test } from '@nestjs/testing'; import { ConflictException, NotFoundException } from '@nestjs/common'; import { TagGroupsService } from './tag-groups.service'; @@ -10,7 +11,7 @@ describe('TagGroupsService', () => { beforeAll(async () => { const moduleRef = await Test.createTestingModule({ - providers: [TagGroupsService, PrismaService], + providers: [TagGroupsService, PrismaService, PublicCacheService], }).compile(); service = moduleRef.get(TagGroupsService); prisma = moduleRef.get(PrismaService); diff --git a/apps/api/src/tag-groups/tag-groups.service.ts b/apps/api/src/tag-groups/tag-groups.service.ts index 2e3feef..0408952 100644 --- a/apps/api/src/tag-groups/tag-groups.service.ts +++ b/apps/api/src/tag-groups/tag-groups.service.ts @@ -5,13 +5,17 @@ import { NotFoundException, } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; +import { PublicCacheService } from '../public/public-cache.service'; import { CreateTagGroupDto } from './dto/create-tag-group.dto'; import { UpdateTagGroupDto } from './dto/update-tag-group.dto'; import { ReorderTagGroupsDto } from './dto/reorder-tag-groups.dto'; @Injectable() export class TagGroupsService { - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + private readonly publicCache: PublicCacheService, + ) {} findAll() { return this.prisma.tagGroup.findMany({ @@ -31,7 +35,7 @@ export class TagGroupsService { async create(dto: CreateTagGroupDto) { try { - return await this.prisma.tagGroup.create({ + const created = await this.prisma.tagGroup.create({ data: { groupName: dto.groupName, groupIcon: dto.groupIcon ?? null, @@ -39,6 +43,8 @@ export class TagGroupsService { sortOrder: dto.sortOrder ?? 0, }, }); + this.publicCache.bump('meta'); + return created; } catch (err) { if ( err instanceof Prisma.PrismaClientKnownRequestError && @@ -58,7 +64,9 @@ export class TagGroupsService { if (dto.groupColor !== undefined) data.groupColor = dto.groupColor; if (dto.sortOrder !== undefined) data.sortOrder = dto.sortOrder; try { - return await this.prisma.tagGroup.update({ where: { id }, data }); + const updated = await this.prisma.tagGroup.update({ where: { id }, data }); + this.publicCache.bump('meta'); + return updated; } catch (err) { if ( err instanceof Prisma.PrismaClientKnownRequestError && @@ -72,11 +80,13 @@ export class TagGroupsService { async remove(id: bigint) { await this.findOne(id); - return this.prisma.tagGroup.delete({ where: { id } }); + const removed = await this.prisma.tagGroup.delete({ where: { id } }); + this.publicCache.bump('meta'); + return removed; } async reorder(dto: ReorderTagGroupsDto) { - return this.prisma.$transaction( + const result = await this.prisma.$transaction( dto.items.map((item) => this.prisma.tagGroup.update({ where: { id: BigInt(item.id) }, @@ -84,5 +94,7 @@ export class TagGroupsService { }), ), ); + this.publicCache.bump('meta'); + return result; } } diff --git a/apps/api/src/tags/tags.service.spec.ts b/apps/api/src/tags/tags.service.spec.ts index 854c57f..0555cba 100644 --- a/apps/api/src/tags/tags.service.spec.ts +++ b/apps/api/src/tags/tags.service.spec.ts @@ -1,3 +1,4 @@ +import { PublicCacheService } from '../public/public-cache.service'; import { Test } from '@nestjs/testing'; import { ConflictException } from '@nestjs/common'; import { validate } from 'class-validator'; @@ -13,7 +14,7 @@ describe('TagsService', () => { beforeAll(async () => { const moduleRef = await Test.createTestingModule({ - providers: [TagsService, PrismaService], + providers: [TagsService, PrismaService, PublicCacheService], }).compile(); service = moduleRef.get(TagsService); prisma = moduleRef.get(PrismaService); diff --git a/apps/api/src/tags/tags.service.ts b/apps/api/src/tags/tags.service.ts index 9cd1078..f2961a8 100644 --- a/apps/api/src/tags/tags.service.ts +++ b/apps/api/src/tags/tags.service.ts @@ -5,13 +5,17 @@ import { NotFoundException, } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; +import { PublicCacheService } from '../public/public-cache.service'; import { CreateTagDto } from './dto/create-tag.dto'; import { UpdateTagDto } from './dto/update-tag.dto'; import { ReorderTagsDto } from './dto/reorder-tags.dto'; @Injectable() export class TagsService { - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + private readonly publicCache: PublicCacheService, + ) {} findAll() { return this.prisma.tag.findMany({ @@ -31,7 +35,7 @@ export class TagsService { async create(dto: CreateTagDto) { try { - return await this.prisma.tag.create({ + const created = await this.prisma.tag.create({ data: { tagName: dto.tagName, tagColor: dto.tagColor ?? null, @@ -41,6 +45,8 @@ export class TagsService { sortOrder: dto.sortOrder ?? 0, }, }); + this.publicCache.bump('meta'); + return created; } catch (err) { if ( err instanceof Prisma.PrismaClientKnownRequestError && @@ -66,7 +72,9 @@ export class TagsService { } if (dto.sortOrder !== undefined) data.sortOrder = dto.sortOrder; try { - return await this.prisma.tag.update({ where: { id }, data }); + const updated = await this.prisma.tag.update({ where: { id }, data }); + this.publicCache.bump('meta'); + return updated; } catch (err) { if ( err instanceof Prisma.PrismaClientKnownRequestError && @@ -80,11 +88,13 @@ export class TagsService { async remove(id: bigint) { await this.findOne(id); - return this.prisma.tag.delete({ where: { id } }); + const removed = await this.prisma.tag.delete({ where: { id } }); + this.publicCache.bump('meta'); + return removed; } async reorder(dto: ReorderTagsDto) { - return this.prisma.$transaction( + const result = await this.prisma.$transaction( dto.items.map((item) => this.prisma.tag.update({ where: { id: BigInt(item.id) }, @@ -100,5 +110,7 @@ export class TagsService { }), ), ); + this.publicCache.bump('meta'); + return result; } }