perf(public): 公开读路径进程内分域缓存——meta/goods/matrix 版本域 + TTL 兜底 + in-flight 合并

- PublicCacheService:分域版本号失效(bump 即作废,不等 TTL)、loader 期间
  bump 的竞态防护(返回但不回写)、并发 miss 单飞、PUBLIC_CACHE_DISABLED
  /TTL_MS/MAX_ENTRIES 应急开关;@Global 模块
- PublicService 六端点接缓存:列表缓存全量物化(分页切片在缓存外按请求执行,
  修复'所有页返回第一页'的切片缓存错误)、详情/首页/树/标签组/树序元数据/
  族最低价聚合各按依赖域缓存
- 全写路径挂钩 bump:admin CRUD(goods/categories/countries/tags/tag-groups/
  positions/origin-goods 标签)、sync 三同步、族重算、整理全家桶——
  事务提交成功后失效对应域,product-families 经 recompute 天然覆盖
- 测试:PublicCacheService 单测 13 例 + 失效链路集成 8 例(读命中/写后立即可见
  端到端/每条写路径域断言);既有两套件测数据语义改为显式禁缓存
This commit is contained in:
yeuimu
2026-09-03 03:40:20 +08:00
parent ca38476047
commit ab79325ab0
33 changed files with 1777 additions and 927 deletions
+3 -2
View File
@@ -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(
+3 -1
View File
@@ -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 });
+2 -1
View File
@@ -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 },
+3
View File
@@ -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,
@@ -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);
+14 -4
View File
@@ -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<CategoryNodeDto[]> {
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(
@@ -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);
+15 -4
View File
@@ -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(
+2
View File
@@ -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,
{
+9
View File
@@ -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<PaginatedGoods> {
@@ -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;
}
@@ -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);
@@ -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);
}
@@ -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);
+14 -4
View File
@@ -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) {
@@ -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();
@@ -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<string, Promise<void>>();
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<void> {
@@ -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);
@@ -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 };
}
@@ -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();
@@ -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<SdsClientService> = {
// 返回库内全部 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 metaCountries/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 内部联动 recomputeFamilygoods+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+goodssyncProducts bump goodspersistProductDetail 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<void> }).persistProductDetail(
originGoodId,
{ id: sdsGoodId },
);
expect(cache.version('goods')).toBeGreaterThanOrEqual(goods1 + 1);
}, 60_000);
});
@@ -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 {}
@@ -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<string>((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<number>((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();
});
});
+171
View File
@@ -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<PublicCacheDomain, number>;
}
@Injectable()
export class PublicCacheService {
private readonly logger = new Logger(PublicCacheService.name);
private readonly versions = new Map<PublicCacheDomain, number>(
PUBLIC_CACHE_DOMAINS.map((domain) => [domain, 0]),
);
private readonly entries = new Map<string, CacheEntry>();
private readonly pending = new Map<string, Promise<unknown>>();
/**
* 读穿透封装:命中(未过期且全部依赖域版本未变)直接返回缓存值,
* 否则执行 loader 并缓存。domains 声明该值依赖的数据域——
* 写路径 bump 其中任一域都会让本条目作废。
*/
async wrap<T>(
key: string,
domains: readonly PublicCacheDomain[],
loader: () => Promise<T>,
): Promise<T> {
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<T>;
const snapshot = this.snapshotVersions(domains);
const run: Promise<T> = (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<unknown>);
// 失败与成功都要摘掉 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<PublicCacheDomain, number>): boolean {
for (const [domain, version] of snapshot) {
if ((this.versions.get(domain) ?? 0) !== version) return false;
}
return true;
}
private snapshotVersions(
domains: readonly PublicCacheDomain[],
): Map<PublicCacheDomain, number> {
return new Map(domains.map((domain) => [domain, this.versions.get(domain) ?? 0]));
}
private writeEntry(
storeKey: string,
value: unknown,
versions: ReadonlyMap<PublicCacheDomain, number>,
): 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)}`);
}
}
}
}
@@ -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(),
);
});
File diff suppressed because it is too large Load Diff
+97 -11
View File
@@ -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-1TTL 只是内存回收
* 兜底,数据新鲜度由写路径 bump 保证,见 public-cache.service.ts)。
* 依赖域声明:
* - meta:分类/国家/标签组等低熵元数据(含 DEFAULT 排序的树序元数据)
* - goodsgoods 行/关联展示数据(含 position)
* - matrix:族 price_matrix 物化 JSON(族最低价聚合)
* 列表/详情同时展示元数据名与族价格 → 三域并依赖,任一写路径 bump 即失效。
*/
async getCategoriesTree(countryId?: string): Promise<PublicCategoryNodeDto[]> {
return this.cache.wrap(`cat-tree:${countryId ?? 'all'}`, ['meta', 'goods'], () =>
this.loadCategoriesTree(countryId),
);
}
private async loadCategoriesTree(countryId?: string): Promise<PublicCategoryNodeDto[]> {
const goodsWhere: Prisma.GoodWhereInput = {
familyId: { not: null },
originGood: { delisted: false },
@@ -133,6 +153,10 @@ export class PublicService {
}
async getCountries(): Promise<PublicCountryDto[]> {
return this.cache.wrap('countries', ['meta', 'goods'], () => this.loadCountries());
}
private async loadCountries(): Promise<PublicCountryDto[]> {
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<PublicTagDto[]> {
return this.cache.wrap('tags', ['meta', 'goods'], () => this.loadTags());
}
private async loadTags(): Promise<PublicTagDto[]> {
const rows = await this.prisma.tag.findMany({
where: {
goodTags: {
@@ -158,6 +186,12 @@ export class PublicService {
}
async getTagGroups(countryId?: string): Promise<PublicTagGroupFilterDto[]> {
return this.cache.wrap(`tag-groups:${countryId ?? 'all'}`, ['meta', 'goods'], () =>
this.loadTagGroups(countryId),
);
}
private async loadTagGroups(countryId?: string): Promise<PublicTagGroupFilterDto[]> {
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<PublicPaginatedGoods> {
// 缓存的是「已排序已分组的全量 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<TreeOrderMeta> {
private loadTreeOrderMeta(): Promise<TreeOrderMeta> {
return this.cache.wrap('tree-order-meta', ['meta'], () => this.queryTreeOrderMeta());
}
private async queryTreeOrderMeta(): Promise<TreeOrderMeta> {
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<Map<string, string>> {
private loadFamilyMinPrices(): Promise<Map<string, string>> {
return this.cache.wrap('family-min-prices', ['matrix'], () => this.queryFamilyMinPrices());
}
private async queryFamilyMinPrices(): Promise<Map<string, string>> {
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<PublicGoodDetailDto | null> {
return this.cache.wrap(`family-detail:${familyId.toString()}`, ['goods', 'matrix', 'meta'], () =>
this.loadGoodByFamilyId(familyId),
);
}
private async loadGoodByFamilyId(familyId: bigint): Promise<PublicGoodDetailDto | null> {
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<PublicGoodDto[]> {
return this.cache.wrap(
`home:${query.countryId ?? 'all'}:${query.limit}`,
['goods', 'matrix', 'meta'],
() => this.loadHomeGoods(query),
);
}
private async loadHomeGoods(query: PublicHomeGoodsQueryDto): Promise<PublicGoodDto[]> {
const rows = await this.prisma.good.findMany({
where: {
positionId: { not: null },
@@ -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,
+3 -1
View File
@@ -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);
+8
View File
@@ -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);
}
@@ -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);
+17 -5
View File
@@ -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;
}
}
+2 -1
View File
@@ -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);
+17 -5
View File
@@ -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;
}
}