local-product-knowledge.store.ts 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168
  1. import { mkdir, readFile, writeFile } from 'node:fs/promises';
  2. import { dirname } from 'node:path';
  3. import type { DomesticDataset } from '../../types/domestic-dataset.js';
  4. import type {
  5. ProductKnowledgePage,
  6. ProductKnowledgeRecord,
  7. ProductKnowledgeRole,
  8. ProductKnowledgeStatus,
  9. ProductKnowledgeStore,
  10. } from './product-knowledge.store.js';
  11. interface LocalKnowledgeFile {
  12. schemaVersion: 1;
  13. items: ProductKnowledgeRecord[];
  14. }
  15. export class LocalProductKnowledgeStore implements ProductKnowledgeStore {
  16. private readonly records = new Map<string, ProductKnowledgeRecord>();
  17. constructor(
  18. dataset: DomesticDataset,
  19. private readonly workspaceId: string,
  20. initialItems: ProductKnowledgeRecord[] = [],
  21. private readonly persistencePath = '',
  22. private readonly now: () => Date = () => new Date(),
  23. ) {
  24. for (const item of initialItems) {
  25. if (item.workspaceId === workspaceId) this.records.set(item.productKey, { ...item, tags: [...item.tags] });
  26. }
  27. if (!this.records.size) this.seedTopProducts(dataset);
  28. }
  29. static async open(input: {
  30. dataset: DomesticDataset;
  31. workspaceId: string;
  32. persistencePath: string;
  33. }): Promise<LocalProductKnowledgeStore> {
  34. let items: ProductKnowledgeRecord[] = [];
  35. try {
  36. const parsed = JSON.parse(await readFile(input.persistencePath, 'utf8')) as Partial<LocalKnowledgeFile>;
  37. if (parsed.schemaVersion === 1 && Array.isArray(parsed.items)) items = parsed.items;
  38. } catch (error) {
  39. if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
  40. }
  41. const store = new LocalProductKnowledgeStore(
  42. input.dataset,
  43. input.workspaceId,
  44. items,
  45. input.persistencePath,
  46. );
  47. await store.persist();
  48. return store;
  49. }
  50. async list(input: {
  51. workspaceId: string;
  52. limit: number;
  53. cursor: string | null;
  54. status?: ProductKnowledgeStatus;
  55. productRole?: ProductKnowledgeRole;
  56. featured?: boolean;
  57. }): Promise<ProductKnowledgePage> {
  58. if (input.workspaceId !== this.workspaceId) return { items: [], nextCursor: null };
  59. const items = [...this.records.values()]
  60. .filter((item) => !input.status || item.status === input.status)
  61. .filter((item) => !input.productRole || item.productRole === input.productRole)
  62. .filter((item) => input.featured === undefined || item.featured === input.featured)
  63. .sort((left, right) => left.productKey.localeCompare(right.productKey));
  64. const cursorIndex = input.cursor ? items.findIndex((item) => item.productKey === input.cursor) : -1;
  65. const start = cursorIndex + 1;
  66. const page = items.slice(start, start + input.limit + 1);
  67. const hasMore = page.length > input.limit;
  68. const visible = page.slice(0, input.limit).map((item) => ({ ...item, tags: [...item.tags] }));
  69. return {
  70. items: visible,
  71. nextCursor: hasMore ? visible.at(-1)?.productKey ?? null : null,
  72. };
  73. }
  74. async upsert(input: {
  75. workspaceId: string;
  76. productKey: string;
  77. productId: string;
  78. productRole: ProductKnowledgeRole;
  79. featured: boolean;
  80. status: ProductKnowledgeStatus;
  81. tags: string[];
  82. note: string;
  83. ownerUserId: string;
  84. actorUserId: string;
  85. }): Promise<ProductKnowledgeRecord> {
  86. const existing = this.records.get(input.productKey);
  87. const timestamp = this.now().toISOString();
  88. const item: ProductKnowledgeRecord = {
  89. id: existing?.id ?? `local-${input.productKey.replace(/[^a-zA-Z0-9_-]/g, '-')}`,
  90. workspaceId: input.workspaceId,
  91. productKey: input.productKey,
  92. productId: input.productId,
  93. productRole: input.productRole,
  94. featured: input.featured,
  95. status: input.status,
  96. tags: [...new Set(input.tags.map((tag) => tag.trim()).filter(Boolean))],
  97. note: input.note.trim(),
  98. ownerUserId: input.ownerUserId.trim(),
  99. createdBy: existing?.createdBy ?? input.actorUserId,
  100. updatedBy: input.actorUserId,
  101. createdAt: existing?.createdAt ?? timestamp,
  102. updatedAt: timestamp,
  103. };
  104. this.records.set(input.productKey, item);
  105. await this.persist();
  106. return { ...item, tags: [...item.tags] };
  107. }
  108. async archive(workspaceId: string, productKey: string, actorUserId: string): Promise<ProductKnowledgeRecord | null> {
  109. if (workspaceId !== this.workspaceId) return null;
  110. const existing = this.records.get(productKey);
  111. if (!existing) return null;
  112. const item: ProductKnowledgeRecord = {
  113. ...existing,
  114. featured: false,
  115. status: 'archived',
  116. updatedBy: actorUserId,
  117. updatedAt: this.now().toISOString(),
  118. };
  119. this.records.set(productKey, item);
  120. await this.persist();
  121. return { ...item, tags: [...item.tags] };
  122. }
  123. private seedTopProducts(dataset: DomesticDataset): void {
  124. const timestamp = this.now().toISOString();
  125. const candidates = [...dataset.products]
  126. .filter((product) => product.role === 'own')
  127. .sort((left, right) => right.summary.gmv - left.summary.gmv || right.summary.soldUnits - left.summary.soldUnits)
  128. .slice(0, 8);
  129. for (const product of candidates) {
  130. const category = product.category3 || product.category2 || product.category1;
  131. const item: ProductKnowledgeRecord = {
  132. id: `local-${product.productKey.replace(/[^a-zA-Z0-9_-]/g, '-')}`,
  133. workspaceId: this.workspaceId,
  134. productKey: product.productKey,
  135. productId: product.productId,
  136. productRole: product.role,
  137. featured: true,
  138. status: 'active',
  139. tags: ['经营TOP', ...(category ? [category] : [])],
  140. note: '按成交金额初始化的重点商品,可在知识库内补充关注事项。',
  141. ownerUserId: '',
  142. createdBy: 'local-bootstrap',
  143. updatedBy: 'local-bootstrap',
  144. createdAt: timestamp,
  145. updatedAt: timestamp,
  146. };
  147. this.records.set(product.productKey, item);
  148. }
  149. }
  150. private async persist(): Promise<void> {
  151. if (!this.persistencePath) return;
  152. await mkdir(dirname(this.persistencePath), { recursive: true });
  153. const payload: LocalKnowledgeFile = {
  154. schemaVersion: 1,
  155. items: [...this.records.values()].sort((left, right) => left.productKey.localeCompare(right.productKey)),
  156. };
  157. await writeFile(this.persistencePath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
  158. }
  159. }