|
|
@@ -0,0 +1,738 @@
|
|
|
+const fs = require('fs');
|
|
|
+const path = require('path');
|
|
|
+const { categoryDir, latestPath } = require('./output-paths');
|
|
|
+
|
|
|
+const STATE_VERSION = '1';
|
|
|
+
|
|
|
+function dashboardDir() {
|
|
|
+ return categoryDir('dashboard');
|
|
|
+}
|
|
|
+
|
|
|
+function stateFilePath() {
|
|
|
+ return path.join(dashboardDir(), 'dashboard-state.json');
|
|
|
+}
|
|
|
+
|
|
|
+function atomicWriteJson(filePath, data) {
|
|
|
+ const dir = path.dirname(filePath);
|
|
|
+ fs.mkdirSync(dir, { recursive: true });
|
|
|
+ const tmpPath = `${filePath}.tmp.${Date.now()}.${Math.random().toString(36).slice(2, 8)}`;
|
|
|
+ try {
|
|
|
+ fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2), 'utf8');
|
|
|
+ fs.renameSync(tmpPath, filePath);
|
|
|
+ } catch (err) {
|
|
|
+ try { fs.unlinkSync(tmpPath); } catch {}
|
|
|
+ throw err;
|
|
|
+ }
|
|
|
+ return filePath;
|
|
|
+}
|
|
|
+
|
|
|
+function defaultState() {
|
|
|
+ return {
|
|
|
+ version: STATE_VERSION,
|
|
|
+ updatedAt: new Date().toISOString(),
|
|
|
+ customers: {},
|
|
|
+ portraits: {},
|
|
|
+ tags: {
|
|
|
+ allTags: [],
|
|
|
+ perCustomer: {}
|
|
|
+ },
|
|
|
+ groupSync: {},
|
|
|
+ transfers: {
|
|
|
+ previews: [],
|
|
|
+ executions: []
|
|
|
+ },
|
|
|
+ operations: []
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+function readState() {
|
|
|
+ const filePath = stateFilePath();
|
|
|
+ if (!fs.existsSync(filePath)) return defaultState();
|
|
|
+ try {
|
|
|
+ const data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
|
+ const state = defaultState();
|
|
|
+ return {
|
|
|
+ ...state,
|
|
|
+ ...data,
|
|
|
+ version: data.version || STATE_VERSION,
|
|
|
+ customers: data.customers || {},
|
|
|
+ portraits: data.portraits || {},
|
|
|
+ tags: {
|
|
|
+ allTags: Array.isArray(data.tags?.allTags) ? data.tags.allTags : [],
|
|
|
+ perCustomer: data.tags?.perCustomer || {}
|
|
|
+ },
|
|
|
+ groupSync: data.groupSync || {},
|
|
|
+ transfers: {
|
|
|
+ previews: Array.isArray(data.transfers?.previews) ? data.transfers.previews : [],
|
|
|
+ executions: Array.isArray(data.transfers?.executions) ? data.transfers.executions : []
|
|
|
+ },
|
|
|
+ operations: Array.isArray(data.operations) ? data.operations : []
|
|
|
+ };
|
|
|
+ } catch {
|
|
|
+ return defaultState();
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function writeState(state) {
|
|
|
+ state.updatedAt = new Date().toISOString();
|
|
|
+ atomicWriteJson(stateFilePath(), state);
|
|
|
+ return state;
|
|
|
+}
|
|
|
+
|
|
|
+function mutateState(mutator) {
|
|
|
+ const state = readState();
|
|
|
+ const result = mutator(state);
|
|
|
+ writeState(state);
|
|
|
+ return result !== undefined ? result : state;
|
|
|
+}
|
|
|
+
|
|
|
+function findCustomerId(state, { customerId, externalUserId, phone }) {
|
|
|
+ if (customerId && state.customers[String(customerId)]) return String(customerId);
|
|
|
+ if (externalUserId) {
|
|
|
+ const target = String(externalUserId);
|
|
|
+ for (const [id, customer] of Object.entries(state.customers)) {
|
|
|
+ if (String(customer.externalUserId || '') === target) return id;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (phone) {
|
|
|
+ const normalized = String(phone).replace(/\D/g, '');
|
|
|
+ for (const [id, customer] of Object.entries(state.customers)) {
|
|
|
+ if (customer.phone && String(customer.phone).replace(/\D/g, '') === normalized) return id;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return customerId || externalUserId || phone || null;
|
|
|
+}
|
|
|
+
|
|
|
+function upsertCustomer(state, input = {}) {
|
|
|
+ const id = findCustomerId(state, input);
|
|
|
+ if (!id) return null;
|
|
|
+ const now = new Date().toISOString();
|
|
|
+ const existing = state.customers[id] || {};
|
|
|
+ const mergeArray = (a, b) => [...new Set([...(Array.isArray(a) ? a : []), ...(Array.isArray(b) ? b : [])].filter(Boolean))];
|
|
|
+ const customer = {
|
|
|
+ ...existing,
|
|
|
+ ...input,
|
|
|
+ sourceRoomIds: mergeArray(existing.sourceRoomIds, input.sourceRoomIds),
|
|
|
+ groupNames: mergeArray(existing.groupNames, input.groupNames),
|
|
|
+ discoveredFromGroups: existing.discoveredFromGroups || input.discoveredFromGroups || undefined,
|
|
|
+ customerId: id,
|
|
|
+ updatedAt: now,
|
|
|
+ createdAt: existing.createdAt || now
|
|
|
+ };
|
|
|
+ state.customers[id] = customer;
|
|
|
+ return customer;
|
|
|
+}
|
|
|
+
|
|
|
+function recordCustomerOperation(input = {}) {
|
|
|
+ return mutateState(state => {
|
|
|
+ const customers = Array.isArray(input.customers) ? input.customers : [input];
|
|
|
+ for (const item of customers) {
|
|
|
+ if (!item) continue;
|
|
|
+ upsertCustomer(state, item);
|
|
|
+ }
|
|
|
+ return state;
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+function memberId(member = {}) {
|
|
|
+ return String(
|
|
|
+ member.externalUserId ||
|
|
|
+ member.userId ||
|
|
|
+ member.wxId ||
|
|
|
+ member.id ||
|
|
|
+ ''
|
|
|
+ ).trim();
|
|
|
+}
|
|
|
+
|
|
|
+function memberName(member = {}) {
|
|
|
+ return String(
|
|
|
+ member.userName ||
|
|
|
+ member.nickname ||
|
|
|
+ member.name ||
|
|
|
+ member.remark ||
|
|
|
+ ''
|
|
|
+ ).trim();
|
|
|
+}
|
|
|
+
|
|
|
+function isLikelyExternalCustomerMember(member = {}) {
|
|
|
+ const id = memberId(member);
|
|
|
+ if (!id) return false;
|
|
|
+ const lowerId = id.toLowerCase();
|
|
|
+ if (lowerId.includes('@chatroom') || lowerId.startsWith('room')) return false;
|
|
|
+ if (member.isExternal === true || member.external === true || member.isCustomer === true) return true;
|
|
|
+
|
|
|
+ const type = Number(member.type ?? member.userType ?? member.memberType);
|
|
|
+ if (Number.isFinite(type) && type > 1) return true;
|
|
|
+
|
|
|
+ // WeCom external contact IDs commonly start with "wm"; keep a conservative
|
|
|
+ // fallback for gateways that omit member type in room detail payloads.
|
|
|
+ return /^wm/i.test(id);
|
|
|
+}
|
|
|
+
|
|
|
+function customerNameFromRoomName(roomName = '') {
|
|
|
+ const text = String(roomName || '').trim();
|
|
|
+ if (!text) return '';
|
|
|
+ const parts = text.split(/[++]/).map(s => s.trim()).filter(Boolean);
|
|
|
+ let name = parts.length > 1 ? parts[parts.length - 1] : '';
|
|
|
+ name = name.replace(/^客户/, '').trim();
|
|
|
+ return name;
|
|
|
+}
|
|
|
+
|
|
|
+function inferCustomerMembersFromRoom(room = {}, memberFrequency = new Map()) {
|
|
|
+ const members = Array.isArray(room.members) ? room.members : [];
|
|
|
+ const explicit = members.filter(isLikelyExternalCustomerMember);
|
|
|
+ if (explicit.length) return explicit;
|
|
|
+
|
|
|
+ if (Number(room.roomExtType) !== 2 || members.length < 2) return [];
|
|
|
+
|
|
|
+ const validMembers = members.filter(m => memberId(m));
|
|
|
+ if (validMembers.length < 2) return [];
|
|
|
+
|
|
|
+ const minFrequency = Math.min(...validMembers.map(m => memberFrequency.get(memberId(m)) || 0));
|
|
|
+ const candidates = validMembers.filter(m => (memberFrequency.get(memberId(m)) || 0) === minFrequency);
|
|
|
+ return candidates.slice(0, 1);
|
|
|
+}
|
|
|
+
|
|
|
+function isConfirmedCustomerGroup(room = {}) {
|
|
|
+ return room.reviewStatus === 'CONFIRMED' || room.reviewStatus === 'AUTO_CONFIRMED';
|
|
|
+}
|
|
|
+
|
|
|
+function recordCustomersFromGroups(rooms = [], { source = 'group-member' } = {}) {
|
|
|
+ if (!Array.isArray(rooms) || !rooms.length) return { discovered: 0, skipped: 0 };
|
|
|
+ const confirmedRooms = rooms.filter(isConfirmedCustomerGroup);
|
|
|
+ if (!confirmedRooms.length) {
|
|
|
+ return { discovered: 0, skipped: rooms.reduce((sum, room) => sum + (Array.isArray(room.members) ? room.members.length : 0), 0) };
|
|
|
+ }
|
|
|
+ return mutateState(state => {
|
|
|
+ let discovered = 0;
|
|
|
+ let skipped = 0;
|
|
|
+ const memberFrequency = new Map();
|
|
|
+
|
|
|
+ for (const room of rooms) {
|
|
|
+ const members = Array.isArray(room.members) ? room.members : [];
|
|
|
+ for (const member of members) {
|
|
|
+ const id = memberId(member);
|
|
|
+ if (id) memberFrequency.set(id, (memberFrequency.get(id) || 0) + 1);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ for (const room of confirmedRooms) {
|
|
|
+ const members = inferCustomerMembersFromRoom(room, memberFrequency);
|
|
|
+ skipped += Math.max(0, (Array.isArray(room.members) ? room.members.length : 0) - members.length);
|
|
|
+ for (const member of members) {
|
|
|
+ const externalUserId = memberId(member);
|
|
|
+ const name = memberName(member) || customerNameFromRoomName(room.roomName);
|
|
|
+ upsertCustomer(state, {
|
|
|
+ externalUserId,
|
|
|
+ name: name || undefined,
|
|
|
+ friendRequestStatus: 'ACCEPTED',
|
|
|
+ groupStatus: 'IN_GROUP',
|
|
|
+ source,
|
|
|
+ discoveredFromGroups: true,
|
|
|
+ sourceRoomIds: room.roomId ? [room.roomId] : [],
|
|
|
+ groupNames: room.roomName ? [room.roomName] : [],
|
|
|
+ lastSeenInGroupAt: room.seenAt || new Date().toISOString()
|
|
|
+ });
|
|
|
+ discovered++;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return { discovered, skipped };
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+function recordFriendRequestResult({ phone, name, externalUserId, status, brokerId, groupNameTemplate }) {
|
|
|
+ return recordCustomerOperation({
|
|
|
+ phone,
|
|
|
+ name,
|
|
|
+ externalUserId,
|
|
|
+ brokerId,
|
|
|
+ groupNameTemplate,
|
|
|
+ friendRequestStatus: status,
|
|
|
+ lastAddAttemptAt: new Date().toISOString()
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+function recordAutoGroupCreated({ externalUserId, customerId, phone, name, roomId, groupName }) {
|
|
|
+ return recordCustomerOperation({
|
|
|
+ externalUserId,
|
|
|
+ customerId,
|
|
|
+ phone,
|
|
|
+ name,
|
|
|
+ groupStatus: 'CREATED',
|
|
|
+ autoCreatedAt: new Date().toISOString(),
|
|
|
+ autoCreatedRoomId: roomId,
|
|
|
+ autoCreatedGroupName: groupName
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+function recordPortrait(externalUserId, { source, messageCount, fields } = {}) {
|
|
|
+ if (!externalUserId) return null;
|
|
|
+ const fieldList = Array.isArray(fields) ? fields.filter(Boolean) : [];
|
|
|
+ if (!fieldList.length) return null;
|
|
|
+ return mutateState(state => {
|
|
|
+ const now = new Date().toISOString();
|
|
|
+ state.portraits[externalUserId] = {
|
|
|
+ externalUserId,
|
|
|
+ updatedAt: now,
|
|
|
+ source: source || 'unknown',
|
|
|
+ messageCount: Number(messageCount) || 0,
|
|
|
+ fields: fieldList
|
|
|
+ };
|
|
|
+ const customerId = findCustomerId(state, { externalUserId });
|
|
|
+ if (customerId) {
|
|
|
+ const customer = state.customers[customerId] || { customerId, externalUserId };
|
|
|
+ customer.hasPortrait = true;
|
|
|
+ customer.lastPortraitAt = now;
|
|
|
+ customer.portraitSource = source || 'unknown';
|
|
|
+ customer.updatedAt = now;
|
|
|
+ state.customers[customerId] = customer;
|
|
|
+ }
|
|
|
+ return state.portraits[externalUserId];
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+function removePortrait(externalUserId) {
|
|
|
+ if (!externalUserId) return null;
|
|
|
+ return mutateState(state => {
|
|
|
+ delete state.portraits[externalUserId];
|
|
|
+ const customerId = findCustomerId(state, { externalUserId });
|
|
|
+ if (customerId && state.customers[customerId]) {
|
|
|
+ const customer = state.customers[customerId];
|
|
|
+ customer.hasPortrait = false;
|
|
|
+ delete customer.lastPortraitAt;
|
|
|
+ delete customer.portraitSource;
|
|
|
+ customer.updatedAt = new Date().toISOString();
|
|
|
+ }
|
|
|
+ return { externalUserId };
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+function recordTags(externalUserId, tags) {
|
|
|
+ if (!externalUserId) return null;
|
|
|
+ const tagList = Array.isArray(tags) ? tags.map(String).filter(Boolean) : [];
|
|
|
+ return mutateState(state => {
|
|
|
+ const now = new Date().toISOString();
|
|
|
+ state.tags.perCustomer[externalUserId] = {
|
|
|
+ externalUserId,
|
|
|
+ tags: [...new Set(tagList)],
|
|
|
+ updatedAt: now
|
|
|
+ };
|
|
|
+ const allTags = new Set(state.tags.allTags);
|
|
|
+ for (const tag of tagList) allTags.add(tag);
|
|
|
+ state.tags.allTags = Array.from(allTags).sort();
|
|
|
+ const customerId = findCustomerId(state, { externalUserId });
|
|
|
+ if (customerId) {
|
|
|
+ const customer = state.customers[customerId] || { customerId, externalUserId };
|
|
|
+ customer.tags = [...new Set(tagList)];
|
|
|
+ customer.lastTagAt = now;
|
|
|
+ customer.updatedAt = now;
|
|
|
+ state.customers[customerId] = customer;
|
|
|
+ }
|
|
|
+ return state.tags.perCustomer[externalUserId];
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+function recordTransferPreview(preview) {
|
|
|
+ if (!preview) return null;
|
|
|
+ return mutateState(state => {
|
|
|
+ const record = {
|
|
|
+ id: `preview-${preview.createdAt || Date.now()}`,
|
|
|
+ fromUserId: preview.fromUserId,
|
|
|
+ toUserId: preview.toUserId,
|
|
|
+ createdAt: preview.createdAt || new Date().toISOString(),
|
|
|
+ status: preview.status || 'DRAFT',
|
|
|
+ itemCount: Array.isArray(preview.items) ? preview.items.length : 0,
|
|
|
+ filePath: preview.filePath || null
|
|
|
+ };
|
|
|
+ state.transfers.previews.unshift(record);
|
|
|
+ state.transfers.previews = state.transfers.previews.slice(0, 200);
|
|
|
+ return record;
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+function recordTransferExecution(preview, results = []) {
|
|
|
+ if (!preview) return null;
|
|
|
+ return mutateState(state => {
|
|
|
+ const success = results.filter(r => r.status === 'SUCCESS').length;
|
|
|
+ const failed = results.filter(r => r.status === 'FAILED').length;
|
|
|
+ const record = {
|
|
|
+ id: `execution-${preview.executedAt || Date.now()}`,
|
|
|
+ fromUserId: preview.fromUserId,
|
|
|
+ toUserId: preview.toUserId,
|
|
|
+ executedAt: preview.executedAt || new Date().toISOString(),
|
|
|
+ status: 'EXECUTED',
|
|
|
+ total: Array.isArray(preview.items) ? preview.items.length : results.length,
|
|
|
+ success,
|
|
|
+ failed,
|
|
|
+ filePath: preview.filePath || null
|
|
|
+ };
|
|
|
+ state.transfers.executions.unshift(record);
|
|
|
+ state.transfers.executions = state.transfers.executions.slice(0, 200);
|
|
|
+ const previewId = state.transfers.previews.findIndex(p => p.fromUserId === preview.fromUserId && p.toUserId === preview.toUserId && p.status === 'DRAFT');
|
|
|
+ if (previewId >= 0) {
|
|
|
+ state.transfers.previews[previewId].status = 'EXECUTED';
|
|
|
+ state.transfers.previews[previewId].executedAt = record.executedAt;
|
|
|
+ }
|
|
|
+ return record;
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+function recordGroupSync(roomId, { messageCount, lastMsgAt, lastSyncSeq } = {}) {
|
|
|
+ if (!roomId) return null;
|
|
|
+ return mutateState(state => {
|
|
|
+ const now = new Date().toISOString();
|
|
|
+ const existing = state.groupSync[roomId] || {};
|
|
|
+ state.groupSync[roomId] = {
|
|
|
+ roomId,
|
|
|
+ lastSyncAt: now,
|
|
|
+ lastMsgAt: lastMsgAt || existing.lastMsgAt || null,
|
|
|
+ lastSyncSeq: Number(lastSyncSeq) || existing.lastSyncSeq || 0,
|
|
|
+ messageCount: Number(messageCount) || existing.messageCount || 0
|
|
|
+ };
|
|
|
+ return state.groupSync[roomId];
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+function recordOperation({ type, summary, resultFile }) {
|
|
|
+ return mutateState(state => {
|
|
|
+ const record = {
|
|
|
+ type,
|
|
|
+ startedAt: new Date().toISOString(),
|
|
|
+ completedAt: new Date().toISOString(),
|
|
|
+ summary: summary || {},
|
|
|
+ resultFile: resultFile || null
|
|
|
+ };
|
|
|
+ state.operations.unshift(record);
|
|
|
+ state.operations = state.operations.slice(0, 500);
|
|
|
+ return record;
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+function getStateSection(section = 'all', { limit, filter } = {}) {
|
|
|
+ const state = readState();
|
|
|
+ const applyLimit = (arr) => {
|
|
|
+ if (!Array.isArray(arr)) return arr;
|
|
|
+ const n = Math.max(1, Number(limit) || 100);
|
|
|
+ return arr.slice(0, n);
|
|
|
+ };
|
|
|
+ const applyCustomerFilter = (customers) => {
|
|
|
+ if (!filter) return customers;
|
|
|
+ const kw = String(filter.keyword || '').toLowerCase();
|
|
|
+ const hasPortrait = filter.hasPortrait;
|
|
|
+ const friendRequestStatus = filter.friendRequestStatus;
|
|
|
+ const tag = filter.tag;
|
|
|
+ return Object.fromEntries(Object.entries(customers).filter(([, c]) => {
|
|
|
+ if (kw) {
|
|
|
+ const text = `${c.phone || ''} ${c.name || ''} ${c.externalUserId || ''}`.toLowerCase();
|
|
|
+ if (!text.includes(kw)) return false;
|
|
|
+ }
|
|
|
+ if (hasPortrait !== undefined && Boolean(c.hasPortrait) !== Boolean(hasPortrait)) return false;
|
|
|
+ if (friendRequestStatus && c.friendRequestStatus !== friendRequestStatus) return false;
|
|
|
+ if (tag && !(Array.isArray(c.tags) && c.tags.includes(tag))) return false;
|
|
|
+ return true;
|
|
|
+ }));
|
|
|
+ };
|
|
|
+
|
|
|
+ switch (section) {
|
|
|
+ case 'summary':
|
|
|
+ return buildSummary(state);
|
|
|
+ case 'customers':
|
|
|
+ return {
|
|
|
+ customers: applyCustomerFilter(state.customers),
|
|
|
+ total: Object.keys(state.customers).length
|
|
|
+ };
|
|
|
+ case 'portraits':
|
|
|
+ return {
|
|
|
+ portraits: state.portraits,
|
|
|
+ total: Object.keys(state.portraits).length
|
|
|
+ };
|
|
|
+ case 'tags':
|
|
|
+ return state.tags;
|
|
|
+ case 'transfers':
|
|
|
+ return {
|
|
|
+ previews: applyLimit(state.transfers.previews),
|
|
|
+ executions: applyLimit(state.transfers.executions)
|
|
|
+ };
|
|
|
+ case 'groups':
|
|
|
+ return state.groupSync;
|
|
|
+ case 'operations':
|
|
|
+ return { operations: applyLimit(state.operations) };
|
|
|
+ case 'all':
|
|
|
+ default:
|
|
|
+ return state;
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+function buildSummary(state) {
|
|
|
+ const customers = Object.values(state.customers);
|
|
|
+ const portraits = Object.values(state.portraits);
|
|
|
+ const now = new Date();
|
|
|
+ const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000).toISOString();
|
|
|
+
|
|
|
+ const totalCustomers = customers.length;
|
|
|
+ const totalPortraits = portraits.length;
|
|
|
+ const portraitCoverage = totalCustomers > 0 ? totalPortraits / totalCustomers : 0;
|
|
|
+ const totalTags = state.tags.allTags.length;
|
|
|
+ const avgTagsPerCustomer = totalCustomers > 0
|
|
|
+ ? customers.reduce((sum, c) => sum + (Array.isArray(c.tags) ? c.tags.length : 0), 0) / totalCustomers
|
|
|
+ : 0;
|
|
|
+
|
|
|
+ const friendStatusCounts = {
|
|
|
+ ACCEPTED: 0,
|
|
|
+ PENDING: 0,
|
|
|
+ NOT_FOUND: 0,
|
|
|
+ FAILED: 0,
|
|
|
+ UNKNOWN: 0
|
|
|
+ };
|
|
|
+ for (const c of customers) {
|
|
|
+ const status = c.friendRequestStatus || 'UNKNOWN';
|
|
|
+ friendStatusCounts[status] = (friendStatusCounts[status] || 0) + 1;
|
|
|
+ }
|
|
|
+
|
|
|
+ const recentPortraits = portraits.filter(p => p.updatedAt && p.updatedAt >= sevenDaysAgo).length;
|
|
|
+ const recentOperations = state.operations.filter(op => op.completedAt && op.completedAt >= sevenDaysAgo);
|
|
|
+
|
|
|
+ const today = now.toISOString().slice(0, 10);
|
|
|
+ const todayNew = customers.filter(c => {
|
|
|
+ if (c.friendRequestStatus !== 'ACCEPTED') return false;
|
|
|
+ if (!c.lastAddAttemptAt) return false;
|
|
|
+ return c.lastAddAttemptAt.slice(0, 10) === today;
|
|
|
+ }).length;
|
|
|
+
|
|
|
+ const previews = state.transfers.previews.length;
|
|
|
+ const executions = state.transfers.executions.length;
|
|
|
+ const transferSuccess = state.transfers.executions.reduce((sum, e) => sum + (e.success || 0), 0);
|
|
|
+ const transferTotal = state.transfers.executions.reduce((sum, e) => sum + (e.total || 0), 0);
|
|
|
+
|
|
|
+ return {
|
|
|
+ customers: {
|
|
|
+ total: totalCustomers,
|
|
|
+ todayNew,
|
|
|
+ friendStatusCounts,
|
|
|
+ portraitCoverage: Math.round(portraitCoverage * 1000) / 10,
|
|
|
+ autoCreatedGroups: customers.filter(c => c.groupStatus === 'CREATED').length
|
|
|
+ },
|
|
|
+ portraits: {
|
|
|
+ total: totalPortraits,
|
|
|
+ coverage: Math.round(portraitCoverage * 1000) / 10,
|
|
|
+ recent7d: recentPortraits,
|
|
|
+ bySource: {
|
|
|
+ keyword: portraits.filter(p => p.source === 'keyword').length,
|
|
|
+ agent: portraits.filter(p => p.source === 'agent').length,
|
|
|
+ other: portraits.filter(p => !['keyword', 'agent'].includes(p.source)).length
|
|
|
+ }
|
|
|
+ },
|
|
|
+ tags: {
|
|
|
+ total: totalTags,
|
|
|
+ avgPerCustomer: Math.round(avgTagsPerCustomer * 10) / 10,
|
|
|
+ allTags: state.tags.allTags
|
|
|
+ },
|
|
|
+ transfers: {
|
|
|
+ previews,
|
|
|
+ executions,
|
|
|
+ successRate: transferTotal > 0 ? Math.round((transferSuccess / transferTotal) * 1000) / 10 : 0,
|
|
|
+ recent7d: state.transfers.executions.filter(e => e.executedAt && e.executedAt >= sevenDaysAgo).length
|
|
|
+ },
|
|
|
+ operations: {
|
|
|
+ total: state.operations.length,
|
|
|
+ recent7d: recentOperations.length,
|
|
|
+ recent: state.operations.slice(0, 20)
|
|
|
+ }
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+function rebuildStateFromOutputs() {
|
|
|
+ const { outputsRoot } = require('./output-paths');
|
|
|
+ const state = defaultState();
|
|
|
+
|
|
|
+ const readJsonSafe = (filePath, fallback = null) => {
|
|
|
+ try {
|
|
|
+ if (!fs.existsSync(filePath)) return fallback;
|
|
|
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
|
+ } catch {
|
|
|
+ return fallback;
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ const customersDir = path.join(outputsRoot(), 'customers');
|
|
|
+ if (fs.existsSync(customersDir)) {
|
|
|
+ for (const file of fs.readdirSync(customersDir)) {
|
|
|
+ if (!file.endsWith('.json') || file.startsWith('index-')) continue;
|
|
|
+ const customer = readJsonSafe(path.join(customersDir, file));
|
|
|
+ if (customer && customer.customerId) {
|
|
|
+ state.customers[customer.customerId] = {
|
|
|
+ customerId: customer.customerId,
|
|
|
+ phone: customer.phone || null,
|
|
|
+ externalUserId: customer.externalUserId || null,
|
|
|
+ name: customer.name || null,
|
|
|
+ brokerId: customer.brokerId || null,
|
|
|
+ groupNameTemplate: customer.groupNameTemplate || null,
|
|
|
+ friendRequestStatus: customer.friendRequestStatus || 'UNKNOWN',
|
|
|
+ groupStatus: customer.groupStatus || 'NONE',
|
|
|
+ hasPortrait: false,
|
|
|
+ tags: [],
|
|
|
+ createdAt: customer.createdAt || null,
|
|
|
+ updatedAt: customer.updatedAt || null
|
|
|
+ };
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ const portraitsDir = path.join(outputsRoot(), 'portraits');
|
|
|
+ if (fs.existsSync(portraitsDir)) {
|
|
|
+ for (const file of fs.readdirSync(portraitsDir)) {
|
|
|
+ if (!file.endsWith('.json') || file.startsWith('context-')) continue;
|
|
|
+ const externalUserId = file.replace(/\.json$/, '');
|
|
|
+ const data = readJsonSafe(path.join(portraitsDir, file));
|
|
|
+ if (data && data.portrait && Object.keys(data.portrait).length) {
|
|
|
+ state.portraits[externalUserId] = {
|
|
|
+ externalUserId,
|
|
|
+ updatedAt: data.updatedAt || null,
|
|
|
+ source: data.source || 'unknown',
|
|
|
+ messageCount: data.messageCount || 0,
|
|
|
+ fields: data.portrait ? Object.keys(data.portrait) : []
|
|
|
+ };
|
|
|
+ const customerId = findCustomerId(state, { externalUserId });
|
|
|
+ if (customerId) {
|
|
|
+ const customer = state.customers[customerId] || { customerId, externalUserId };
|
|
|
+ customer.hasPortrait = true;
|
|
|
+ customer.lastPortraitAt = data.updatedAt || null;
|
|
|
+ customer.portraitSource = data.source || 'unknown';
|
|
|
+ state.customers[customerId] = customer;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ const tagsDir = path.join(outputsRoot(), 'tags');
|
|
|
+ if (fs.existsSync(tagsDir)) {
|
|
|
+ for (const file of fs.readdirSync(tagsDir)) {
|
|
|
+ if (!file.endsWith('.json')) continue;
|
|
|
+ const externalUserId = file.replace(/\.json$/, '');
|
|
|
+ const data = readJsonSafe(path.join(tagsDir, file));
|
|
|
+ const tags = Array.isArray(data?.tags) ? data.tags : [];
|
|
|
+ state.tags.perCustomer[externalUserId] = {
|
|
|
+ externalUserId,
|
|
|
+ tags,
|
|
|
+ updatedAt: data?.updatedAt || null
|
|
|
+ };
|
|
|
+ for (const tag of tags) {
|
|
|
+ if (!state.tags.allTags.includes(tag)) state.tags.allTags.push(tag);
|
|
|
+ }
|
|
|
+ const customerId = findCustomerId(state, { externalUserId });
|
|
|
+ if (customerId) {
|
|
|
+ const customer = state.customers[customerId] || { customerId, externalUserId };
|
|
|
+ customer.tags = tags;
|
|
|
+ customer.lastTagAt = data?.updatedAt || null;
|
|
|
+ state.customers[customerId] = customer;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ state.tags.allTags.sort();
|
|
|
+ }
|
|
|
+
|
|
|
+ const groupsLatestPath = latestPath('groups', 'rooms-latest.json');
|
|
|
+ const latestRooms = readJsonSafe(groupsLatestPath, []);
|
|
|
+ if (Array.isArray(latestRooms) && latestRooms.length) {
|
|
|
+ const confirmedMapping = readJsonSafe(path.join(outputsRoot(), 'groups', 'confirmed-mapping.json'), {});
|
|
|
+ const roomsWithConfirmedStatus = latestRooms.map(room => {
|
|
|
+ const mapped = confirmedMapping[room.roomId];
|
|
|
+ if (!mapped) return room;
|
|
|
+ return {
|
|
|
+ ...room,
|
|
|
+ reviewStatus: 'CONFIRMED',
|
|
|
+ customerId: mapped.customerId,
|
|
|
+ externalUserId: mapped.externalUserId,
|
|
|
+ customerName: mapped.customerName,
|
|
|
+ roomName: room.roomName || mapped.roomName
|
|
|
+ };
|
|
|
+ });
|
|
|
+ const confirmedRooms = roomsWithConfirmedStatus.filter(isConfirmedCustomerGroup);
|
|
|
+ const memberFrequency = new Map();
|
|
|
+ for (const room of roomsWithConfirmedStatus) {
|
|
|
+ const members = Array.isArray(room.members) ? room.members : [];
|
|
|
+ for (const member of members) {
|
|
|
+ const id = memberId(member);
|
|
|
+ if (id) memberFrequency.set(id, (memberFrequency.get(id) || 0) + 1);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ for (const room of confirmedRooms) {
|
|
|
+ const members = room.externalUserId
|
|
|
+ ? [{ externalUserId: room.externalUserId, userName: room.customerName || '' }]
|
|
|
+ : inferCustomerMembersFromRoom(room, memberFrequency);
|
|
|
+ for (const member of members) {
|
|
|
+ upsertCustomer(state, {
|
|
|
+ externalUserId: memberId(member),
|
|
|
+ name: memberName(member) || room.customerName || customerNameFromRoomName(room.roomName) || undefined,
|
|
|
+ friendRequestStatus: 'ACCEPTED',
|
|
|
+ groupStatus: 'IN_GROUP',
|
|
|
+ source: 'group-member',
|
|
|
+ discoveredFromGroups: true,
|
|
|
+ sourceRoomIds: room.roomId ? [room.roomId] : [],
|
|
|
+ groupNames: room.roomName ? [room.roomName] : [],
|
|
|
+ lastSeenInGroupAt: room.seenAt || null
|
|
|
+ });
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ const transfersDir = path.join(outputsRoot(), 'transfers');
|
|
|
+ if (fs.existsSync(transfersDir)) {
|
|
|
+ for (const file of fs.readdirSync(transfersDir)) {
|
|
|
+ if (!file.endsWith('.json')) continue;
|
|
|
+ const data = readJsonSafe(path.join(transfersDir, file));
|
|
|
+ if (!data) continue;
|
|
|
+ if (file.startsWith('preview-')) {
|
|
|
+ state.transfers.previews.push({
|
|
|
+ id: file.replace(/\.json$/, ''),
|
|
|
+ fromUserId: data.fromUserId,
|
|
|
+ toUserId: data.toUserId,
|
|
|
+ createdAt: data.createdAt,
|
|
|
+ status: data.status || 'DRAFT',
|
|
|
+ itemCount: Array.isArray(data.items) ? data.items.length : 0,
|
|
|
+ filePath: path.join('transfers', file)
|
|
|
+ });
|
|
|
+ } else if (file.startsWith('execution-')) {
|
|
|
+ const results = Array.isArray(data.results) ? data.results : [];
|
|
|
+ state.transfers.executions.push({
|
|
|
+ id: file.replace(/\.json$/, ''),
|
|
|
+ fromUserId: data.fromUserId,
|
|
|
+ toUserId: data.toUserId,
|
|
|
+ executedAt: data.executedAt,
|
|
|
+ status: 'EXECUTED',
|
|
|
+ total: Array.isArray(data.items) ? data.items.length : results.length,
|
|
|
+ success: results.filter(r => r.status === 'SUCCESS').length,
|
|
|
+ failed: results.filter(r => r.status === 'FAILED').length,
|
|
|
+ filePath: path.join('transfers', file)
|
|
|
+ });
|
|
|
+ }
|
|
|
+ }
|
|
|
+ state.transfers.previews.sort((a, b) => (b.createdAt || '').localeCompare(a.createdAt || ''));
|
|
|
+ state.transfers.executions.sort((a, b) => (b.executedAt || '').localeCompare(a.executedAt || ''));
|
|
|
+ }
|
|
|
+
|
|
|
+ writeState(state);
|
|
|
+ return state;
|
|
|
+}
|
|
|
+
|
|
|
+module.exports = {
|
|
|
+ readState,
|
|
|
+ writeState,
|
|
|
+ mutateState,
|
|
|
+ recordCustomerOperation,
|
|
|
+ recordCustomersFromGroups,
|
|
|
+ recordFriendRequestResult,
|
|
|
+ recordAutoGroupCreated,
|
|
|
+ recordPortrait,
|
|
|
+ removePortrait,
|
|
|
+ recordTags,
|
|
|
+ recordTransferPreview,
|
|
|
+ recordTransferExecution,
|
|
|
+ recordGroupSync,
|
|
|
+ recordOperation,
|
|
|
+ getStateSection,
|
|
|
+ buildSummary,
|
|
|
+ rebuildStateFromOutputs,
|
|
|
+ stateFilePath
|
|
|
+};
|