Files
inkreach-official-website/apps/api/src/product-families/organize.service.ts
T
yeuimu ab79325ab0 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 例(读命中/写后立即可见
  端到端/每条写路径域断言);既有两套件测数据语义改为显式禁缓存
2026-09-03 03:40:20 +08:00

494 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { FamilyRecomputeService } from './family-recompute.service';
import { ProductFamiliesService } from './product-families.service';
import { PublicCacheService } from '../public/public-cache.service';
import { parseOriginName } from './origin-name.parser';
import {
DERIVED_TAG_GROUP_SPECS,
deriveLinkTagNames,
isAutoTagGroupName,
normalizeCraftTag,
} from './auto-tag-rules';
/**
* 整理服务(解析去运行时化的唯一解析入口):
* 所有"按上游链接名解析/派生"的逻辑集中于此,由显式人工动作触发——
* CLIpnpm --filter @inkreach/api organize)或后台「整理」按钮(POST /product-families/organize)。
*
* 运行时(同步/重算/公开读)永不解析名称:
* - 同步 = 纯镜像;族重算 = 结构化聚合(成员变体 + 链接标签 + 覆盖价)。
*
* 人工接管(tagsManual=true)的链接派生永不触碰;幂等可重跑。
*/
@Injectable()
export class OrganizeService {
private readonly logger = new Logger(OrganizeService.name);
constructor(
private readonly prisma: PrismaService,
private readonly recompute: FamilyRecomputeService,
private readonly families: ProductFamiliesService,
private readonly publicCache: PublicCacheService,
) {}
async organize() {
const labels = await this.backfillLabels();
const tags = await this.deriveAllTags();
const grouped = await this.families.autoGroup(true);
const fragments = await this.consolidateFragments();
const families = await this.prisma.productFamily.findMany({ select: { id: true } });
for (const f of families) {
await this.recompute.recomputeFamily(f.id);
}
const result = {
labelsParsed: labels.parsed,
labelsUnparsable: labels.unparsable,
linksUpdated: tags.linksUpdated,
goodsUpdated: tags.goodsUpdated,
familiesCreated: grouped.applied,
familiesMerged: grouped.merged,
familiesSkipped: grouped.skipped?.length ?? 0,
fragmentsConsolidated: fragments.consolidated,
fragmentsManualReview: fragments.manualReview.length,
familiesRecomputed: families.length,
};
this.logger.log(`organize done: ${JSON.stringify(result)}`);
// 整理是全量标签/归族/矩阵重写动作,三域全失效(末尾逐族重算只覆盖 goods+matrix
// 标签镜像与标签组创建落在 meta 域)
this.publicCache.bump('meta', 'goods', 'matrix');
return result;
}
/**
* 碎片族合并:同一 SDS 分类出现多个族时(历史 autoGroup 只建不并所致),
* 把「纯碎片族」(autoManaged、无商品、无覆盖价)的成员并入最老族并删除空族;
* 带商品/覆盖价或人工锁定的碎片族保留,写进人工复审报告(由后台手动移动成员)。
* keeper 选择:同簇中最早带商品的族,否则最老的 autoManaged 族。
*/
async consolidateFragments(): Promise<{
consolidated: number;
manualReview: Array<{ keeperFamilyId: string; fragmentFamilyId: string; reason: string }>;
}> {
const families = await this.prisma.productFamily.findMany({
select: { id: true, autoManaged: true },
orderBy: { id: 'asc' },
});
const members = await this.prisma.originGood.findMany({
where: { familyId: { not: null }, delisted: false },
select: { familyId: true, sdsCategoryId: true },
});
const goodCounts = await this.prisma.good.groupBy({
by: ['familyId'],
_count: { _all: true },
where: { familyId: { not: null } },
});
const goodsByFamily = new Map(goodCounts.map((g) => [g.familyId!.toString(), g._count._all]));
const overrideCounts = await this.prisma.familyPriceOverride.groupBy({
by: ['familyId'],
_count: { _all: true },
});
const overridesByFamily = new Map(
overrideCounts.map((o) => [o.familyId.toString(), o._count._all]),
);
const autoManagedById = new Map(families.map((f) => [f.id.toString(), f.autoManaged]));
const familyCatIds = new Map<string, Set<string>>();
for (const m of members) {
if (!m.sdsCategoryId) continue;
const key = m.familyId!.toString();
familyCatIds.set(key, (familyCatIds.get(key) ?? new Set()).add(m.sdsCategoryId));
}
// 按 SDS 分类聚簇:同分类的多个族互为碎片
const clusterByFamily = new Map<string, Set<string>>();
const catOwners = new Map<string, string[]>();
for (const f of families) {
const cats = familyCatIds.get(f.id.toString()) ?? new Set();
for (const cat of cats) {
const owners = catOwners.get(cat) ?? [];
owners.push(f.id.toString());
catOwners.set(cat, owners);
}
}
for (const owners of catOwners.values()) {
if (owners.length < 2) continue;
for (const id of owners) {
const cluster = clusterByFamily.get(id) ?? new Set();
owners.forEach((o) => cluster.add(o));
clusterByFamily.set(id, cluster);
}
}
let consolidated = 0;
const manualReview: Array<{ keeperFamilyId: string; fragmentFamilyId: string; reason: string }> = [];
const processedClusters = new Set<string>();
for (const [familyIdStr, cluster] of clusterByFamily) {
const sorted = [...cluster].sort((a, b) => Number(BigInt(a) - BigInt(b)));
const clusterKey = sorted.join(',');
if (processedClusters.has(clusterKey)) continue;
processedClusters.add(clusterKey);
// keeper:最早带商品的族,否则最老的 autoManaged 族
const withGoods = sorted.find((id) => (goodsByFamily.get(id) ?? 0) > 0);
const keeper =
withGoods ??
sorted.find((id) => autoManagedById.get(id) === true) ??
sorted[0];
for (const id of sorted) {
if (id === keeper) continue;
const reasons: string[] = [];
if ((goodsByFamily.get(id) ?? 0) > 0) reasons.push('has-goods');
if ((overridesByFamily.get(id) ?? 0) > 0) reasons.push('has-overrides');
if (autoManagedById.get(id) !== true) reasons.push('manual-locked');
if (reasons.length) {
manualReview.push({
keeperFamilyId: keeper,
fragmentFamilyId: id,
reason: reasons.join(','),
});
continue;
}
// 纯碎片:成员并入 keeper,删除空族
const keeperId = BigInt(keeper);
await this.prisma.originGood.updateMany({
where: { familyId: BigInt(id) },
data: { familyId: keeperId },
});
await this.prisma.good.updateMany({
where: { familyId: BigInt(id) },
data: { familyId: keeperId },
});
await this.prisma.productFamily.delete({ where: { id: BigInt(id) } });
consolidated += 1;
}
await this.recompute.recomputeFamily(BigInt(keeper));
}
return { consolidated, manualReview };
}
/**
* 回填结构化解析列(skuCode/logisticsLabel/craftLabel/warehouseLabel)。
* 只补 NULL 列——存量正确数据不覆盖(同步已不再写入这些列)。
*/
async backfillLabels(): Promise<{ parsed: number; unparsable: number }> {
const ogs = await this.prisma.originGood.findMany({
where: {
OR: [
{ skuCode: null },
{ logisticsLabel: null },
{ craftLabel: null },
{ warehouseLabel: null },
],
},
select: { id: true, goodName: true, skuCode: true, logisticsLabel: true, craftLabel: true, warehouseLabel: true },
});
let parsed = 0;
let unparsable = 0;
for (const og of ogs) {
const p = parseOriginName(og.goodName);
const next = {
skuCode: og.skuCode ?? p.skuCode,
logisticsLabel: og.logisticsLabel ?? p.logisticsLabel,
craftLabel: og.craftLabel ?? p.craftLabel,
warehouseLabel: og.warehouseLabel ?? p.warehouseLabel,
};
if (!p.skuCode && !p.craftLabel) unparsable += 1;
await this.prisma.originGood.update({ where: { id: og.id }, data: next });
parsed += 1;
}
// 结构化标签列是 CUSTOM 成员的矩阵归因来源(下次重算生效),保守失效 matrix+goods
this.publicCache.bump('goods', 'matrix');
return { parsed, unparsable };
}
/** 全量派生:未人工接管的 SDS 链接按名称刷新自动标签,人工接管不动;随后镜像商品并重算受影响族 */
async deriveAllTags(): Promise<{ linksUpdated: number; goodsUpdated: number; familiesRecomputed: number }> {
const families = await this.prisma.productFamily.findMany({
select: { id: true },
orderBy: { id: 'asc' },
});
let linksUpdated = 0;
let goodsUpdated = 0;
const touchedFamilies: bigint[] = [];
for (const f of families) {
const r = await this.deriveFamilyTags(f.id);
linksUpdated += r.linksUpdated;
goodsUpdated += r.goodsUpdated;
if (r.linksUpdated > 0 || r.goodsUpdated > 0) touchedFamilies.push(f.id);
}
// 散链接(无族)也派生并镜像,保证族化之前标签已就绪
const loose = await this.prisma.originGood.findMany({
where: { familyId: null, delisted: false, source: 'SDS', tagsManual: false },
select: { id: true },
});
for (const og of loose) {
const r = await this.deriveTagsForOg(og.id);
linksUpdated += r.linksUpdated;
}
for (const fid of touchedFamilies) {
await this.recompute.recomputeFamily(fid);
}
// 标签镜像(good_tags)落 meta 域;散链接无族不会被重算覆盖,入口统一失效
this.publicCache.bump('meta', 'goods', 'matrix');
return { linksUpdated, goodsUpdated, familiesRecomputed: touchedFamilies.length };
}
/**
* 族 → 标签派生(标签与「产品链接」一一对应):
* 1) 链接级:未人工接管(tagsManual=false)的 SDS 链接,按链接名称刷新
* origin_good_tags 的派生行(manual=false);人工行(manual=true)永远保留;
* 2) 商品级镜像:good 标签 = 自身链接的有效标签 ∪ 非自动组的既有标签。
* 仅更新发生变化的行,幂等。
*/
async deriveFamilyTags(
familyId: bigint,
): Promise<{ goodsUpdated: number; linksUpdated: number }> {
const tagMap = await this.ensureDerivedTagMap();
const family = await this.prisma.productFamily.findUnique({
where: { id: familyId },
include: {
originGoods: {
where: { delisted: false },
include: { originGoodTags: true },
},
},
});
if (!family) return { goodsUpdated: 0, linksUpdated: 0 };
let linksUpdated = 0;
for (const og of family.originGoods) {
if (og.tagsManual || og.source !== 'SDS') continue;
const derivedIds = this.resolveDerivedIds(og.goodName, tagMap);
const autoRows = og.originGoodTags.filter((r) => !r.manual);
const currentIds = autoRows.map((r) => r.tagId).sort((a, b) => Number(a - b));
const same =
derivedIds.length === currentIds.length &&
derivedIds.every((id, i) => id === currentIds[i]);
if (same) continue;
await this.prisma.$transaction([
this.prisma.originGoodTag.deleteMany({
where: { originGoodId: og.id, manual: false },
}),
...(!derivedIds.length
? []
: [
this.prisma.originGoodTag.createMany({
data: derivedIds.map((tagId) => ({
originGoodId: og.id,
tagId,
manual: false,
})),
}),
]),
]);
linksUpdated += 1;
}
// 商品级镜像:派生写入后重新读取链接标签(上面的 include 是派生前快照)
const familyOgIds = family.originGoods.map((o) => o.id);
const freshRows = familyOgIds.length
? await this.prisma.originGoodTag.findMany({
where: { originGoodId: { in: familyOgIds } },
})
: [];
const tagsByOg = new Map<string, bigint[]>();
for (const r of freshRows) {
const key = r.originGoodId.toString();
tagsByOg.set(key, [...(tagsByOg.get(key) ?? []), r.tagId]);
}
const goods = await this.prisma.good.findMany({
where: { familyId },
include: {
goodTags: { include: { tag: { select: { id: true, tagGroupId: true } } } },
originGood: { select: { id: true } },
},
});
const strayOgIds = [
...new Set(
goods
.map((g) => g.originGood?.id.toString())
.filter((id): id is string => !!id && !tagsByOg.has(id)),
),
];
if (strayOgIds.length) {
const rows = await this.prisma.originGoodTag.findMany({
where: { originGoodId: { in: strayOgIds.map((v) => BigInt(v)) } },
});
for (const r of rows) {
const key = r.originGoodId.toString();
tagsByOg.set(key, [...(tagsByOg.get(key) ?? []), r.tagId]);
}
}
const { autoGroupIds } = await this.autoGroupIds();
let goodsUpdated = 0;
for (const good of goods) {
const linkTagIds = good.originGood
? (tagsByOg.get(good.originGood.id.toString()) ?? [])
: [];
const keep = good.goodTags
.filter((gt) => !autoGroupIds.has(gt.tag.tagGroupId?.toString() ?? ''))
.map((gt) => gt.tagId);
const target = [...new Set([...keep, ...linkTagIds])].sort((a, b) =>
Number(a - b),
);
const current = good.goodTags.map((gt) => gt.tagId).sort((a, b) => Number(a - b));
const same =
target.length === current.length && target.every((id, i) => id === current[i]);
if (same) continue;
await this.prisma.$transaction([
this.prisma.goodTag.deleteMany({ where: { goodId: good.id } }),
...(!target.length
? []
: [
this.prisma.goodTag.createMany({
data: target.map((tagId) => ({ goodId: good.id, tagId })),
}),
]),
]);
goodsUpdated += 1;
}
this.publicCache.bump('meta', 'goods');
return { goodsUpdated, linksUpdated };
}
/**
* 单链接派生(供「恢复自动」等显式人工动作复用):
* 未人工接管时按名称刷新派生标签,并把有效标签镜像到其名下商品。
*/
async deriveTagsForOg(ogId: bigint): Promise<{ linksUpdated: number }> {
const og = await this.prisma.originGood.findUnique({
where: { id: ogId },
include: { originGoodTags: true },
});
if (!og) return { linksUpdated: 0 };
let linksUpdated = 0;
if (!og.tagsManual && og.source === 'SDS') {
const tagMap = await this.ensureDerivedTagMap();
const derivedIds = this.resolveDerivedIds(og.goodName, tagMap);
await this.prisma.$transaction([
this.prisma.originGoodTag.deleteMany({
where: { originGoodId: og.id, manual: false },
}),
...(!derivedIds.length
? []
: [
this.prisma.originGoodTag.createMany({
data: derivedIds.map((tagId) => ({
originGoodId: og.id,
tagId,
manual: false,
})),
}),
]),
]);
linksUpdated = 1;
}
await this.recompute.mirrorLinkTagsToGoods(og.id);
this.publicCache.bump('meta', 'goods');
return { linksUpdated };
}
/**
* 人工标签缺维补齐:三个定价维度组(印花数量/工艺/物流)任一组在当前标签里
* 没有封闭词表取值时,按链接名称派生补上;工艺维度经别名归一后判断(热转印≈烫画)。
* 人工已勾的维度永不覆盖;名称派生不出该维度时不补(由调用方提示)。
*/
async fillMissingDimTags(
goodName: string | null,
currentTagIds: bigint[],
): Promise<{ tagIds: bigint[]; names: string[] }> {
const tagMap = await this.ensureDerivedTagMap();
const current = currentTagIds.length
? await this.prisma.tag.findMany({
where: { id: { in: currentTagIds } },
select: { tagName: true },
})
: [];
const currentNames = new Set(current.map((t) => normalizeCraftTag(t.tagName)));
const derivedNames = deriveLinkTagNames(goodName);
const missingNames: string[] = [];
for (const spec of DERIVED_TAG_GROUP_SPECS) {
if (spec.tags.some((name) => currentNames.has(name))) continue;
const fromName = spec.tags.find((name) => derivedNames.includes(name));
if (fromName) missingNames.push(fromName);
}
const tagIds = missingNames
.map((name) => tagMap.get(name))
.filter((id): id is bigint => id !== undefined);
return { tagIds, names: missingNames };
}
private async autoGroupIds(): Promise<{ autoGroupIds: Set<string> }> {
const groups = await this.prisma.tagGroup.findMany({
select: { id: true, groupName: true },
});
return {
autoGroupIds: new Set(
groups
.filter((g) => isAutoTagGroupName(g.groupName))
.map((g) => g.id.toString()),
),
};
}
private resolveDerivedIds(
name: string | null | undefined,
tagMap: Map<string, bigint>,
): bigint[] {
return [
...new Set(
deriveLinkTagNames(name)
.map((n) => tagMap.get(n))
.filter((id): id is bigint => id !== undefined),
),
].sort((a, b) => Number(a - b));
}
/** 确保派生标签组与标签存在,返回「标签名 → 标签 id」映射(并发下取最小 id,天然去重) */
private async ensureDerivedTagMap(): Promise<Map<string, bigint>> {
const map = new Map<string, bigint>();
for (const spec of DERIVED_TAG_GROUP_SPECS) {
const findGroups = () =>
this.prisma.tagGroup.findMany({
where: { groupName: { contains: spec.group } },
orderBy: { id: 'asc' },
});
let group = (await findGroups())[0] ?? null;
if (!group) {
try {
group = await this.prisma.tagGroup.create({ data: { groupName: spec.group } });
} catch {
group = (await findGroups())[0] ?? null;
}
}
if (!group) continue;
const tags = await this.prisma.tag.findMany({
where: { tagGroupId: group.id, tagName: { in: spec.tags } },
orderBy: { id: 'asc' },
});
for (const tagName of spec.tags) {
const existing = tags.find((t) => t.tagName === tagName);
if (existing) {
map.set(tagName, existing.id);
continue;
}
try {
const created = await this.prisma.tag.create({
data: { tagGroupId: group.id, tagName },
});
map.set(tagName, created.id);
} catch {
const fallback = await this.prisma.tag.findFirst({
where: { tagGroupId: group.id, tagName },
orderBy: { id: 'asc' },
});
if (fallback) map.set(tagName, fallback.id);
}
}
}
return map;
}
}