import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { dirname } from 'node:path'; import type { DomesticDataset } from '../../types/domestic-dataset.js'; import type { ProductKnowledgePage, ProductKnowledgeRecord, ProductKnowledgeRole, ProductKnowledgeStatus, ProductKnowledgeStore, } from './product-knowledge.store.js'; interface LocalKnowledgeFile { schemaVersion: 1; items: ProductKnowledgeRecord[]; } export class LocalProductKnowledgeStore implements ProductKnowledgeStore { private readonly records = new Map(); constructor( dataset: DomesticDataset, private readonly workspaceId: string, initialItems: ProductKnowledgeRecord[] = [], private readonly persistencePath = '', private readonly now: () => Date = () => new Date(), ) { for (const item of initialItems) { if (item.workspaceId === workspaceId) this.records.set(item.productKey, { ...item, tags: [...item.tags] }); } if (!this.records.size) this.seedTopProducts(dataset); } static async open(input: { dataset: DomesticDataset; workspaceId: string; persistencePath: string; }): Promise { let items: ProductKnowledgeRecord[] = []; try { const parsed = JSON.parse(await readFile(input.persistencePath, 'utf8')) as Partial; if (parsed.schemaVersion === 1 && Array.isArray(parsed.items)) items = parsed.items; } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; } const store = new LocalProductKnowledgeStore( input.dataset, input.workspaceId, items, input.persistencePath, ); await store.persist(); return store; } async list(input: { workspaceId: string; limit: number; cursor: string | null; status?: ProductKnowledgeStatus; productRole?: ProductKnowledgeRole; featured?: boolean; }): Promise { if (input.workspaceId !== this.workspaceId) return { items: [], nextCursor: null }; const items = [...this.records.values()] .filter((item) => !input.status || item.status === input.status) .filter((item) => !input.productRole || item.productRole === input.productRole) .filter((item) => input.featured === undefined || item.featured === input.featured) .sort((left, right) => left.productKey.localeCompare(right.productKey)); const cursorIndex = input.cursor ? items.findIndex((item) => item.productKey === input.cursor) : -1; const start = cursorIndex + 1; const page = items.slice(start, start + input.limit + 1); const hasMore = page.length > input.limit; const visible = page.slice(0, input.limit).map((item) => ({ ...item, tags: [...item.tags] })); return { items: visible, nextCursor: hasMore ? visible.at(-1)?.productKey ?? null : null, }; } async upsert(input: { workspaceId: string; productKey: string; productId: string; productRole: ProductKnowledgeRole; featured: boolean; status: ProductKnowledgeStatus; tags: string[]; note: string; ownerUserId: string; actorUserId: string; }): Promise { const existing = this.records.get(input.productKey); const timestamp = this.now().toISOString(); const item: ProductKnowledgeRecord = { id: existing?.id ?? `local-${input.productKey.replace(/[^a-zA-Z0-9_-]/g, '-')}`, workspaceId: input.workspaceId, productKey: input.productKey, productId: input.productId, productRole: input.productRole, featured: input.featured, status: input.status, tags: [...new Set(input.tags.map((tag) => tag.trim()).filter(Boolean))], note: input.note.trim(), ownerUserId: input.ownerUserId.trim(), createdBy: existing?.createdBy ?? input.actorUserId, updatedBy: input.actorUserId, createdAt: existing?.createdAt ?? timestamp, updatedAt: timestamp, }; this.records.set(input.productKey, item); await this.persist(); return { ...item, tags: [...item.tags] }; } async archive(workspaceId: string, productKey: string, actorUserId: string): Promise { if (workspaceId !== this.workspaceId) return null; const existing = this.records.get(productKey); if (!existing) return null; const item: ProductKnowledgeRecord = { ...existing, featured: false, status: 'archived', updatedBy: actorUserId, updatedAt: this.now().toISOString(), }; this.records.set(productKey, item); await this.persist(); return { ...item, tags: [...item.tags] }; } private seedTopProducts(dataset: DomesticDataset): void { const timestamp = this.now().toISOString(); const candidates = [...dataset.products] .filter((product) => product.role === 'own') .sort((left, right) => right.summary.gmv - left.summary.gmv || right.summary.soldUnits - left.summary.soldUnits) .slice(0, 8); for (const product of candidates) { const category = product.category3 || product.category2 || product.category1; const item: ProductKnowledgeRecord = { id: `local-${product.productKey.replace(/[^a-zA-Z0-9_-]/g, '-')}`, workspaceId: this.workspaceId, productKey: product.productKey, productId: product.productId, productRole: product.role, featured: true, status: 'active', tags: ['经营TOP', ...(category ? [category] : [])], note: '按成交金额初始化的重点商品,可在知识库内补充关注事项。', ownerUserId: '', createdBy: 'local-bootstrap', updatedBy: 'local-bootstrap', createdAt: timestamp, updatedAt: timestamp, }; this.records.set(product.productKey, item); } } private async persist(): Promise { if (!this.persistencePath) return; await mkdir(dirname(this.persistencePath), { recursive: true }); const payload: LocalKnowledgeFile = { schemaVersion: 1, items: [...this.records.values()].sort((left, right) => left.productKey.localeCompare(right.productKey)), }; await writeFile(this.persistencePath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8'); } }