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:
@@ -9,6 +9,7 @@
|
|||||||
* 后台等价入口:POST /product-families/organize(「整理」按钮)。
|
* 后台等价入口:POST /product-families/organize(「整理」按钮)。
|
||||||
*/
|
*/
|
||||||
import { PrismaService } from '../src/prisma/prisma.service';
|
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 { FamilyRecomputeService } from '../src/product-families/family-recompute.service';
|
||||||
import { ProductFamiliesService } from '../src/product-families/product-families.service';
|
import { ProductFamiliesService } from '../src/product-families/product-families.service';
|
||||||
import { OrganizeService } from '../src/product-families/organize.service';
|
import { OrganizeService } from '../src/product-families/organize.service';
|
||||||
@@ -16,9 +17,9 @@ import { OrganizeService } from '../src/product-families/organize.service';
|
|||||||
async function main() {
|
async function main() {
|
||||||
const prisma = new PrismaService();
|
const prisma = new PrismaService();
|
||||||
await prisma.onModuleInit();
|
await prisma.onModuleInit();
|
||||||
const recompute = new FamilyRecomputeService(prisma);
|
const recompute = new FamilyRecomputeService(prisma, new PublicCacheService());
|
||||||
const families = new ProductFamiliesService(prisma, recompute);
|
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();
|
const result = await organize.organize();
|
||||||
console.log(
|
console.log(
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
* origin_goods.good_name 为 SDS 纯镜像(每小时同步覆盖),本脚本只改 goods.good_name。
|
* origin_goods.good_name 为 SDS 纯镜像(每小时同步覆盖),本脚本只改 goods.good_name。
|
||||||
*/
|
*/
|
||||||
import { PrismaService } from '../src/prisma/prisma.service';
|
import { PrismaService } from '../src/prisma/prisma.service';
|
||||||
|
import { PublicCacheService } from '../src/public/public-cache.service';
|
||||||
import { SyncService } from '../src/sync/sync.service';
|
import { SyncService } from '../src/sync/sync.service';
|
||||||
import { FamilyRecomputeService } from '../src/product-families/family-recompute.service';
|
import { FamilyRecomputeService } from '../src/product-families/family-recompute.service';
|
||||||
import { GoodsService } from '../src/goods/goods.service';
|
import { GoodsService } from '../src/goods/goods.service';
|
||||||
@@ -16,12 +17,13 @@ async function main() {
|
|||||||
const dryRun = !process.argv.includes('--apply');
|
const dryRun = !process.argv.includes('--apply');
|
||||||
const prisma = new PrismaService();
|
const prisma = new PrismaService();
|
||||||
await prisma.onModuleInit();
|
await prisma.onModuleInit();
|
||||||
const recompute = new FamilyRecomputeService(prisma);
|
const recompute = new FamilyRecomputeService(prisma, new PublicCacheService());
|
||||||
// 回填路径不触发详情同步,SyncService 仅作占位依赖
|
// 回填路径不触发详情同步,SyncService 仅作占位依赖
|
||||||
const goods = new GoodsService(
|
const goods = new GoodsService(
|
||||||
prisma,
|
prisma,
|
||||||
{ queueProductDetailSync: async () => undefined } as unknown as SyncService,
|
{ queueProductDetailSync: async () => undefined } as unknown as SyncService,
|
||||||
recompute,
|
recompute,
|
||||||
|
new PublicCacheService(),
|
||||||
);
|
);
|
||||||
|
|
||||||
const result = await goods.backfillPureSkuGoodNames({ dryRun });
|
const result = await goods.backfillPureSkuGoodNames({ dryRun });
|
||||||
|
|||||||
@@ -7,12 +7,13 @@
|
|||||||
* 运行:pnpm --filter @inkreach/api recompute:families
|
* 运行:pnpm --filter @inkreach/api recompute:families
|
||||||
*/
|
*/
|
||||||
import { PrismaService } from '../src/prisma/prisma.service';
|
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 { FamilyRecomputeService } from '../src/product-families/family-recompute.service';
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const prisma = new PrismaService();
|
const prisma = new PrismaService();
|
||||||
await prisma.onModuleInit();
|
await prisma.onModuleInit();
|
||||||
const recompute = new FamilyRecomputeService(prisma);
|
const recompute = new FamilyRecomputeService(prisma, new PublicCacheService());
|
||||||
|
|
||||||
const families = await prisma.productFamily.findMany({
|
const families = await prisma.productFamily.findMany({
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { ProductFamiliesModule } from './product-families/product-families.modul
|
|||||||
import { GoodsModule } from './goods/goods.module';
|
import { GoodsModule } from './goods/goods.module';
|
||||||
import { SyncModule } from './sync/sync.module';
|
import { SyncModule } from './sync/sync.module';
|
||||||
import { PublicModule } from './public/public.module';
|
import { PublicModule } from './public/public.module';
|
||||||
|
import { PublicCacheModule } from './public/public-cache.module';
|
||||||
import { UploadModule } from './upload/upload.module';
|
import { UploadModule } from './upload/upload.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -31,6 +32,8 @@ import { UploadModule } from './upload/upload.module';
|
|||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
|
// public 读路径缓存(分域版本失效,@Global 供各写路径注入 bump 入口)
|
||||||
|
PublicCacheModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
CountriesModule,
|
CountriesModule,
|
||||||
CategoriesModule,
|
CategoriesModule,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { PublicCacheService } from '../public/public-cache.service';
|
||||||
import { Test } from '@nestjs/testing';
|
import { Test } from '@nestjs/testing';
|
||||||
import {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
@@ -13,7 +14,7 @@ describe('CategoriesService', () => {
|
|||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
const moduleRef = await Test.createTestingModule({
|
const moduleRef = await Test.createTestingModule({
|
||||||
providers: [CategoriesService, PrismaService],
|
providers: [CategoriesService, PrismaService, PublicCacheService],
|
||||||
}).compile();
|
}).compile();
|
||||||
service = moduleRef.get(CategoriesService);
|
service = moduleRef.get(CategoriesService);
|
||||||
prisma = moduleRef.get(PrismaService);
|
prisma = moduleRef.get(PrismaService);
|
||||||
|
|||||||
@@ -5,13 +5,17 @@ import {
|
|||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { PublicCacheService } from '../public/public-cache.service';
|
||||||
import { CreateCategoryDto } from './dto/create-category.dto';
|
import { CreateCategoryDto } from './dto/create-category.dto';
|
||||||
import { UpdateCategoryDto } from './dto/update-category.dto';
|
import { UpdateCategoryDto } from './dto/update-category.dto';
|
||||||
import { CategoryNodeDto } from './dto/category-node.dto';
|
import { CategoryNodeDto } from './dto/category-node.dto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CategoriesService {
|
export class CategoriesService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly publicCache: PublicCacheService,
|
||||||
|
) {}
|
||||||
|
|
||||||
async findAll(): Promise<CategoryNodeDto[]> {
|
async findAll(): Promise<CategoryNodeDto[]> {
|
||||||
const all = await this.prisma.category.findMany({
|
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.
|
// Validate the parent exists to produce a clean 404 instead of FK error.
|
||||||
await this.findOne(BigInt(dto.parentCategoryId));
|
await this.findOne(BigInt(dto.parentCategoryId));
|
||||||
}
|
}
|
||||||
return this.prisma.category.create({
|
const created = await this.prisma.category.create({
|
||||||
data: {
|
data: {
|
||||||
categoryName: dto.categoryName,
|
categoryName: dto.categoryName,
|
||||||
categoryIcon: dto.categoryIcon ?? null,
|
categoryIcon: dto.categoryIcon ?? null,
|
||||||
@@ -47,6 +51,8 @@ export class CategoriesService {
|
|||||||
: BigInt(dto.parentCategoryId),
|
: BigInt(dto.parentCategoryId),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
this.publicCache.bump('meta');
|
||||||
|
return created;
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(id: bigint, dto: UpdateCategoryDto) {
|
async update(id: bigint, dto: UpdateCategoryDto) {
|
||||||
@@ -66,7 +72,9 @@ export class CategoriesService {
|
|||||||
? { disconnect: true }
|
? { disconnect: true }
|
||||||
: { connect: { id: BigInt(dto.parentCategoryId) } };
|
: { 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) {
|
async remove(id: bigint) {
|
||||||
@@ -80,7 +88,9 @@ export class CategoriesService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
try {
|
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) {
|
} catch (err) {
|
||||||
if (this.isForeignKeyViolation(err)) {
|
if (this.isForeignKeyViolation(err)) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { PublicCacheService } from '../public/public-cache.service';
|
||||||
import { Test } from '@nestjs/testing';
|
import { Test } from '@nestjs/testing';
|
||||||
import {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
@@ -14,7 +15,7 @@ describe('CountriesService', () => {
|
|||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
const moduleRef = await Test.createTestingModule({
|
const moduleRef = await Test.createTestingModule({
|
||||||
providers: [CountriesService, PrismaService],
|
providers: [CountriesService, PrismaService, PublicCacheService],
|
||||||
}).compile();
|
}).compile();
|
||||||
service = moduleRef.get(CountriesService);
|
service = moduleRef.get(CountriesService);
|
||||||
prisma = moduleRef.get(PrismaService);
|
prisma = moduleRef.get(PrismaService);
|
||||||
|
|||||||
@@ -6,13 +6,17 @@ import {
|
|||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { PublicCacheService } from '../public/public-cache.service';
|
||||||
import { CreateCountryDto } from './dto/create-country.dto';
|
import { CreateCountryDto } from './dto/create-country.dto';
|
||||||
import { UpdateCountryDto } from './dto/update-country.dto';
|
import { UpdateCountryDto } from './dto/update-country.dto';
|
||||||
import { ReorderCountriesDto } from './dto/reorder-countries.dto';
|
import { ReorderCountriesDto } from './dto/reorder-countries.dto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CountriesService {
|
export class CountriesService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly publicCache: PublicCacheService,
|
||||||
|
) {}
|
||||||
|
|
||||||
findAll() {
|
findAll() {
|
||||||
return this.prisma.country.findMany({
|
return this.prisma.country.findMany({
|
||||||
@@ -30,6 +34,7 @@ export class CountriesService {
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
this.publicCache.bump('meta');
|
||||||
return this.findAll();
|
return this.findAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,12 +48,14 @@ export class CountriesService {
|
|||||||
|
|
||||||
async create(dto: CreateCountryDto) {
|
async create(dto: CreateCountryDto) {
|
||||||
try {
|
try {
|
||||||
return await this.prisma.country.create({
|
const created = await this.prisma.country.create({
|
||||||
data: {
|
data: {
|
||||||
countryName: dto.countryName,
|
countryName: dto.countryName,
|
||||||
countryIcon: dto.countryIcon ?? null,
|
countryIcon: dto.countryIcon ?? null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
this.publicCache.bump('meta');
|
||||||
|
return created;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (
|
if (
|
||||||
err instanceof Prisma.PrismaClientKnownRequestError &&
|
err instanceof Prisma.PrismaClientKnownRequestError &&
|
||||||
@@ -63,13 +70,15 @@ export class CountriesService {
|
|||||||
async update(id: bigint, dto: UpdateCountryDto) {
|
async update(id: bigint, dto: UpdateCountryDto) {
|
||||||
await this.findOne(id);
|
await this.findOne(id);
|
||||||
try {
|
try {
|
||||||
return await this.prisma.country.update({
|
const updated = await this.prisma.country.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: {
|
data: {
|
||||||
countryName: dto.countryName,
|
countryName: dto.countryName,
|
||||||
countryIcon: dto.countryIcon === undefined ? undefined : dto.countryIcon,
|
countryIcon: dto.countryIcon === undefined ? undefined : dto.countryIcon,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
this.publicCache.bump('meta');
|
||||||
|
return updated;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (
|
if (
|
||||||
err instanceof Prisma.PrismaClientKnownRequestError &&
|
err instanceof Prisma.PrismaClientKnownRequestError &&
|
||||||
@@ -84,7 +93,9 @@ export class CountriesService {
|
|||||||
async remove(id: bigint) {
|
async remove(id: bigint) {
|
||||||
await this.findOne(id);
|
await this.findOne(id);
|
||||||
try {
|
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) {
|
} catch (err) {
|
||||||
if (this.isForeignKeyViolation(err)) {
|
if (this.isForeignKeyViolation(err)) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { PublicCacheService } from '../public/public-cache.service';
|
||||||
import { Test } from '@nestjs/testing';
|
import { Test } from '@nestjs/testing';
|
||||||
import {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
@@ -30,6 +31,7 @@ describe('GoodsService', () => {
|
|||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
const moduleRef = await Test.createTestingModule({
|
const moduleRef = await Test.createTestingModule({
|
||||||
providers: [
|
providers: [
|
||||||
|
PublicCacheService,
|
||||||
GoodsService,
|
GoodsService,
|
||||||
PrismaService,
|
PrismaService,
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { BatchPriorityDto } from './dto/batch-priority.dto';
|
|||||||
import { GoodDetailDto, GoodDto, PaginatedGoods } from './dto/good.dto';
|
import { GoodDetailDto, GoodDto, PaginatedGoods } from './dto/good.dto';
|
||||||
import { SyncService } from '../sync/sync.service';
|
import { SyncService } from '../sync/sync.service';
|
||||||
import { FamilyRecomputeService } from '../product-families/family-recompute.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 { isAutoTagGroupName } from '../product-families/auto-tag-rules';
|
||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
import {
|
import {
|
||||||
@@ -120,6 +121,7 @@ export class GoodsService {
|
|||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly syncService: SyncService,
|
private readonly syncService: SyncService,
|
||||||
private readonly familyRecompute: FamilyRecomputeService,
|
private readonly familyRecompute: FamilyRecomputeService,
|
||||||
|
private readonly publicCache: PublicCacheService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async findAll(query: QueryGoodDto): Promise<PaginatedGoods> {
|
async findAll(query: QueryGoodDto): Promise<PaginatedGoods> {
|
||||||
@@ -240,6 +242,7 @@ export class GoodsService {
|
|||||||
// 建商品后把链接有效标签镜像到该商品(纯聚合,不派生——派生在整理流程)
|
// 建商品后把链接有效标签镜像到该商品(纯聚合,不派生——派生在整理流程)
|
||||||
await this.familyRecompute.mirrorLinkTagsToGoods(BigInt(result.originGoodId));
|
await this.familyRecompute.mirrorLinkTagsToGoods(BigInt(result.originGoodId));
|
||||||
}
|
}
|
||||||
|
this.publicCache.bump('goods');
|
||||||
if (
|
if (
|
||||||
result.originGood?.source === 'SDS' &&
|
result.originGood?.source === 'SDS' &&
|
||||||
result.originGood.sdsGoodId &&
|
result.originGood.sdsGoodId &&
|
||||||
@@ -314,6 +317,7 @@ export class GoodsService {
|
|||||||
}
|
}
|
||||||
return good.id;
|
return good.id;
|
||||||
});
|
});
|
||||||
|
this.publicCache.bump('goods');
|
||||||
if (family) this.familyRecompute.enqueue(family.id);
|
if (family) this.familyRecompute.enqueue(family.id);
|
||||||
return this.findOne(goodId);
|
return this.findOne(goodId);
|
||||||
}
|
}
|
||||||
@@ -369,6 +373,7 @@ export class GoodsService {
|
|||||||
await tx.good.update({ where: { id }, data: goodData });
|
await tx.good.update({ where: { id }, data: goodData });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
this.publicCache.bump('goods');
|
||||||
return this.findOne(id);
|
return this.findOne(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -495,6 +500,7 @@ export class GoodsService {
|
|||||||
// 归族联动后重算矩阵(成员变化 → 自动重算,结构化聚合)
|
// 归族联动后重算矩阵(成员变化 → 自动重算,结构化聚合)
|
||||||
await this.familyRecompute.recomputeFamily(familyIdForTags);
|
await this.familyRecompute.recomputeFamily(familyIdForTags);
|
||||||
}
|
}
|
||||||
|
this.publicCache.bump('goods');
|
||||||
if (
|
if (
|
||||||
result.originGood?.source === 'SDS' &&
|
result.originGood?.source === 'SDS' &&
|
||||||
result.originGood.sdsGoodId &&
|
result.originGood.sdsGoodId &&
|
||||||
@@ -522,6 +528,7 @@ export class GoodsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
this.publicCache.bump('goods');
|
||||||
return { id: id.toString() };
|
return { id: id.toString() };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -539,6 +546,7 @@ export class GoodsService {
|
|||||||
}
|
}
|
||||||
return { count: dto.items.length };
|
return { count: dto.items.length };
|
||||||
});
|
});
|
||||||
|
this.publicCache.bump('goods');
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -634,6 +642,7 @@ export class GoodsService {
|
|||||||
)) {
|
)) {
|
||||||
this.syncService.queueProductDetailSync(goodId);
|
this.syncService.queueProductDetailSync(goodId);
|
||||||
}
|
}
|
||||||
|
this.publicCache.bump('goods');
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { PublicCacheService } from '../public/public-cache.service';
|
||||||
import { Test } from '@nestjs/testing';
|
import { Test } from '@nestjs/testing';
|
||||||
import { OriginGoodsService } from './origin-goods.service';
|
import { OriginGoodsService } from './origin-goods.service';
|
||||||
import { FamilyRecomputeService } from '../product-families/family-recompute.service';
|
import { FamilyRecomputeService } from '../product-families/family-recompute.service';
|
||||||
@@ -15,7 +16,7 @@ describe('OriginGoodsService', () => {
|
|||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
const moduleRef = await Test.createTestingModule({
|
const moduleRef = await Test.createTestingModule({
|
||||||
providers: [OriginGoodsService, FamilyRecomputeService, ProductFamiliesService, OrganizeService, PrismaService],
|
providers: [OriginGoodsService, FamilyRecomputeService, ProductFamiliesService, OrganizeService, PrismaService, PublicCacheService],
|
||||||
}).compile();
|
}).compile();
|
||||||
service = moduleRef.get(OriginGoodsService);
|
service = moduleRef.get(OriginGoodsService);
|
||||||
organize = moduleRef.get(OrganizeService);
|
organize = moduleRef.get(OrganizeService);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { PrismaService } from '../prisma/prisma.service';
|
|||||||
import { FamilyRecomputeService } from '../product-families/family-recompute.service';
|
import { FamilyRecomputeService } from '../product-families/family-recompute.service';
|
||||||
import { OrganizeService } from '../product-families/organize.service';
|
import { OrganizeService } from '../product-families/organize.service';
|
||||||
import { ProductFamiliesService } from '../product-families/product-families.service';
|
import { ProductFamiliesService } from '../product-families/product-families.service';
|
||||||
|
import { PublicCacheService } from '../public/public-cache.service';
|
||||||
import { QueryOriginGoodDto } from './dto/query-origin-good.dto';
|
import { QueryOriginGoodDto } from './dto/query-origin-good.dto';
|
||||||
|
|
||||||
/** 链接标签(origin_good_tags 行,含人工/派生标记) */
|
/** 链接标签(origin_good_tags 行,含人工/派生标记) */
|
||||||
@@ -97,6 +98,7 @@ export class OriginGoodsService {
|
|||||||
private readonly familyRecompute: FamilyRecomputeService,
|
private readonly familyRecompute: FamilyRecomputeService,
|
||||||
private readonly organize: OrganizeService,
|
private readonly organize: OrganizeService,
|
||||||
private readonly families: ProductFamiliesService,
|
private readonly families: ProductFamiliesService,
|
||||||
|
private readonly publicCache: PublicCacheService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** 链接当前标签(含 manual 标记) */
|
/** 链接当前标签(含 manual 标记) */
|
||||||
@@ -171,6 +173,9 @@ export class OriginGoodsService {
|
|||||||
this.prisma.originGood.update({ where: { id }, data: { tagsManual: true } }),
|
this.prisma.originGood.update({ where: { id }, data: { tagsManual: true } }),
|
||||||
]);
|
]);
|
||||||
await this.familyRecompute.mirrorLinkTagsToGoods(id);
|
await this.familyRecompute.mirrorLinkTagsToGoods(id);
|
||||||
|
// 标签镜像改变 good_tags(meta 域的标签组过滤);无族链接挂靠失败时
|
||||||
|
// 后续 recompute 不会执行,此处显式 bump 兜住全部路径(含 matrix 归因)
|
||||||
|
this.publicCache.bump('meta', 'goods', 'matrix');
|
||||||
// 人工改标签 → 归因维度可能变化,自动重算族矩阵(有族才重算)
|
// 人工改标签 → 归因维度可能变化,自动重算族矩阵(有族才重算)
|
||||||
const ogFull = await this.prisma.originGood.findUnique({
|
const ogFull = await this.prisma.originGood.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
@@ -205,6 +210,7 @@ export class OriginGoodsService {
|
|||||||
]);
|
]);
|
||||||
// 派生集中在整理服务(解析去运行时化);「恢复自动」本身是显式人工动作
|
// 派生集中在整理服务(解析去运行时化);「恢复自动」本身是显式人工动作
|
||||||
await this.organize.deriveTagsForOg(id);
|
await this.organize.deriveTagsForOg(id);
|
||||||
|
this.publicCache.bump('meta', 'goods', 'matrix');
|
||||||
if (og.familyId) await this.familyRecompute.recomputeFamily(og.familyId);
|
if (og.familyId) await this.familyRecompute.recomputeFamily(og.familyId);
|
||||||
return this.getTags(id);
|
return this.getTags(id);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { PublicCacheService } from '../public/public-cache.service';
|
||||||
import { Test } from '@nestjs/testing';
|
import { Test } from '@nestjs/testing';
|
||||||
import { NotFoundException } from '@nestjs/common';
|
import { NotFoundException } from '@nestjs/common';
|
||||||
import { PositionsService } from './positions.service';
|
import { PositionsService } from './positions.service';
|
||||||
@@ -12,7 +13,7 @@ describe('PositionsService', () => {
|
|||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
const moduleRef = await Test.createTestingModule({
|
const moduleRef = await Test.createTestingModule({
|
||||||
providers: [PositionsService, PrismaService],
|
providers: [PositionsService, PrismaService, PublicCacheService],
|
||||||
}).compile();
|
}).compile();
|
||||||
service = moduleRef.get(PositionsService);
|
service = moduleRef.get(PositionsService);
|
||||||
prisma = moduleRef.get(PrismaService);
|
prisma = moduleRef.get(PrismaService);
|
||||||
|
|||||||
@@ -3,12 +3,16 @@ import {
|
|||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { PublicCacheService } from '../public/public-cache.service';
|
||||||
import { CreatePositionDto } from './dto/create-position.dto';
|
import { CreatePositionDto } from './dto/create-position.dto';
|
||||||
import { UpdatePositionDto } from './dto/update-position.dto';
|
import { UpdatePositionDto } from './dto/update-position.dto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PositionsService {
|
export class PositionsService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly publicCache: PublicCacheService,
|
||||||
|
) {}
|
||||||
|
|
||||||
findAll(filters?: { countryId?: bigint; categoryId?: bigint }) {
|
findAll(filters?: { countryId?: bigint; categoryId?: bigint }) {
|
||||||
const where: { countryId?: bigint; categoryId?: bigint } = {};
|
const where: { countryId?: bigint; categoryId?: bigint } = {};
|
||||||
@@ -37,7 +41,7 @@ export class PositionsService {
|
|||||||
if (dto.categoryId !== undefined) {
|
if (dto.categoryId !== undefined) {
|
||||||
await this.ensureCategory(dto.categoryId);
|
await this.ensureCategory(dto.categoryId);
|
||||||
}
|
}
|
||||||
return this.prisma.position.create({
|
const created = await this.prisma.position.create({
|
||||||
data: {
|
data: {
|
||||||
indexVal: dto.indexVal,
|
indexVal: dto.indexVal,
|
||||||
countryId: dto.countryId === undefined ? null : BigInt(dto.countryId),
|
countryId: dto.countryId === undefined ? null : BigInt(dto.countryId),
|
||||||
@@ -45,6 +49,8 @@ export class PositionsService {
|
|||||||
},
|
},
|
||||||
include: { country: true, category: true },
|
include: { country: true, category: true },
|
||||||
});
|
});
|
||||||
|
this.publicCache.bump('goods');
|
||||||
|
return created;
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(id: bigint, dto: UpdatePositionDto) {
|
async update(id: bigint, dto: UpdatePositionDto) {
|
||||||
@@ -55,7 +61,7 @@ export class PositionsService {
|
|||||||
if (dto.categoryId !== undefined && dto.categoryId !== null) {
|
if (dto.categoryId !== undefined && dto.categoryId !== null) {
|
||||||
await this.ensureCategory(dto.categoryId);
|
await this.ensureCategory(dto.categoryId);
|
||||||
}
|
}
|
||||||
return this.prisma.position.update({
|
const updated = await this.prisma.position.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: {
|
data: {
|
||||||
indexVal: dto.indexVal,
|
indexVal: dto.indexVal,
|
||||||
@@ -74,11 +80,15 @@ export class PositionsService {
|
|||||||
},
|
},
|
||||||
include: { country: true, category: true },
|
include: { country: true, category: true },
|
||||||
});
|
});
|
||||||
|
this.publicCache.bump('goods');
|
||||||
|
return updated;
|
||||||
}
|
}
|
||||||
|
|
||||||
async remove(id: bigint) {
|
async remove(id: bigint) {
|
||||||
await this.findOne(id);
|
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) {
|
private async ensureCountry(id: number) {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { PublicCacheService } from '../public/public-cache.service';
|
||||||
import { Test } from '@nestjs/testing';
|
import { Test } from '@nestjs/testing';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import {
|
import {
|
||||||
@@ -103,13 +104,14 @@ describe('FamilyRecomputeService', () => {
|
|||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
const moduleRef = await Test.createTestingModule({
|
const moduleRef = await Test.createTestingModule({
|
||||||
providers: [FamilyRecomputeService, PrismaService],
|
providers: [FamilyRecomputeService, PrismaService, PublicCacheService],
|
||||||
}).compile();
|
}).compile();
|
||||||
service = moduleRef.get(FamilyRecomputeService);
|
service = moduleRef.get(FamilyRecomputeService);
|
||||||
organize = new OrganizeService(
|
organize = new OrganizeService(
|
||||||
moduleRef.get(PrismaService),
|
moduleRef.get(PrismaService),
|
||||||
service,
|
service,
|
||||||
new ProductFamiliesService(moduleRef.get(PrismaService), service),
|
new ProductFamiliesService(moduleRef.get(PrismaService), service),
|
||||||
|
moduleRef.get(PublicCacheService),
|
||||||
);
|
);
|
||||||
prisma = moduleRef.get(PrismaService);
|
prisma = moduleRef.get(PrismaService);
|
||||||
await prisma.onModuleInit();
|
await prisma.onModuleInit();
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { PublicCacheService } from '../public/public-cache.service';
|
||||||
import { isAutoTagGroupName } from './auto-tag-rules';
|
import { isAutoTagGroupName } from './auto-tag-rules';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -326,7 +327,10 @@ export class FamilyRecomputeService {
|
|||||||
private readonly logger = new Logger(FamilyRecomputeService.name);
|
private readonly logger = new Logger(FamilyRecomputeService.name);
|
||||||
private readonly pending = new Map<string, Promise<void>>();
|
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 {
|
enqueue(familyId: bigint): void {
|
||||||
@@ -405,7 +409,9 @@ export class FamilyRecomputeService {
|
|||||||
data: { stale: true },
|
data: { stale: true },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// 族物化 JSON(矩阵/尺码表)与成员口径变化 → public 列表价/详情族块失效。
|
||||||
|
// 人工接管族(autoManaged=false)虽未重写矩阵,但其成员/详情已变,goods 域同样失效。
|
||||||
|
this.publicCache.bump('goods', 'matrix');
|
||||||
}
|
}
|
||||||
/** 把链接的有效标签镜像到其名下商品(good 标签 = 链接标签 ∪ 非自动组既有标签) */
|
/** 把链接的有效标签镜像到其名下商品(good 标签 = 链接标签 ∪ 非自动组既有标签) */
|
||||||
async mirrorLinkTagsToGoods(ogId: bigint): Promise<void> {
|
async mirrorLinkTagsToGoods(ogId: bigint): Promise<void> {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { PublicCacheService } from '../public/public-cache.service';
|
||||||
import { Test } from '@nestjs/testing';
|
import { Test } from '@nestjs/testing';
|
||||||
import { FamilyRecomputeService } from './family-recompute.service';
|
import { FamilyRecomputeService } from './family-recompute.service';
|
||||||
import { ProductFamiliesService } from './product-families.service';
|
import { ProductFamiliesService } from './product-families.service';
|
||||||
@@ -29,7 +30,7 @@ describe('链接级标签:派生 / 人工接管 / 商品镜像', () => {
|
|||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
const moduleRef = await Test.createTestingModule({
|
const moduleRef = await Test.createTestingModule({
|
||||||
providers: [FamilyRecomputeService, ProductFamiliesService, OrganizeService, OriginGoodsService, PrismaService],
|
providers: [FamilyRecomputeService, ProductFamiliesService, OrganizeService, OriginGoodsService, PrismaService, PublicCacheService],
|
||||||
}).compile();
|
}).compile();
|
||||||
recompute = moduleRef.get(FamilyRecomputeService);
|
recompute = moduleRef.get(FamilyRecomputeService);
|
||||||
organize = moduleRef.get(OrganizeService);
|
organize = moduleRef.get(OrganizeService);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common';
|
|||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { FamilyRecomputeService } from './family-recompute.service';
|
import { FamilyRecomputeService } from './family-recompute.service';
|
||||||
import { ProductFamiliesService } from './product-families.service';
|
import { ProductFamiliesService } from './product-families.service';
|
||||||
|
import { PublicCacheService } from '../public/public-cache.service';
|
||||||
import { parseOriginName } from './origin-name.parser';
|
import { parseOriginName } from './origin-name.parser';
|
||||||
import {
|
import {
|
||||||
DERIVED_TAG_GROUP_SPECS,
|
DERIVED_TAG_GROUP_SPECS,
|
||||||
@@ -28,6 +29,7 @@ export class OrganizeService {
|
|||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly recompute: FamilyRecomputeService,
|
private readonly recompute: FamilyRecomputeService,
|
||||||
private readonly families: ProductFamiliesService,
|
private readonly families: ProductFamiliesService,
|
||||||
|
private readonly publicCache: PublicCacheService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async organize() {
|
async organize() {
|
||||||
@@ -52,6 +54,9 @@ export class OrganizeService {
|
|||||||
familiesRecomputed: families.length,
|
familiesRecomputed: families.length,
|
||||||
};
|
};
|
||||||
this.logger.log(`organize done: ${JSON.stringify(result)}`);
|
this.logger.log(`organize done: ${JSON.stringify(result)}`);
|
||||||
|
// 整理是全量标签/归族/矩阵重写动作,三域全失效(末尾逐族重算只覆盖 goods+matrix,
|
||||||
|
// 标签镜像与标签组创建落在 meta 域)
|
||||||
|
this.publicCache.bump('meta', 'goods', 'matrix');
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,6 +195,8 @@ export class OrganizeService {
|
|||||||
await this.prisma.originGood.update({ where: { id: og.id }, data: next });
|
await this.prisma.originGood.update({ where: { id: og.id }, data: next });
|
||||||
parsed += 1;
|
parsed += 1;
|
||||||
}
|
}
|
||||||
|
// 结构化标签列是 CUSTOM 成员的矩阵归因来源(下次重算生效),保守失效 matrix+goods
|
||||||
|
this.publicCache.bump('goods', 'matrix');
|
||||||
return { parsed, unparsable };
|
return { parsed, unparsable };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -220,6 +227,8 @@ export class OrganizeService {
|
|||||||
for (const fid of touchedFamilies) {
|
for (const fid of touchedFamilies) {
|
||||||
await this.recompute.recomputeFamily(fid);
|
await this.recompute.recomputeFamily(fid);
|
||||||
}
|
}
|
||||||
|
// 标签镜像(good_tags)落 meta 域;散链接无族不会被重算覆盖,入口统一失效
|
||||||
|
this.publicCache.bump('meta', 'goods', 'matrix');
|
||||||
return { linksUpdated, goodsUpdated, familiesRecomputed: touchedFamilies.length };
|
return { linksUpdated, goodsUpdated, familiesRecomputed: touchedFamilies.length };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -339,6 +348,7 @@ export class OrganizeService {
|
|||||||
]);
|
]);
|
||||||
goodsUpdated += 1;
|
goodsUpdated += 1;
|
||||||
}
|
}
|
||||||
|
this.publicCache.bump('meta', 'goods');
|
||||||
return { goodsUpdated, linksUpdated };
|
return { goodsUpdated, linksUpdated };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -375,6 +385,7 @@ export class OrganizeService {
|
|||||||
linksUpdated = 1;
|
linksUpdated = 1;
|
||||||
}
|
}
|
||||||
await this.recompute.mirrorLinkTagsToGoods(og.id);
|
await this.recompute.mirrorLinkTagsToGoods(og.id);
|
||||||
|
this.publicCache.bump('meta', 'goods');
|
||||||
return { linksUpdated };
|
return { linksUpdated };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { PublicCacheService } from '../public/public-cache.service';
|
||||||
import { Test } from '@nestjs/testing';
|
import { Test } from '@nestjs/testing';
|
||||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
@@ -28,13 +29,14 @@ describe('ProductFamiliesService', () => {
|
|||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
const moduleRef = await Test.createTestingModule({
|
const moduleRef = await Test.createTestingModule({
|
||||||
providers: [ProductFamiliesService, FamilyRecomputeService, PrismaService],
|
providers: [ProductFamiliesService, FamilyRecomputeService, PrismaService, PublicCacheService],
|
||||||
}).compile();
|
}).compile();
|
||||||
service = moduleRef.get(ProductFamiliesService);
|
service = moduleRef.get(ProductFamiliesService);
|
||||||
organize = new OrganizeService(
|
organize = new OrganizeService(
|
||||||
moduleRef.get(PrismaService),
|
moduleRef.get(PrismaService),
|
||||||
moduleRef.get(FamilyRecomputeService),
|
moduleRef.get(FamilyRecomputeService),
|
||||||
service,
|
service,
|
||||||
|
moduleRef.get(PublicCacheService),
|
||||||
);
|
);
|
||||||
prisma = moduleRef.get(PrismaService);
|
prisma = moduleRef.get(PrismaService);
|
||||||
await prisma.onModuleInit();
|
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 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<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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 { Test } from '@nestjs/testing';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { PublicService } from './public.service';
|
import { PublicService } from './public.service';
|
||||||
@@ -24,8 +25,11 @@ describe('PublicService family block (PUBLIC_DETAIL_FROM_FAMILY)', () => {
|
|||||||
let familyId = 0n;
|
let familyId = 0n;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
|
// 本套件测数据语义(裸 prisma 改数后立即读公开端点),不走带 bump 的写路径——
|
||||||
|
// 禁用 public 缓存;缓存命中/失效语义由 public-cache.invalidation.spec.ts 覆盖
|
||||||
|
process.env.PUBLIC_CACHE_DISABLED = 'true';
|
||||||
const moduleRef = await Test.createTestingModule({
|
const moduleRef = await Test.createTestingModule({
|
||||||
providers: [PublicService, PrismaService],
|
providers: [PublicService, PrismaService, PublicCacheService],
|
||||||
}).compile();
|
}).compile();
|
||||||
service = moduleRef.get(PublicService);
|
service = moduleRef.get(PublicService);
|
||||||
prisma = moduleRef.get(PrismaService);
|
prisma = moduleRef.get(PrismaService);
|
||||||
@@ -71,11 +75,12 @@ describe('PublicService family block (PUBLIC_DETAIL_FROM_FAMILY)', () => {
|
|||||||
createdFamilyIds.push(family.id);
|
createdFamilyIds.push(family.id);
|
||||||
await prisma.originGood.update({ where: { id: og.id }, data: { familyId: 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(
|
const organizeSvc = new OrganizeService(
|
||||||
prisma,
|
prisma,
|
||||||
recomputeSvc,
|
recomputeSvc,
|
||||||
new ProductFamiliesService(prisma, recomputeSvc),
|
new ProductFamiliesService(prisma, recomputeSvc),
|
||||||
|
new PublicCacheService(),
|
||||||
);
|
);
|
||||||
await organizeSvc.deriveFamilyTags(family.id);
|
await organizeSvc.deriveFamilyTags(family.id);
|
||||||
await recomputeSvc.recomputeFamily(family.id);
|
await recomputeSvc.recomputeFamily(family.id);
|
||||||
@@ -196,13 +201,14 @@ describe('Good familyId derivation', () => {
|
|||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
const moduleRef = await Test.createTestingModule({
|
const moduleRef = await Test.createTestingModule({
|
||||||
providers: [PrismaService],
|
providers: [PrismaService, PublicCacheService],
|
||||||
}).compile();
|
}).compile();
|
||||||
prisma = moduleRef.get(PrismaService);
|
prisma = moduleRef.get(PrismaService);
|
||||||
await prisma.onModuleInit();
|
await prisma.onModuleInit();
|
||||||
familiesService = new (require('../product-families/product-families.service').ProductFamiliesService)(
|
familiesService = new (require('../product-families/product-families.service').ProductFamiliesService)(
|
||||||
prisma,
|
prisma,
|
||||||
new FamilyRecomputeService(prisma),
|
new FamilyRecomputeService(prisma, new PublicCacheService()),
|
||||||
|
new PublicCacheService(),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { PublicCacheService } from './public-cache.service';
|
||||||
import { Test } from '@nestjs/testing';
|
import { Test } from '@nestjs/testing';
|
||||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||||
import { PublicService } from './public.service';
|
import { PublicService } from './public.service';
|
||||||
@@ -20,8 +21,11 @@ describe('PublicService', () => {
|
|||||||
let goodIds: bigint[] = [];
|
let goodIds: bigint[] = [];
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
|
// 本套件测数据语义(裸 prisma 改数后立即读公开端点),不走带 bump 的写路径——
|
||||||
|
// 禁用 public 缓存;缓存命中/失效语义由 public-cache.invalidation.spec.ts 覆盖
|
||||||
|
process.env.PUBLIC_CACHE_DISABLED = 'true';
|
||||||
const moduleRef = await Test.createTestingModule({
|
const moduleRef = await Test.createTestingModule({
|
||||||
providers: [PublicService, PrismaService],
|
providers: [PublicService, PrismaService, PublicCacheService],
|
||||||
}).compile();
|
}).compile();
|
||||||
service = moduleRef.get(PublicService);
|
service = moduleRef.get(PublicService);
|
||||||
prisma = moduleRef.get(PrismaService);
|
prisma = moduleRef.get(PrismaService);
|
||||||
@@ -188,7 +192,7 @@ describe('PublicService', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
// 物化族矩阵(公开详情 family 块依赖 priceMatrix 已重算)
|
// 物化族矩阵(公开详情 family 块依赖 priceMatrix 已重算)
|
||||||
await new FamilyRecomputeService(prisma).recomputeFamily(family.id);
|
await new FamilyRecomputeService(prisma, new PublicCacheService()).recomputeFamily(family.id);
|
||||||
|
|
||||||
// Seed a good in `otherCategory` so the "onlyHaveGoods" filter
|
// Seed a good in `otherCategory` so the "onlyHaveGoods" filter
|
||||||
// returns more than one category.
|
// returns more than one category.
|
||||||
@@ -406,7 +410,7 @@ describe('PublicService', () => {
|
|||||||
expect(before.items).toHaveLength(1);
|
expect(before.items).toHaveLength(1);
|
||||||
expect(before.items[0].price).toBe('50');
|
expect(before.items[0].price).toBe('50');
|
||||||
|
|
||||||
await new FamilyRecomputeService(prisma).recomputeFamily(family.id);
|
await new FamilyRecomputeService(prisma, new PublicCacheService()).recomputeFamily(family.id);
|
||||||
const after = await service.getGoods({
|
const after = await service.getGoods({
|
||||||
page: 1,
|
page: 1,
|
||||||
pageSize: 50,
|
pageSize: 50,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { Category as PrismaCategory, Prisma } from '@prisma/client';
|
import { Category as PrismaCategory, Prisma } from '@prisma/client';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { PublicCacheService } from './public-cache.service';
|
||||||
import {
|
import {
|
||||||
PublicHomeGoodsQueryDto,
|
PublicHomeGoodsQueryDto,
|
||||||
PublicQueryGoodDto,
|
PublicQueryGoodDto,
|
||||||
@@ -86,9 +87,28 @@ interface TreeOrderMeta {
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PublicService {
|
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<PublicCategoryNodeDto[]> {
|
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 = {
|
const goodsWhere: Prisma.GoodWhereInput = {
|
||||||
familyId: { not: null },
|
familyId: { not: null },
|
||||||
originGood: { delisted: false },
|
originGood: { delisted: false },
|
||||||
@@ -133,6 +153,10 @@ export class PublicService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getCountries(): Promise<PublicCountryDto[]> {
|
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({
|
const rows = await this.prisma.country.findMany({
|
||||||
where: { goods: { some: { familyId: { not: null }, originGood: { delisted: false } } } },
|
where: { goods: { some: { familyId: { not: null }, originGood: { delisted: false } } } },
|
||||||
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
|
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
|
||||||
@@ -141,6 +165,10 @@ export class PublicService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getTags(): Promise<PublicTagDto[]> {
|
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({
|
const rows = await this.prisma.tag.findMany({
|
||||||
where: {
|
where: {
|
||||||
goodTags: {
|
goodTags: {
|
||||||
@@ -158,6 +186,12 @@ export class PublicService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getTagGroups(countryId?: string): Promise<PublicTagGroupFilterDto[]> {
|
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 = {
|
const goodWhere: Prisma.GoodWhereInput = {
|
||||||
familyId: { not: null },
|
familyId: { not: null },
|
||||||
originGood: { delisted: false },
|
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> {
|
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 = {
|
const where: Prisma.GoodWhereInput = {
|
||||||
familyId: { not: null },
|
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),
|
query.sort === 'PRICE_ASC' ? num(a.price) - num(b.price) : num(b.price) - num(a.price),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const total = items.length;
|
return { items, 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,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 款序元数据:countries.sort_order(一级)+ 新树二/三级 categories.sort_order
|
* 款序元数据:countries.sort_order(一级)+ 新树二/三级 categories.sort_order
|
||||||
* (款顺序,回填自排序表)。key 用 origin_goods.sds_category_id 关联商品→款。
|
* (款顺序,回填自排序表)。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([
|
const [countries, leaves] = await Promise.all([
|
||||||
this.prisma.country.findMany({ select: { id: true, sortOrder: true } }),
|
this.prisma.country.findMany({ select: { id: true, sortOrder: true } }),
|
||||||
this.prisma.$queryRaw<
|
this.prisma.$queryRaw<
|
||||||
@@ -331,7 +399,11 @@ export class PublicService {
|
|||||||
* 会让每个商品都携带整份矩阵(实测全量 ~330ms);PG 端展开聚合只回传
|
* 会让每个商品都携带整份矩阵(实测全量 ~330ms);PG 端展开聚合只回传
|
||||||
* 每族一个数字。非数字/缺失 price 的行跳过,与旧内存版过滤语义一致。
|
* 每族一个数字。非数字/缺失 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<
|
const rows = await this.prisma.$queryRaw<
|
||||||
Array<{ family_id: bigint | string; min_price: Prisma.Decimal | null }>
|
Array<{ family_id: bigint | string; min_price: Prisma.Decimal | null }>
|
||||||
>`
|
>`
|
||||||
@@ -365,6 +437,12 @@ export class PublicService {
|
|||||||
|
|
||||||
/** 族视角详情:代表 Good 提供公共字段(名称/主图/国家/分类),变体取全体成员并集 */
|
/** 族视角详情:代表 Good 提供公共字段(名称/主图/国家/分类),变体取全体成员并集 */
|
||||||
private async getGoodByFamilyId(familyId: bigint): Promise<PublicGoodDetailDto | null> {
|
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([
|
const [family, goods] = await Promise.all([
|
||||||
this.prisma.productFamily.findUnique({ where: { id: familyId } }),
|
this.prisma.productFamily.findUnique({ where: { id: familyId } }),
|
||||||
this.prisma.good.findMany({
|
this.prisma.good.findMany({
|
||||||
@@ -405,6 +483,14 @@ export class PublicService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getHomeGoods(query: PublicHomeGoodsQueryDto): Promise<PublicGoodDto[]> {
|
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({
|
const rows = await this.prisma.good.findMany({
|
||||||
where: {
|
where: {
|
||||||
positionId: { not: null },
|
positionId: { not: null },
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { PublicCacheService } from '../public/public-cache.service';
|
||||||
import { Test } from '@nestjs/testing';
|
import { Test } from '@nestjs/testing';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import { SyncService } from './sync.service';
|
import { SyncService } from './sync.service';
|
||||||
@@ -27,6 +28,7 @@ describe('SyncService family hooks', () => {
|
|||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
const moduleRef = await Test.createTestingModule({
|
const moduleRef = await Test.createTestingModule({
|
||||||
providers: [
|
providers: [
|
||||||
|
PublicCacheService,
|
||||||
SyncService,
|
SyncService,
|
||||||
{
|
{
|
||||||
provide: SdsClientService,
|
provide: SdsClientService,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { PublicCacheService } from '../public/public-cache.service';
|
||||||
import { Test } from '@nestjs/testing';
|
import { Test } from '@nestjs/testing';
|
||||||
import { ConfigModule } from '@nestjs/config';
|
import { ConfigModule } from '@nestjs/config';
|
||||||
import {
|
import {
|
||||||
@@ -25,6 +26,7 @@ describe('SyncService', () => {
|
|||||||
const moduleRef = await Test.createTestingModule({
|
const moduleRef = await Test.createTestingModule({
|
||||||
imports: [ConfigModule.forRoot({ isGlobal: true })],
|
imports: [ConfigModule.forRoot({ isGlobal: true })],
|
||||||
providers: [
|
providers: [
|
||||||
|
PublicCacheService,
|
||||||
SyncService,
|
SyncService,
|
||||||
{ provide: SdsClientService, useValue: sdsMock },
|
{ provide: SdsClientService, useValue: sdsMock },
|
||||||
{ provide: FamilyRecomputeService, useValue: { enqueue: jest.fn() } },
|
{ provide: FamilyRecomputeService, useValue: { enqueue: jest.fn() } },
|
||||||
@@ -295,7 +297,7 @@ describe('SyncService product detail scopes', () => {
|
|||||||
const familyRecompute = {
|
const familyRecompute = {
|
||||||
enqueue: jest.fn(),
|
enqueue: jest.fn(),
|
||||||
} as unknown as FamilyRecomputeService;
|
} as unknown as FamilyRecomputeService;
|
||||||
const scopedService = new SyncService(prisma, sds, familyRecompute);
|
const scopedService = new SyncService(prisma, sds, familyRecompute, new PublicCacheService());
|
||||||
jest
|
jest
|
||||||
.spyOn(scopedService as any, 'persistProductDetail')
|
.spyOn(scopedService as any, 'persistProductDetail')
|
||||||
.mockResolvedValue(undefined);
|
.mockResolvedValue(undefined);
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
} from './sds-client.service';
|
} from './sds-client.service';
|
||||||
import { normalizeProductDetail } from './sds-product-detail.mapper';
|
import { normalizeProductDetail } from './sds-product-detail.mapper';
|
||||||
import { FamilyRecomputeService } from '../product-families/family-recompute.service';
|
import { FamilyRecomputeService } from '../product-families/family-recompute.service';
|
||||||
|
import { PublicCacheService } from '../public/public-cache.service';
|
||||||
|
|
||||||
|
|
||||||
export interface CategorySyncResult {
|
export interface CategorySyncResult {
|
||||||
@@ -96,6 +97,7 @@ export class SyncService {
|
|||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly sds: SdsClientService,
|
private readonly sds: SdsClientService,
|
||||||
private readonly familyRecompute: FamilyRecomputeService,
|
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}`,
|
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 };
|
return { inserted, updated, total: flat.length, deletedStale };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const message = err instanceof Error ? err.message : String(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}`,
|
message: `inserted=${inserted} updated=${updated} total=${total} delisted=${delistedCount} reactivated=${reactivatedCount} leafCategories=${leafRows.length}`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
// 链接镜像(名称/图/价/上下架)变化 → public 商品列表/详情失效
|
||||||
|
this.publicCache.bump('goods');
|
||||||
return {
|
return {
|
||||||
inserted,
|
inserted,
|
||||||
updated,
|
updated,
|
||||||
@@ -677,6 +683,8 @@ export class SyncService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
// 详情/变体/价格落库 → public 详情缓存失效;族矩阵变化由重算钩子 bump matrix
|
||||||
|
this.publicCache.bump('goods');
|
||||||
// 族成员的详情/变体变化 → 异步重算该族(进程内去重)
|
// 族成员的详情/变体变化 → 异步重算该族(进程内去重)
|
||||||
await this.maybeEnqueueFamilyRecompute(originGoodId);
|
await this.maybeEnqueueFamilyRecompute(originGoodId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { PublicCacheService } from '../public/public-cache.service';
|
||||||
import { Test } from '@nestjs/testing';
|
import { Test } from '@nestjs/testing';
|
||||||
import { ConflictException, NotFoundException } from '@nestjs/common';
|
import { ConflictException, NotFoundException } from '@nestjs/common';
|
||||||
import { TagGroupsService } from './tag-groups.service';
|
import { TagGroupsService } from './tag-groups.service';
|
||||||
@@ -10,7 +11,7 @@ describe('TagGroupsService', () => {
|
|||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
const moduleRef = await Test.createTestingModule({
|
const moduleRef = await Test.createTestingModule({
|
||||||
providers: [TagGroupsService, PrismaService],
|
providers: [TagGroupsService, PrismaService, PublicCacheService],
|
||||||
}).compile();
|
}).compile();
|
||||||
service = moduleRef.get(TagGroupsService);
|
service = moduleRef.get(TagGroupsService);
|
||||||
prisma = moduleRef.get(PrismaService);
|
prisma = moduleRef.get(PrismaService);
|
||||||
|
|||||||
@@ -5,13 +5,17 @@ import {
|
|||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { PublicCacheService } from '../public/public-cache.service';
|
||||||
import { CreateTagGroupDto } from './dto/create-tag-group.dto';
|
import { CreateTagGroupDto } from './dto/create-tag-group.dto';
|
||||||
import { UpdateTagGroupDto } from './dto/update-tag-group.dto';
|
import { UpdateTagGroupDto } from './dto/update-tag-group.dto';
|
||||||
import { ReorderTagGroupsDto } from './dto/reorder-tag-groups.dto';
|
import { ReorderTagGroupsDto } from './dto/reorder-tag-groups.dto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TagGroupsService {
|
export class TagGroupsService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly publicCache: PublicCacheService,
|
||||||
|
) {}
|
||||||
|
|
||||||
findAll() {
|
findAll() {
|
||||||
return this.prisma.tagGroup.findMany({
|
return this.prisma.tagGroup.findMany({
|
||||||
@@ -31,7 +35,7 @@ export class TagGroupsService {
|
|||||||
|
|
||||||
async create(dto: CreateTagGroupDto) {
|
async create(dto: CreateTagGroupDto) {
|
||||||
try {
|
try {
|
||||||
return await this.prisma.tagGroup.create({
|
const created = await this.prisma.tagGroup.create({
|
||||||
data: {
|
data: {
|
||||||
groupName: dto.groupName,
|
groupName: dto.groupName,
|
||||||
groupIcon: dto.groupIcon ?? null,
|
groupIcon: dto.groupIcon ?? null,
|
||||||
@@ -39,6 +43,8 @@ export class TagGroupsService {
|
|||||||
sortOrder: dto.sortOrder ?? 0,
|
sortOrder: dto.sortOrder ?? 0,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
this.publicCache.bump('meta');
|
||||||
|
return created;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (
|
if (
|
||||||
err instanceof Prisma.PrismaClientKnownRequestError &&
|
err instanceof Prisma.PrismaClientKnownRequestError &&
|
||||||
@@ -58,7 +64,9 @@ export class TagGroupsService {
|
|||||||
if (dto.groupColor !== undefined) data.groupColor = dto.groupColor;
|
if (dto.groupColor !== undefined) data.groupColor = dto.groupColor;
|
||||||
if (dto.sortOrder !== undefined) data.sortOrder = dto.sortOrder;
|
if (dto.sortOrder !== undefined) data.sortOrder = dto.sortOrder;
|
||||||
try {
|
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) {
|
} catch (err) {
|
||||||
if (
|
if (
|
||||||
err instanceof Prisma.PrismaClientKnownRequestError &&
|
err instanceof Prisma.PrismaClientKnownRequestError &&
|
||||||
@@ -72,11 +80,13 @@ export class TagGroupsService {
|
|||||||
|
|
||||||
async remove(id: bigint) {
|
async remove(id: bigint) {
|
||||||
await this.findOne(id);
|
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) {
|
async reorder(dto: ReorderTagGroupsDto) {
|
||||||
return this.prisma.$transaction(
|
const result = await this.prisma.$transaction(
|
||||||
dto.items.map((item) =>
|
dto.items.map((item) =>
|
||||||
this.prisma.tagGroup.update({
|
this.prisma.tagGroup.update({
|
||||||
where: { id: BigInt(item.id) },
|
where: { id: BigInt(item.id) },
|
||||||
@@ -84,5 +94,7 @@ export class TagGroupsService {
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
this.publicCache.bump('meta');
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { PublicCacheService } from '../public/public-cache.service';
|
||||||
import { Test } from '@nestjs/testing';
|
import { Test } from '@nestjs/testing';
|
||||||
import { ConflictException } from '@nestjs/common';
|
import { ConflictException } from '@nestjs/common';
|
||||||
import { validate } from 'class-validator';
|
import { validate } from 'class-validator';
|
||||||
@@ -13,7 +14,7 @@ describe('TagsService', () => {
|
|||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
const moduleRef = await Test.createTestingModule({
|
const moduleRef = await Test.createTestingModule({
|
||||||
providers: [TagsService, PrismaService],
|
providers: [TagsService, PrismaService, PublicCacheService],
|
||||||
}).compile();
|
}).compile();
|
||||||
service = moduleRef.get(TagsService);
|
service = moduleRef.get(TagsService);
|
||||||
prisma = moduleRef.get(PrismaService);
|
prisma = moduleRef.get(PrismaService);
|
||||||
|
|||||||
@@ -5,13 +5,17 @@ import {
|
|||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { PublicCacheService } from '../public/public-cache.service';
|
||||||
import { CreateTagDto } from './dto/create-tag.dto';
|
import { CreateTagDto } from './dto/create-tag.dto';
|
||||||
import { UpdateTagDto } from './dto/update-tag.dto';
|
import { UpdateTagDto } from './dto/update-tag.dto';
|
||||||
import { ReorderTagsDto } from './dto/reorder-tags.dto';
|
import { ReorderTagsDto } from './dto/reorder-tags.dto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TagsService {
|
export class TagsService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly publicCache: PublicCacheService,
|
||||||
|
) {}
|
||||||
|
|
||||||
findAll() {
|
findAll() {
|
||||||
return this.prisma.tag.findMany({
|
return this.prisma.tag.findMany({
|
||||||
@@ -31,7 +35,7 @@ export class TagsService {
|
|||||||
|
|
||||||
async create(dto: CreateTagDto) {
|
async create(dto: CreateTagDto) {
|
||||||
try {
|
try {
|
||||||
return await this.prisma.tag.create({
|
const created = await this.prisma.tag.create({
|
||||||
data: {
|
data: {
|
||||||
tagName: dto.tagName,
|
tagName: dto.tagName,
|
||||||
tagColor: dto.tagColor ?? null,
|
tagColor: dto.tagColor ?? null,
|
||||||
@@ -41,6 +45,8 @@ export class TagsService {
|
|||||||
sortOrder: dto.sortOrder ?? 0,
|
sortOrder: dto.sortOrder ?? 0,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
this.publicCache.bump('meta');
|
||||||
|
return created;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (
|
if (
|
||||||
err instanceof Prisma.PrismaClientKnownRequestError &&
|
err instanceof Prisma.PrismaClientKnownRequestError &&
|
||||||
@@ -66,7 +72,9 @@ export class TagsService {
|
|||||||
}
|
}
|
||||||
if (dto.sortOrder !== undefined) data.sortOrder = dto.sortOrder;
|
if (dto.sortOrder !== undefined) data.sortOrder = dto.sortOrder;
|
||||||
try {
|
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) {
|
} catch (err) {
|
||||||
if (
|
if (
|
||||||
err instanceof Prisma.PrismaClientKnownRequestError &&
|
err instanceof Prisma.PrismaClientKnownRequestError &&
|
||||||
@@ -80,11 +88,13 @@ export class TagsService {
|
|||||||
|
|
||||||
async remove(id: bigint) {
|
async remove(id: bigint) {
|
||||||
await this.findOne(id);
|
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) {
|
async reorder(dto: ReorderTagsDto) {
|
||||||
return this.prisma.$transaction(
|
const result = await this.prisma.$transaction(
|
||||||
dto.items.map((item) =>
|
dto.items.map((item) =>
|
||||||
this.prisma.tag.update({
|
this.prisma.tag.update({
|
||||||
where: { id: BigInt(item.id) },
|
where: { id: BigInt(item.id) },
|
||||||
@@ -100,5 +110,7 @@ export class TagsService {
|
|||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
this.publicCache.bump('meta');
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user