Browse Source

feat(qiwei): merge local dashboard operations

ETO-kai 2 months ago
parent
commit
1198fcfab7
37 changed files with 5501 additions and 505 deletions
  1. 11 0
      claude-code/claude-code-qiwe-assistant/docs/OUTPUT-STANDARD.md
  2. 0 0
      claude-code/claude-code-qiwe-assistant/docs/guides/DASHBOARD-DEV-LOG.md
  3. 160 0
      claude-code/claude-code-qiwe-assistant/mcp/src/core/account-connection-monitor.js
  4. 30 0
      claude-code/claude-code-qiwe-assistant/mcp/src/core/credentials.js
  5. 234 0
      claude-code/claude-code-qiwe-assistant/mcp/src/core/customer-broker-store.js
  6. 738 0
      claude-code/claude-code-qiwe-assistant/mcp/src/core/dashboard-state.js
  7. 130 0
      claude-code/claude-code-qiwe-assistant/mcp/src/core/device-broker-mapping.js
  8. 185 0
      claude-code/claude-code-qiwe-assistant/mcp/src/core/llm-client.js
  9. 5 1
      claude-code/claude-code-qiwe-assistant/mcp/src/core/output-paths.js
  10. 28 24
      claude-code/claude-code-qiwe-assistant/mcp/src/core/relay-config.js
  11. 58 0
      claude-code/claude-code-qiwe-assistant/mcp/src/core/webhook-config.js
  12. 666 0
      claude-code/claude-code-qiwe-assistant/mcp/src/core/webhook-processor.js
  13. 46 52
      claude-code/claude-code-qiwe-assistant/mcp/src/core/webhook-server.js
  14. 162 0
      claude-code/claude-code-qiwe-assistant/mcp/src/core/webhook-store.js
  15. 348 0
      claude-code/claude-code-qiwe-assistant/mcp/src/core/webhook-types.js
  16. 79 0
      claude-code/claude-code-qiwe-assistant/mcp/src/core/webhook-verify.js
  17. 627 96
      claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/app.js
  18. 34 0
      claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/echarts.min.js
  19. 1 0
      claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/index.html
  20. 60 3
      claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/server.js
  21. 336 0
      claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/styles.css
  22. 1 0
      claude-code/claude-code-qiwe-assistant/mcp/src/providers/fmode-wecom-gateway.js
  23. 40 1
      claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-broker-playbook-run.js
  24. 127 5
      claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-customer-ops-run.js
  25. 6 18
      claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-customer-transfer-run.js
  26. 257 11
      claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-group-management-run.js
  27. 2 0
      claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-login-run.js
  28. 248 96
      claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-portrait-tags-run.js
  29. 376 102
      claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-webhook-relay-run.js
  30. 1 0
      claude-code/claude-code-qiwe-assistant/package.json
  31. 39 0
      claude-code/claude-code-qiwe-assistant/scripts/account-connection-monitor-smoke-test.js
  32. 109 0
      claude-code/claude-code-qiwe-assistant/scripts/friend-polling-worker.js
  33. 307 0
      claude-code/claude-code-qiwe-assistant/scripts/message-polling-worker.js
  34. 6 33
      claude-code/claude-code-qiwe-assistant/scripts/smoke-test.js
  35. 15 15
      claude-code/claude-code-qiwe-assistant/scripts/start-relay-client.js
  36. 16 7
      claude-code/claude-code-qiwe-assistant/skills/qiwei-portrait-tags/SKILL.md
  37. 13 41
      claude-code/claude-code-qiwe-assistant/skills/qiwei-webhook-relay/SKILL.md

+ 11 - 0
claude-code/claude-code-qiwe-assistant/docs/OUTPUT-STANDARD.md

@@ -30,6 +30,17 @@ claude-code-qiwei-assistant/
 | `messages/` | 消息发送记录与回执 |
 | `knowledge/` | 持续知识库与知识沉淀(当前注册 `meetings/`) |
 | `goals/` | 目标、里程碑与行动项状态 |
+| `groups/` | 外部群同步结果与确认/导入映射 |
+| `portraits/` | 客户画像 JSON |
+| `broker-playbooks/` | 顾问 playbook JSON |
+| `tags/` | 本地客户标签 |
+| `transfers/` | 客户交接包 |
+| `voice/` | 语音消息本地文件与转写结果 |
+| `webhook/` | Relay / 本地 webhook 回调事件(按 run 目录归档) |
+| `relay/` | Relay 客户端运行日志与状态 |
+| `customers/` | 客户档案(customerId 为主键) |
+| `brokers/` | 经纪人/顾问档案(brokerId 为主键) |
+| `devices/` | 设备 guid → wecomUserId / brokerId 映射 |
 | `smoke/` | 冒烟测试产物 |
 | `tmp/` | 临时文件,可随时清理 |
 

+ 0 - 0
claude-code/claude-code-qiwe-assistant/docs/DASHBOARD-DEV-LOG.md → claude-code/claude-code-qiwe-assistant/docs/guides/DASHBOARD-DEV-LOG.md


+ 160 - 0
claude-code/claude-code-qiwe-assistant/mcp/src/core/account-connection-monitor.js

@@ -0,0 +1,160 @@
+'use strict';
+
+function cloneResult(result) {
+  return {
+    ...(result || {}),
+    summary: { ...(result && result.summary) },
+    data: { ...(result && result.data) },
+    warnings: [...((result && result.warnings) || [])],
+  };
+}
+
+function createAccountConnectionMonitor({
+  checkStatus,
+  recoverLogin,
+  pollIntervalMs = 15000,
+  cacheTtlMs = 5000,
+  graceMs = 120000,
+  offlineConfirmations = 2,
+  recoveryCooldownMs = 60000,
+  now = () => Date.now(),
+  setIntervalFn = setInterval,
+  clearIntervalFn = clearInterval,
+} = {}) {
+  if (typeof checkStatus !== 'function') throw new TypeError('checkStatus is required');
+
+  const state = {
+    uid: '', lastOnlineResult: null, lastOnlineAt: 0, lastProbeAt: 0,
+    lastOutput: null, consecutiveOffline: 0, consecutiveErrors: 0, lastRecoveryAt: 0,
+  };
+  let inFlight = null;
+  let timer = null;
+
+  function resetForUid(uid) {
+    if (!uid || !state.uid || uid === state.uid) {
+      if (uid) state.uid = uid;
+      return;
+    }
+    Object.assign(state, {
+      uid, lastOnlineResult: null, lastOnlineAt: 0, lastOutput: null,
+      consecutiveOffline: 0, consecutiveErrors: 0, lastRecoveryAt: 0,
+    });
+  }
+
+  function decorate(result, connectionState, observedOnline, extra = {}) {
+    const output = cloneResult(result);
+    const stableOnline = connectionState === 'online' || connectionState === 'verifying';
+    output.summary = {
+      ...output.summary,
+      online: stableOnline,
+      observedOnline,
+      connectionState,
+      lastOnlineAt: state.lastOnlineAt ? new Date(state.lastOnlineAt).toISOString() : null,
+      consecutiveOffline: state.consecutiveOffline,
+      consecutiveErrors: state.consecutiveErrors,
+      ...extra,
+    };
+    output.data = { ...output.data, online: stableOnline, observedOnline, connectionState };
+    return output;
+  }
+
+  function verifyingResult(observed, reason) {
+    const output = decorate(state.lastOnlineResult || observed, 'verifying', false, { transientReason: reason });
+    output.warnings.push(reason === 'probe_error'
+      ? '账号状态查询暂时失败,正在使用最近一次可信在线状态复核。'
+      : '远端短暂返回离线,正在进行连续复核。');
+    return output;
+  }
+
+  async function runProbe() {
+    const checkedAt = now();
+    let observed;
+    try {
+      observed = await checkStatus();
+    } catch (error) {
+      observed = {
+        status: 'error', summary: { errorKind: 'upstream' }, data: {}, warnings: [],
+        errors: [String(error && error.message ? error.message : error)],
+      };
+    }
+
+    state.lastProbeAt = checkedAt;
+    resetForUid(String(observed?.summary?.uid || observed?.data?.uid || '').trim());
+    const successfulProbe = observed?.status === 'ok' && observed?.summary?.online !== undefined;
+    const observedOnline = successfulProbe && Boolean(observed.summary.online);
+
+    if (observedOnline) {
+      state.lastOnlineAt = checkedAt;
+      state.lastOnlineResult = cloneResult(observed);
+      state.consecutiveOffline = 0;
+      state.consecutiveErrors = 0;
+      state.lastOutput = decorate(observed, 'online', true);
+      return state.lastOutput;
+    }
+
+    if (!successfulProbe) {
+      state.consecutiveErrors += 1;
+      const withinGrace = state.lastOnlineAt && checkedAt - state.lastOnlineAt < graceMs;
+      state.lastOutput = withinGrace ? verifyingResult(observed, 'probe_error') : decorate(observed, 'unknown', false);
+      return state.lastOutput;
+    }
+
+    state.consecutiveErrors = 0;
+    state.consecutiveOffline += 1;
+    const withinGrace = state.lastOnlineAt && checkedAt - state.lastOnlineAt < graceMs;
+    if (state.consecutiveOffline < offlineConfirmations && withinGrace) {
+      state.lastOutput = verifyingResult(observed, 'offline_sample');
+      return state.lastOutput;
+    }
+
+    const statusCode = Number(observed?.summary?.statusCode ?? observed?.data?.statusCode);
+    const canRecover = statusCode === 0 && typeof recoverLogin === 'function';
+    if (canRecover && checkedAt - state.lastRecoveryAt >= recoveryCooldownMs) {
+      state.lastRecoveryAt = checkedAt;
+      try {
+        const recovered = await recoverLogin();
+        const recoveredCode = Number(recovered?.summary?.statusCode);
+        if (recovered?.summary?.loggedIn || recoveredCode === 2) {
+          const verified = await checkStatus();
+          if (verified?.status === 'ok' && verified?.summary?.online) {
+            state.lastOnlineAt = now();
+            state.lastOnlineResult = cloneResult(verified);
+            state.consecutiveOffline = 0;
+            state.consecutiveErrors = 0;
+            state.lastOutput = decorate(verified, 'online', true, { autoRecovered: true });
+            return state.lastOutput;
+          }
+        }
+      } catch {
+        // The confirmed offline result below remains authoritative.
+      }
+    }
+
+    state.lastOutput = decorate(observed, 'offline', false, { recoveryPending: canRecover });
+    return state.lastOutput;
+  }
+
+  async function getStatus({ force = false } = {}) {
+    if (!force && state.lastOutput && now() - state.lastProbeAt < cacheTtlMs) return state.lastOutput;
+    if (inFlight) return inFlight;
+    inFlight = runProbe().finally(() => { inFlight = null; });
+    return inFlight;
+  }
+
+  function start() {
+    if (timer) return timer;
+    timer = setIntervalFn(() => { void getStatus({ force: true }); }, pollIntervalMs);
+    if (timer && typeof timer.unref === 'function') timer.unref();
+    return timer;
+  }
+
+  function stop() {
+    if (!timer) return;
+    clearIntervalFn(timer);
+    timer = null;
+  }
+
+  return { getStatus, start, stop, state };
+}
+
+module.exports = { createAccountConnectionMonitor };

+ 30 - 0
claude-code/claude-code-qiwe-assistant/mcp/src/core/credentials.js

@@ -259,6 +259,34 @@ function readQiweiGuid(input = {}) {
   ]);
 }
 
+function readFmodeApiKey(input = {}) {
+  const fmodeConfig = readFmodeConfig();
+  return firstNonEmpty([
+    input.fmodeApiKey,
+    input.newapiToken,
+    input.fmodeApiToken,
+    process.env.FMODE_API_KEY,
+    process.env.FMODE_API_TOKEN,
+    process.env.NEWAPI_TOKEN,
+    fmodeConfig.newapiToken,
+    fmodeConfig.fmodeApiToken,
+    fmodeConfig.newApiToken
+  ]);
+}
+
+function readFmodeLlmBase(input = {}) {
+  const fmodeConfig = readFmodeConfig();
+  return String(firstNonEmpty([
+    input.fmodeLlmBase,
+    input.llmBaseUrl,
+    process.env.FMODE_LLM_BASE_URL,
+    process.env.FMODE_API_BASE,
+    fmodeConfig.llmBaseUrl,
+    fmodeConfig.apiBase,
+    'https://api.fmode.cn'
+  ]) || 'https://api.fmode.cn').replace(/\/$/, '');
+}
+
 function ensureQiweiUid(input = {}) {
   const existing = readQiweiUid(input);
   if (existing) return existing;
@@ -277,6 +305,8 @@ module.exports = {
   readQiweiAuthToken,
   readQiweiUid,
   readQiweiGuid,
+  readFmodeApiKey,
+  readFmodeLlmBase,
   ensureQiweiUid,
   readQiweiApiBase,
   saveQiweiClientConfig,

+ 234 - 0
claude-code/claude-code-qiwe-assistant/mcp/src/core/customer-broker-store.js

@@ -0,0 +1,234 @@
+const fs = require('fs');
+const path = require('path');
+const { categoryDir } = require('./output-paths');
+
+function customersDir() {
+  return categoryDir('customers');
+}
+
+function brokersDir() {
+  return categoryDir('brokers');
+}
+
+function customerFilePath(customerId) {
+  return path.join(customersDir(), `${customerId}.json`);
+}
+
+function brokerFilePath(brokerId) {
+  return path.join(brokersDir(), `${brokerId}.json`);
+}
+
+function phoneIndexPath() {
+  return path.join(customersDir(), 'index-phone.json');
+}
+
+function externalUserIdIndexPath() {
+  return path.join(customersDir(), 'index-external-user-id.json');
+}
+
+function safeReadJson(filePath, fallback = null) {
+  try {
+    if (!filePath || !fs.existsSync(filePath)) return fallback;
+    return JSON.parse(fs.readFileSync(filePath, 'utf8'));
+  } catch {
+    return fallback;
+  }
+}
+
+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 normalizePhone(raw) {
+  if (!raw) return '';
+  let value = String(raw).trim().replace(/[^\d]/g, '');
+  if (value.length >= 12 && /^(?:86)?1[3-9]\d{9}$/.test(value)) value = value.replace(/^86/, '');
+  return value;
+}
+
+function buildCustomerId(input = {}) {
+  if (input.customerId) return String(input.customerId).trim();
+  if (input.externalUserId) return String(input.externalUserId).trim();
+  const phone = normalizePhone(input.phone);
+  if (phone) return phone;
+  return `cust-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
+}
+
+function readCustomerById(customerId) {
+  if (!customerId) return null;
+  return safeReadJson(customerFilePath(customerId));
+}
+
+function writeCustomer(customer) {
+  if (!customer || !customer.customerId) throw new Error('customer.customerId 必填');
+  const filePath = customerFilePath(customer.customerId);
+  const existing = safeReadJson(filePath) || {};
+  const now = new Date().toISOString();
+  const record = {
+    ...existing,
+    ...customer,
+    customerId: customer.customerId,
+    createdAt: existing.createdAt || now,
+    updatedAt: now
+  };
+  atomicWriteJson(filePath, record);
+  rebuildCustomerIndexIfNeeded();
+  return record;
+}
+
+function readPhoneIndex() {
+  return safeReadJson(phoneIndexPath(), {});
+}
+
+function readExternalUserIdIndex() {
+  return safeReadJson(externalUserIdIndexPath(), {});
+}
+
+function writePhoneIndex(index) {
+  atomicWriteJson(phoneIndexPath(), index);
+}
+
+function writeExternalUserIdIndex(index) {
+  atomicWriteJson(externalUserIdIndexPath(), index);
+}
+
+function rebuildCustomerIndexIfNeeded() {
+  const dir = customersDir();
+  if (!fs.existsSync(dir)) return;
+  const phoneIndex = {};
+  const externalUserIdIndex = {};
+  for (const file of fs.readdirSync(dir)) {
+    if (!file.endsWith('.json') || file.startsWith('index-')) continue;
+    const customerId = file.replace(/\.json$/, '');
+    const customer = safeReadJson(path.join(dir, file));
+    if (!customer) continue;
+    const phone = normalizePhone(customer.phone);
+    if (phone) phoneIndex[phone] = customer.customerId || customerId;
+    if (customer.externalUserId) externalUserIdIndex[customer.externalUserId] = customer.customerId || customerId;
+  }
+  writePhoneIndex(phoneIndex);
+  writeExternalUserIdIndex(externalUserIdIndex);
+}
+
+function readCustomerByExternalUserId(externalUserId) {
+  if (!externalUserId) return null;
+  const index = readExternalUserIdIndex();
+  const customerId = index[externalUserId];
+  if (customerId) {
+    const customer = readCustomerById(customerId);
+    if (customer) return customer;
+  }
+  // fallback: 扫描文件
+  const dir = customersDir();
+  if (!fs.existsSync(dir)) return null;
+  for (const file of fs.readdirSync(dir)) {
+    if (!file.endsWith('.json') || file.startsWith('index-')) continue;
+    const customer = safeReadJson(path.join(dir, file));
+    if (customer && customer.externalUserId === externalUserId) return customer;
+  }
+  return null;
+}
+
+function readCustomerByPhone(phone) {
+  const normalized = normalizePhone(phone);
+  if (!normalized) return null;
+  const index = readPhoneIndex();
+  const customerId = index[normalized];
+  if (customerId) {
+    const customer = readCustomerById(customerId);
+    if (customer) return customer;
+  }
+  // fallback: 扫描文件
+  const dir = customersDir();
+  if (!fs.existsSync(dir)) return null;
+  for (const file of fs.readdirSync(dir)) {
+    if (!file.endsWith('.json') || file.startsWith('index-')) continue;
+    const customer = safeReadJson(path.join(dir, file));
+    if (customer && normalizePhone(customer.phone) === normalized) return customer;
+  }
+  return null;
+}
+
+function readBrokerById(brokerId) {
+  if (!brokerId) return null;
+  return safeReadJson(brokerFilePath(brokerId));
+}
+
+function writeBroker(broker) {
+  if (!broker || !broker.brokerId) throw new Error('broker.brokerId 必填');
+  const filePath = brokerFilePath(broker.brokerId);
+  const existing = safeReadJson(filePath) || {};
+  const now = new Date().toISOString();
+  const guidList = Array.from(new Set([
+    ...(existing.guidList || []),
+    ...(broker.guidList || [])
+  ].filter(Boolean)));
+  const record = {
+    ...existing,
+    ...broker,
+    brokerId: broker.brokerId,
+    guidList,
+    createdAt: existing.createdAt || now,
+    updatedAt: now
+  };
+  atomicWriteJson(filePath, record);
+  return record;
+}
+
+function readBrokerByWecomUserId(wecomUserId) {
+  if (!wecomUserId) return null;
+  const dir = brokersDir();
+  if (!fs.existsSync(dir)) return null;
+  for (const file of fs.readdirSync(dir)) {
+    if (!file.endsWith('.json')) continue;
+    const broker = safeReadJson(path.join(dir, file));
+    if (broker && broker.wecomUserId === wecomUserId) return broker;
+  }
+  return null;
+}
+
+function bindBrokerToDevice(brokerId, guid) {
+  if (!brokerId || !guid) return null;
+  const broker = readBrokerById(brokerId) || { brokerId };
+  broker.guidList = Array.from(new Set([...(broker.guidList || []), guid].filter(Boolean)));
+  return writeBroker(broker);
+}
+
+function buildCustomerPhoneIndex() {
+  rebuildCustomerIndexIfNeeded();
+  return readPhoneIndex();
+}
+
+function buildCustomerExternalUserIdIndex() {
+  rebuildCustomerIndexIfNeeded();
+  return readExternalUserIdIndex();
+}
+
+module.exports = {
+  normalizePhone,
+  buildCustomerId,
+  readCustomerById,
+  writeCustomer,
+  readCustomerByExternalUserId,
+  readCustomerByPhone,
+  readBrokerById,
+  writeBroker,
+  readBrokerByWecomUserId,
+  bindBrokerToDevice,
+  buildCustomerPhoneIndex,
+  buildCustomerExternalUserIdIndex,
+  customerFilePath,
+  brokerFilePath,
+  phoneIndexPath,
+  externalUserIdIndexPath
+};

+ 738 - 0
claude-code/claude-code-qiwe-assistant/mcp/src/core/dashboard-state.js

@@ -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
+};

+ 130 - 0
claude-code/claude-code-qiwe-assistant/mcp/src/core/device-broker-mapping.js

@@ -0,0 +1,130 @@
+const fs = require('fs');
+const path = require('path');
+const { categoryDir, latestPath } = require('./output-paths');
+
+function devicesDir() {
+  return categoryDir('devices');
+}
+
+function deviceFilePath(guid) {
+  return path.join(devicesDir(), `${guid}.json`);
+}
+
+function safeReadJson(filePath, fallback = null) {
+  try {
+    if (!filePath || !fs.existsSync(filePath)) return fallback;
+    return JSON.parse(fs.readFileSync(filePath, 'utf8'));
+  } catch {
+    return fallback;
+  }
+}
+
+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 readDeviceBrokerMapping() {
+  const dir = devicesDir();
+  if (!fs.existsSync(dir)) return {};
+  const mapping = {};
+  for (const file of fs.readdirSync(dir)) {
+    if (!file.endsWith('.json')) continue;
+    const guid = file.replace(/\.json$/, '');
+    const data = safeReadJson(path.join(dir, file));
+    if (data && data.guid) {
+      mapping[data.guid] = data;
+    } else if (data) {
+      mapping[guid] = { ...data, guid };
+    }
+  }
+  return mapping;
+}
+
+function writeDeviceBrokerMapping(mapping) {
+  const dir = devicesDir();
+  fs.mkdirSync(dir, { recursive: true });
+  for (const [guid, record] of Object.entries(mapping)) {
+    atomicWriteJson(deviceFilePath(guid), { ...record, guid });
+  }
+  return dir;
+}
+
+function resolveBrokerIdForGuid(guid) {
+  if (!guid) return null;
+  const record = safeReadJson(deviceFilePath(guid));
+  return record && record.brokerId ? record.brokerId : null;
+}
+
+function resolveWecomUserIdForGuid(guid) {
+  if (!guid) return null;
+  const record = safeReadJson(deviceFilePath(guid));
+  return record && record.wecomUserId ? record.wecomUserId : null;
+}
+
+function resolveBrokerIdForWecomUserId(wecomUserId) {
+  if (!wecomUserId) return null;
+  const mapping = readDeviceBrokerMapping();
+  for (const record of Object.values(mapping)) {
+    if (record.wecomUserId === wecomUserId && record.brokerId) {
+      return record.brokerId;
+    }
+  }
+  return null;
+}
+
+function resolveWecomUserIdForBrokerId(brokerId) {
+  if (!brokerId) return null;
+  const mapping = readDeviceBrokerMapping();
+  for (const record of Object.values(mapping)) {
+    if (record.brokerId === brokerId && record.wecomUserId) {
+      return record.wecomUserId;
+    }
+  }
+  return null;
+}
+
+function recordDeviceGuid(guid, detail = {}) {
+  if (!guid) return null;
+  const filePath = deviceFilePath(guid);
+  const existing = safeReadJson(filePath) || {};
+  const record = {
+    ...existing,
+    guid,
+    wecomUserId: detail.wecomUserId || existing.wecomUserId || undefined,
+    nickname: detail.nickname || existing.nickname || undefined,
+    brokerId: detail.brokerId || existing.brokerId || undefined,
+    recordedAt: existing.recordedAt || new Date().toISOString()
+  };
+  if (detail.wecomUserId || detail.nickname || detail.brokerId) {
+    record.updatedAt = new Date().toISOString();
+  }
+  atomicWriteJson(filePath, record);
+  return record;
+}
+
+function bindBrokerToGuid(guid, brokerId) {
+  if (!guid) return null;
+  return recordDeviceGuid(guid, { brokerId });
+}
+
+module.exports = {
+  readDeviceBrokerMapping,
+  writeDeviceBrokerMapping,
+  resolveBrokerIdForGuid,
+  resolveWecomUserIdForGuid,
+  resolveBrokerIdForWecomUserId,
+  resolveWecomUserIdForBrokerId,
+  recordDeviceGuid,
+  bindBrokerToGuid,
+  deviceFilePath
+};

+ 185 - 0
claude-code/claude-code-qiwe-assistant/mcp/src/core/llm-client.js

@@ -0,0 +1,185 @@
+const { readFmodeApiKey, readFmodeLlmBase } = require('./credentials');
+const { redactSecret } = require('../providers/fmode-wecom-gateway');
+
+const DEFAULT_MODEL = 'deepseek-v4-pro';
+const DEFAULT_TIMEOUT_MS = 120000;
+
+function buildLlmUrl(apiBase) {
+  const root = String(apiBase || readFmodeLlmBase()).replace(/\/$/, '');
+  return `${root}/v1/chat/completions`;
+}
+
+function buildPortraitAnalysisPrompt(context) {
+  const messages = Array.isArray(context.sampleMessages) ? context.sampleMessages : [];
+  const messageLines = messages.map((m, idx) => {
+    const time = m.timestamp ? new Date(m.timestamp).toLocaleString('zh-CN') : '未知时间';
+    const sender = m.senderName || m.senderId || '未知';
+    const text = m.content || '';
+    return `${idx + 1}. [${time}] ${sender}: ${text}`;
+  }).join('\n');
+
+  return `你是房产经纪行业的客户画像分析专家。请根据下面这位客户在企微群聊中的历史消息,生成一份结构化客户画像 JSON。
+
+客户 externalUserId:${context.externalUserId}
+消息数量:${context.messageCount || messages.length}
+
+历史消息:
+${messageLines || '(无文本消息)'}
+
+分析维度(JSON 字段):
+- intent: 购房/租房/出售/置换/投资等意图;无信号填 null
+- budgetRange: 预算范围,例如 "200-300万"、"首付100万";无信号填 null
+- preferredAreas: 意向区域数组;无信号填 null
+- houseType: 房型偏好;无信号填 null
+- timeline: 时间线;无信号填 null
+- keyConcerns: 关键关注点数组(学区、地铁、医院、商圈、装修、物业、停车、电梯、采光等);无信号填 null
+- urgency: 急迫程度(高/中/低/null)
+- aiSummary: 用 1-3 句话总结客户状态
+- personaType: 客户 persona 类型,例如 "刚需首套"、"改善置换"、"投资客"、"潜在客户(待激活)" 等
+- fiveW2H: 对象,包含 Who/What/When/Where/Why/How/HowMuch
+- priorityMatrix: 对象,包含 needsClarity/engagement/actionPriority
+- confidence: 对画像整体置信度(high/medium/low)
+- coreAnxiety: 核心顾虑;无信号填 null
+- decisionMaker: 决策人情况;无信号填 null
+- loanCapacity: 贷款能力;无信号填 null
+- negotiationStage: 当前谈判/跟进阶段
+
+要求:
+1. 只输出合法 JSON,不要 markdown 代码块,不要额外解释。
+2. 没有依据的字段必须填 null 或 "暂无法推断",禁止编造预算、区域、房型。
+3. 如果消息极少或没有有效需求信息,confidence 必须为 low,aiSummary 要如实说明。
+4. 字段名必须严格使用上述英文,值可用中文。`;
+}
+
+function extractJsonFromLlmOutput(text) {
+  if (!text) return null;
+  const trimmed = String(text).trim();
+
+  // 尝试直接解析
+  try {
+    return JSON.parse(trimmed);
+  } catch {
+    // ignore
+  }
+
+  // 尝试从 markdown 代码块中提取
+  const codeBlockMatch = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/);
+  if (codeBlockMatch) {
+    try {
+      return JSON.parse(codeBlockMatch[1].trim());
+    } catch {
+      // ignore
+    }
+  }
+
+  // 尝试从第一个 { 到最后一个 } 提取
+  const firstBrace = trimmed.indexOf('{');
+  const lastBrace = trimmed.lastIndexOf('}');
+  if (firstBrace >= 0 && lastBrace > firstBrace) {
+    try {
+      return JSON.parse(trimmed.slice(firstBrace, lastBrace + 1));
+    } catch {
+      // ignore
+    }
+  }
+
+  return null;
+}
+
+async function callLlmChat({ messages, model, apiKey, apiBase, timeoutMs, temperature }) {
+  const key = apiKey || readFmodeApiKey();
+  const base = apiBase || readFmodeLlmBase();
+  if (!key) {
+    const err = new Error('缺少 Fmode API Key,请在 .env.local 配置 FMODE_API_KEY 或确保 ~/.fmode/config.json 存在');
+    err.kind = 'auth';
+    throw err;
+  }
+
+  const url = buildLlmUrl(base);
+  const controller = new AbortController();
+  const timer = setTimeout(() => controller.abort(), Math.max(5000, Number(timeoutMs) || DEFAULT_TIMEOUT_MS));
+
+  try {
+    const response = await fetch(url, {
+      method: 'POST',
+      headers: {
+        Authorization: `Bearer ${key}`,
+        'Content-Type': 'application/json'
+      },
+      body: JSON.stringify({
+        model: model || DEFAULT_MODEL,
+        messages,
+        temperature: temperature !== undefined ? temperature : 0.3
+      }),
+      signal: controller.signal
+    });
+
+    const text = await response.text();
+    let json;
+    try {
+      json = JSON.parse(text);
+    } catch {
+      json = null;
+    }
+
+    if (!response.ok) {
+      const message = json?.error?.message || json?.message || text.slice(0, 500) || `HTTP ${response.status}`;
+      const err = new Error(`LLM 请求失败:${redactSecret(message)}`);
+      err.kind = response.status === 401 || response.status === 403 ? 'auth' : 'upstream';
+      err.httpStatus = response.status;
+      throw err;
+    }
+
+    const content = json?.choices?.[0]?.message?.content;
+    if (!content) {
+      const err = new Error('LLM 返回为空,无法解析画像');
+      err.kind = 'upstream';
+      throw err;
+    }
+
+    return { content, usage: json?.usage || null };
+  } catch (error) {
+    if (error.name === 'AbortError') {
+      const err = new Error('LLM 请求超时');
+      err.kind = 'upstream';
+      throw err;
+    }
+    throw error;
+  } finally {
+    clearTimeout(timer);
+  }
+}
+
+async function analyzePortraitWithLlm(context, options = {}) {
+  const promptMessages = [
+    { role: 'system', content: '你是一位严谨的客户画像分析助手,只输出合法 JSON,不编造无依据的信息。' },
+    { role: 'user', content: buildPortraitAnalysisPrompt(context) }
+  ];
+
+  const { content, usage } = await callLlmChat({
+    messages: promptMessages,
+    model: options.model,
+    apiKey: options.apiKey,
+    apiBase: options.apiBase,
+    timeoutMs: options.timeoutMs,
+    temperature: options.temperature
+  });
+
+  const portrait = extractJsonFromLlmOutput(content);
+  if (!portrait || typeof portrait !== 'object') {
+    const err = new Error('LLM 输出无法解析为 JSON');
+    err.kind = 'upstream';
+    err.rawOutput = content.slice(0, 2000);
+    throw err;
+  }
+
+  return { portrait, rawOutput: content, usage };
+}
+
+module.exports = {
+  analyzePortraitWithLlm,
+  callLlmChat,
+  buildPortraitAnalysisPrompt,
+  extractJsonFromLlmOutput,
+  DEFAULT_MODEL
+};

+ 5 - 1
claude-code/claude-code-qiwe-assistant/mcp/src/core/output-paths.js

@@ -21,8 +21,12 @@ const OUTPUT_CATEGORIES = Object.freeze([
   'voice',
   'webhook',
   'relay',
+  'customers',
+  'brokers',
+  'devices',
   'smoke',
-  'tmp'
+  'tmp',
+  'dashboard'
 ]);
 
 const OUTPUT_PERSISTENT_STORES = Object.freeze({

+ 28 - 24
claude-code/claude-code-qiwe-assistant/mcp/src/core/relay-config.js

@@ -1,7 +1,7 @@
 const fs = require('fs');
 const path = require('path');
 const os = require('os');
-const { outputsRoot } = require('./output-paths');
+const { PACKAGE_ROOT, outputsRoot } = require('./output-paths');
 
 const RELAY_CONFIG_FILE = path.join(outputsRoot(), 'webhook', 'relay-config.json');
 const CROSS_PROJECT_CREDENTIALS_FILE = path.join(os.homedir(), '.qiwe-skill', 'relay-credentials.json');
@@ -16,27 +16,34 @@ function ensureDir(filePath) {
 function readJsonMaybe(filePath) {
   try {
     if (!filePath || !fs.existsSync(filePath)) return {};
-    return JSON.parse(fs.readFileSync(filePath, 'utf8').replace(/^/, ''));
+    return JSON.parse(fs.readFileSync(filePath, 'utf8').replace(/^\uFEFF/, ''));
   } catch {
     return {};
   }
 }
 
 function readEnvLocal() {
-  const envPath = path.resolve(process.cwd(), '.env.local');
-  if (!fs.existsSync(envPath)) return {};
+  const candidates = [
+    path.resolve(process.cwd(), '.env.local'),
+    path.resolve(process.cwd(), '.env'),
+    path.join(PACKAGE_ROOT, '.env.local'),
+    path.join(PACKAGE_ROOT, '.env')
+  ];
   const env = {};
-  const content = fs.readFileSync(envPath, 'utf8').replace(/^/, '');
-  for (const rawLine of content.split(/\r?\n/)) {
-    const line = rawLine.trim();
-    if (!line || line.startsWith('#')) continue;
-    const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/);
-    if (!match) continue;
-    let value = match[2].trim();
-    if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
-      value = value.slice(1, -1);
+  for (const envPath of [...new Set(candidates)]) {
+    if (!fs.existsSync(envPath)) continue;
+    const content = fs.readFileSync(envPath, 'utf8').replace(/^\uFEFF/, '');
+    for (const rawLine of content.split(/\r?\n/)) {
+      const line = rawLine.trim();
+      if (!line || line.startsWith('#')) continue;
+      const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/);
+      if (!match || env[match[1]]) continue;
+      let value = match[2].trim();
+      if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
+        value = value.slice(1, -1);
+      }
+      env[match[1]] = value;
     }
-    env[match[1]] = value;
   }
   return env;
 }
@@ -57,7 +64,7 @@ function writeRelayConfigFile(config) {
 
 function isRelayEnabled() {
   const env = readEnvLocal();
-  const baseUrl = process.env.RELAY_BASE_URL || env.RELAY_BASE_URL || '';
+  const baseUrl = process.env.RELAY_BASE_URL || env.RELAY_BASE_URL || readRelayConfigFile().relayBaseUrl || '';
   const apiKey = process.env.TENANT_API_KEY || env.TENANT_API_KEY || '';
   const apiSecret = process.env.TENANT_API_SECRET || env.TENANT_API_SECRET || '';
   return !!(baseUrl && apiKey && apiSecret);
@@ -65,7 +72,7 @@ function isRelayEnabled() {
 
 function getRelayBaseUrl() {
   const env = readEnvLocal();
-  return (process.env.RELAY_BASE_URL || env.RELAY_BASE_URL || 'http://8.138.37.248:4000').replace(/\/$/, '');
+  return (process.env.RELAY_BASE_URL || env.RELAY_BASE_URL || readRelayConfigFile().relayBaseUrl || 'http://8.138.37.248:4000').replace(/\/$/, '');
 }
 
 function getTenantApiKey() {
@@ -115,7 +122,7 @@ function normalizePrivateKey(privateKey) {
 }
 
 function saveRelayCredentialsToEnv(creds, envPath) {
-  const targetPath = envPath || path.resolve(process.cwd(), '.env.local');
+  const targetPath = envPath || path.join(PACKAGE_ROOT, '.env.local');
   ensureDir(targetPath);
   let content = '';
   if (fs.existsSync(targetPath)) {
@@ -134,7 +141,7 @@ function saveRelayCredentialsToEnv(creds, envPath) {
 
   for (const [key, value] of Object.entries(entries)) {
     if (!value) continue;
-    const regex = new RegExp(`^${key}\s*=.*$`, 'm');
+    const regex = new RegExp(`^${key}\\s*=.*$`, 'm');
     const line = `${key}=${value}`;
     if (regex.test(content)) {
       content = content.replace(regex, line);
@@ -145,7 +152,6 @@ function saveRelayCredentialsToEnv(creds, envPath) {
 
   fs.writeFileSync(targetPath, content.trim() + '\n', 'utf8');
 
-  // 同时更新当前进程环境变量,使后续调用立即生效
   for (const [key, value] of Object.entries(entries)) {
     if (value) process.env[key] = value;
   }
@@ -156,14 +162,12 @@ function saveRelayCredentialsToEnv(creds, envPath) {
 function saveRelayCredentials(creds) {
   const saved = [];
 
-  // 保存到 .env.local
   try {
     saved.push(saveRelayCredentialsToEnv(creds));
   } catch (err) {
-    console.warn('[RelayConfig] 保存到 .env.local 失败:', err.message);
+    console.warn('[RelayConfig] Failed to save .env.local:', err.message);
   }
 
-  // 保存公钥/租户信息到 relay-config.json
   try {
     writeRelayConfigFile({
       relayBaseUrl: creds.relayBaseUrl || getRelayBaseUrl(),
@@ -173,7 +177,7 @@ function saveRelayCredentials(creds) {
     });
     saved.push(RELAY_CONFIG_FILE);
   } catch (err) {
-    console.warn('[RelayConfig] 保存到 relay-config.json 失败:', err.message);
+    console.warn('[RelayConfig] Failed to save relay-config.json:', err.message);
   }
 
   return saved;
@@ -189,7 +193,7 @@ function saveRelayCredentialsToFile(creds) {
     );
     return CROSS_PROJECT_CREDENTIALS_FILE;
   } catch (err) {
-    console.warn('[RelayConfig] 保存跨项目凭证失败:', err.message);
+    console.warn('[RelayConfig] Failed to save cross-project credentials:', err.message);
     return null;
   }
 }

+ 58 - 0
claude-code/claude-code-qiwe-assistant/mcp/src/core/webhook-config.js

@@ -0,0 +1,58 @@
+const fs = require('fs');
+const path = require('path');
+const { outputsRoot } = require('./output-paths');
+
+function webhookDir() {
+  return path.join(outputsRoot(), 'webhook');
+}
+
+function ensureWebhookDir() {
+  fs.mkdirSync(webhookDir(), { recursive: true });
+}
+
+function configPath() {
+  ensureWebhookDir();
+  return path.join(webhookDir(), 'webhook-config.json');
+}
+
+function readConfig() {
+  const filePath = configPath();
+  if (!fs.existsSync(filePath)) return { callbackUrl: null, secret: null, autoCreateGroup: true };
+  try {
+    return { callbackUrl: null, secret: null, autoCreateGroup: true, ...JSON.parse(fs.readFileSync(filePath, 'utf8')) };
+  } catch {
+    return { callbackUrl: null, secret: null, autoCreateGroup: true };
+  }
+}
+
+function writeConfig(config) {
+  ensureWebhookDir();
+  fs.writeFileSync(configPath(), JSON.stringify({ ...readConfig(), ...config, updatedAt: new Date().toISOString() }, null, 2), 'utf8');
+}
+
+function relayConfigPath() {
+  ensureWebhookDir();
+  return path.join(webhookDir(), 'relay-config.json');
+}
+
+function readRelayConfig() {
+  const filePath = relayConfigPath();
+  if (!fs.existsSync(filePath)) return {};
+  try {
+    return JSON.parse(fs.readFileSync(filePath, 'utf8'));
+  } catch {
+    return {};
+  }
+}
+
+function writeRelayConfig(config) {
+  ensureWebhookDir();
+  fs.writeFileSync(relayConfigPath(), JSON.stringify({ ...readRelayConfig(), ...config, updatedAt: new Date().toISOString() }, null, 2), 'utf8');
+}
+
+module.exports = {
+  readConfig,
+  writeConfig,
+  readRelayConfig,
+  writeRelayConfig
+};

+ 666 - 0
claude-code/claude-code-qiwe-assistant/mcp/src/core/webhook-processor.js

@@ -0,0 +1,666 @@
+const fs = require('fs');
+const path = require('path');
+const { outputsRoot } = require('./output-paths');
+const { readQiweiGuid } = require('./credentials');
+const {
+  readCustomerByExternalUserId,
+  readCustomerByPhone,
+  writeCustomer,
+  readBrokerById,
+  readBrokerByWecomUserId,
+  normalizePhone
+} = require('./customer-broker-store');
+const {
+  resolveBrokerIdForGuid,
+  resolveWecomUserIdForGuid
+} = require('./device-broker-mapping');
+const {
+  saveWebhookEventStructured,
+  isDuplicateEvent,
+  markEventProcessed,
+  updateWebhookEventStatus
+} = require('./webhook-store');
+const { parseWebhookEnvelope, ParsedWebhookEvent, SystemMsgType, decodeChangedMemberList } = require('./webhook-types');
+const {
+  qiweiAutoCreateGroup,
+  qiweiCheckFriendStatus
+} = require('../tools/qiwei-customer-ops-run');
+const {
+  readImportedMapping,
+  writeImportedMapping,
+  readConfirmedMapping,
+  writeConfirmedMapping,
+  updateConfirmedMapping,
+  updateImportedMapping,
+  appendWebhookMessage,
+  messageExists
+} = require('../tools/qiwei-group-management-run');
+const { enqueuePortraitUpdate } = require('../tools/qiwei-portrait-tags-run');
+const { qiweiTranscribeVoice } = require('../tools/qiwei-voice-run');
+
+const REQUIRED_METHODS = {
+  getWxContactList: '/contact/getWxContactList'
+};
+
+function webhookConfigPath() {
+  return path.join(outputsRoot(), 'webhook', 'webhook-config.json');
+}
+
+function readWebhookConfig() {
+  try {
+    if (!fs.existsSync(webhookConfigPath())) return { callbackUrl: null, secret: null, autoCreateGroup: true };
+    return { callbackUrl: null, secret: null, autoCreateGroup: true, ...JSON.parse(fs.readFileSync(webhookConfigPath(), 'utf8')) };
+  } catch {
+    return { callbackUrl: null, secret: null, autoCreateGroup: true };
+  }
+}
+
+function groupsDir() {
+  return path.join(outputsRoot(), 'groups');
+}
+
+function customersDir() {
+  return path.join(outputsRoot(), 'customers');
+}
+
+function ensureGroupsDir() {
+  fs.mkdirSync(groupsDir(), { recursive: true });
+}
+
+function hasActiveGroup(brokerId, customerId) {
+  const mapping = readConfirmedMapping();
+  for (const entry of Object.values(mapping)) {
+    if (entry.brokerId === brokerId && entry.customerId === customerId && entry.status === 'ACTIVE') {
+      return true;
+    }
+  }
+  return false;
+}
+
+function recordConfirmedGroup({ roomId, roomName, brokerId, customerId, externalUserId, source = 'webhook-auto' }) {
+  const mapping = readConfirmedMapping();
+  mapping[roomId] = {
+    roomId,
+    roomName: roomName || mapping[roomId]?.roomName,
+    brokerId,
+    customerId,
+    externalUserId,
+    status: 'ACTIVE',
+    reviewStatus: 'AUTO_CONFIRMED',
+    source,
+    confirmedAt: new Date().toISOString(),
+    ...(mapping[roomId] || {})
+  };
+  writeConfirmedMapping(mapping);
+  return mapping[roomId];
+}
+
+function resolveDeviceGuid(input = {}) {
+  if (input.guid) return input.guid;
+  if (input.deviceGuid) return input.deviceGuid;
+  return readQiweiGuid();
+}
+
+function buildContext(input = {}) {
+  const { buildContext: sharedBuildContext } = require('./shared-gateway');
+  return sharedBuildContext(input);
+}
+
+function gatewayCall(ctx, method, params) {
+  const { gatewayCall: sharedGatewayCall } = require('./shared-gateway');
+  return sharedGatewayCall(ctx, method, params);
+}
+
+function assertMethodsInCatalog(methods) {
+  const { assertMethodsInCatalog: sharedAssert } = require('./shared-gateway');
+  return sharedAssert(methods);
+}
+
+async function findNewlyAddedContact(guid) {
+  if (!guid) return undefined;
+  try {
+    assertMethodsInCatalog({ getWxContactList: REQUIRED_METHODS.getWxContactList });
+    const ctx = buildContext({ guid });
+    const data = await gatewayCall(ctx, REQUIRED_METHODS.getWxContactList, {
+      guid,
+      currentSeq: 0,
+      limit: 50,
+      bizType: 1
+    });
+    const list = Array.isArray(data && data.contactList) ? data.contactList : [];
+    for (const contact of list) {
+      const userId = String(contact.userId || contact.externalUserId || '');
+      if (!userId) continue;
+      const customer = readCustomerByExternalUserId(userId);
+      if (customer) {
+        console.log(`[Webhook] 在联系人列表中找到匹配客户: ${userId}`);
+        return userId;
+      }
+    }
+    console.log(`[Webhook] 设备 ${guid} 的联系人列表中没有匹配到客户`);
+    return undefined;
+  } catch (err) {
+    console.warn(`[Webhook] 设备 ${guid} 获取联系人列表失败:`, err.message);
+    return undefined;
+  }
+}
+
+async function findNewlyAddedContactAcrossDevices() {
+  // 目标项目简化为:尝试从当前 guid 获取联系人列表
+  const guid = readQiweiGuid();
+  if (!guid) {
+    console.warn('[Webhook] 缺少 guid,无法跨设备查找联系人');
+    return undefined;
+  }
+  return findNewlyAddedContact(guid);
+}
+
+function buildGroupName(customer, broker) {
+  const template = customer.groupNameTemplate || '{name} 专属服务群';
+  const phone = normalizePhone(customer.phone);
+  const phoneSuffix = phone.length >= 4 ? phone.slice(-4) : '';
+  const customerLabel = customer.name || (phoneSuffix ? `客户${phoneSuffix}` : '客户');
+  return template
+    .replace(/{name}/g, customerLabel)
+    .replace(/{phone}/g, phone)
+    .replace(/{brokerName}/g, broker.name || '');
+}
+
+async function triggerAutoCreateGroup(event, eventFilePath = null) {
+  const guid = resolveDeviceGuid(event);
+
+  let externalUserId = event.externalUserId;
+
+  if (!externalUserId && event.msgType === SystemMsgType.CONTACT_EXTERNAL_CHANGE) {
+    externalUserId = await findNewlyAddedContact(guid);
+    if (!externalUserId) {
+      externalUserId = await findNewlyAddedContactAcrossDevices();
+    }
+  }
+
+  if (!externalUserId) {
+    if (eventFilePath) updateWebhookEventStatus(eventFilePath, 'IGNORED', '无法提取 externalUserId');
+    return { success: false, ignored: true, reason: '无法提取 externalUserId' };
+  }
+
+  let customer = readCustomerByExternalUserId(externalUserId);
+  if (!customer && event.contactNickname) {
+    console.log(`[Webhook] 未通过 externalUserId 匹配客户,尝试备注/昵称: ${event.contactNickname}`);
+  }
+
+  if (!customer) {
+    if (eventFilePath) updateWebhookEventStatus(eventFilePath, 'IGNORED', `externalUserId=${externalUserId} 未匹配到客户档案`);
+    return { success: false, ignored: true, reason: '未匹配到客户档案' };
+  }
+
+  const customerId = customer.customerId;
+  let brokerId = customer.brokerId;
+
+  if (!brokerId) {
+    brokerId = resolveBrokerIdForGuid(guid);
+    if (!brokerId) {
+      if (eventFilePath) updateWebhookEventStatus(eventFilePath, 'IGNORED', `客户 ${customerId} 无归属经纪人`);
+      return { success: false, ignored: true, reason: '客户无归属经纪人' };
+    }
+    customer.brokerId = brokerId;
+  }
+
+  const broker = readBrokerById(brokerId);
+  if (!broker) {
+    if (eventFilePath) updateWebhookEventStatus(eventFilePath, 'IGNORED', `经纪人 ${brokerId} 不存在`);
+    return { success: false, ignored: true, reason: '经纪人不存在' };
+  }
+
+  if (!guid) {
+    if (eventFilePath) updateWebhookEventStatus(eventFilePath, 'IGNORED', '没有可用的企微设备');
+    return { success: false, ignored: true, reason: '没有可用的企微设备' };
+  }
+
+  let shouldSaveCustomer = false;
+  if (!customer.externalUserId) {
+    customer.externalUserId = externalUserId;
+    shouldSaveCustomer = true;
+  }
+  if (customer.friendRequestStatus !== 'ACCEPTED') {
+    customer.friendRequestStatus = 'ACCEPTED';
+    shouldSaveCustomer = true;
+  }
+  if (shouldSaveCustomer) {
+    writeCustomer(customer);
+  }
+
+  const checkResult = await qiweiCheckFriendStatus({
+    guid,
+    customers: customer.phone ? [{ phone: customer.phone, name: customer.name }] : [],
+    externalUserIds: customer.phone ? [] : [externalUserId]
+  });
+
+  const confirmed = checkResult.status === 'ok' && checkResult.data && checkResult.data.details && checkResult.data.details.some(d => d.statusText === 'already_friend');
+
+  if (!confirmed) {
+    if (eventFilePath) updateWebhookEventStatus(eventFilePath, 'IGNORED', '二次确认:客户尚未成为好友');
+    return { success: false, ignored: true, reason: '客户尚未成为好友' };
+  }
+
+  if (hasActiveGroup(brokerId, customerId)) {
+    if (eventFilePath) updateWebhookEventStatus(eventFilePath, 'IGNORED', '客户已有活跃服务群,跳过建群');
+    return { success: false, ignored: true, reason: '已有活跃服务群' };
+  }
+
+  const supportMemberIds = Array.isArray(customer.supportBrokerId)
+    ? customer.supportBrokerId
+    : customer.supportBrokerId ? [customer.supportBrokerId] : [];
+  const resolvedSupportIds = [];
+  for (const supportId of supportMemberIds) {
+    if (!supportId || supportId === brokerId) continue;
+    const supportBroker = readBrokerById(supportId);
+    if (supportBroker && supportBroker.wecomUserId) {
+      resolvedSupportIds.push(supportBroker.wecomUserId);
+    }
+  }
+
+  const groupName = buildGroupName(customer, broker);
+
+  const createResult = await qiweiAutoCreateGroup({
+    guid,
+    memberList: [externalUserId],
+    supportMemberIds: resolvedSupportIds,
+    groupName,
+    isOuterRoom: 1
+  });
+
+  if (createResult.status !== 'ok' || !createResult.data || !createResult.data.roomId) {
+    const reason = createResult.assistantMessage || '建群接口调用失败';
+    if (eventFilePath) updateWebhookEventStatus(eventFilePath, 'ERROR', reason);
+    return { success: false, error: reason };
+  }
+
+  const roomId = createResult.data.roomId;
+  const roomName = createResult.data.groupName || groupName;
+
+  recordConfirmedGroup({
+    roomId,
+    roomName,
+    brokerId,
+    customerId,
+    externalUserId,
+    source: 'webhook-auto'
+  });
+
+  if (eventFilePath) updateWebhookEventStatus(eventFilePath, 'AUTO_GROUP_CREATED', `自动建群成功: roomId=${roomId}, groupName=${roomName}`);
+
+  console.log(`[Webhook] 自动建群成功: brokerId=${brokerId}, customerId=${customerId}, roomId=${roomId}`);
+
+  return { success: true, roomId, roomName };
+}
+
+async function identifyServiceGroupMembers(event, raw, changedMemberListRaw) {
+  let brokerId = null;
+
+  // 1. 优先 senderId 匹配 broker.wecomUserId
+  const senderId = raw.senderId ? String(raw.senderId) : '';
+  if (senderId) {
+    const broker = readBrokerByWecomUserId(senderId);
+    if (broker) {
+      brokerId = broker.brokerId;
+      console.log(`[Webhook] GROUP_CREATED: 通过 senderId=${senderId} 识别经纪人: ${brokerId}`);
+    }
+  }
+
+  // 2. 回退 event.guid -> device -> brokerId
+  if (!brokerId && event.guid) {
+    brokerId = resolveBrokerIdForGuid(event.guid);
+    if (brokerId) {
+      console.log(`[Webhook] GROUP_CREATED: 通过 guid=${event.guid} 回退识别经纪人: ${brokerId}`);
+    }
+  }
+
+  // 3. 再回退群成员中匹配 broker.wecomUserId
+  let memberIds = decodeChangedMemberList(changedMemberListRaw);
+  if (!brokerId && memberIds.length) {
+    for (const memberId of memberIds) {
+      const broker = readBrokerByWecomUserId(memberId);
+      if (broker) {
+        brokerId = broker.brokerId;
+        console.log(`[Webhook] GROUP_CREATED: 通过群成员 ${memberId} 识别经纪人: ${brokerId}`);
+        break;
+      }
+    }
+  }
+
+  // 客户识别:排除经纪人后匹配 customer.externalUserId
+  let customerId = null;
+  let customerExternalUserId = null;
+  const brokerWecomUserIds = new Set();
+  if (senderId) brokerWecomUserIds.add(senderId);
+  if (brokerId) {
+    const broker = readBrokerById(brokerId);
+    if (broker && broker.wecomUserId) brokerWecomUserIds.add(broker.wecomUserId);
+  }
+
+  const potentialCustomers = memberIds.filter(id => !brokerWecomUserIds.has(id));
+  for (const memberId of potentialCustomers) {
+    const customer = readCustomerByExternalUserId(memberId);
+    if (customer) {
+      customerId = customer.customerId;
+      customerExternalUserId = memberId;
+      console.log(`[Webhook] GROUP_CREATED: 通过成员 ${memberId} 识别客户: ${customerId}`);
+      break;
+    }
+  }
+
+  return { brokerId, customerId, customerExternalUserId };
+}
+
+async function handleGroupCreateWebhook(event, eventFilePath = null) {
+  const raw = event.raw || {};
+  const roomId = String(raw.fromRoomId || '');
+  if (!roomId || roomId === '0') {
+    if (eventFilePath) updateWebhookEventStatus(eventFilePath, 'IGNORED', '缺少 fromRoomId');
+    return { success: false, ignored: true, reason: '缺少 fromRoomId' };
+  }
+
+  const changedMemberListRaw = raw.changedMemberList;
+  const { brokerId, customerId, customerExternalUserId } = await identifyServiceGroupMembers(event, raw, changedMemberListRaw);
+  const memberIds = decodeChangedMemberList(changedMemberListRaw);
+
+  if (brokerId && customerId) {
+    updateConfirmedMapping(roomId, {
+      roomId,
+      brokerId,
+      customerId,
+      externalUserId: customerExternalUserId,
+      status: 'ACTIVE',
+      reviewStatus: 'AUTO_CONFIRMED',
+      memberCount: memberIds.length,
+      seenAt: new Date().toISOString()
+    });
+    if (eventFilePath) updateWebhookEventStatus(eventFilePath, 'PROCESSED', `自动捕获群创建: roomId=${roomId}, brokerId=${brokerId}, customerId=${customerId}`);
+    console.log(`[Webhook] GROUP_CREATED: 新群 ${roomId} 已录入 (brokerId=${brokerId}, customerId=${customerId})`);
+    return { success: true, brokerId, customerId };
+  }
+
+  if (brokerId && !customerId) {
+    updateImportedMapping(roomId, {
+      roomId,
+      brokerId,
+      customerId: 'orphan',
+      status: 'IMPORTED',
+      reviewStatus: 'IMPORTED',
+      memberCount: memberIds.length,
+      seenAt: new Date().toISOString()
+    });
+    if (eventFilePath) updateWebhookEventStatus(eventFilePath, 'PROCESSED', `群 ${roomId} 已创建 orphan 记录 (brokerId=${brokerId})`);
+    console.log(`[Webhook] GROUP_CREATED: 群 ${roomId} 已创建 orphan 记录(经纪人=${brokerId})`);
+    return { success: true, orphan: true, brokerId };
+  }
+
+  updateImportedMapping(roomId, {
+    roomId,
+    brokerId: null,
+    customerId: customerId || 'orphan',
+    externalUserId: customerExternalUserId,
+    status: 'IMPORTED',
+    reviewStatus: 'IMPORTED',
+    memberCount: memberIds.length,
+    seenAt: new Date().toISOString()
+  });
+  const debugInfo = `senderId=${raw.senderId || 'N/A'}, guid=${event.guid || 'N/A'}`;
+  if (eventFilePath) updateWebhookEventStatus(eventFilePath, 'PROCESSED', `无法识别经纪人 (${debugInfo}),已导入待补充`);
+  console.log(`[Webhook] GROUP_CREATED: 群 ${roomId} 无法识别经纪人,已导入 (${debugInfo})`);
+  return { success: true, imported: true };
+}
+
+function resolveSenderType(senderId, msgType, customerId, customerExternalUserId, brokerWecomUserId) {
+  if (!senderId) return 'UNKNOWN';
+  if (customerExternalUserId && senderId === customerExternalUserId) return 'CUSTOMER';
+  if (brokerWecomUserId && senderId === brokerWecomUserId) return 'BROKER';
+
+  // 通过 customer 档案进一步判断
+  if (customerId) {
+    const customer = readCustomerById(customerId);
+    if (customer) {
+      if (customer.externalUserId && senderId === customer.externalUserId) return 'CUSTOMER';
+      if (customer.supportBrokerId) {
+        const supportIds = Array.isArray(customer.supportBrokerId) ? customer.supportBrokerId : [customer.supportBrokerId];
+        for (const supportId of supportIds) {
+          const supportBroker = readBrokerById(supportId);
+          if (supportBroker && supportBroker.wecomUserId === senderId) return 'SUPPORT';
+        }
+      }
+    }
+  }
+
+  const broker = readBrokerByWecomUserId(senderId);
+  if (broker) return 'BROKER';
+
+  return 'UNKNOWN';
+}
+
+function ensureRelatedContact(customerId, externalUserId, name) {
+  if (!customerId || !externalUserId) return null;
+  const filePath = path.join(customersDir(), `${customerId}-related-${externalUserId}.json`);
+  if (fs.existsSync(filePath)) return filePath;
+  const record = {
+    customerId,
+    externalUserId,
+    name: name || '',
+    createdAt: new Date().toISOString()
+  };
+  fs.writeFileSync(filePath, JSON.stringify(record, null, 2), 'utf8');
+  return filePath;
+}
+
+function extractMessageContent(raw) {
+  if (!raw) return '';
+  if (typeof raw.content === 'string' && raw.content) return raw.content;
+  if (typeof raw.msgContent === 'string' && raw.msgContent) return raw.msgContent;
+  if (raw.msgData) {
+    if (typeof raw.msgData.content === 'string') return raw.msgData.content;
+    if (typeof raw.msgData.text === 'string') return raw.msgData.text;
+  }
+  return '';
+}
+
+async function processVoiceMessage(msgUniqueId, msgType, msgData) {
+  const isVoice = msgType === 16 || msgType === 34 || String(msgType) === '16' || String(msgType) === '34';
+  if (!isVoice) return {};
+
+  const voiceUrl = msgData && (msgData.voiceUrl || msgData.url || msgData.cdnUrl);
+  const base64Audio = msgData && (msgData.base64 || msgData.base64Data);
+  if (!voiceUrl && !base64Audio) return {};
+
+  try {
+    const result = await qiweiTranscribeVoice({
+      voiceUrl,
+      base64Audio,
+      transcribe: true
+    });
+    if (result.status === 'ok' && result.data) {
+      return {
+        voiceTranscript: result.data.transcript || result.data.text || '',
+        voiceLocalPath: result.data.filePath || result.data.localPath || ''
+      };
+    }
+  } catch (err) {
+    console.warn(`[Webhook] 语音转写失败 ${msgUniqueId}:`, err.message);
+  }
+  return {};
+}
+
+function isKnownRoom(roomId) {
+  const confirmed = readConfirmedMapping();
+  if (confirmed[roomId]) return true;
+  const imported = readImportedMapping();
+  if (imported[roomId]) return true;
+  return false;
+}
+
+async function storeGroupMessageFromWebhook(event, eventFilePath = null) {
+  const raw = event.raw || {};
+  const roomId = String(raw.fromRoomId || '');
+  if (!roomId || roomId === '0') {
+    if (eventFilePath) updateWebhookEventStatus(eventFilePath, 'IGNORED', '非群消息,跳过');
+    return { success: false, ignored: true, reason: '非群消息' };
+  }
+
+  const msgUniqueId = String(raw.msgUniqueIdentifier || '');
+  if (!msgUniqueId) {
+    if (eventFilePath) updateWebhookEventStatus(eventFilePath, 'IGNORED', '缺少 msgUniqueIdentifier');
+    return { success: false, ignored: true, reason: '缺少 msgUniqueIdentifier' };
+  }
+
+  const msgType = Number(raw.msgType) || 0;
+  const SKIP_MSG_TYPES = new Set([2001, 2005]);
+  if (SKIP_MSG_TYPES.has(msgType)) {
+    if (eventFilePath) updateWebhookEventStatus(eventFilePath, 'IGNORED', `msgType=${msgType} 为通知类消息,跳过`);
+    return { success: false, ignored: true, reason: '通知类消息' };
+  }
+
+  if (!isKnownRoom(roomId)) {
+    if (eventFilePath) updateWebhookEventStatus(eventFilePath, 'IGNORED', `roomId=${roomId} 不在群映射中,跳过`);
+    return { success: false, ignored: true, reason: '不在群映射中' };
+  }
+
+  if (messageExists(roomId, msgUniqueId)) {
+    if (eventFilePath) updateWebhookEventStatus(eventFilePath, 'IGNORED', `消息 ${msgUniqueId} 已存在`);
+    return { success: false, ignored: true, reason: '消息已存在' };
+  }
+
+  const content = extractMessageContent(raw);
+  const timestamp = Number(raw.timestamp) || 0;
+  const senderId = String(raw.senderId || '');
+
+  // 解析群对应的客户/经纪人
+  const confirmed = readConfirmedMapping();
+  const imported = readImportedMapping();
+  const groupMapping = confirmed[roomId] || imported[roomId] || {};
+  const customerId = groupMapping.customerId;
+  const customerExternalUserId = groupMapping.externalUserId;
+  const brokerId = groupMapping.brokerId;
+  const broker = brokerId ? readBrokerById(brokerId) : null;
+  const brokerWecomUserId = broker ? broker.wecomUserId : null;
+
+  const senderType = resolveSenderType(senderId, msgType, customerId, customerExternalUserId, brokerWecomUserId);
+
+  if (senderType === 'UNKNOWN' && senderId) {
+    ensureRelatedContact(customerId, senderId, String(raw.senderName || ''));
+  }
+
+  const voiceMeta = await processVoiceMessage(msgUniqueId, msgType, raw.msgData);
+
+  const message = {
+    msgId: msgUniqueId,
+    seq: raw.seq || 0,
+    senderId,
+    senderName: String(raw.senderName || ''),
+    senderType,
+    msgType: String(msgType),
+    content,
+    timestamp: timestamp ? new Date(timestamp * 1000).toISOString() : new Date().toISOString(),
+    isRevoked: raw.isRevoked ? true : false,
+    ...voiceMeta,
+    rawData: raw
+  };
+
+  appendWebhookMessage(roomId, message);
+
+  // 更新 mapping 的 lastMsgAt / lastSyncSeq
+  if (confirmed[roomId]) {
+    confirmed[roomId].lastMsgAt = message.timestamp;
+    confirmed[roomId].lastSyncSeq = Math.max(confirmed[roomId].lastSyncSeq || 0, Number(raw.seq) || 0);
+    writeConfirmedMapping(confirmed);
+  } else if (imported[roomId]) {
+    imported[roomId].lastMsgAt = message.timestamp;
+    imported[roomId].lastSyncSeq = Math.max(imported[roomId].lastSyncSeq || 0, Number(raw.seq) || 0);
+    writeImportedMapping(imported);
+  }
+
+  // 触发客户画像更新
+  if (customerExternalUserId && senderType === 'CUSTOMER') {
+    enqueuePortraitUpdate(customerExternalUserId, 'WEBHOOK');
+  }
+
+  if (eventFilePath) updateWebhookEventStatus(eventFilePath, 'PROCESSED', `群消息已入库: ${content.substring(0, 80)}`);
+  console.log(`[Webhook] 群消息已入库: roomId=${roomId}, seq=${raw.seq}`);
+  return { success: true };
+}
+
+async function processWebhookEvents(envelope) {
+  const events = parseWebhookEnvelope(envelope);
+  if (!events.length) {
+    console.log('[Webhook] 回调中无有效事件');
+    return { processed: 0, ignored: 0, errors: 0 };
+  }
+
+  const result = { processed: 0, ignored: 0, errors: 0 };
+
+  for (const event of events) {
+    if (isDuplicateEvent(event.eventId)) {
+      console.log(`[Webhook] 重复事件已跳过: ${event.eventId}`);
+      result.ignored++;
+      continue;
+    }
+
+    const eventFilePath = saveWebhookEventStructured(event, envelope.source || 'callback', envelope.__rawBody);
+    markEventProcessed(event.eventId);
+
+    try {
+      const config = readWebhookConfig();
+      const autoCreateGroupEnabled = config.autoCreateGroup !== false;
+
+      if (event.parsedType === ParsedWebhookEvent.NEW_MESSAGE) {
+        const msgResult = await storeGroupMessageFromWebhook(event, eventFilePath);
+        if (msgResult.success) result.processed++;
+        else if (msgResult.ignored) result.ignored++;
+        else result.errors++;
+        continue;
+      }
+
+      if (event.parsedType === ParsedWebhookEvent.GROUP_CREATED) {
+        const groupResult = await handleGroupCreateWebhook(event, eventFilePath);
+        if (groupResult.success) result.processed++;
+        else if (groupResult.ignored) result.ignored++;
+        else result.errors++;
+        continue;
+      }
+
+      if (autoCreateGroupEnabled && (
+        event.parsedType === ParsedWebhookEvent.CONTACT_ADDED_OR_CHANGED ||
+        event.parsedType === ParsedWebhookEvent.FRIEND_REQUEST_RECEIVED
+      )) {
+        const autoResult = await triggerAutoCreateGroup(event, eventFilePath);
+        if (autoResult.success) result.processed++;
+        else if (autoResult.ignored) result.ignored++;
+        else result.errors++;
+        continue;
+      }
+
+      updateWebhookEventStatus(eventFilePath, 'IGNORED', `事件类型 ${event.parsedType} 不需要自动处理`);
+      result.ignored++;
+    } catch (err) {
+      console.error('[Webhook] 事件处理异常:', err && err.message ? err.message : err);
+      updateWebhookEventStatus(eventFilePath, 'ERROR', String(err && err.message ? err.message : err));
+      result.errors++;
+    }
+  }
+
+  return result;
+}
+
+module.exports = {
+  processWebhookEvents,
+  triggerAutoCreateGroup,
+  findNewlyAddedContact,
+  findNewlyAddedContactAcrossDevices,
+  resolveDeviceGuid,
+  handleGroupCreateWebhook,
+  identifyServiceGroupMembers,
+  storeGroupMessageFromWebhook,
+  resolveSenderType,
+  ensureRelatedContact,
+  readConfirmedMapping,
+  writeConfirmedMapping,
+  recordConfirmedGroup,
+  hasActiveGroup
+};

+ 46 - 52
claude-code/claude-code-qiwe-assistant/mcp/src/core/webhook-server.js

@@ -2,9 +2,14 @@ const http = require('http');
 const fs = require('fs');
 const path = require('path');
 const { outputsRoot, createRunDir, writeRunManifest } = require('./output-paths');
+const { verifyWebhookSignature } = require('./webhook-verify');
+const { saveRawEventFailed } = require('./webhook-store');
+const { readConfig, writeConfig, readRelayConfig, writeRelayConfig } = require('./webhook-config');
+const { processWebhookEvents } = require('./webhook-processor');
 
 let activeServer = null;
 let activePort = 0;
+let activeHost = '127.0.0.1';
 
 function webhookDir() {
   return path.join(outputsRoot(), 'webhook');
@@ -14,56 +19,18 @@ function ensureWebhookDir() {
   fs.mkdirSync(webhookDir(), { recursive: true });
 }
 
-function configPath() {
-  ensureWebhookDir();
-  return path.join(webhookDir(), 'webhook-config.json');
-}
-
-function readConfig() {
-  const filePath = configPath();
-  if (!fs.existsSync(filePath)) return { callbackUrl: null, secret: null };
-  try {
-    return JSON.parse(fs.readFileSync(filePath, 'utf8'));
-  } catch {
-    return { callbackUrl: null, secret: null };
-  }
-}
-
-function writeConfig(config) {
-  ensureWebhookDir();
-  fs.writeFileSync(configPath(), JSON.stringify({ ...readConfig(), ...config, updatedAt: new Date().toISOString() }, null, 2), 'utf8');
-}
-
-function relayConfigPath() {
-  ensureWebhookDir();
-  return path.join(webhookDir(), 'relay-config.json');
-}
-
-function readRelayConfig() {
-  const filePath = relayConfigPath();
-  if (!fs.existsSync(filePath)) return {};
-  try {
-    return JSON.parse(fs.readFileSync(filePath, 'utf8'));
-  } catch {
-    return {};
-  }
-}
-
-function writeRelayConfig(config) {
-  ensureWebhookDir();
-  fs.writeFileSync(relayConfigPath(), JSON.stringify({ ...readRelayConfig(), ...config, updatedAt: new Date().toISOString() }, null, 2), 'utf8');
-}
-
 function readBody(req) {
   return new Promise((resolve, reject) => {
-    let body = '';
+    let rawBody = '';
     req.setEncoding('utf8');
-    req.on('data', chunk => { body += chunk; });
+    req.on('data', chunk => { rawBody += chunk; });
     req.on('end', () => {
       try {
-        resolve(body ? JSON.parse(body) : {});
+        const body = rawBody ? JSON.parse(rawBody) : {};
+        body.__rawBody = rawBody;
+        resolve(body);
       } catch {
-        resolve({ raw: body });
+        resolve({ raw: rawBody, __rawBody: rawBody });
       }
     });
     req.on('error', reject);
@@ -95,22 +62,44 @@ function saveWebhookEvent(event, source = 'callback') {
   return filePath;
 }
 
-function startWebhookServer(port = 0) {
+function startWebhookServer(port = 0, host = '127.0.0.1') {
   if (activeServer) {
-    return { port: activePort, alreadyRunning: true };
+    return { port: activePort, host: activeHost, alreadyRunning: true };
   }
 
   const server = http.createServer(async (req, res) => {
+    if (req.url === '/health' && req.method === 'GET') {
+      res.writeHead(200, { 'Content-Type': 'application/json' });
+      res.end(JSON.stringify({ code: 0, msg: 'ok' }));
+      return;
+    }
     if (req.method !== 'POST') {
       res.writeHead(405);
       res.end('method not allowed');
       return;
     }
+    let rawBody = '';
     try {
       const body = await readBody(req);
-      const filePath = saveWebhookEvent(body, 'callback');
+      rawBody = body.__rawBody || '';
+
+      if (!verifyWebhookSignature(rawBody, req.headers)) {
+        saveRawEventFailed(body, 'callback', 'SIGNATURE_FAILED', rawBody);
+        res.writeHead(401, { 'Content-Type': 'application/json' });
+        res.end(JSON.stringify({ code: 401, message: 'Webhook 签名验证失败' }));
+        return;
+      }
+
       res.writeHead(200, { 'Content-Type': 'application/json' });
-      res.end(JSON.stringify({ code: 200, message: 'ok', file: path.basename(filePath) }));
+      res.end(JSON.stringify({ code: 0, msg: 'received' }));
+
+      setImmediate(async () => {
+        try {
+          await processWebhookEvents(body);
+        } catch (err) {
+          console.error('[Webhook] 事件处理异常:', err && err.message ? err.message : err);
+        }
+      });
     } catch (error) {
       res.writeHead(500);
       res.end(JSON.stringify({ code: 500, message: String(error && error.message ? error.message : error) }));
@@ -118,11 +107,12 @@ function startWebhookServer(port = 0) {
   });
 
   return new Promise((resolve, reject) => {
-    server.listen(port, '127.0.0.1', (err) => {
+    server.listen(port, host, (err) => {
       if (err) return reject(err);
       activeServer = server;
       activePort = server.address().port;
-      resolve({ port: activePort });
+      activeHost = host;
+      resolve({ port: activePort, host });
     });
   });
 }
@@ -133,6 +123,7 @@ function stopWebhookServer() {
     activeServer.close(() => {
       activeServer = null;
       activePort = 0;
+      activeHost = '127.0.0.1';
       resolve({ stopped: true });
     });
   });
@@ -142,7 +133,8 @@ function getWebhookServerStatus() {
   return {
     running: !!activeServer,
     port: activePort,
-    localUrl: activePort ? `http://127.0.0.1:${activePort}/callback` : null
+    host: activeHost,
+    localUrl: activePort ? `http://${activeHost}:${activePort}/callback` : null
   };
 }
 
@@ -154,5 +146,7 @@ module.exports = {
   writeConfig,
   readRelayConfig,
   writeRelayConfig,
-  saveWebhookEvent
+  saveWebhookEvent,
+  processWebhookEvents,
+  readBody
 };

+ 162 - 0
claude-code/claude-code-qiwe-assistant/mcp/src/core/webhook-store.js

@@ -0,0 +1,162 @@
+const fs = require('fs');
+const path = require('path');
+const crypto = require('crypto');
+const { categoryDir, createRunDir, writeRunManifest } = require('./output-paths');
+
+const PROCESSED_EVENT_IDS_FILE = path.join(categoryDir('webhook'), 'processed-event-ids.json');
+const MAX_PROCESSED_IDS = 5000;
+
+function safeReadJson(filePath, fallback = null) {
+  try {
+    if (!filePath || !fs.existsSync(filePath)) return fallback;
+    return JSON.parse(fs.readFileSync(filePath, 'utf8'));
+  } catch {
+    return fallback;
+  }
+}
+
+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 sanitizePayload(payload) {
+  if (!payload || typeof payload !== 'object') return payload;
+  const output = {};
+  for (const [key, value] of Object.entries(payload)) {
+    if (/^(token|tokenId|secret|signature|privateKey|authorization)$/i.test(key)) {
+      output[key] = '••••••••';
+      continue;
+    }
+    if (value && typeof value === 'object') {
+      output[key] = sanitizePayload(value);
+    } else {
+      output[key] = value;
+    }
+  }
+  return output;
+}
+
+function readProcessedEventIds() {
+  const data = safeReadJson(PROCESSED_EVENT_IDS_FILE, { ids: [], count: 0 });
+  const ids = Array.isArray(data.ids) ? data.ids : [];
+  return new Set(ids);
+}
+
+function writeProcessedEventIds(set) {
+  let ids = Array.from(set);
+  if (ids.length > MAX_PROCESSED_IDS) {
+    ids = ids.slice(ids.length - MAX_PROCESSED_IDS);
+  }
+  atomicWriteJson(PROCESSED_EVENT_IDS_FILE, {
+    ids,
+    count: ids.length,
+    updatedAt: new Date().toISOString()
+  });
+  return ids;
+}
+
+function markEventProcessed(eventId) {
+  const set = readProcessedEventIds();
+  set.add(eventId);
+  writeProcessedEventIds(set);
+  return true;
+}
+
+function isDuplicateEvent(eventId) {
+  const set = readProcessedEventIds();
+  return set.has(eventId);
+}
+
+function saveWebhookEventStructured(event, source = 'callback', rawBody = null) {
+  const runDir = createRunDir('webhook', source);
+  const eventId = event.eventId || `evt-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
+  const fileName = `event-${eventId}.json`;
+  const filePath = path.join(runDir, fileName);
+
+  const record = {
+    receivedAt: new Date().toISOString(),
+    source,
+    eventId,
+    parsedType: event.parsedType,
+    cmd: event.cmd,
+    msgType: event.msgType,
+    guid: event.guid,
+    externalUserId: event.externalUserId || undefined,
+    status: 'PENDING',
+    event: sanitizePayload(event.raw),
+    rawBody: rawBody ? sanitizePayload(typeof rawBody === 'string' ? JSON.parse(rawBody) : rawBody) : undefined
+  };
+
+  atomicWriteJson(filePath, record);
+
+  writeRunManifest(runDir, {
+    tool: 'webhook-receiver',
+    summary: { eventId, parsedType: event.parsedType, source },
+    files: [fileName]
+  });
+
+  return filePath;
+}
+
+function saveRawEventFailed(body, source, reason, rawBody = null) {
+  const runDir = createRunDir('webhook', `${source}-failed`);
+  const hash = crypto.createHash('sha256').update(JSON.stringify(body || {})).digest('hex').slice(0, 16);
+  const fileName = `event-failed-${hash}.json`;
+  const filePath = path.join(runDir, fileName);
+
+  const record = {
+    receivedAt: new Date().toISOString(),
+    source,
+    reason,
+    status: 'FAILED',
+    body: sanitizePayload(body),
+    rawBody: rawBody ? sanitizePayload(typeof rawBody === 'string' ? JSON.parse(rawBody) : rawBody) : undefined
+  };
+
+  atomicWriteJson(filePath, record);
+
+  writeRunManifest(runDir, {
+    tool: 'webhook-receiver',
+    summary: { reason, source },
+    files: [fileName]
+  });
+
+  return filePath;
+}
+
+function updateWebhookEventStatus(eventFilePath, status, result = null) {
+  if (!eventFilePath || !fs.existsSync(eventFilePath)) return null;
+  try {
+    const record = safeReadJson(eventFilePath) || {};
+    record.status = status;
+    record.processedAt = new Date().toISOString();
+    if (result !== null) record.result = result;
+    atomicWriteJson(eventFilePath, record);
+    return record;
+  } catch (err) {
+    console.warn('[WebhookStore] 更新事件状态失败:', err.message);
+    return null;
+  }
+}
+
+module.exports = {
+  PROCESSED_EVENT_IDS_FILE,
+  readProcessedEventIds,
+  writeProcessedEventIds,
+  markEventProcessed,
+  isDuplicateEvent,
+  saveWebhookEventStructured,
+  saveRawEventFailed,
+  updateWebhookEventStatus,
+  sanitizePayload
+};

+ 348 - 0
claude-code/claude-code-qiwe-assistant/mcp/src/core/webhook-types.js

@@ -0,0 +1,348 @@
+const crypto = require('crypto');
+
+const WebhookCommand = Object.freeze({
+  ACCOUNT_STATUS: 11016,
+  API_ASYNC: 20000,
+  SYSTEM_MESSAGE: 15500,
+  NORMAL_MESSAGE: 15000
+});
+
+const SystemMsgType = Object.freeze({
+  CONTACT_EXTERNAL_CHANGE: 2131,
+  CONTACT_EXTERNAL_BLACKLIST: 2313,
+  CONTACT_INTERNAL_CHANGE: 2188,
+  CONTACT_FRIEND_REQUEST_2357: 2357,
+  CONTACT_FRIEND_REQUEST_2132: 2132,
+  CONTACT_DND_TOP: 2104,
+  CONTACT_MARK: 2115,
+
+  GROUP_NAME_CHANGE: 1001,
+  GROUP_MEMBER_ADD: 1002,
+  GROUP_MEMBER_REMOVE: 1003,
+  GROUP_MEMBER_QUIT: 1005,
+  GROUP_CREATE: 1006,
+  GROUP_OPERATION_TIP: 1011,
+  GROUP_OWNER_TRANSFER: 1022,
+  GROUP_DISMISS: 1023,
+  GROUP_INVITE_APPLY: 1029,
+  GROUP_ADMIN_CHANGE: 1043,
+  GROUP_INFO_CHANGE: 2118,
+
+  SESSION_CLEAR: 2055,
+  SESSION_DELETE: 2002,
+  CALL_END: 40,
+  CALL_NOTIFY: 503,
+
+  TAG_CHAT_CHANGE: 2160,
+  TAG_CHAT_CONTACT_CHANGE: 2161,
+  TAG_CORP_ADD_DEL: 2185,
+  TAG_PERSONAL_ADD_DEL: 2186,
+
+  MOMENT_CHANGE: 2215,
+  MOMENT_PUSH: 517
+});
+
+const ParsedWebhookEvent = Object.freeze({
+  CONTACT_ADDED_OR_CHANGED: 'CONTACT_ADDED_OR_CHANGED',
+  FRIEND_REQUEST_RECEIVED: 'FRIEND_REQUEST_RECEIVED',
+  ACCOUNT_ONLINE: 'ACCOUNT_ONLINE',
+  ACCOUNT_OFFLINE: 'ACCOUNT_OFFLINE',
+  GROUP_MEMBER_JOINED: 'GROUP_MEMBER_JOINED',
+  GROUP_CREATED: 'GROUP_CREATED',
+  GROUP_EVENT: 'GROUP_EVENT',
+  NEW_MESSAGE: 'NEW_MESSAGE',
+  UNKNOWN: 'UNKNOWN'
+});
+
+const V2_CONTENT_TYPE_TO_V1 = Object.freeze({
+  text: 2,
+  image: 7,
+  voice: 16,
+  video: 22,
+  file: 15,
+  location: 6,
+  link: 13,
+  card: 41,
+  miniprogram: 78
+});
+
+function detectWebhookVersion(body) {
+  if (!body || typeof body !== 'object') return 'unknown';
+  if (body.version === '2.0' && typeof body.event === 'string' && body.data && typeof body.data === 'object') return 'v2';
+  if (typeof body.code === 'number' && Array.isArray(body.data)) return 'v1';
+  return 'unknown';
+}
+
+function classifyV2Event(eventName) {
+  switch (eventName) {
+    case 'msg.private':
+      return { cmd: WebhookCommand.NORMAL_MESSAGE, parsedType: ParsedWebhookEvent.NEW_MESSAGE, defaultMsgType: 0 };
+    case 'msg.group':
+      return { cmd: WebhookCommand.NORMAL_MESSAGE, parsedType: ParsedWebhookEvent.NEW_MESSAGE, defaultMsgType: 0 };
+    case 'contact.friend_request':
+      return { cmd: WebhookCommand.SYSTEM_MESSAGE, parsedType: ParsedWebhookEvent.FRIEND_REQUEST_RECEIVED, defaultMsgType: SystemMsgType.CONTACT_FRIEND_REQUEST_2357 };
+    case 'contact.external_change':
+    case 'contact.added':
+      return { cmd: WebhookCommand.SYSTEM_MESSAGE, parsedType: ParsedWebhookEvent.CONTACT_ADDED_OR_CHANGED, defaultMsgType: SystemMsgType.CONTACT_EXTERNAL_CHANGE };
+    case 'group.member_add':
+      return { cmd: WebhookCommand.NORMAL_MESSAGE, parsedType: ParsedWebhookEvent.GROUP_MEMBER_JOINED, defaultMsgType: SystemMsgType.GROUP_MEMBER_ADD };
+    case 'group.create':
+      return { cmd: WebhookCommand.NORMAL_MESSAGE, parsedType: ParsedWebhookEvent.GROUP_CREATED, defaultMsgType: SystemMsgType.GROUP_CREATE };
+    case 'account.online':
+    case 'device.online':
+      return { cmd: WebhookCommand.ACCOUNT_STATUS, parsedType: ParsedWebhookEvent.ACCOUNT_ONLINE, defaultMsgType: 0 };
+    case 'account.offline':
+    case 'device.offline':
+      return { cmd: WebhookCommand.ACCOUNT_STATUS, parsedType: ParsedWebhookEvent.ACCOUNT_OFFLINE, defaultMsgType: 0 };
+    default:
+      if (eventName.startsWith('group.')) {
+        return { cmd: WebhookCommand.NORMAL_MESSAGE, parsedType: ParsedWebhookEvent.GROUP_EVENT, defaultMsgType: 0 };
+      }
+      if (eventName.startsWith('account.') || eventName.startsWith('device.')) {
+        return { cmd: WebhookCommand.ACCOUNT_STATUS, parsedType: ParsedWebhookEvent.ACCOUNT_OFFLINE, defaultMsgType: 0 };
+      }
+      return { cmd: WebhookCommand.NORMAL_MESSAGE, parsedType: ParsedWebhookEvent.UNKNOWN, defaultMsgType: 0 };
+  }
+}
+
+function convertV2ToV1Item(v2) {
+  const cls = classifyV2Event(v2.event);
+  const contentType = (v2.data && v2.data.content && v2.data.content.type || '').toLowerCase();
+  const msgType = V2_CONTENT_TYPE_TO_V1[contentType] || cls.defaultMsgType;
+
+  const eventIdBase = [
+    (v2.meta && v2.meta.token_id) || '',
+    v2.event,
+    (v2.meta && v2.meta.timestamp) || '',
+    String((v2.meta && v2.meta.seq) || ''),
+    (v2.data && v2.data.from && v2.data.from.wxid) || '',
+    (v2.data && v2.data.room && v2.data.room.id) || (v2.data && v2.data.from && v2.data.from.roomId) || ''
+  ].join('|');
+  const eventId = crypto.createHash('sha256').update(eventIdBase).digest('hex').slice(0, 32);
+
+  const timestamp = v2.meta && v2.meta.timestamp
+    ? Math.floor(new Date(v2.meta.timestamp).getTime() / 1000)
+    : Math.floor(Date.now() / 1000);
+
+  let msgData = null;
+  if (cls.parsedType === ParsedWebhookEvent.FRIEND_REQUEST_RECEIVED) {
+    msgData = {
+      applyTime: timestamp,
+      contactId: (v2.data && v2.data.contact && v2.data.contact.wxid) || (v2.data && v2.data.from && v2.data.from.wxid),
+      contactNickname: (v2.data && v2.data.contact && v2.data.contact.name) || (v2.data && v2.data.from && v2.data.from.name),
+      contactType: (v2.data && v2.data.contact && v2.data.contact.type) || '微信'
+    };
+  } else if (v2.data && v2.data.content) {
+    msgData = v2.data.content;
+  }
+
+  return {
+    guid: '',
+    cmd: cls.cmd,
+    msgType,
+    msgUniqueIdentifier: eventId,
+    senderId: (v2.data && v2.data.from && v2.data.from.wxid) || '',
+    senderName: (v2.data && v2.data.from && v2.data.from.name) || '',
+    fromRoomId: (v2.data && v2.data.room && v2.data.room.id) || (v2.data && v2.data.from && v2.data.from.roomId) || '',
+    timestamp,
+    seq: Number((v2.meta && v2.meta.seq) || 0),
+    msgData,
+    _v2EventName: v2.event,
+    _v2Version: v2.version,
+    _v2ParsedType: cls.parsedType
+  };
+}
+
+function parseWebhookEnvelope(body) {
+  const version = detectWebhookVersion(body);
+  if (version === 'v2') {
+    const item = convertV2ToV1Item(body);
+    return normalizeItems([item]);
+  }
+  if (version === 'v1') {
+    if (!body.data || !Array.isArray(body.data)) {
+      console.warn('[Webhook] v1 回调体格式异常,缺少 data 数组');
+      return [];
+    }
+    return normalizeItems(body.data);
+  }
+  console.warn('[Webhook] 无法识别的回调格式');
+  return [];
+}
+
+function normalizeItems(items) {
+  const events = [];
+  for (const item of items) {
+    const cmd = item.cmd;
+    const msgType = item.msgType;
+    const guid = item.guid || '';
+    const timestamp = item.timestamp || 0;
+    const eventId = item.msgUniqueIdentifier || `${guid}_${cmd}_${msgType}_${item.seq || 0}`;
+
+    const normalized = {
+      guid,
+      cmd,
+      msgType,
+      parsedType: ParsedWebhookEvent.UNKNOWN,
+      eventId,
+      timestamp,
+      raw: item
+    };
+
+    if (item._v2ParsedType) {
+      normalized.parsedType = item._v2ParsedType;
+      if (normalized.parsedType === ParsedWebhookEvent.FRIEND_REQUEST_RECEIVED) {
+        const msgData = item.msgData || null;
+        if (msgData && msgData.contactId) {
+          normalized.externalUserId = String(msgData.contactId);
+          normalized.contactNickname = msgData.contactNickname;
+          normalized.contactType = msgData.contactType;
+        }
+      }
+      events.push(normalized);
+      continue;
+    }
+
+    if (cmd === WebhookCommand.SYSTEM_MESSAGE || cmd === WebhookCommand.NORMAL_MESSAGE) {
+      parseSystemOrNormalMessage(normalized, item);
+    } else if (cmd === WebhookCommand.ACCOUNT_STATUS) {
+      parseAccountStatus(normalized, item);
+    }
+
+    events.push(normalized);
+  }
+  return events;
+}
+
+function parseSystemOrNormalMessage(event, item) {
+  const msgType = item.msgType;
+
+  if (msgType === SystemMsgType.CONTACT_EXTERNAL_CHANGE) {
+    event.parsedType = ParsedWebhookEvent.CONTACT_ADDED_OR_CHANGED;
+    return;
+  }
+
+  if (msgType === SystemMsgType.CONTACT_FRIEND_REQUEST_2357) {
+    event.parsedType = ParsedWebhookEvent.FRIEND_REQUEST_RECEIVED;
+    const msgData = item.msgData || null;
+    if (msgData && msgData.contactId) {
+      event.externalUserId = String(msgData.contactId);
+      event.contactNickname = msgData.contactNickname;
+      event.contactType = msgData.contactType;
+    }
+    return;
+  }
+
+  if (msgType === SystemMsgType.CONTACT_FRIEND_REQUEST_2132) {
+    event.parsedType = ParsedWebhookEvent.FRIEND_REQUEST_RECEIVED;
+    return;
+  }
+
+  if (msgType === SystemMsgType.CONTACT_EXTERNAL_BLACKLIST || msgType === SystemMsgType.CONTACT_INTERNAL_CHANGE) {
+    event.parsedType = ParsedWebhookEvent.CONTACT_ADDED_OR_CHANGED;
+    return;
+  }
+
+  if (msgType === SystemMsgType.GROUP_MEMBER_ADD) {
+    event.parsedType = ParsedWebhookEvent.GROUP_MEMBER_JOINED;
+    return;
+  }
+
+  if (msgType === SystemMsgType.GROUP_CREATE) {
+    event.parsedType = ParsedWebhookEvent.GROUP_CREATED;
+    return;
+  }
+
+  const GROUP_EVENT_MSG_TYPES = new Set([
+    SystemMsgType.GROUP_NAME_CHANGE,
+    SystemMsgType.GROUP_MEMBER_REMOVE,
+    SystemMsgType.GROUP_MEMBER_QUIT,
+    SystemMsgType.GROUP_OPERATION_TIP,
+    SystemMsgType.GROUP_OWNER_TRANSFER,
+    SystemMsgType.GROUP_DISMISS,
+    SystemMsgType.GROUP_INVITE_APPLY,
+    SystemMsgType.GROUP_ADMIN_CHANGE,
+    SystemMsgType.GROUP_INFO_CHANGE
+  ]);
+  if (GROUP_EVENT_MSG_TYPES.has(msgType)) {
+    event.parsedType = ParsedWebhookEvent.GROUP_EVENT;
+    return;
+  }
+
+  const SESSION_EVENT_MSG_TYPES = new Set([
+    SystemMsgType.SESSION_CLEAR,
+    SystemMsgType.SESSION_DELETE,
+    SystemMsgType.CALL_END,
+    SystemMsgType.CALL_NOTIFY
+  ]);
+  if (SESSION_EVENT_MSG_TYPES.has(msgType)) {
+    event.parsedType = ParsedWebhookEvent.UNKNOWN;
+    return;
+  }
+
+  const TAG_AND_MOMENT_MSG_TYPES = new Set([
+    SystemMsgType.TAG_CHAT_CHANGE,
+    SystemMsgType.TAG_CHAT_CONTACT_CHANGE,
+    SystemMsgType.TAG_CORP_ADD_DEL,
+    SystemMsgType.TAG_PERSONAL_ADD_DEL,
+    SystemMsgType.MOMENT_CHANGE,
+    SystemMsgType.MOMENT_PUSH
+  ]);
+  if (TAG_AND_MOMENT_MSG_TYPES.has(msgType)) {
+    event.parsedType = ParsedWebhookEvent.UNKNOWN;
+    return;
+  }
+
+  if (event.cmd === WebhookCommand.NORMAL_MESSAGE) {
+    event.parsedType = ParsedWebhookEvent.NEW_MESSAGE;
+    return;
+  }
+
+  event.parsedType = ParsedWebhookEvent.UNKNOWN;
+}
+
+function parseAccountStatus(event, item) {
+  const msgData = item.msgData || null;
+  const code = msgData && msgData.code;
+  if (code === 11001) {
+    event.parsedType = ParsedWebhookEvent.ACCOUNT_ONLINE;
+  } else {
+    event.parsedType = ParsedWebhookEvent.ACCOUNT_OFFLINE;
+  }
+}
+
+function shouldCheckGroupCreation(event) {
+  return (
+    event.parsedType === ParsedWebhookEvent.CONTACT_ADDED_OR_CHANGED ||
+    event.parsedType === ParsedWebhookEvent.FRIEND_REQUEST_RECEIVED
+  );
+}
+
+function extractUserIdFromEvent(event) {
+  return event.externalUserId;
+}
+
+function decodeChangedMemberList(base64Data) {
+  if (!base64Data) return [];
+  try {
+    const decoded = Buffer.from(base64Data, 'base64').toString('utf8');
+    return decoded.split(';').map(id => id.trim()).filter(id => id.length > 0);
+  } catch {
+    return [];
+  }
+}
+
+module.exports = {
+  WebhookCommand,
+  SystemMsgType,
+  ParsedWebhookEvent,
+  V2_CONTENT_TYPE_TO_V1,
+  detectWebhookVersion,
+  classifyV2Event,
+  convertV2ToV1Item,
+  parseWebhookEnvelope,
+  normalizeItems,
+  shouldCheckGroupCreation,
+  extractUserIdFromEvent,
+  decodeChangedMemberList
+};

+ 79 - 0
claude-code/claude-code-qiwe-assistant/mcp/src/core/webhook-verify.js

@@ -0,0 +1,79 @@
+const crypto = require('crypto');
+const { readConfig } = require('./webhook-config');
+
+function getHeader(headers, name) {
+  const lower = name.toLowerCase();
+  for (const [key, value] of Object.entries(headers)) {
+    if (key.toLowerCase() === lower) {
+      if (Array.isArray(value)) return value[0];
+      return value;
+    }
+  }
+  return undefined;
+}
+
+function extractSignature(headers) {
+  const auth = getHeader(headers, 'authorization');
+  if (auth) {
+    const bearerMatch = auth.match(/^Bearer\s+(.+)$/i);
+    if (bearerMatch) return bearerMatch[1].trim();
+    return auth.trim();
+  }
+  const sigHeaders = ['x-qiwe-signature', 'x-qiweapi-signature', 'x-signature'];
+  for (const name of sigHeaders) {
+    const val = getHeader(headers, name);
+    if (val) return val;
+  }
+  return null;
+}
+
+function timingSafeEqualStrings(a, b) {
+  if (a.length !== b.length) return false;
+  return crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b));
+}
+
+function verifyWebhookSignature(rawBody, headers) {
+  const config = readConfig();
+
+  if (process.env.QIWEI_WEBHOOK_ALLOW_UNSECURED === 'true') {
+    console.warn('[Webhook] QIWEI_WEBHOOK_ALLOW_UNSECURED=true,允许未签名回调(仅开发环境使用)');
+    return true;
+  }
+
+  if (!config.secret) {
+    console.warn('[Webhook] Webhook 未配置 secret,回调已拒绝');
+    return false;
+  }
+
+  const signature = extractSignature(headers);
+  if (!signature) {
+    console.warn('[Webhook] 请求缺少签名信息');
+    return false;
+  }
+
+  if (timingSafeEqualStrings(signature, config.secret)) {
+    return true;
+  }
+
+  try {
+    const expected = crypto
+      .createHmac('sha256', config.secret)
+      .update(rawBody, 'utf8')
+      .digest('hex');
+    if (timingSafeEqualStrings(expected, signature)) {
+      return true;
+    }
+  } catch {
+    // ignore
+  }
+
+  console.warn('[Webhook] 签名验证失败');
+  return false;
+}
+
+module.exports = {
+  verifyWebhookSignature,
+  extractSignature,
+  getHeader,
+  timingSafeEqualStrings
+};

File diff suppressed because it is too large
+ 627 - 96
claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/app.js


File diff suppressed because it is too large
+ 34 - 0
claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/echarts.min.js


+ 1 - 0
claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/index.html

@@ -81,6 +81,7 @@
   </main>
   <div class="toast-container" id="toast-container" aria-live="polite"></div>
 </div>
+<script src="/dashboard/echarts.min.js"></script>
 <script src="/dashboard/app.js"></script>
 </body>
 </html>

+ 60 - 3
claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/server.js

@@ -20,11 +20,13 @@ const {
   qiweiLoginVerify
 } = require('../tools/qiwei-login-run');
 const { qiweiSubscriptionStatus } = require('../tools/qiwei-subscription-run');
+const { createAccountConnectionMonitor } = require('../core/account-connection-monitor');
 const {
   qiweiBatchAddFriends,
   qiweiCheckFriendStatus,
   qiweiGetCustomerProfile,
-  qiweiAutoCreateGroup
+  qiweiAutoCreateGroup,
+  qiweiUpdateCustomerInfo
 } = require('../tools/qiwei-customer-ops-run');
 const {
   qiweiUpdateCustomerPortrait,
@@ -45,6 +47,10 @@ const {
   qiweiPreviewTransferPackage,
   qiweiExecuteTransfer
 } = require('../tools/qiwei-customer-transfer-run');
+const {
+  getStateSection,
+  rebuildStateFromOutputs
+} = require('../core/dashboard-state');
 const {
   switchActiveAccount,
   getAgentStatus,
@@ -110,6 +116,12 @@ const TMP_DIR = path.join(outputsRoot(), 'tmp');
 
 const jobs = new Map();
 
+const accountConnectionMonitor = createAccountConnectionMonitor({
+  checkStatus: () => qiweiLoginStatus({}),
+  recoverLogin: () => qiweiLoginCheck({ manual: true, persistConfig: false })
+});
+accountConnectionMonitor.start();
+
 function ensureTmpDir() {
   fs.mkdirSync(TMP_DIR, { recursive: true });
 }
@@ -228,8 +240,10 @@ function getContentType(filePath) {
 }
 
 async function combinedStatus() {
-  const login = await qiweiLoginStatus({});
-  const subscription = await qiweiSubscriptionStatus({});
+  const [login, subscription] = await Promise.all([
+    accountConnectionMonitor.getStatus(),
+    qiweiSubscriptionStatus({})
+  ]);
   return {
     status: 'ok',
     summary: {
@@ -449,6 +463,45 @@ async function handleRequest(req, res) {
       else json(res, 200, await regenerateDraft(draftId));
       return;
     }
+    if (pathname === '/api/dashboard/summary' && req.method === 'GET') {
+      json(res, 200, { status: 'ok', data: getStateSection('summary') });
+      return;
+    }
+    if (pathname === '/api/dashboard/customers' && req.method === 'GET') {
+      const filter = {
+        keyword: url.searchParams.get('keyword') || '',
+        hasPortrait: url.searchParams.has('hasPortrait') ? url.searchParams.get('hasPortrait') === 'true' : undefined,
+        friendRequestStatus: url.searchParams.get('friendRequestStatus') || '',
+        tag: url.searchParams.get('tag') || ''
+      };
+      json(res, 200, { status: 'ok', data: getStateSection('customers', { filter }) });
+      return;
+    }
+    if (pathname === '/api/dashboard/portraits' && req.method === 'GET') {
+      json(res, 200, { status: 'ok', data: getStateSection('portraits') });
+      return;
+    }
+    if (pathname === '/api/dashboard/tags' && req.method === 'GET') {
+      json(res, 200, { status: 'ok', data: getStateSection('tags') });
+      return;
+    }
+    if (pathname === '/api/dashboard/transfers' && req.method === 'GET') {
+      json(res, 200, { status: 'ok', data: getStateSection('transfers', { limit: url.searchParams.get('limit') }) });
+      return;
+    }
+    if (pathname === '/api/dashboard/groups' && req.method === 'GET') {
+      json(res, 200, { status: 'ok', data: getStateSection('groups') });
+      return;
+    }
+    if (pathname === '/api/dashboard/operations' && req.method === 'GET') {
+      json(res, 200, { status: 'ok', data: getStateSection('operations', { limit: url.searchParams.get('limit') }) });
+      return;
+    }
+    if (pathname === '/api/dashboard/rebuild' && req.method === 'POST') {
+      const state = rebuildStateFromOutputs();
+      json(res, 200, { status: 'ok', data: { message: '索引已重建', summary: getStateSection('summary'), updatedAt: state.updatedAt } });
+      return;
+    }
     if (pathname === '/api/login/start' && req.method === 'POST') {
       const body = await readBody(req);
       const result = await qiweiLoginStart({
@@ -551,6 +604,10 @@ async function handleRequest(req, res) {
       json(res, 200, await qiweiGetCustomerProfile(body));
       return;
     }
+    if (pathname === '/api/customer-ops/update' && req.method === 'POST') {
+      json(res, 200, await qiweiUpdateCustomerInfo(await readBody(req)));
+      return;
+    }
     if (pathname === '/api/customer-ops/auto-create-group' && req.method === 'POST') {
       const body = await readBody(req);
       const id = createJob(() => qiweiAutoCreateGroup(body));

+ 336 - 0
claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/styles.css

@@ -2100,3 +2100,339 @@ body.business-modal-open { overflow: hidden; }
     transition-duration: 0.01ms !important;
   }
 }
+
+/* ==================== Customer Operations Workspaces ==================== */
+.customer-workspace-page {
+  display: grid;
+  gap: 16px;
+  min-width: 0;
+}
+
+.customer-workspace-hero {
+  position: relative;
+  isolation: isolate;
+  margin-bottom: 0;
+  min-height: 150px;
+  align-items: center;
+}
+
+.customer-workspace-hero::before,
+.customer-workspace-hero::after {
+  position: absolute;
+  z-index: -1;
+  content: '';
+  border: 1px solid rgba(255,255,255,.09);
+  border-radius: 50%;
+  pointer-events: none;
+}
+
+.customer-workspace-hero::before { width: 260px; height: 260px; top: -190px; right: 240px; }
+.customer-workspace-hero::after { width: 210px; height: 210px; right: -80px; bottom: -155px; }
+.group-hero { background: linear-gradient(135deg, #153449, #215b70 62%, #428090); }
+.portrait-dashboard-hero { background: linear-gradient(135deg, #123c32, #1d7156 62%, #5b8d59); }
+.transfer-dashboard-hero { background: linear-gradient(135deg, #172d4f, #294d7e 62%, #5179a8); }
+
+.hero-feature-list {
+  display: flex;
+  flex: 0 0 auto;
+  flex-wrap: wrap;
+  justify-content: flex-end;
+  gap: 8px;
+  max-width: 330px;
+}
+
+.hero-feature-list span {
+  padding: 9px 12px;
+  border: 1px solid rgba(255,255,255,.17);
+  border-radius: 999px;
+  color: rgba(255,255,255,.82);
+  background: rgba(255,255,255,.08);
+  font-size: 11px;
+  font-weight: 650;
+  backdrop-filter: blur(8px);
+}
+
+.handover-flow {
+  display: flex;
+  flex: 0 0 auto;
+  align-items: center;
+  gap: 8px;
+  padding: 12px 14px;
+  border: 1px solid rgba(255,255,255,.15);
+  border-radius: 14px;
+  background: rgba(255,255,255,.08);
+  backdrop-filter: blur(8px);
+}
+
+.handover-flow span { display: flex; align-items: center; gap: 6px; color: rgba(255,255,255,.8); font-size: 10px; white-space: nowrap; }
+.handover-flow b { width: 22px; height: 22px; display: grid; place-items: center; border-radius: 50%; color: #fff; background: rgba(255,255,255,.14); font-size: 10px; }
+.handover-flow i { width: 22px; height: 1px; background: rgba(255,255,255,.25); }
+
+/* Group management */
+.group-stat-row > div { min-width: 100px; }
+.group-workspace {
+  min-width: 0;
+  display: grid;
+  grid-template-columns: 300px minmax(0, 1fr);
+  overflow: hidden;
+  border: 1px solid #e2e7ea;
+  border-radius: 16px;
+  background: #fff;
+  box-shadow: 0 10px 30px rgba(27,49,65,.055);
+}
+
+.group-sync-panel { min-width: 0; padding: 18px; border-right: 1px solid #e6ebee; background: #f8fafb; }
+.group-list-panel { min-width: 0; padding: 18px; }
+.group-sync-card,
+.group-filter-card,
+.group-list-panel #groups-table-card > .card { margin: 0; box-shadow: none; }
+.group-sync-card { padding: 0; border: 0; border-radius: 0; background: transparent; }
+.group-sync-card:hover,
+.group-filter-card:hover,
+.group-list-panel #groups-table-card > .card:hover { box-shadow: none; }
+.group-sync-card .card-header { margin-bottom: 16px; }
+.group-sync-fields { display: grid; grid-template-columns: minmax(0, 1fr) 92px; gap: 10px; }
+.group-sync-fields .form-group { min-width: 0; }
+.field-help { margin-top: -2px; color: var(--text-muted); font-size: 10px; line-height: 1.4; }
+.advanced-settings { margin: 14px 0; border: 1px solid #dfe6e9; border-radius: 10px; background: #fff; }
+.advanced-settings summary { padding: 10px 12px; color: #4c5c64; font-size: 11px; font-weight: 650; cursor: pointer; }
+.advanced-settings summary::marker { color: #6c8793; }
+.advanced-settings-body { display: grid; gap: 12px; padding: 0 12px 12px; }
+.advanced-settings textarea { resize: vertical; min-height: 72px; line-height: 1.5; }
+.group-auto-classify { margin: 0 0 14px; }
+.group-primary-action { width: 100%; }
+.group-sync-card .progress-wrap { margin-bottom: 0; }
+
+.operation-note {
+  display: flex;
+  align-items: flex-start;
+  gap: 10px;
+  margin-bottom: 12px;
+  padding: 11px 13px;
+  border: 1px solid #cfe4dc;
+  border-radius: 11px;
+  color: #315f50;
+  background: #f2faf6;
+}
+
+.operation-note svg { width: 17px; height: 17px; flex: 0 0 auto; margin-top: 1px; fill: #2f8565; }
+.operation-note div { display: grid; gap: 2px; }
+.operation-note strong { font-size: 11px; }
+.operation-note span { color: #6e8078; font-size: 10px; line-height: 1.5; }
+.group-filter-card { padding: 14px 15px; border-radius: 12px 12px 0 0; border-bottom-color: transparent; }
+.group-filter-card .card-header { align-items: flex-end; margin: 0; }
+.group-filter-card .filter-bar { justify-content: flex-end; }
+.group-filter-card .filter-bar input { width: 170px; }
+.group-list-panel #groups-table-card > .card { border-radius: 0 0 12px 12px; }
+.group-list-panel #groups-table-card table { min-width: 920px; }
+.group-list-panel #groups-table-card th,
+.group-list-panel #groups-table-card td { padding: 11px 12px; }
+
+/* Shared dashboard composition */
+.dashboard-grid {
+  min-width: 0;
+  display: grid;
+  grid-template-columns: repeat(6, minmax(0, 1fr));
+  gap: 14px;
+}
+
+.kpi-row {
+  grid-column: 1 / -1;
+  display: grid;
+  grid-template-columns: repeat(6, minmax(0, 1fr));
+  gap: 10px;
+}
+
+.kpi-card {
+  min-width: 0;
+  min-height: 104px;
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  padding: 15px 16px;
+  overflow: hidden;
+  border: 1px solid #e2e7ea;
+  border-radius: 13px;
+  background: #fff;
+  box-shadow: 0 7px 22px rgba(28,45,59,.045);
+}
+
+.kpi-label { overflow: hidden; color: #748089; font-size: 10px; text-overflow: ellipsis; white-space: nowrap; }
+.kpi-value { margin-top: 6px; color: #1e3139; font-size: 24px; font-weight: 800; line-height: 1; letter-spacing: -.02em; }
+.kpi-sub { margin-top: 6px; overflow: hidden; color: #98a1a6; font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }
+.kpi-trend.up { color: #25845e; }
+.kpi-trend.down { color: #ca5b52; }
+
+.chart-card {
+  grid-column: span 3;
+  min-width: 0;
+  overflow: hidden;
+  border: 1px solid #e2e7ea;
+  border-radius: 14px;
+  background: #fff;
+  box-shadow: 0 7px 22px rgba(28,45,59,.04);
+}
+
+.chart-card.third { grid-column: span 2; }
+.chart-card.full { grid-column: 1 / -1; }
+.chart-title { min-height: 50px; display: flex; align-items: center; justify-content: space-between; gap: 12px; margin: 0; padding: 0 17px; border-bottom: 1px solid #edf0f2; color: #2b3b43; font-size: 12px; }
+.chart-subtitle { color: #919aa0; font-size: 9px; font-weight: 400; }
+.chart-container { width: 100%; height: 238px; padding: 4px; }
+.chart-container.small { height: 220px; }
+.empty-chart { height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; color: #9aa3a8; font-size: 10px; }
+
+.operation-toolbar {
+  grid-column: 1 / -1;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  gap: 12px;
+  padding: 13px 15px;
+  border: 1px solid #e1e7ea;
+  border-radius: 13px;
+  background: #fff;
+  box-shadow: 0 6px 18px rgba(28,45,59,.035);
+}
+
+.toolbar-left,
+.toolbar-right { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
+.data-table-card {
+  grid-column: 1 / -1;
+  min-width: 0;
+  padding: 17px;
+  overflow: hidden;
+  border: 1px solid #e1e7ea;
+  border-radius: 14px;
+  background: #fff;
+  box-shadow: 0 8px 24px rgba(28,45,59,.04);
+}
+
+.data-table-card > .card-header { margin-bottom: 13px; }
+.data-table-card .filter-bar { margin-bottom: 13px !important; }
+.data-table-card .table-wrap { max-width: 100%; border-radius: 10px; }
+.data-table-card table { min-width: 820px; }
+.data-table-card th,
+.data-table-card td { padding: 11px 12px; }
+.data-table-card .tag { margin: 2px; }
+
+.pagination { display: flex; align-items: center; justify-content: flex-end; gap: 8px; padding-top: 13px; color: #7d888e; font-size: 10px; }
+.pagination span { margin-right: auto; }
+.pagination button { min-height: 32px; padding: 0 12px; border: 1px solid #dfe5e8; border-radius: 8px; color: #58666d; background: #fff; cursor: pointer; }
+.pagination button:hover:not(:disabled) { color: var(--brand-dark); border-color: #f0b879; background: #fff9f1; }
+.pagination button:disabled { opacity: .45; cursor: not-allowed; }
+
+.loading-overlay { position: relative; min-height: 120px; }
+.loading-spinner { position: absolute; top: 50%; left: 50%; width: 30px; height: 30px; margin: -15px; border: 3px solid #e7ecef; border-top-color: var(--brand); border-radius: 50%; animation: spin .75s linear infinite; }
+.table-skeleton,
+.skeleton-list { display: grid; gap: 12px; padding: 18px 4px; }
+.skeleton { position: relative; overflow: hidden; border-radius: 6px; background: #edf1f3; }
+.skeleton::after { position: absolute; inset: 0; content: ''; transform: translateX(-100%); background: linear-gradient(90deg, transparent, rgba(255,255,255,.72), transparent); animation: dashboardShimmer 1.35s infinite; }
+.skeleton-line { height: 30px; }
+@keyframes dashboardShimmer { to { transform: translateX(100%); } }
+
+.error-state { min-height: 180px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 7px; padding: 24px; text-align: center; }
+.error-state svg { width: 36px; height: 36px; fill: #e08865; }
+.error-state-title { color: #784733; font-size: 13px; font-weight: 700; }
+.error-state-desc { max-width: 420px; color: #8a7770; font-size: 10px; line-height: 1.55; }
+
+/* Transfer stage conversion card */
+.transfer-stage-board {
+  min-height: 238px;
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  gap: 16px;
+  padding: 17px;
+}
+
+.transfer-stage-track {
+  display: grid;
+  grid-template-columns: minmax(0, 1fr) 48px minmax(0, 1fr) 48px minmax(0, 1fr);
+  align-items: center;
+  gap: 7px;
+}
+
+.transfer-stage {
+  min-width: 0;
+  display: grid;
+  justify-items: center;
+  gap: 8px;
+  padding: 12px 7px;
+  border: 1px solid #e2e8ec;
+  border-radius: 12px;
+  text-align: center;
+  background: #f9fbfc;
+}
+
+.transfer-stage.preview { border-color: #dce6f0; background: #f5f8fc; }
+.transfer-stage.execution { border-color: #f1dfcb; background: #fff9f2; }
+.transfer-stage.success { border-color: #cfe7dc; background: #f2faf6; }
+.transfer-stage-icon { width: 34px; height: 34px; display: grid; place-items: center; border-radius: 10px; color: #527494; background: #e7eef5; }
+.transfer-stage.execution .transfer-stage-icon { color: #b46b25; background: #fbeddd; }
+.transfer-stage.success .transfer-stage-icon { color: #27805d; background: #dff1e8; }
+.transfer-stage-icon svg { width: 18px; height: 18px; fill: currentColor; }
+.transfer-stage > div:last-child { min-width: 0; }
+.transfer-stage span,
+.transfer-stage strong,
+.transfer-stage small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.transfer-stage span { color: #77858d; font-size: 8px; font-weight: 650; }
+.transfer-stage strong { margin-top: 4px; color: #21343d; font-size: 22px; line-height: 1; }
+.transfer-stage small { margin-top: 4px; color: #929ca2; font-size: 8px; }
+
+.transfer-stage-connector { min-width: 0; display: grid; justify-items: center; gap: 3px; text-align: center; }
+.transfer-stage-connector strong { color: #52636c; font-size: 11px; }
+.transfer-stage-connector span { color: #9aa3a8; font-size: 7px; white-space: nowrap; }
+.transfer-stage-connector i,
+.transfer-overall-progress > i { width: 100%; height: 4px; overflow: hidden; border-radius: 999px; background: #e7ecef; }
+.transfer-stage-connector b,
+.transfer-overall-progress > i b { display: block; height: 100%; border-radius: inherit; background: linear-gradient(90deg, #5d86b1, #51a480); transition: width .3s ease; }
+
+.transfer-overall-progress { display: grid; gap: 6px; padding: 10px 12px; border-radius: 10px; background: #f6f8fa; }
+.transfer-overall-progress > div { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
+.transfer-overall-progress span { color: #5f6f77; font-size: 9px; font-weight: 650; }
+.transfer-overall-progress strong { color: #274f67; font-size: 13px; }
+.transfer-overall-progress small { overflow: hidden; color: #8b979d; font-size: 8px; text-overflow: ellipsis; white-space: nowrap; }
+
+@media (max-width: 1180px) {
+  .group-workspace { grid-template-columns: 260px minmax(0, 1fr); }
+  .kpi-row { grid-template-columns: repeat(3, minmax(0, 1fr)); }
+  .chart-card.third { grid-column: span 3; }
+  .handover-flow { max-width: 380px; flex-wrap: wrap; }
+}
+
+@media (max-width: 900px) {
+  .customer-workspace-hero { align-items: flex-start; flex-direction: column; }
+  .hero-feature-list { justify-content: flex-start; }
+  .group-workspace { display: block; }
+  .group-sync-panel { border-right: 0; border-bottom: 1px solid #e6ebee; }
+  .group-sync-fields { grid-template-columns: minmax(0, 1fr) 110px; }
+  .chart-card,
+  .chart-card.third { grid-column: 1 / -1; }
+}
+
+@media (max-width: 620px) {
+  .customer-workspace-hero { min-height: 0; padding: 22px; }
+  .customer-workspace-hero h2 { font-size: 25px; }
+  .group-stat-row { width: 100%; display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); }
+  .group-stat-row > div { min-width: 0; padding: 10px; }
+  .group-list-panel,
+  .group-sync-panel { padding: 13px; }
+  .group-sync-fields,
+  .kpi-row { grid-template-columns: 1fr 1fr; }
+  .operation-toolbar { align-items: stretch; flex-direction: column; }
+  .toolbar-left,
+  .toolbar-right { width: 100%; }
+  .toolbar-left .btn { flex: 1 1 auto; }
+  .handover-flow i { display: none; }
+  .data-table-card { padding: 13px; }
+  .data-table-card .filter-bar input,
+  .data-table-card .filter-bar select,
+  .group-filter-card .filter-bar input,
+  .group-filter-card .filter-bar select { width: 100% !important; }
+  .transfer-stage-board { padding: 14px; }
+  .transfer-stage-track { grid-template-columns: 1fr; gap: 7px; }
+  .transfer-stage { grid-template-columns: 34px minmax(0, 1fr); justify-items: stretch; align-items: center; padding: 10px 12px; text-align: left; }
+  .transfer-stage-connector { grid-template-columns: 42px 1fr; justify-items: stretch; align-items: center; padding: 0 12px; text-align: left; }
+  .transfer-stage-connector span { display: none; }
+  .transfer-stage-connector i { height: 3px; }
+}

+ 1 - 0
claude-code/claude-code-qiwe-assistant/mcp/src/providers/fmode-wecom-gateway.js

@@ -158,6 +158,7 @@ async function callFmodeWecomGateway({
     err.kind = kind;
     err.httpStatus = code || response.status;
     err.bizCode = code;
+    err.bizMessage = message;
     throw err;
   }
 

+ 40 - 1
claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-broker-playbook-run.js

@@ -70,9 +70,47 @@ function extractRoomIdFromFileName(fileName) {
   return match ? match[1] : null;
 }
 
+function listRoomIdsFromSubdirectories() {
+  const dir = path.join(outputsRoot(), 'messages');
+  if (!fs.existsSync(dir)) return [];
+  return fs.readdirSync(dir)
+    .filter(f => {
+      const fullPath = path.join(dir, f);
+      return fs.statSync(fullPath).isDirectory() && /^[a-zA-Z0-9_-]+$/.test(f);
+    });
+}
+
+function listRoomMessageFiles(roomId) {
+  const dir = path.join(outputsRoot(), 'messages', roomId);
+  if (!fs.existsSync(dir)) return [];
+  return fs.readdirSync(dir)
+    .filter(f => f.endsWith('.json'))
+    .map(f => ({ name: f, path: path.join(dir, f), mtime: fs.statSync(path.join(dir, f)).mtime }))
+    .sort((a, b) => a.name.localeCompare(b.name));
+}
+
 function collectMessagesForBroker(brokerUserId, roomIds = null) {
-  const files = listMessageFiles();
   const messages = [];
+
+  // 优先:新单条文件格式
+  const roomIdList = roomIds || listRoomIdsFromSubdirectories();
+  for (const roomId of roomIdList) {
+    if (roomIds && !roomIds.includes(roomId)) continue;
+    const files = listRoomMessageFiles(roomId);
+    for (const file of files) {
+      try {
+        const msg = JSON.parse(fs.readFileSync(file.path, 'utf8'));
+        if (msg.senderId === brokerUserId) {
+          messages.push({ ...msg, roomId });
+        }
+      } catch {
+        // ignore
+      }
+    }
+  }
+
+  // 兼容:旧数组文件格式
+  const files = listMessageFiles();
   for (const file of files) {
     const roomId = extractRoomIdFromFileName(file.name);
     if (roomIds && !roomIds.includes(roomId)) continue;
@@ -88,6 +126,7 @@ function collectMessagesForBroker(brokerUserId, roomIds = null) {
       // ignore
     }
   }
+
   return messages;
 }
 

+ 127 - 5
claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-customer-ops-run.js

@@ -10,6 +10,14 @@ const {
   assertMethodsInCatalog,
   safeResult
 } = require('../core/shared-gateway');
+const { writeCustomer, buildCustomerId, normalizePhone, readCustomerByExternalUserId, readCustomerById } = require('../core/customer-broker-store');
+const {
+  getStateSection,
+  recordCustomerOperation,
+  recordFriendRequestResult,
+  recordAutoGroupCreated,
+  recordOperation
+} = require('../core/dashboard-state');
 
 const REQUIRED_METHODS = {
   searchContact: '/contact/searchContact',
@@ -110,6 +118,9 @@ const qiweiBatchAddFriends = safeResult(async function qiweiBatchAddFriends(inpu
     return errorResult('没有有效手机号', { summary: { invalidRemoved }, data: { invalid } });
   }
 
+  const brokerId = String(input.brokerId || '').trim() || undefined;
+  const groupNameTemplate = String(input.groupNameTemplate || input.groupName || '').trim() || undefined;
+
   const perMinute = Math.max(1, Number(input.rateLimitPerMinute || 12));
   const intervalMs = Math.ceil(60000 / perMinute);
   const retry = Math.max(1, Math.min(5, Number(input.maxAttempts || 2)));
@@ -135,12 +146,28 @@ const qiweiBatchAddFriends = safeResult(async function qiweiBatchAddFriends(inpu
         }
 
         if (found.searchStatus === 2 || found.searchStatus === 3) {
+          const externalUserId = found.externalUserId || '';
+          if (brokerId || externalUserId) {
+            try {
+              writeCustomer({
+                customerId: buildCustomerId({ phone: customer.phone, externalUserId }),
+                phone: normalizePhone(customer.phone),
+                name: customer.name,
+                brokerId,
+                groupNameTemplate,
+                externalUserId,
+                friendRequestStatus: found.searchStatus === 2 ? 'ACCEPTED' : 'PENDING'
+              });
+            } catch (err) {
+              console.warn('[BatchAddFriends] 写入客户档案失败:', err.message);
+            }
+          }
           details.push({
             phone: customer.phone,
             name: customer.name,
             status: 'SUCCESS',
             reason: found.searchStatus === 2 ? 'already_friend' : 'already_added_by_other',
-            externalUserId: found.externalUserId,
+            externalUserId,
             attempts: attempt
           });
           success++;
@@ -170,11 +197,28 @@ const qiweiBatchAddFriends = safeResult(async function qiweiBatchAddFriends(inpu
           break;
         }
 
+        const addExternalUserId = addData && addData.userId ? String(addData.userId) : found.externalUserId;
+        if (brokerId || addExternalUserId) {
+          try {
+            writeCustomer({
+              customerId: buildCustomerId({ phone: customer.phone, externalUserId: addExternalUserId }),
+              phone: normalizePhone(customer.phone),
+              name: customer.name,
+              brokerId,
+              groupNameTemplate,
+              externalUserId: addExternalUserId,
+              friendRequestStatus: 'PENDING'
+            });
+          } catch (err) {
+            console.warn('[BatchAddFriends] 写入客户档案失败:', err.message);
+          }
+        }
+
         details.push({
           phone: customer.phone,
           name: customer.name,
           status: 'SUCCESS',
-          externalUserId: addData && addData.userId ? String(addData.userId) : found.externalUserId,
+          externalUserId: addExternalUserId,
           attempts: attempt
         });
         success++;
@@ -193,9 +237,23 @@ const qiweiBatchAddFriends = safeResult(async function qiweiBatchAddFriends(inpu
     if (i < customers.length - 1) await sleep(intervalMs);
   }
 
+  const summary = { total: customers.length, success, failed, duplicateRemoved, invalidRemoved, rateLimitPerMinute: perMinute };
+  recordOperation({ type: 'batch-add-friends', summary });
+  for (const item of details) {
+    if (item.status !== 'SUCCESS') continue;
+    recordFriendRequestResult({
+      phone: item.phone,
+      name: item.name,
+      externalUserId: item.externalUserId,
+      status: item.reason === 'already_friend' ? 'ACCEPTED' : 'PENDING',
+      brokerId,
+      groupNameTemplate
+    });
+  }
+
   return okResult({
     assistantMessage: `批量加好友完成:成功 ${success},失败 ${failed}。`,
-    summary: { total: customers.length, success, failed, duplicateRemoved, invalidRemoved, rateLimitPerMinute: perMinute },
+    summary,
     data: { details, invalid }
   });
 });
@@ -268,9 +326,15 @@ const qiweiAutoCreateGroup = safeResult(async function qiweiAutoCreateGroup(inpu
     }
   }
 
+  const summary = { roomId, groupName: groupName || null, memberCount: memberList.length + supportMemberIds.length, supportInvited, welcomeSent };
+  recordOperation({ type: 'auto-create-group', summary });
+  for (const memberId of memberList) {
+    recordAutoGroupCreated({ externalUserId: memberId, roomId, groupName: groupName || null });
+  }
+
   return okResult({
     assistantMessage: `自动建群完成:roomId=${roomId}${groupName ? `,群名=${groupName}` : ''}。`,
-    summary: { roomId, groupName: groupName || null, memberCount: memberList.length + supportMemberIds.length, supportInvited, welcomeSent },
+    summary,
     data: { roomId, groupName: groupName || null, memberList, supportMemberIds, supportInvited, welcomeSent },
     warnings
   });
@@ -353,9 +417,24 @@ const qiweiCheckFriendStatus = safeResult(async function qiweiCheckFriendStatus(
     }
   }
 
+  const summary = { total: targets.length, confirmed, pending, notFound };
+  recordOperation({ type: 'check-friend-status', summary });
+  for (const item of details) {
+    if (!item.phone) continue;
+    let status = 'PENDING';
+    if (item.statusText === 'already_friend') status = 'ACCEPTED';
+    else if (item.statusText === 'not_found') status = 'NOT_FOUND';
+    recordFriendRequestResult({
+      phone: item.phone,
+      name: item.name,
+      externalUserId: item.externalUserId,
+      status
+    });
+  }
+
   return okResult({
     assistantMessage: `好友状态检查完成:已确认 ${confirmed},待通过 ${pending},未找到 ${notFound}。`,
-    summary: { total: targets.length, confirmed, pending, notFound },
+    summary,
     data: { details }
   });
 });
@@ -422,11 +501,54 @@ const qiweiGetCustomerProfile = safeResult(async function qiweiGetCustomerProfil
   });
 });
 
+const qiweiUpdateCustomerInfo = safeResult(async function qiweiUpdateCustomerInfo(input = {}) {
+  const externalUserId = String(input.externalUserId || input.customerId || '').trim();
+  const name = String(input.name || '').trim() || undefined;
+  const phone = cleanPhone(input.phone);
+  if (!externalUserId) return errorResult('缺少 externalUserId / customerId');
+
+  // 必须找到已有客户才允许更新,避免手机号变更时误创建新客户
+  const stateCustomers = (getStateSection('customers') || {}).customers || {};
+  const stateCustomer = stateCustomers[externalUserId] || Object.values(stateCustomers).find(c => String(c.externalUserId || '') === externalUserId);
+  if (!stateCustomer) {
+    return errorResult('未找到该客户,无法更新', { data: { externalUserId, name, phone } });
+  }
+
+  const targetCustomerId = String(stateCustomer.customerId || externalUserId);
+
+  // 同步更新 dashboard-state
+  recordCustomerOperation({
+    customerId: targetCustomerId,
+    externalUserId: stateCustomer.externalUserId || externalUserId,
+    name,
+    phone: phone || undefined
+  });
+
+  // 同步更新客户档案文件
+  const existingFile = readCustomerByExternalUserId(externalUserId) || readCustomerById(targetCustomerId) || {};
+  const updated = writeCustomer({
+    customerId: targetCustomerId,
+    externalUserId: stateCustomer.externalUserId || externalUserId,
+    name: name || existingFile.name,
+    phone: phone || existingFile.phone,
+    friendRequestStatus: existingFile.friendRequestStatus,
+    brokerId: existingFile.brokerId,
+    groupNameTemplate: existingFile.groupNameTemplate
+  });
+
+  return okResult({
+    assistantMessage: `客户信息已更新:${externalUserId}`,
+    summary: { externalUserId, customerId: targetCustomerId, name: updated.name, phone: updated.phone },
+    data: updated
+  });
+});
+
 module.exports = {
   qiweiBatchAddFriends,
   qiweiAutoCreateGroup,
   qiweiCheckFriendStatus,
   qiweiGetCustomerProfile,
+  qiweiUpdateCustomerInfo,
   cleanPhone,
   normalizeCustomers,
   readCustomersFromExcel

+ 6 - 18
claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-customer-transfer-run.js

@@ -1,7 +1,8 @@
 const fs = require('fs');
 const path = require('path');
 const { okResult, errorResult } = require('../core/result-envelope');
-const { createRunDir, outputsRoot, writeRunManifest } = require('../core/output-paths');
+const { createRunDir, outputsRoot } = require('../core/output-paths');
+const { recordTransferPreview, recordTransferExecution } = require('../core/dashboard-state');
 const {
   buildContext,
   gatewayCall,
@@ -106,23 +107,17 @@ const qiweiPreviewTransferPackage = safeResult(async function qiweiPreviewTransf
   const fileName = `preview-${new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)}.json`;
   const filePath = path.join(runDir, fileName);
   fs.writeFileSync(filePath, JSON.stringify(preview, null, 2), 'utf8');
-  const manifestPath = writeRunManifest(runDir, {
-    kind: 'customer-transfer-preview',
-    fromUserId,
-    toUserId,
-    itemCount: items.length,
-    files: [filePath]
-  });
 
   // 同时写入 latest
   const latestFile = path.join(transfersDir(), fileName);
   fs.writeFileSync(latestFile, JSON.stringify(preview, null, 2), 'utf8');
+  recordTransferPreview({ ...preview, filePath: path.relative(outputsRoot(), filePath) });
 
   return okResult({
     assistantMessage: `交接包预览已生成:${items.length} 条记录。`,
     summary: { fromUserId, toUserId, itemCount: items.length, status: 'DRAFT' },
     data: { preview, filePath: path.relative(outputsRoot(), filePath) },
-    files: [filePath, manifestPath],
+    files: [filePath],
     nextActions: ['确认后调用 qiwei_execute_transfer 执行交接']
   });
 });
@@ -200,20 +195,13 @@ const qiweiExecuteTransfer = safeResult(async function qiweiExecuteTransfer(inpu
   const fileName = `execution-${new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)}.json`;
   const filePath = path.join(runDir, fileName);
   fs.writeFileSync(filePath, JSON.stringify(preview, null, 2), 'utf8');
-  const manifestPath = writeRunManifest(runDir, {
-    kind: 'customer-transfer-execution',
-    fromUserId: preview.fromUserId,
-    toUserId: preview.toUserId,
-    success,
-    failed,
-    files: [filePath]
-  });
+  recordTransferExecution({ ...preview, filePath: path.relative(outputsRoot(), filePath) }, results);
 
   return okResult({
     assistantMessage: `交接执行完成:成功 ${success},失败 ${failed}。`,
     summary: { success, failed, total: preview.items.length, removeOldBroker },
     data: { results, filePath: path.relative(outputsRoot(), filePath) },
-    files: [filePath, manifestPath]
+    files: [filePath]
   });
 });
 

+ 257 - 11
claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-group-management-run.js

@@ -2,6 +2,7 @@ const fs = require('fs');
 const path = require('path');
 const { okResult, errorResult } = require('../core/result-envelope');
 const { createRunDir, latestPath, outputsRoot, writeRunManifest } = require('../core/output-paths');
+const { recordGroupSync, recordCustomersFromGroups } = require('../core/dashboard-state');
 const {
   buildContext,
   gatewayCall,
@@ -47,6 +48,10 @@ function messagesDir() {
   return path.join(outputsRoot(), 'messages');
 }
 
+function roomMessagesDir(roomId) {
+  return path.join(messagesDir(), roomId);
+}
+
 function ensureGroupsDir() {
   fs.mkdirSync(groupsDir(), { recursive: true });
 }
@@ -55,6 +60,150 @@ function ensureMessagesDir() {
   fs.mkdirSync(messagesDir(), { recursive: true });
 }
 
+function ensureRoomMessagesDir(roomId) {
+  return fs.mkdirSync(roomMessagesDir(roomId), { recursive: true });
+}
+
+function messageFilePath(roomId, seq, msgUniqueId) {
+  ensureRoomMessagesDir(roomId);
+  const safeUniqueId = String(msgUniqueId || '').replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 80);
+  return path.join(roomMessagesDir(roomId), `${seq}-${safeUniqueId}.json`);
+}
+
+let gbkEncodeMap = null;
+
+function getGbkEncodeMap() {
+  if (gbkEncodeMap) return gbkEncodeMap;
+  gbkEncodeMap = new Map();
+  const decoder = new TextDecoder('gbk');
+  for (let first = 0x81; first <= 0xfe; first++) {
+    for (let second = 0x40; second <= 0xfe; second++) {
+      if (second === 0x7f) continue;
+      const bytes = Buffer.from([first, second]);
+      const char = decoder.decode(bytes);
+      if (!char || char === '\uFFFD' || char.length !== 1) continue;
+      if (!gbkEncodeMap.has(char)) gbkEncodeMap.set(char, bytes);
+    }
+  }
+  return gbkEncodeMap;
+}
+
+function repairUtf8DecodedAsGbk(value) {
+  const text = String(value || '');
+  if (!/[\u4e00-\u9fa5]/.test(text)) return text;
+  const map = getGbkEncodeMap();
+  const chunks = [];
+  for (const char of text) {
+    const code = char.codePointAt(0);
+    if (code <= 0x7f) {
+      chunks.push(Buffer.from([code]));
+      continue;
+    }
+    const bytes = map.get(char);
+    if (!bytes) return text;
+    chunks.push(bytes);
+  }
+  const repaired = Buffer.concat(chunks).toString('utf8');
+  if (repaired.includes('\uFFFD')) return text;
+  return /[\u4e00-\u9fa5]/.test(repaired) ? repaired : text;
+}
+
+function cleanExtractedText(value) {
+  const cleaned = String(value || '').replace(/[\u0000-\u001f\u007f]/g, '').trim();
+  return repairUtf8DecodedAsGbk(cleaned);
+}
+
+function extractUtf8TextFromBase64(value) {
+  if (!value) return '';
+  let buffer;
+  try {
+    buffer = Buffer.from(String(value), 'base64');
+  } catch {
+    return '';
+  }
+  const candidates = [];
+  for (let i = 0; i < buffer.length - 1; i++) {
+    const len = buffer[i];
+    if (!len || len > 240 || i + 1 + len > buffer.length) continue;
+    const text = cleanExtractedText(buffer.slice(i + 1, i + 1 + len).toString('utf8'));
+    if (!text || text.includes('\uFFFD')) continue;
+    if (/[\u4e00-\u9fa5]/.test(text)) candidates.push(text);
+  }
+  return candidates.sort((a, b) => b.length - a.length)[0] || '';
+}
+
+function extractMessageContent(msg = {}) {
+  const rawText = extractUtf8TextFromBase64(msg.base64RawData || msg.msgData?.extras?.base64RawData);
+  if (rawText) return rawText;
+  if (typeof msg.content === 'string' && msg.content) return cleanExtractedText(msg.content);
+  if (typeof msg.msgContent === 'string' && msg.msgContent) return cleanExtractedText(msg.msgContent);
+  if (msg.msgData) {
+    if (typeof msg.msgData.content === 'string' && msg.msgData.content) return cleanExtractedText(msg.msgData.content);
+    if (typeof msg.msgData.text === 'string' && msg.msgData.text) return cleanExtractedText(msg.msgData.text);
+    if (Array.isArray(msg.msgData.moreDetail)) {
+      return cleanExtractedText(msg.msgData.moreDetail.map(item => item && item.text).filter(Boolean).join(''));
+    }
+    if (typeof msg.msgData.notifyTitle === 'string' && msg.msgData.notifyTitle) return cleanExtractedText(msg.msgData.notifyTitle);
+  }
+  return '';
+}
+
+function appendWebhookMessage(roomId, message) {
+  if (!roomId || !message) return null;
+  const seq = Number(message.seq) || 0;
+  const msgUniqueId = message.msgUniqueIdentifier || message.msgId || `${seq}`;
+  const filePath = messageFilePath(roomId, seq, msgUniqueId);
+  if (fs.existsSync(filePath)) {
+    const existing = safeReadJson(filePath, null);
+    if (!existing) return null;
+    const merged = {
+      ...existing,
+      content: existing.content || message.content || '',
+      rawData: existing.rawData || message.rawData,
+      fromRoomId: existing.fromRoomId || message.fromRoomId,
+      receiverId: existing.receiverId || message.receiverId || ''
+    };
+    const improved = (!existing.content && merged.content) || (!existing.rawData && merged.rawData);
+    if (!improved) return null;
+    fs.writeFileSync(filePath, JSON.stringify(merged, null, 2), 'utf8');
+    return filePath;
+  }
+  fs.writeFileSync(filePath, JSON.stringify(message, null, 2), 'utf8');
+  return filePath;
+}
+
+function messageExists(roomId, msgUniqueId) {
+  if (!roomId || !msgUniqueId) return false;
+  const dir = roomMessagesDir(roomId);
+  if (!fs.existsSync(dir)) return false;
+  const safeUniqueId = String(msgUniqueId).replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 80);
+  const files = fs.readdirSync(dir);
+  return files.some(f => f.endsWith(`-${safeUniqueId}.json`));
+}
+
+function listRoomMessageFiles(roomId) {
+  const dir = roomMessagesDir(roomId);
+  if (!fs.existsSync(dir)) return [];
+  return fs.readdirSync(dir)
+    .filter(f => f.endsWith('.json'))
+    .map(f => ({ name: f, path: path.join(dir, f), mtime: fs.statSync(path.join(dir, f)).mtime }))
+    .sort((a, b) => a.name.localeCompare(b.name));
+}
+
+function readRoomMessages(roomId) {
+  const files = listRoomMessageFiles(roomId);
+  const messages = [];
+  for (const file of files) {
+    try {
+      const msg = JSON.parse(fs.readFileSync(file.path, 'utf8'));
+      messages.push(msg);
+    } catch {
+      // ignore
+    }
+  }
+  return messages;
+}
+
 function safeReadJson(filePath, fallback) {
   if (!filePath || !fs.existsSync(filePath)) return fallback;
   try {
@@ -197,7 +346,7 @@ function createRoomRecord(raw, source) {
         : [];
   return {
     roomId: String(raw.roomId || ''),
-    roomName: decodeRoomName(raw.roomName || ''),
+    roomName: decodeRoomName(raw.roomName || raw.sessionName || ''),
     memberCount: Number(raw.roomMemberCount) || 0,
     roomHeadimgUrl: raw.roomHeadimgUrl || raw.roomAvatarUrl || undefined,
     roomExtType: raw.roomExtType !== undefined ? Number(raw.roomExtType) : undefined,
@@ -496,6 +645,32 @@ const qiweiSyncExternalGroups = safeResult(async function qiweiSyncExternalGroup
   }
 
   const deduped = dedupRooms(collected);
+  const roomsNeedingDetails = deduped
+    .filter(r => Number(r.memberCount || 0) > 0 && (!Array.isArray(r.members) || !r.members.length))
+    .map(r => r.roomId)
+    .filter(Boolean);
+
+  if (roomsNeedingDetails.length) {
+    try {
+      const detailRooms = await enrichRoomsWithDetails(ctx, roomsNeedingDetails, 20);
+      const detailMap = new Map(detailRooms.map(r => [r.roomId, r]));
+      for (let i = 0; i < deduped.length; i++) {
+        const detail = detailMap.get(deduped[i].roomId);
+        if (!detail) continue;
+        deduped[i] = {
+          ...deduped[i],
+          ...detail,
+          roomName: deduped[i].roomName || detail.roomName,
+          source: deduped[i].source,
+          sources: [...new Set([...(deduped[i].sources || []), ...(detail.sources || [])])],
+          seenAt: deduped[i].seenAt || detail.seenAt
+        };
+      }
+    } catch {
+      // 群详情不是同步列表的硬依赖;拿不到成员明细时仍保留群列表结果。
+    }
+  }
+
   const filtered = deduped.filter(r => isExternalRoom(r, includeInternalGroups));
 
   const classifiedRooms = [];
@@ -564,6 +739,8 @@ const qiweiSyncExternalGroups = safeResult(async function qiweiSyncExternalGroup
     imported: classifiedRooms.filter(r => r.reviewStatus === 'IMPORTED').length
   }, null, 2), 'utf8');
 
+  const discoveredCustomers = recordCustomersFromGroups(classifiedRooms, { source: 'group-sync' });
+
   return okResult({
     assistantMessage: `外部群同步完成:扫描到 ${classifiedRooms.length} 个群(scope=${scope})。`,
     summary: {
@@ -576,6 +753,7 @@ const qiweiSyncExternalGroups = safeResult(async function qiweiSyncExternalGroup
       autoConfirmed: classifiedRooms.filter(r => r.reviewStatus === 'AUTO_CONFIRMED').length,
       suggested: classifiedRooms.filter(r => r.reviewStatus === 'SUGGESTED').length,
       imported: classifiedRooms.filter(r => r.reviewStatus === 'IMPORTED').length,
+      discoveredCustomers: discoveredCustomers.discovered,
       previewMessages,
       previewOnlyForUnsure,
       pages: perSource.roomList.pages + perSource.session.pages + perSource.messages.pages
@@ -737,13 +915,16 @@ const qiweiAnalyzeGroupMembers = safeResult(async function qiweiAnalyzeGroupMemb
     fs.writeFileSync(latestPath('groups', 'rooms-latest.json'), JSON.stringify(Array.from(snapshotMap.values()), null, 2), 'utf8');
   }
 
+  const discoveredCustomers = recordCustomersFromGroups(classified, { source: 'group-analysis' });
+
   return okResult({
     assistantMessage: `群成员分析完成:分析 ${classified.length} 个群。`,
     summary: {
       analyzed: classified.length,
       autoConfirmed: classified.filter(d => d.reviewStatus === 'AUTO_CONFIRMED').length,
       suggested: classified.filter(d => d.reviewStatus === 'SUGGESTED').length,
-      imported: classified.filter(d => d.reviewStatus === 'IMPORTED').length
+      imported: classified.filter(d => d.reviewStatus === 'IMPORTED').length,
+      discoveredCustomers: discoveredCustomers.discovered
     },
     data: { details: classified }
   });
@@ -756,6 +937,34 @@ function updateConfirmedMapping(roomId, fields) {
   return mapping[roomId];
 }
 
+function importedMappingPath() {
+  ensureGroupsDir();
+  return path.join(groupsDir(), 'imported-mapping.json');
+}
+
+function readImportedMapping() {
+  return safeReadJson(importedMappingPath(), {});
+}
+
+function writeImportedMapping(mapping) {
+  ensureGroupsDir();
+  fs.writeFileSync(importedMappingPath(), JSON.stringify(mapping, null, 2), 'utf8');
+}
+
+function updateConfirmedMapping(roomId, fields) {
+  const mapping = readConfirmedMapping();
+  mapping[roomId] = { ...(mapping[roomId] || {}), ...fields, confirmedAt: new Date().toISOString() };
+  writeConfirmedMapping(mapping);
+  return mapping[roomId];
+}
+
+function updateImportedMapping(roomId, fields) {
+  const mapping = readImportedMapping();
+  mapping[roomId] = { ...(mapping[roomId] || {}), ...fields, importedAt: new Date().toISOString() };
+  writeImportedMapping(mapping);
+  return mapping[roomId];
+}
+
 const qiweiConfirmExternalGroup = safeResult(async function qiweiConfirmExternalGroup(input = {}) {
   const roomId = String(input.roomId || '').trim();
   if (!roomId) return errorResult('缺少 roomId');
@@ -769,10 +978,21 @@ const qiweiConfirmExternalGroup = safeResult(async function qiweiConfirmExternal
   if (!room) return errorResult(`roomId ${roomId} 不在最近一次同步的群列表中,请先调用 qiwei_sync_external_groups 或改用 qiwei_add_external_group`);
 
   const record = updateConfirmedMapping(roomId, { customerId, externalUserId, customerName, roomName: room.roomName });
+  const roomsForDiscovery = rooms.map(item => item.roomId === roomId
+    ? {
+        ...item,
+        reviewStatus: 'CONFIRMED',
+        customerId,
+        externalUserId,
+        customerName,
+        roomName: item.roomName || room.roomName
+      }
+    : item);
+  const discoveredCustomers = recordCustomersFromGroups(roomsForDiscovery, { source: 'group-confirm' });
 
   return okResult({
     assistantMessage: `已确认外部群为客户群:${room.roomName || roomId}。`,
-    summary: { roomId, roomName: room.roomName, reviewStatus: 'CONFIRMED' },
+    summary: { roomId, roomName: room.roomName, reviewStatus: 'CONFIRMED', discoveredCustomers: discoveredCustomers.discovered },
     data: record
   });
 });
@@ -900,9 +1120,12 @@ const qiweiSyncGroupMessages = safeResult(async function qiweiSyncGroupMessages(
           seq: msg.seq,
           senderId: msg.senderId || '',
           senderName: msg.senderName || '',
+          receiverId: msg.receiverId || '',
+          fromRoomId: msg.fromRoomId || roomId,
           msgType: String(msg.msgType),
-          content: msg.content || msg.msgContent || '',
-          timestamp: msg.timestamp ? new Date(msg.timestamp * 1000).toISOString() : new Date().toISOString()
+          content: extractMessageContent(msg),
+          timestamp: msg.timestamp ? new Date(msg.timestamp * 1000).toISOString() : new Date().toISOString(),
+          rawData: msg
         });
         newCount++;
       }
@@ -912,12 +1135,29 @@ const qiweiSyncGroupMessages = safeResult(async function qiweiSyncGroupMessages(
 
     if (messages.length) {
       ensureMessagesDir();
-      const fileName = `room-${roomId}-${new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)}.json`;
-      const filePath = path.join(messagesDir(), fileName);
-      fs.writeFileSync(filePath, JSON.stringify(messages, null, 2), 'utf8');
-      results.push({ roomId, newCount: messages.length, filePath: path.relative(outputsRoot(), filePath) });
+      let writtenCount = 0;
+      for (const message of messages) {
+        if (appendWebhookMessage(roomId, message)) writtenCount++;
+      }
+
+      // 更新 confirmed-mapping 的 lastMsgAt / lastSyncSeq
+      const mapping = readConfirmedMapping();
+      if (mapping[roomId]) {
+        const maxSeq = Math.max(...messages.map(m => Number(m.seq) || 0));
+        const lastMsg = messages[messages.length - 1];
+        mapping[roomId].lastMsgAt = lastMsg.timestamp || new Date().toISOString();
+        mapping[roomId].lastSyncSeq = Math.max(mapping[roomId].lastSyncSeq || 0, maxSeq);
+        writeConfirmedMapping(mapping);
+        recordGroupSync(roomId, {
+          messageCount: writtenCount,
+          lastMsgAt: lastMsg.timestamp || new Date().toISOString(),
+          lastSyncSeq: mapping[roomId].lastSyncSeq
+        });
+      }
+
+      results.push({ roomId, newCount: writtenCount, fileCount: messages.length });
     } else {
-      results.push({ roomId, newCount: 0, filePath: null });
+      results.push({ roomId, newCount: 0, fileCount: 0 });
     }
   }
 
@@ -938,5 +1178,11 @@ module.exports = {
   qiweiAddExternalGroup,
   qiweiConfigureGroupKeywords,
   qiweiRejectExternalGroup,
-  qiweiSyncGroupMessages
+  qiweiSyncGroupMessages,
+  readConfirmedMapping,
+  writeConfirmedMapping,
+  updateConfirmedMapping,
+  readImportedMapping,
+  writeImportedMapping,
+  updateImportedMapping
 };

+ 2 - 0
claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-login-run.js

@@ -15,6 +15,7 @@ const { saveSubscribePage, QIWEI_MONTHLY_PRICE } = require('../core/subscribe-pa
 const { startLoginFlowServer } = require('../core/login-flow-server');
 const { startLoginFallbackServer } = require('../core/login-fallback-server');
 const { isRelayEnabled } = require('../core/relay-config');
+const { recordDeviceGuid } = require('../core/device-broker-mapping');
 const { ensureRelayWebhookConfigured, registerRelayTenant } = require('./qiwei-webhook-relay-run');
 
 const QR_STATUS = {
@@ -393,6 +394,7 @@ async function qiweiLoginCheck(input = {}) {
       const guid = String(detail.guid || readQiweiGuid(input) || '').trim();
       if (guid && input.persistConfig !== false) {
         saveQiweiClientConfig({ guid, apiBase });
+        recordDeviceGuid(guid, { wecomUserId: detail.userId, nickname: detail.nickname });
       }
 
       let relaySetup = null;

+ 248 - 96
claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-portrait-tags-run.js

@@ -1,9 +1,9 @@
 const fs = require('fs');
 const path = require('path');
 const { okResult, errorResult } = require('../core/result-envelope');
-const { createRunDir, latestPath, outputsRoot, writeRunManifest } = require('../core/output-paths');
-const { AgentWorkbenchDb } = require('../core/agent-workbench-db');
-const { writeObjectRows } = require('../core/xlsx-io');
+const { createRunDir, outputsRoot } = require('../core/output-paths');
+const { readState, recordPortrait, removePortrait, recordTags, recordOperation } = require('../core/dashboard-state');
+const { analyzePortraitWithLlm } = require('../core/llm-client');
 const {
   buildContext,
   gatewayCall,
@@ -40,6 +40,10 @@ function tagsDir() {
   return path.join(outputsRoot(), 'tags');
 }
 
+function portraitsQueuePath() {
+  return path.join(portraitsDir(), 'queue.json');
+}
+
 function ensurePortraitsDir() {
   fs.mkdirSync(portraitsDir(), { recursive: true });
 }
@@ -48,58 +52,67 @@ function ensureTagsDir() {
   fs.mkdirSync(tagsDir(), { recursive: true });
 }
 
-function portraitFilePath(externalUserId) {
-  ensurePortraitsDir();
-  return path.join(portraitsDir(), `${externalUserId}.json`);
+function readPortraitQueue() {
+  try {
+    if (!fs.existsSync(portraitsQueuePath())) return [];
+    const data = JSON.parse(fs.readFileSync(portraitsQueuePath(), 'utf8'));
+    return Array.isArray(data) ? data : [];
+  } catch {
+    return [];
+  }
 }
 
-function withCustomerIntelligenceDb(callback) {
-  const db = new AgentWorkbenchDb(latestPath('messages', 'agent-workbench.db'), {
-    globalPaused: true,
-    defaultMode: 'review',
-    autoSendConfidence: 0.88,
-  });
-  try { return callback(db); }
-  finally { db.close(); }
+function writePortraitQueue(queue) {
+  ensurePortraitsDir();
+  fs.writeFileSync(portraitsQueuePath(), JSON.stringify(queue, null, 2), 'utf8');
 }
 
-function publicCanonicalProfile(profile = {}) {
-  const { __evidence, ...visible } = profile || {};
-  return visible;
+function enqueuePortraitUpdate(externalUserId, reason = 'WEBHOOK') {
+  if (!externalUserId) return false;
+  const queue = readPortraitQueue();
+  const now = Date.now();
+  const fiveMinutes = 5 * 60 * 1000;
+  const existing = queue.find(item => item.externalUserId === externalUserId);
+  if (existing) {
+    if (now - new Date(existing.enqueuedAt).getTime() < fiveMinutes) {
+      existing.reason = reason;
+      existing.enqueuedAt = new Date().toISOString();
+      writePortraitQueue(queue);
+      return false;
+    }
+  }
+  queue.push({ externalUserId, reason, enqueuedAt: new Date().toISOString() });
+  writePortraitQueue(queue);
+  return true;
 }
 
-function readCanonicalPortrait(externalUserId) {
-  return withCustomerIntelligenceDb(db => {
-    const conversation = db.getConversationByContactId(externalUserId);
-    if (!conversation) return null;
-    const record = db.getProfile(conversation.id);
-    if (!Object.keys(record.profile || {}).length && !(record.tags || []).length) return null;
-    return {
-      externalUserId,
-      portrait: publicCanonicalProfile(record.profile),
-      tags: record.tags || [],
-      source: 'customer-intelligence-db',
-      updatedAt: record.updatedAt,
-    };
-  });
+async function processPortraitQueue(limit = 10) {
+  const queue = readPortraitQueue();
+  if (!queue.length) return { processed: 0 };
+  const toProcess = queue.slice(0, Math.max(1, Number(limit) || 10));
+  const remaining = queue.slice(toProcess.length);
+  let processed = 0;
+
+  for (const item of toProcess) {
+    try {
+      await qiweiUpdateCustomerPortrait({ externalUserId: item.externalUserId });
+      processed++;
+    } catch (err) {
+      console.warn(`[PortraitQueue] 处理 ${item.externalUserId} 失败:`, err.message);
+      remaining.push(item);
+    }
+  }
+
+  writePortraitQueue(remaining);
+  return { processed, remaining: remaining.length };
 }
 
-function mergeCanonicalPortrait(externalUserId, portrait = {}, tags) {
-  return withCustomerIntelligenceDb(db => {
-    const conversation = db.ensureConversation(externalUserId, '');
-    const current = db.getProfile(conversation.id);
-    const patch = portrait?.portrait && typeof portrait.portrait === 'object' ? portrait.portrait : portrait;
-    return db.updateProfile(
-      conversation.id,
-      { ...current.profile, ...(patch || {}) },
-      tags === undefined ? current.tags : tags,
-    );
-  });
+function portraitFilePath(externalUserId) {
+  ensurePortraitsDir();
+  return path.join(portraitsDir(), `${externalUserId}.json`);
 }
 
 function readPortrait(externalUserId) {
-  const canonical = readCanonicalPortrait(externalUserId);
-  if (canonical) return canonical;
   const filePath = portraitFilePath(externalUserId);
   if (!fs.existsSync(filePath)) return null;
   try {
@@ -112,7 +125,13 @@ function readPortrait(externalUserId) {
 function writePortrait(externalUserId, portrait) {
   const filePath = portraitFilePath(externalUserId);
   fs.writeFileSync(filePath, JSON.stringify(portrait, null, 2), 'utf8');
-  mergeCanonicalPortrait(externalUserId, portrait);
+  return filePath;
+}
+
+function deletePortrait(externalUserId) {
+  const filePath = portraitFilePath(externalUserId);
+  if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
+  removePortrait(externalUserId);
   return filePath;
 }
 
@@ -122,8 +141,6 @@ function tagFilePath(externalUserId) {
 }
 
 function readTags(externalUserId) {
-  const canonical = readCanonicalPortrait(externalUserId);
-  if (canonical && Array.isArray(canonical.tags)) return canonical.tags;
   const filePath = tagFilePath(externalUserId);
   if (!fs.existsSync(filePath)) return [];
   try {
@@ -136,9 +153,7 @@ function readTags(externalUserId) {
 
 function writeTags(externalUserId, tags) {
   const filePath = tagFilePath(externalUserId);
-  const uniqueTags = [...new Set(tags)];
-  fs.writeFileSync(filePath, JSON.stringify({ externalUserId, tags: uniqueTags, updatedAt: new Date().toISOString() }, null, 2), 'utf8');
-  mergeCanonicalPortrait(externalUserId, {}, uniqueTags);
+  fs.writeFileSync(filePath, JSON.stringify({ externalUserId, tags: [...new Set(tags)], updatedAt: new Date().toISOString() }, null, 2), 'utf8');
   return filePath;
 }
 
@@ -156,10 +171,82 @@ function extractRoomIdFromFileName(fileName) {
   return match ? match[1] : null;
 }
 
+function listRoomIdsFromSubdirectories() {
+  const dir = path.join(outputsRoot(), 'messages');
+  if (!fs.existsSync(dir)) return [];
+  return fs.readdirSync(dir)
+    .filter(f => {
+      const fullPath = path.join(dir, f);
+      return fs.statSync(fullPath).isDirectory() && /^[a-zA-Z0-9_-]+$/.test(f);
+    });
+}
+
+function listRoomMessageFiles(roomId) {
+  const dir = path.join(outputsRoot(), 'messages', roomId);
+  if (!fs.existsSync(dir)) return [];
+  return fs.readdirSync(dir)
+    .filter(f => f.endsWith('.json'))
+    .map(f => ({ name: f, path: path.join(dir, f), mtime: fs.statSync(path.join(dir, f)).mtime }))
+    .sort((a, b) => a.name.localeCompare(b.name));
+}
+
+function textOfMessage(message = {}) {
+  const rawData = message.rawData || {};
+  const msgData = message.msgData || rawData.msgData || {};
+  const raw = String(
+    message.content ||
+    message.text ||
+    message.msgContent ||
+    rawData.content ||
+    rawData.msgContent ||
+    msgData.content ||
+    msgData.text ||
+    (Array.isArray(msgData.moreDetail) ? msgData.moreDetail.map(item => item && item.text).filter(Boolean).join('') : '') ||
+    ''
+  );
+  return raw
+    .replace(/[\u0000-\u001f\u007f]/g, '')
+    .replace(/^[A-Za-z]{1,3}(?=[\u4e00-\u9fa5])/u, '')
+    .trim();
+}
+
+function inferRoomIdsForExternalUserId(externalUserId) {
+  try {
+    const state = readState();
+    for (const customer of Object.values(state.customers || {})) {
+      if (customer && customer.externalUserId === externalUserId && Array.isArray(customer.sourceRoomIds) && customer.sourceRoomIds.length) {
+        return customer.sourceRoomIds.map(String).filter(Boolean);
+      }
+    }
+  } catch {
+    // ignore
+  }
+  return null;
+}
+
 function collectMessagesForExternalUserId(externalUserId, roomIds = null) {
-  const files = listMessageFiles();
   const messages = [];
-  for (const file of files) {
+
+  // 优先:新单条文件格式 outputs/messages/{roomId}/{seq}-{msgUniqueId}.json
+  const roomIdList = roomIds || listRoomIdsFromSubdirectories();
+  for (const roomId of roomIdList) {
+    if (roomIds && !roomIds.includes(roomId)) continue;
+    const files = listRoomMessageFiles(roomId);
+    for (const file of files) {
+      try {
+        const msg = JSON.parse(fs.readFileSync(file.path, 'utf8'));
+        if (msg.senderId === externalUserId) {
+          messages.push({ ...msg, content: textOfMessage(msg), roomId });
+        }
+      } catch {
+        // ignore
+      }
+    }
+  }
+
+  // 兼容:旧数组文件格式 outputs/messages/room-{roomId}-{timestamp}.json
+  const legacyFiles = listMessageFiles();
+  for (const file of legacyFiles) {
     const roomId = extractRoomIdFromFileName(file.name);
     if (roomIds && !roomIds.includes(roomId)) continue;
     try {
@@ -167,18 +254,19 @@ function collectMessagesForExternalUserId(externalUserId, roomIds = null) {
       if (!Array.isArray(data)) continue;
       for (const msg of data) {
         if (msg.senderId === externalUserId) {
-          messages.push({ ...msg, roomId });
+          messages.push({ ...msg, content: textOfMessage(msg), roomId });
         }
       }
     } catch {
       // ignore
     }
   }
+
   return messages;
 }
 
 function simpleKeywordPortrait(messages) {
-  const text = messages.map(m => m.content || '').join(' ');
+  const text = messages.map(m => textOfMessage(m)).filter(Boolean).join(' ');
   const portrait = {};
   for (const [field, keywords] of Object.entries(PORTRAIT_KEYWORDS)) {
     const matched = keywords.filter(kw => text.includes(kw));
@@ -190,9 +278,6 @@ function simpleKeywordPortrait(messages) {
       };
     }
   }
-  if (!Object.keys(portrait).length) {
-    portrait.aiSummary = { note: `收集了 ${messages.length} 条消息,未识别到明确画像关键词` };
-  }
   return portrait;
 }
 
@@ -212,7 +297,7 @@ const qiweiPrepareCustomerPortrait = safeResult(async function qiweiPrepareCusto
   const externalUserId = String(input.externalUserId || '').trim();
   if (!externalUserId) return errorResult('缺少 externalUserId');
 
-  const roomIds = Array.isArray(input.roomIds) ? input.roomIds.map(String) : null;
+  const roomIds = Array.isArray(input.roomIds) ? input.roomIds.map(String) : inferRoomIdsForExternalUserId(externalUserId);
   const messages = collectMessagesForExternalUserId(externalUserId, roomIds);
 
   const context = buildPortraitContext(externalUserId, messages);
@@ -220,18 +305,12 @@ const qiweiPrepareCustomerPortrait = safeResult(async function qiweiPrepareCusto
   const runDir = createRunDir('portraits', `context-${externalUserId}`);
   const filePath = path.join(runDir, `context-${externalUserId}.json`);
   fs.writeFileSync(filePath, JSON.stringify(context, null, 2), 'utf8');
-  const manifestPath = writeRunManifest(runDir, {
-    kind: 'customer-portrait-context',
-    externalUserId,
-    messageCount: messages.length,
-    files: [filePath]
-  });
 
   return okResult({
     assistantMessage: `已为客户 ${externalUserId} 准备画像分析上下文:共 ${messages.length} 条消息。`,
     summary: { externalUserId, messageCount: messages.length },
     data: { context, contextFile: path.relative(outputsRoot(), filePath) },
-    files: [filePath, manifestPath],
+    files: [filePath],
     nextActions: ['基于 context 分析后调用 qiwei_save_customer_portrait 保存']
   });
 });
@@ -241,41 +320,99 @@ const qiweiUpdateCustomerPortrait = safeResult(async function qiweiUpdateCustome
   if (!externalUserId) return errorResult('缺少 externalUserId');
 
   const aiMode = String(input.aiMode || '').trim();
-  const roomIds = Array.isArray(input.roomIds) ? input.roomIds.map(String) : null;
+  const roomIds = Array.isArray(input.roomIds) ? input.roomIds.map(String) : inferRoomIdsForExternalUserId(externalUserId);
   const messages = collectMessagesForExternalUserId(externalUserId, roomIds);
+  const textMessageCount = messages.filter(m => textOfMessage(m)).length;
+
+  if (!textMessageCount) {
+    deletePortrait(externalUserId);
+    return errorResult(`客户 ${externalUserId} 没有可用于生成画像的文本消息,请先同步该客户群的聊天记录。`, {
+      summary: { externalUserId, messageCount: messages.length, textMessageCount: 0, skipped: true }
+    });
+  }
 
   if (aiMode === 'keyword') {
     const portrait = simpleKeywordPortrait(messages);
+    const fields = Object.keys(portrait);
+    if (!fields.length) {
+      deletePortrait(externalUserId);
+      const sampleMessages = messages
+        .map(m => ({ senderId: m.senderId, senderName: m.senderName, content: textOfMessage(m), timestamp: m.timestamp }))
+        .filter(m => m.content)
+        .slice(0, 5);
+      return errorResult(`客户 ${externalUserId} 已同步到 ${textMessageCount} 条文本消息,但暂未识别到预算、区域、房型、时间或关注点等画像信息,未标记为已生成。`, {
+        summary: { externalUserId, messageCount: messages.length, textMessageCount, fields: [], skipped: true },
+        data: { sampleMessages }
+      });
+    }
     const saved = { externalUserId, portrait, source: 'keyword', messageCount: messages.length, updatedAt: new Date().toISOString() };
     const filePath = writePortrait(externalUserId, saved);
+    recordPortrait(externalUserId, { source: 'keyword', messageCount: messages.length, fields });
     return okResult({
       assistantMessage: `关键词模式画像更新完成:${externalUserId}。`,
-      summary: { externalUserId, messageCount: messages.length, fields: Object.keys(portrait) },
+      summary: { externalUserId, messageCount: messages.length, textMessageCount, fields },
       data: saved,
       files: [filePath]
     });
   }
 
-  // 默认 Agent 驱动:返回上下文
+  // 默认 LLM Agent 模式:生成上下文 → 调 LLM → 解析 JSON → 保存
   const context = buildPortraitContext(externalUserId, messages);
   ensurePortraitsDir();
   const runDir = createRunDir('portraits', `context-${externalUserId}`);
-  const filePath = path.join(runDir, `context-${externalUserId}.json`);
-  fs.writeFileSync(filePath, JSON.stringify(context, null, 2), 'utf8');
-  const manifestPath = writeRunManifest(runDir, {
-    kind: 'customer-portrait-context',
-    externalUserId,
-    messageCount: messages.length,
-    files: [filePath]
-  });
+  const contextFilePath = path.join(runDir, `context-${externalUserId}.json`);
+  fs.writeFileSync(contextFilePath, JSON.stringify(context, null, 2), 'utf8');
 
-  return okResult({
-    assistantMessage: `已为客户 ${externalUserId} 准备画像分析上下文,请 Agent 分析后调用 qiwei_save_customer_portrait 保存。`,
-    summary: { externalUserId, messageCount: messages.length, requiresAgentAnalysis: true },
-    data: { context, contextFile: path.relative(outputsRoot(), filePath), saveEndpoint: 'qiwei_save_customer_portrait' },
-    files: [filePath, manifestPath],
-    nextActions: ['分析 context 后调用 qiwei_save_customer_portrait']
-  });
+  try {
+    const { portrait, usage } = await analyzePortraitWithLlm(context, {
+      model: input.model,
+      apiKey: input.apiKey,
+      apiBase: input.apiBase,
+      timeoutMs: input.timeoutMs,
+      temperature: input.temperature
+    });
+
+    const fields = Object.keys(portrait).filter(Boolean);
+    if (!fields.length) {
+      deletePortrait(externalUserId);
+      return errorResult(`LLM 未返回有效画像字段:${externalUserId}`, {
+        summary: { externalUserId, messageCount: messages.length, textMessageCount, skipped: true },
+        data: { context, contextFile: path.relative(outputsRoot(), contextFilePath), usage }
+      });
+    }
+
+    const saved = {
+      externalUserId,
+      portrait,
+      source: 'agent',
+      messageCount: messages.length,
+      updatedAt: new Date().toISOString(),
+      llmUsage: usage
+    };
+    const filePath = writePortrait(externalUserId, saved);
+    recordPortrait(externalUserId, { source: 'agent', messageCount: messages.length, fields });
+
+    return okResult({
+      assistantMessage: `LLM Agent 画像更新完成:${externalUserId}。`,
+      summary: { externalUserId, messageCount: messages.length, textMessageCount, fields, source: 'agent' },
+      data: saved,
+      files: [filePath, contextFilePath]
+    });
+  } catch (error) {
+    // LLM 失败时保留上下文文件,方便人工排查
+    return {
+      status: error.kind === 'auth' ? 'needs_auth' : 'error',
+      assistantMessage: `LLM Agent 画像生成失败:${error.message}`,
+      summary: { externalUserId, messageCount: messages.length, textMessageCount, errorKind: error.kind || 'runtime' },
+      data: { context, contextFile: path.relative(outputsRoot(), contextFilePath), rawOutput: error.rawOutput || undefined },
+      files: [contextFilePath],
+      nextActions: error.kind === 'auth'
+        ? ['检查 FMODE_API_KEY / ~/.fmode/config.json 中的 newapiToken']
+        : ['查看 context 文件并考虑手动调用 qiwei_save_customer_portrait'],
+      warnings: [],
+      errors: [{ message: error.message, kind: error.kind || 'runtime' }]
+    };
+  }
 });
 
 const qiweiSaveCustomerPortrait = safeResult(async function qiweiSaveCustomerPortrait(input = {}) {
@@ -291,11 +428,14 @@ const qiweiSaveCustomerPortrait = safeResult(async function qiweiSaveCustomerPor
     messageCount: input.messageCount || 0,
     updatedAt: new Date().toISOString()
   };
+  const fields = Object.keys(portrait).filter(Boolean);
+  if (!fields.length) return errorResult('画像 JSON 为空,未保存');
   const filePath = writePortrait(externalUserId, saved);
+  recordPortrait(externalUserId, { source: saved.source, messageCount: saved.messageCount, fields });
 
   return okResult({
     assistantMessage: `客户画像已保存:${externalUserId}。`,
-    summary: { externalUserId, fields: Object.keys(portrait) },
+    summary: { externalUserId, fields },
     data: saved,
     files: [filePath]
   });
@@ -311,15 +451,19 @@ const qiweiBatchUpdateCustomerPortrait = safeResult(async function qiweiBatchUpd
   for (const externalUserId of externalUserIds) {
     try {
       const result = await qiweiUpdateCustomerPortrait({ ...input, externalUserId });
-      results.push({ externalUserId, status: result.status, summary: result.summary });
+      results.push({ externalUserId, status: result.status, summary: result.summary, message: result.assistantMessage });
     } catch (error) {
       results.push({ externalUserId, status: 'error', message: String(error && error.message ? error.message : error) });
     }
   }
+  const succeeded = results.filter(r => r.status === 'ok').length;
+  const failed = results.length - succeeded;
 
   return okResult({
-    assistantMessage: `批量画像更新完成:${results.filter(r => r.status === 'ok').length}/${results.length}。`,
-    summary: { total: results.length, succeeded: results.filter(r => r.status === 'ok').length, failed: results.filter(r => r.status !== 'ok').length },
+    assistantMessage: failed
+      ? `批量画像更新完成:成功 ${succeeded}/${results.length},${failed} 位因缺少有效聊天内容或 LLM 生成失败。`
+      : `批量画像更新完成:成功 ${succeeded}/${results.length}。`,
+    summary: { total: results.length, succeeded, failed },
     data: { results }
   });
 });
@@ -359,6 +503,13 @@ const qiweiExportCustomerPortraits = safeResult(async function qiweiExportCustom
 
   if (!externalUserIds.length) return errorResult('没有可导出的画像');
 
+  let XLSX;
+  try {
+    XLSX = require('xlsx');
+  } catch {
+    return errorResult('当前包尚未安装 xlsx,无法导出 Excel;请运行 npm install xlsx');
+  }
+
   const rows = [];
   for (const externalUserId of externalUserIds) {
     const data = readPortrait(externalUserId);
@@ -369,7 +520,10 @@ const qiweiExportCustomerPortraits = safeResult(async function qiweiExportCustom
   ensurePortraitsDir();
   const fileName = `export-${new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)}.xlsx`;
   const filePath = path.join(portraitsDir(), fileName);
-  await writeObjectRows(filePath, 'portraits', rows);
+  const worksheet = XLSX.utils.json_to_sheet(rows);
+  const workbook = XLSX.utils.book_new();
+  XLSX.utils.book_append_sheet(workbook, worksheet, 'portraits');
+  XLSX.writeFile(workbook, filePath);
 
   return okResult({
     assistantMessage: `已导出 ${rows.length} 条客户画像:${filePath}。`,
@@ -401,6 +555,7 @@ const qiweiAddCustomerTags = safeResult(async function qiweiAddCustomerTags(inpu
   const current = readTags(externalUserId);
   const updated = [...new Set([...current, ...tags])];
   writeTags(externalUserId, updated);
+  recordTags(externalUserId, updated);
 
   return okResult({
     assistantMessage: `已为客户 ${externalUserId} 添加标签:${tags.join('、')}。`,
@@ -419,6 +574,7 @@ const qiweiRemoveCustomerTags = safeResult(async function qiweiRemoveCustomerTag
   const tagSet = new Set(tags.map(t => t.toLowerCase()));
   const updated = current.filter(t => !tagSet.has(String(t).toLowerCase()));
   writeTags(externalUserId, updated);
+  recordTags(externalUserId, updated);
 
   return okResult({
     assistantMessage: `已为客户 ${externalUserId} 移除标签:${tags.join('、')}。`,
@@ -469,13 +625,7 @@ const qiweiSyncPersonalLabels = safeResult(async function qiweiSyncPersonalLabel
     currentSeq: 0,
     labelType: 2
   });
-  const labels = Array.isArray(data && data.labelList)
-    ? data.labelList.map(item => ({
-      ...item,
-      labelName: item.labelName || item.name || '',
-      labelSuperId: item.labelSuperId || item.groupId || ''
-    }))
-    : [];
+  const labels = Array.isArray(data && data.labelList) ? data.labelList : [];
 
   return okResult({
     assistantMessage: `已同步 ${labels.length} 个企微个人标签。`,
@@ -591,5 +741,7 @@ module.exports = {
   qiweiCreatePersonalLabel,
   qiweiUpdatePersonalLabel,
   qiweiDeletePersonalLabel,
-  qiweiApplyPersonalLabels
+  qiweiApplyPersonalLabels,
+  enqueuePortraitUpdate,
+  processPortraitQueue
 };

+ 376 - 102
claude-code/claude-code-qiwe-assistant/mcp/src/tools/qiwei-webhook-relay-run.js

@@ -1,3 +1,6 @@
+const fs = require('fs');
+const path = require('path');
+const { fork } = require('child_process');
 const { okResult, errorResult } = require('../core/result-envelope');
 const {
   buildContext,
@@ -5,6 +8,7 @@ const {
   assertMethodsInCatalog,
   safeResult
 } = require('../core/shared-gateway');
+const { outputsRoot } = require('../core/output-paths');
 const {
   readQiweiGuid,
   readQiweiAuthToken,
@@ -13,36 +17,24 @@ const {
   saveQiweiClientConfig
 } = require('../core/credentials');
 const {
-  startWebhookServer,
-  stopWebhookServer,
-  getWebhookServerStatus,
   readConfig,
   writeConfig,
   readRelayConfig,
   writeRelayConfig
-} = require('../core/webhook-server');
+} = require('../core/webhook-config');
 const {
-  isRelayEnabled,
-  getRelayBaseUrl,
-  getTenantApiSecret,
-  getTenantId,
-  getRelayPrivateKey,
-  saveRelayCredentials,
-  writeRelayConfigFile,
-  readRelayConfigFile
-} = require('../core/relay-config');
+  processWebhookEvents,
+  startWebhookServer,
+  stopWebhookServer,
+  getWebhookServerStatus
+} = require('../core/webhook-server');
+const { recordDeviceGuid } = require('../core/device-broker-mapping');
+const { isRelayEnabled, getRelayBaseUrl, getTenantApiKey, getTenantApiSecret, getTenantId, getRelayPrivateKey, getRelayCredentials, saveRelayCredentials, writeRelayConfigFile, readRelayConfigFile } = require('../core/relay-config');
 
 const REQUIRED_METHODS = {
   setCallback: '/client/setCallback'
 };
 
-function generateSecret() {
-  const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
-  let result = '';
-  for (let i = 0; i < 32; i++) result += chars.charAt(Math.floor(Math.random() * chars.length));
-  return result;
-}
-
 function generateRelaySecret() {
   const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
   let result = '';
@@ -152,10 +144,13 @@ async function ensureRelayWebhookConfigured(input = {}, options = {}) {
   let relaySecret;
   try {
     const deviceRes = await fetchRelay(baseUrl, '/api/tenant/device', apiSecret, { guid });
-    relaySecret = deviceRes.relaySecret || generateRelaySecret();
+    relaySecret = deviceRes.relaySecret;
   } catch (err) {
     console.warn('[Relay] 注册设备到 Relay 失败,使用本地生成 secret:', err.message);
-    relaySecret = generateRelaySecret();
+    return { success: false, error: `Relay device registration failed: ${err.message}` };
+  }
+  if (!relaySecret) {
+    return { success: false, error: 'Relay did not return relaySecret for this device' };
   }
 
   const callbackUrl = `${baseUrl}/api/webhook/ingest/${tenantId}/${guid}`;
@@ -168,11 +163,29 @@ async function ensureRelayWebhookConfigured(input = {}, options = {}) {
 
   // 调用 Fmode 网关设置回调
   const ctx = buildContext(input);
-  await gatewayCall(ctx, REQUIRED_METHODS.setCallback, {
-    callbackUrl,
-    authSecret: relaySecret,
-    authType: 'Authorization'
-  });
+  try {
+    await gatewayCall(ctx, REQUIRED_METHODS.setCallback, {
+      callbackUrl,
+      authSecret: relaySecret,
+      authType: 'Authorization'
+    });
+  } catch (err) {
+    const message = String(err.bizMessage || err.message || '');
+    if (/专用端点|登录或设备接口|登录.*设备接口/i.test(message)) {
+      // Fmode 禁止通过 /doApi 自动配置回调,降级为手动配置模式
+      writeConfig({ callbackUrl, secret: relaySecret });
+      writeRelayConfigFile({ deviceGuid: guid, tenantId });
+      return {
+        success: true,
+        callbackUrl,
+        source: 'manual',
+        manualSetup: true,
+        error: '该 Fmode 账号/Token 禁止通过 API 自动设置回调,请手动在 Fmode 后台配置上述回调地址',
+        authSecret: relaySecret
+      };
+    }
+    throw err;
+  }
 
   writeConfig({ callbackUrl, secret: relaySecret });
   writeRelayConfigFile({ deviceGuid: guid, tenantId });
@@ -180,51 +193,95 @@ async function ensureRelayWebhookConfigured(input = {}, options = {}) {
   return { success: true, callbackUrl, source: 'relay' };
 }
 
-const qiweiWebhookStatus = safeResult(async function qiweiWebhookStatus() {
+async function ensureLocalWebhookConfigured(input = {}, options = {}) {
+  const guid = String(input.guid || readQiweiGuid(input) || '').trim();
+  if (!guid) {
+    return { success: false, error: '缺少 guid,请先完成企微设备登录' };
+  }
+
   const status = getWebhookServerStatus();
+  if (!status.running) {
+    return { success: false, error: '本地 webhook server 未启动,请先调用 qiwei_webhook_server_start' };
+  }
+
+  // 使用用户传入的公网 URL,或回退到本地 URL
+  const callbackUrl = String(input.callbackUrl || input.publicUrl || '').trim() || status.localUrl;
+  const localSecret = generateRelaySecret();
+
   const config = readConfig();
-  const relayEnabled = isRelayEnabled();
-  return okResult({
-    assistantMessage: status.running
-      ? `本地 webhook server 运行中:${status.localUrl}${relayEnabled ? ';Relay 模式已启用' : ''}`
-      : `本地 webhook server 未运行。${relayEnabled ? 'Relay 模式已启用' : ''}`,
-    summary: { running: status.running, port: status.port, relayEnabled },
-    data: { status, config, relayEnabled }
-  });
-});
+  if (config.callbackUrl === callbackUrl && config.secret && !options.force) {
+    return { success: true, callbackUrl, source: 'cache', secret: config.secret };
+  }
 
-const qiweiWebhookDiscover = safeResult(async function qiweiWebhookDiscover() {
+  // 调用 Fmode 网关设置回调
+  const ctx = buildContext(input);
+  try {
+    await gatewayCall(ctx, REQUIRED_METHODS.setCallback, {
+      callbackUrl,
+      authSecret: localSecret,
+      authType: 'Authorization'
+    });
+  } catch (err) {
+    const message = String(err.bizMessage || err.message || '');
+    if (/专用端点|登录或设备接口|登录.*设备接口/i.test(message)) {
+      writeConfig({ callbackUrl, secret: localSecret });
+      recordDeviceGuid(guid);
+      return {
+        success: true,
+        callbackUrl,
+        source: 'manual',
+        manualSetup: true,
+        error: '该 Fmode 账号/Token 禁止通过 API 自动设置回调,请手动在 Fmode 后台配置上述回调地址',
+        authSecret: localSecret
+      };
+    }
+    throw err;
+  }
+
+  writeConfig({ callbackUrl, secret: localSecret });
+  recordDeviceGuid(guid);
+  return { success: true, callbackUrl, source: 'local', secret: localSecret };
+}
+
+const qiweiWebhookDiscover = safeResult(async function qiweiWebhookDiscover(input = {}) {
+  const guid = String(input.guid || readQiweiGuid(input) || '').trim();
+  if (!guid) {
+    return errorResult('缺少 guid,请先完成企微设备登录');
+  }
   const status = getWebhookServerStatus();
   if (!status.running) {
-    return okResult({
-      assistantMessage: '本地 webhook server 未运行,请先调用 qiwei_webhook_server_start。',
-      summary: { running: false },
-      data: {}
-    });
+    return errorResult('本地 webhook server 未启动,请先调用 qiwei_webhook_server_start');
   }
+  const callbackUrl = String(input.callbackUrl || input.publicUrl || '').trim() || status.localUrl;
   return okResult({
-    assistantMessage: `本地回调地址:${status.localUrl}`,
-    summary: { running: true, port: status.port },
-    data: { localUrl: status.localUrl }
+    assistantMessage: `本地 webhook server 运行中:${status.localUrl}。对外回调地址:${callbackUrl}`,
+    summary: { ...status, callbackUrl },
+    data: { ...status, callbackUrl, guid }
   });
 });
 
 const qiweiWebhookServerStart = safeResult(async function qiweiWebhookServerStart(input = {}) {
-  const port = Number(input.port || 0);
-  const result = await startWebhookServer(port);
-  const status = getWebhookServerStatus();
-  writeConfig({ callbackUrl: status.localUrl });
+  const guid = String(input.guid || readQiweiGuid(input) || '').trim();
+  if (!guid) {
+    return errorResult('缺少 guid,请先完成企微设备登录');
+  }
+  const port = Number(input.port) || 0;
+  const host = String(input.host || '127.0.0.1').trim();
+  const result = await startWebhookServer(port, host);
+  recordDeviceGuid(guid);
   return okResult({
-    assistantMessage: `本地 webhook server 已启动:${status.localUrl}`,
-    summary: { port: status.port, alreadyRunning: result.alreadyRunning || false },
-    data: { localUrl: status.localUrl }
+    assistantMessage: result.alreadyRunning
+      ? `本地 webhook server 已在运行:${result.host}:${result.port}`
+      : `本地 webhook server 已启动:${result.host}:${result.port}。请把公网回调地址指向 http://${host === '0.0.0.0' ? '<本机公网IP>' : host}:${result.port}/callback`,
+    summary: { ...result, localUrl: `http://${result.host}:${result.port}/callback` },
+    data: { ...result, localUrl: `http://${result.host}:${result.port}/callback`, guid }
   });
 });
 
 const qiweiWebhookServerStop = safeResult(async function qiweiWebhookServerStop() {
   const result = await stopWebhookServer();
   return okResult({
-    assistantMessage: result.stopped ? '本地 webhook server 已停止。' : '本地 webhook server 未运行。',
+    assistantMessage: result.stopped ? '本地 webhook server 已停止' : '本地 webhook server 未运行',
     summary: result,
     data: result
   });
@@ -233,67 +290,60 @@ const qiweiWebhookServerStop = safeResult(async function qiweiWebhookServerStop(
 const qiweiWebhookAutoSetup = safeResult(async function qiweiWebhookAutoSetup(input = {}) {
   assertMethodsInCatalog(REQUIRED_METHODS);
 
-  // Relay 模式优先
-  if (isRelayEnabled() || input.relayBaseUrl || process.env.RELAY_BASE_URL) {
-    const guid = String(input.guid || readQiweiGuid(input) || '').trim();
-    if (!guid) {
-      return errorResult('Relay 自动配置需要 guid,请先完成企微设备登录或显式传入 guid');
-    }
-    const result = await ensureRelayWebhookConfigured({ ...input, guid }, { force: input.force === true });
-    if (!result.success) return errorResult(result.error);
-    return okResult({
-      assistantMessage: `已自动配置 Relay 回调:${result.callbackUrl}。请在服务器上执行 npm run relay 启动轮询客户端。`,
-      summary: { callbackUrl: result.callbackUrl, source: result.source },
-      data: { callbackUrl: result.callbackUrl, source: result.source }
-    });
+  const guid = String(input.guid || readQiweiGuid(input) || '').trim();
+  if (!guid) {
+    return errorResult('自动配置需要 guid,请先完成企微设备登录或显式传入 guid');
   }
 
-  // 本地 webhook server 模式
-  const ctx = buildContext(input);
-  const status = getWebhookServerStatus();
-  if (!status.running) {
-    await startWebhookServer(Number(input.port || 0));
+  if (!isRelayEnabled()) {
+    return errorResult('Relay is not configured. Run qiwei_relay_register before qiwei_webhook_auto_setup.');
   }
-  const finalStatus = getWebhookServerStatus();
-  const callbackUrl = String(input.callbackUrl || finalStatus.localUrl || '').trim();
-  if (!callbackUrl) return errorResult('无法确定回调地址,请显式传入 callbackUrl 或先启动 webhook server');
-
-  const secret = String(input.secret || generateSecret()).trim();
-  await gatewayCall(ctx, REQUIRED_METHODS.setCallback, {
-    callbackUrl,
-    authSecret: secret,
-    authType: 'Authorization'
-  });
-
-  writeConfig({ callbackUrl, secret });
 
+  const result = await ensureRelayWebhookConfigured({ ...input, guid }, { force: input.force === true });
+  if (!result.success) return errorResult(result.error);
+  if (result.manualSetup) {
+    return okResult({
+      assistantMessage: [
+        `该 Fmode 账号禁止通过 API 自动设置回调,已降级为手动配置模式。`,
+        `请在 Fmode 后台将以下地址配置为回调地址:`,
+        `${result.callbackUrl}`,
+        ``,
+        `认证密钥(Authorization header):Bearer ${result.authSecret}`
+      ].join('\n'),
+      summary: { callbackUrl: result.callbackUrl, source: result.source, manualSetup: true },
+      data: { callbackUrl: result.callbackUrl, source: result.source, manualSetup: true, authSecret: result.authSecret },
+      nextActions: ['在 Fmode 后台手动配置回调地址后,执行 npm run relay 启动轮询客户端']
+    });
+  }
   return okResult({
-    assistantMessage: `已自动配置企微回调:${callbackUrl}。`,
-    summary: { callbackUrl },
-    data: { callbackUrl, secret }
+    assistantMessage: `已自动配置 Relay 回调:${result.callbackUrl}。请在服务器上执行 npm run relay 启动轮询客户端。`,
+    summary: { callbackUrl: result.callbackUrl, source: result.source },
+    data: { callbackUrl: result.callbackUrl, source: result.source }
   });
 });
 
 const qiweiWebhookSetup = safeResult(async function qiweiWebhookSetup(input = {}) {
-  assertMethodsInCatalog(REQUIRED_METHODS);
-  const ctx = buildContext(input);
-
-  const callbackUrl = String(input.callbackUrl || '').trim();
-  if (!callbackUrl) return errorResult('缺少 callbackUrl');
-  const secret = String(input.secret || generateSecret()).trim();
+  const guid = String(input.guid || readQiweiGuid(input) || '').trim();
+  if (!guid) {
+    return errorResult('缺少 guid,请先完成企微设备登录或显式传入 guid');
+  }
 
-  await gatewayCall(ctx, REQUIRED_METHODS.setCallback, {
-    callbackUrl,
-    authSecret: secret,
-    authType: 'Authorization'
-  });
+  const status = getWebhookServerStatus();
+  if (!status.running) {
+    return errorResult('本地 webhook server 未启动,请先调用 qiwei_webhook_server_start');
+  }
 
-  writeConfig({ callbackUrl, secret });
+  const callbackUrl = String(input.callbackUrl || input.publicUrl || status.localUrl || '').trim();
+  if (!callbackUrl) {
+    return errorResult('缺少 callbackUrl/publicUrl');
+  }
 
+  const result = await ensureLocalWebhookConfigured({ ...input, guid }, { force: input.force === true });
+  if (!result.success) return errorResult(result.error);
   return okResult({
-    assistantMessage: `已配置企微回调:${callbackUrl}。`,
-    summary: { callbackUrl },
-    data: { callbackUrl, secret }
+    assistantMessage: `本地回调已配置:${result.callbackUrl}`,
+    summary: { callbackUrl: result.callbackUrl, source: result.source },
+    data: { callbackUrl: result.callbackUrl, source: result.source, secret: result.secret }
   });
 });
 
@@ -394,6 +444,23 @@ const qiweiRelayConnect = safeResult(async function qiweiRelayConnect(input = {}
   const result = await ensureRelayWebhookConfigured({ ...input, guid }, { force: input.force === true });
   if (!result.success) return errorResult(result.error);
 
+  recordDeviceGuid(guid);
+
+  if (result.manualSetup) {
+    return okResult({
+      assistantMessage: [
+        `该 Fmode 账号禁止通过 API 自动设置回调,已降级为手动配置模式。`,
+        `请在 Fmode 后台将以下地址配置为回调地址:`,
+        `${result.callbackUrl}`,
+        ``,
+        `认证密钥(Authorization header):Bearer ${result.authSecret}`
+      ].join('\n'),
+      summary: { callbackUrl: result.callbackUrl, source: result.source, manualSetup: true },
+      data: { callbackUrl: result.callbackUrl, source: result.source, manualSetup: true, authSecret: result.authSecret },
+      nextActions: ['在 Fmode 后台手动配置回调地址后,执行 npm run relay 启动轮询客户端']
+    });
+  }
+
   return okResult({
     assistantMessage: `Relay 回调已配置:${result.callbackUrl}。请在服务器上执行 npm run relay 启动轮询客户端(或使用 systemd/pm2 持久化运行)。`,
     summary: { callbackUrl: result.callbackUrl, source: result.source },
@@ -402,6 +469,208 @@ const qiweiRelayConnect = safeResult(async function qiweiRelayConnect(input = {}
   });
 });
 
+// ============================================================
+// 阶段 6:webhook 状态 / 重放 / 清理 / Relay 子进程控制
+// ============================================================
+
+let relayChildProcess = null;
+
+function webhookDir() {
+  return path.join(outputsRoot(), 'webhook');
+}
+
+function safeReadJson(filePath, fallback = null) {
+  try {
+    if (!filePath || !fs.existsSync(filePath)) return fallback;
+    return JSON.parse(fs.readFileSync(filePath, 'utf8'));
+  } catch {
+    return fallback;
+  }
+}
+
+function isValidWebhookPath(requestedPath) {
+  if (!requestedPath) return false;
+  const resolved = path.resolve(requestedPath);
+  const root = path.resolve(webhookDir());
+  return resolved.startsWith(root) && fs.existsSync(resolved);
+}
+
+function scanWebhookEventFiles() {
+  const root = webhookDir();
+  if (!fs.existsSync(root)) return [];
+  const files = [];
+  for (const dateDir of fs.readdirSync(root)) {
+    const datePath = path.join(root, dateDir);
+    if (!fs.statSync(datePath).isDirectory()) continue;
+    for (const runDir of fs.readdirSync(datePath)) {
+      const runPath = path.join(datePath, runDir);
+      if (!fs.statSync(runPath).isDirectory()) continue;
+      for (const file of fs.readdirSync(runPath)) {
+        if (!file.startsWith('event-') || !file.endsWith('.json')) continue;
+        files.push({
+          path: path.join(runPath, file),
+          name: file,
+          dir: runPath,
+          mtime: fs.statSync(path.join(runPath, file)).mtime
+        });
+      }
+    }
+  }
+  return files.sort((a, b) => b.mtime - a.mtime);
+}
+
+function getWebhookEventStats() {
+  const files = scanWebhookEventFiles();
+  const counts = { PENDING: 0, PROCESSED: 0, IGNORED: 0, ERROR: 0, AUTO_GROUP_CREATED: 0, FAILED: 0 };
+  let lastReceivedAt = null;
+  let lastProcessedType = null;
+  for (const file of files) {
+    const record = safeReadJson(file.path);
+    if (!record) continue;
+    if (record.receivedAt && (!lastReceivedAt || record.receivedAt > lastReceivedAt)) {
+      lastReceivedAt = record.receivedAt;
+      lastProcessedType = record.parsedType || record.status;
+    }
+    if (record.status && counts[record.status] !== undefined) {
+      counts[record.status]++;
+    } else {
+      counts.OTHER = (counts.OTHER || 0) + 1;
+    }
+  }
+  return {
+    total: files.length,
+    pendingCount: counts.PENDING + counts.AUTO_GROUP_CREATED,
+    counts,
+    lastReceivedAt,
+    lastProcessedType,
+    relayRunning: !!(relayChildProcess && !relayChildProcess.killed && relayChildProcess.exitCode === null)
+  };
+}
+
+const qiweiWebhookStatus = safeResult(async function qiweiWebhookStatus() {
+  const config = readConfig();
+  const relayEnabled = isRelayEnabled();
+  const stats = getWebhookEventStats();
+  return okResult({
+    assistantMessage: relayEnabled
+      ? `本地 webhook 模式已重新启用;Relay 模式状态:${relayEnabled ? '已启用' : '未启用'}。`
+      : '本地 webhook 模式已重新启用;Relay 尚未配置。',
+    summary: {
+      localMode: 'enabled',
+      relayEnabled,
+      ...stats
+    },
+    data: { config, relayEnabled, relay: getRelayCredentials(), stats, localServer: getWebhookServerStatus() }
+  });
+});
+
+const qiweiWebhookReplay = safeResult(async function qiweiWebhookReplay(input = {}) {
+  const eventFilePath = String(input.eventFilePath || '').trim();
+  if (!eventFilePath) return errorResult('缺少 eventFilePath');
+  if (!isValidWebhookPath(eventFilePath)) return errorResult('eventFilePath 必须在 outputs/webhook/ 目录内');
+
+  const record = safeReadJson(eventFilePath);
+  if (!record || !record.event) return errorResult('事件文件格式异常');
+
+  // 构造 v1 envelope
+  const envelope = {
+    code: 0,
+    msg: 'replay',
+    data: [record.event],
+    source: record.source || 'replay',
+    __rawBody: JSON.stringify({ code: 0, msg: 'replay', data: [record.event] })
+  };
+
+  const result = await processWebhookEvents(envelope);
+  return okResult({
+    assistantMessage: `事件重放完成:processed=${result.processed}, ignored=${result.ignored}, errors=${result.errors}`,
+    summary: result,
+    data: { eventFilePath, result }
+  });
+});
+
+const qiweiWebhookPurge = safeResult(async function qiweiWebhookPurge(input = {}) {
+  const days = Math.max(1, Number(input.days || 30));
+  const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
+  const root = webhookDir();
+  if (!fs.existsSync(root)) return okResult({ assistantMessage: '没有可清理的 webhook 事件', summary: { deleted: 0 } });
+
+  let deleted = 0;
+  for (const dateDir of fs.readdirSync(root)) {
+    const datePath = path.join(root, dateDir);
+    if (!fs.statSync(datePath).isDirectory()) continue;
+    const dirDate = new Date(dateDir);
+    if (isNaN(dirDate.getTime()) || dirDate >= cutoff) continue;
+
+    for (const runDir of fs.readdirSync(datePath)) {
+      const runPath = path.join(datePath, runDir);
+      if (!fs.statSync(runPath).isDirectory()) continue;
+      for (const file of fs.readdirSync(runPath)) {
+        fs.unlinkSync(path.join(runPath, file));
+      }
+      fs.rmdirSync(runPath);
+    }
+    fs.rmdirSync(datePath);
+    deleted++;
+  }
+
+  return okResult({
+    assistantMessage: `已清理 ${deleted} 个 ${days} 天前的 webhook 日期目录`,
+    summary: { deleted, days }
+  });
+});
+
+function getRelayChildStatus() {
+  return {
+    running: !!(relayChildProcess && !relayChildProcess.killed && relayChildProcess.exitCode === null),
+    pid: relayChildProcess ? relayChildProcess.pid : null
+  };
+}
+
+const qiweiRelayStart = safeResult(async function qiweiRelayStart(input = {}) {
+  if (getRelayChildStatus().running) {
+    return okResult({
+      assistantMessage: `Relay 子进程已在运行中,pid=${relayChildProcess.pid}`,
+      summary: getRelayChildStatus()
+    });
+  }
+
+  const guid = String(input.guid || readQiweiGuid(input) || '').trim();
+  if (!guid) return errorResult('缺少 guid,请先完成企微设备登录或显式传入 guid');
+
+  const scriptPath = path.join(process.cwd(), 'scripts', 'start-relay-client.js');
+  relayChildProcess = fork(scriptPath, [guid], {
+    detached: false,
+    stdio: ['ignore', 'pipe', 'pipe', 'ipc']
+  });
+
+  relayChildProcess.on('exit', (code) => {
+    console.log(`[MCP] Relay 子进程退出,code=${code}`);
+    relayChildProcess = null;
+  });
+  relayChildProcess.on('error', (err) => {
+    console.error('[MCP] Relay 子进程错误:', err.message);
+  });
+
+  return okResult({
+    assistantMessage: `Relay 子进程已启动,pid=${relayChildProcess.pid}`,
+    summary: getRelayChildStatus(),
+    nextActions: ['调用 qiwei_relay_stop 停止子进程']
+  });
+});
+
+const qiweiRelayStop = safeResult(async function qiweiRelayStop() {
+  if (!getRelayChildStatus().running) {
+    return okResult({ assistantMessage: 'Relay 子进程未运行', summary: getRelayChildStatus() });
+  }
+  const pid = relayChildProcess.pid;
+  relayChildProcess.kill('SIGTERM');
+  return okResult({
+    assistantMessage: `已发送停止信号给 Relay 子进程 pid=${pid}`,
+    summary: { stopped: true, pid }
+  });
+});
+
 module.exports = {
   qiweiWebhookStatus,
   qiweiWebhookDiscover,
@@ -409,10 +678,15 @@ module.exports = {
   qiweiWebhookServerStop,
   qiweiWebhookAutoSetup,
   qiweiWebhookSetup,
+  qiweiWebhookReplay,
+  qiweiWebhookPurge,
   qiweiRelayConfig,
   qiweiRelaySaveConfig,
   qiweiRelayRegister,
   qiweiRelayConnect,
+  qiweiRelayStart,
+  qiweiRelayStop,
   ensureRelayWebhookConfigured,
+  ensureLocalWebhookConfigured,
   registerRelayTenant
 };

+ 1 - 0
claude-code/claude-code-qiwe-assistant/package.json

@@ -32,6 +32,7 @@
     "dashboard": "node scripts/start-dashboard.js",
     "relay": "node scripts/start-relay-client.js",
     "agent:smoke": "node scripts/agent-console-smoke-test.js",
+    "connection:smoke": "node scripts/account-connection-monitor-smoke-test.js",
     "deadline:smoke": "node scripts/deadline-parser-smoke-test.js",
     "agent:session": "node scripts/open-customer-session.js",
     "agent:session:list": "node scripts/open-customer-session.js --list",

+ 39 - 0
claude-code/claude-code-qiwe-assistant/scripts/account-connection-monitor-smoke-test.js

@@ -0,0 +1,39 @@
+#!/usr/bin/env node
+'use strict';
+
+const assert = require('assert/strict');
+const { createAccountConnectionMonitor } = require('../mcp/src/core/account-connection-monitor');
+
+function result(online, statusCode = online ? 2 : 0) {
+  return { status: 'ok', summary: { uid: 'device-1', online, statusCode }, data: { uid: 'device-1', online, statusCode }, warnings: [], errors: [] };
+}
+
+async function main() {
+  let clock = 100000;
+  let recoveryCalls = 0;
+  const samples = [result(true), result(false), result(false), result(true)];
+  const monitor = createAccountConnectionMonitor({
+    checkStatus: async () => samples.shift(),
+    recoverLogin: async () => {
+      recoveryCalls += 1;
+      return { summary: { loggedIn: true, statusCode: 2 } };
+    },
+    now: () => clock,
+    cacheTtlMs: 0,
+    recoveryCooldownMs: 0,
+  });
+
+  assert.equal((await monitor.getStatus({ force: true })).summary.connectionState, 'online');
+  clock += 15000;
+  const transient = await monitor.getStatus({ force: true });
+  assert.equal(transient.summary.online, true);
+  assert.equal(transient.summary.connectionState, 'verifying');
+  clock += 15000;
+  const recovered = await monitor.getStatus({ force: true });
+  assert.equal(recoveryCalls, 1);
+  assert.equal(recovered.summary.online, true);
+  assert.equal(recovered.summary.autoRecovered, true);
+  process.stdout.write(JSON.stringify({ status: 'ok', checks: 7 }) + '\n');
+}
+
+main().catch(error => { console.error(error); process.exit(1); });

+ 109 - 0
claude-code/claude-code-qiwe-assistant/scripts/friend-polling-worker.js

@@ -0,0 +1,109 @@
+#!/usr/bin/env node
+/**
+ * 好友轮询兜底 Worker
+ *
+ * 独立运行,定期检查已加好友但尚未建群的客户,触发自动建群。
+ *
+ * 启动方式:
+ *   node scripts/friend-polling-worker.js
+ *   INTERVAL_MS=300000 node scripts/friend-polling-worker.js
+ */
+
+const fs = require('fs');
+const path = require('path');
+const { outputsRoot } = require('../mcp/src/core/output-paths');
+const { readCustomerById, writeCustomer } = require('../mcp/src/core/customer-broker-store');
+const { readConfirmedMapping } = require('../mcp/src/core/webhook-processor');
+const { triggerAutoCreateGroup } = require('../mcp/src/core/webhook-processor');
+
+const INTERVAL_MS = Number(process.env.FRIEND_POLL_INTERVAL_MS || 300000);
+
+function customersDir() {
+  return path.join(outputsRoot(), 'customers');
+}
+
+function safeReadJson(filePath) {
+  try {
+    if (!fs.existsSync(filePath)) return null;
+    return JSON.parse(fs.readFileSync(filePath, 'utf8'));
+  } catch {
+    return null;
+  }
+}
+
+function listCustomerFiles() {
+  const dir = customersDir();
+  if (!fs.existsSync(dir)) return [];
+  return fs.readdirSync(dir)
+    .filter(f => f.endsWith('.json') && !f.startsWith('index-'))
+    .map(f => path.join(dir, f));
+}
+
+function hasActiveGroupForCustomer(customerId) {
+  const mapping = readConfirmedMapping();
+  for (const entry of Object.values(mapping)) {
+    if (entry.customerId === customerId && entry.status === 'ACTIVE') return true;
+  }
+  return false;
+}
+
+async function runOnce() {
+  const files = listCustomerFiles();
+  let checked = 0;
+  let created = 0;
+  let ignored = 0;
+  let errors = 0;
+
+  for (const file of files) {
+    const customer = safeReadJson(file);
+    if (!customer || !customer.customerId) continue;
+
+    // 只处理有 brokerId 且标记为已通过好友但尚未建群的客户
+    if (!customer.brokerId) continue;
+    if (customer.friendRequestStatus !== 'ACCEPTED' && customer.friendRequestStatus !== 'PENDING') continue;
+    if (hasActiveGroupForCustomer(customer.customerId)) continue;
+
+    checked++;
+
+    const event = {
+      parsedType: 'CONTACT_ADDED_OR_CHANGED',
+      msgType: 2131,
+      externalUserId: customer.externalUserId,
+      guid: undefined,
+      timestamp: Math.floor(Date.now() / 1000),
+      eventId: `poll-${customer.customerId}-${Date.now()}`,
+      raw: {}
+    };
+
+    const result = await triggerAutoCreateGroup(event);
+    if (result.success) {
+      created++;
+      customer.friendRequestStatus = 'ACCEPTED';
+      writeCustomer(customer);
+    } else if (result.ignored) {
+      ignored++;
+    } else {
+      errors++;
+    }
+  }
+
+  console.log(`[FriendPolling] 本轮完成: 检查 ${checked}, 建群 ${created}, 忽略 ${ignored}, 错误 ${errors}`);
+  return { checked, created, ignored, errors };
+}
+
+async function main() {
+  console.log(`[FriendPolling] 启动,轮询间隔 ${INTERVAL_MS}ms`);
+  while (true) {
+    try {
+      await runOnce();
+    } catch (err) {
+      console.error('[FriendPolling] 本轮异常:', err.message);
+    }
+    await new Promise(resolve => setTimeout(resolve, INTERVAL_MS));
+  }
+}
+
+main().catch(err => {
+  console.error('[FriendPolling] 致命错误:', err);
+  process.exit(1);
+});

+ 307 - 0
claude-code/claude-code-qiwe-assistant/scripts/message-polling-worker.js

@@ -0,0 +1,307 @@
+#!/usr/bin/env node
+/**
+ * Message polling fallback worker.
+ *
+ * This is a demo-safe alternative when webhook callback configuration cannot
+ * be changed. It pulls messages through /msg/syncMsg and writes them into the
+ * same outputs/messages tree used by webhook processing.
+ */
+
+const fs = require('fs');
+const path = require('path');
+const { outputsRoot } = require('../mcp/src/core/output-paths');
+const { buildContext, gatewayCall } = require('../mcp/src/core/shared-gateway');
+const { readQiweiGuid } = require('../mcp/src/core/credentials');
+const { recordGroupSync } = require('../mcp/src/core/dashboard-state');
+
+const INTERVAL_MS = Math.max(3000, Number(process.env.MESSAGE_POLL_INTERVAL_MS || 5000));
+const PAGE_LIMIT = Math.max(20, Math.min(500, Number(process.env.MESSAGE_POLL_PAGE_LIMIT || 200)));
+const MAX_PAGES_PER_TICK = Math.max(1, Math.min(20, Number(process.env.MESSAGE_POLL_MAX_PAGES || 5)));
+const IMPORT_HISTORY = process.argv.includes('--import-history') || process.env.MESSAGE_POLL_IMPORT_HISTORY === 'true';
+const RESET = process.argv.includes('--reset');
+const ONCE = process.argv.includes('--once');
+const LATEST_SEQ_PROBE = 999999999;
+const REQUEST_RETRIES = Math.max(0, Math.min(5, Number(process.env.MESSAGE_POLL_REQUEST_RETRIES || 3)));
+let gbkEncodeMap = null;
+
+function messagesDir() {
+  return path.join(outputsRoot(), 'messages');
+}
+
+function statePath() {
+  return path.join(messagesDir(), 'polling-state.json');
+}
+
+function ensureDir(dir) {
+  fs.mkdirSync(dir, { recursive: true });
+}
+
+function safeReadJson(filePath, fallback = null) {
+  try {
+    if (!fs.existsSync(filePath)) return fallback;
+    return JSON.parse(fs.readFileSync(filePath, 'utf8').replace(/^\uFEFF/, ''));
+  } catch {
+    return fallback;
+  }
+}
+
+function writeJson(filePath, data) {
+  ensureDir(path.dirname(filePath));
+  fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf8');
+}
+
+function sleep(ms) {
+  return new Promise(resolve => setTimeout(resolve, ms));
+}
+
+function readState() {
+  if (RESET) return {};
+  return safeReadJson(statePath(), {}) || {};
+}
+
+function writeState(state) {
+  writeJson(statePath(), { ...state, updatedAt: new Date().toISOString() });
+}
+
+function sanitizeFilePart(value, fallback = 'message') {
+  const safe = String(value || '').replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 90);
+  return safe || fallback;
+}
+
+function conversationIdForMessage(msg) {
+  const roomId = String(msg.fromRoomId || '');
+  if (roomId && roomId !== '0') return roomId;
+
+  const senderId = String(msg.senderId || '');
+  const receiverId = String(msg.receiverId || '');
+  const otherId = senderId && senderId !== readQiweiGuid() ? senderId : receiverId || senderId;
+  return `private-${sanitizeFilePart(otherId || 'unknown')}`;
+}
+
+function getGbkEncodeMap() {
+  if (gbkEncodeMap) return gbkEncodeMap;
+  gbkEncodeMap = new Map();
+  const decoder = new TextDecoder('gbk');
+  for (let first = 0x81; first <= 0xfe; first++) {
+    for (let second = 0x40; second <= 0xfe; second++) {
+      if (second === 0x7f) continue;
+      const bytes = Buffer.from([first, second]);
+      const char = decoder.decode(bytes);
+      if (!char || char === '\uFFFD' || char.length !== 1) continue;
+      if (!gbkEncodeMap.has(char)) gbkEncodeMap.set(char, bytes);
+    }
+  }
+  return gbkEncodeMap;
+}
+
+function repairUtf8DecodedAsGbk(value) {
+  const text = String(value || '');
+  if (!/[\u4e00-\u9fa5]/.test(text)) return text;
+  const map = getGbkEncodeMap();
+  const chunks = [];
+  for (const char of text) {
+    const code = char.codePointAt(0);
+    if (code <= 0x7f) {
+      chunks.push(Buffer.from([code]));
+      continue;
+    }
+    const bytes = map.get(char);
+    if (!bytes) return text;
+    chunks.push(bytes);
+  }
+  const repaired = Buffer.concat(chunks).toString('utf8');
+  if (repaired.includes('\uFFFD')) return text;
+  return /[\u4e00-\u9fa5]/.test(repaired) ? repaired : text;
+}
+
+function cleanExtractedText(value) {
+  const cleaned = String(value || '').replace(/[\u0000-\u001f\u007f]/g, '').trim();
+  return repairUtf8DecodedAsGbk(cleaned);
+}
+
+function extractUtf8TextFromBase64(value) {
+  if (!value) return '';
+  let buffer;
+  try {
+    buffer = Buffer.from(String(value), 'base64');
+  } catch {
+    return '';
+  }
+  const candidates = [];
+  for (let i = 0; i < buffer.length - 1; i++) {
+    const len = buffer[i];
+    if (!len || len > 240 || i + 1 + len > buffer.length) continue;
+    const text = cleanExtractedText(buffer.slice(i + 1, i + 1 + len).toString('utf8'));
+    if (!text || text.includes('\uFFFD')) continue;
+    if (/[\u4e00-\u9fa5]/.test(text)) candidates.push(text);
+  }
+  return candidates.sort((a, b) => b.length - a.length)[0] || '';
+}
+
+function extractContent(msg) {
+  const rawText = extractUtf8TextFromBase64(msg.base64RawData || msg.msgData?.extras?.base64RawData);
+  if (rawText) return rawText;
+  if (typeof msg.content === 'string' && msg.content) return cleanExtractedText(msg.content);
+  if (typeof msg.msgContent === 'string' && msg.msgContent) return cleanExtractedText(msg.msgContent);
+  if (msg.msgData) {
+    if (typeof msg.msgData.content === 'string') return cleanExtractedText(msg.msgData.content);
+    if (Array.isArray(msg.msgData.moreDetail)) {
+      return cleanExtractedText(msg.msgData.moreDetail.map(item => item && item.text).filter(Boolean).join(''));
+    }
+    if (typeof msg.msgData.notifyTitle === 'string') return cleanExtractedText(msg.msgData.notifyTitle);
+  }
+  return '';
+}
+
+function normalizeMessage(msg, conversationId) {
+  const timestamp = Number(msg.timestamp) || 0;
+  return {
+    msgId: String(msg.msgUniqueIdentifier || msg.msgServerId || `${conversationId}_${msg.seq || Date.now()}`),
+    seq: Number(msg.seq) || 0,
+    senderId: String(msg.senderId || ''),
+    senderName: String(msg.senderName || ''),
+    receiverId: String(msg.receiverId || ''),
+    msgType: String(msg.msgType || ''),
+    content: extractContent(msg),
+    timestamp: timestamp ? new Date(timestamp * 1000).toISOString() : new Date().toISOString(),
+    isRevoked: Boolean(msg.isRevoked),
+    source: 'polling',
+    rawData: msg
+  };
+}
+
+function messageFilePath(conversationId, message) {
+  const dir = path.join(messagesDir(), sanitizeFilePart(conversationId, 'conversation'));
+  ensureDir(dir);
+  const seq = Number(message.seq) || 0;
+  return path.join(dir, `${seq}-${sanitizeFilePart(message.msgId)}.json`);
+}
+
+function saveMessage(msg) {
+  const conversationId = conversationIdForMessage(msg);
+  const message = normalizeMessage(msg, conversationId);
+  const filePath = messageFilePath(conversationId, message);
+  if (fs.existsSync(filePath)) return { written: false, conversationId, filePath, message };
+  writeJson(filePath, message);
+  return { written: true, conversationId, filePath, message };
+}
+
+async function syncPage(ctx, msgSeq) {
+  let lastError = null;
+  for (let attempt = 0; attempt <= REQUEST_RETRIES; attempt++) {
+    try {
+      const data = await gatewayCall(ctx, '/msg/syncMsg', {
+        guid: ctx.guid,
+        msgSeq,
+        limit: PAGE_LIMIT
+      });
+      return {
+        messages: Array.isArray(data && data.syncMsgList) ? data.syncMsgList : [],
+        hasMore: Boolean(data && data.hasMore),
+        nextSeq: data && data.travelSyncKey !== undefined ? Number(data.travelSyncKey) : msgSeq
+      };
+    } catch (err) {
+      lastError = err;
+      if (attempt >= REQUEST_RETRIES) break;
+      await sleep(500 * (attempt + 1));
+    }
+  }
+  throw lastError;
+}
+
+async function readLatestSessionSeq(ctx) {
+  const data = await gatewayCall(ctx, '/session/getSessionPage', {
+    guid: ctx.guid,
+    page: 1,
+    limit: 20
+  });
+  const seq = Number(data && data.currentSeq);
+  return Number.isFinite(seq) && seq > 0 ? seq : 0;
+}
+
+async function seedCursor(ctx) {
+  let msgSeq = 0;
+  try {
+    msgSeq = await readLatestSessionSeq(ctx);
+  } catch {
+    const page = await syncPage(ctx, LATEST_SEQ_PROBE);
+    msgSeq = page.nextSeq || LATEST_SEQ_PROBE;
+  }
+  writeState({ msgSeq, seededAt: new Date().toISOString(), imported: false });
+  console.log(`[MessagePolling] Seeded cursor at ${msgSeq}. New messages after this point will be imported.`);
+  return { msgSeq };
+}
+
+async function runOnce(ctx, state) {
+  let msgSeq = Number(state.msgSeq || 0);
+  let pages = 0;
+  let fetched = 0;
+  let written = 0;
+  const touched = new Map();
+
+  while (pages < MAX_PAGES_PER_TICK) {
+    pages++;
+    const page = await syncPage(ctx, msgSeq);
+    fetched += page.messages.length;
+    msgSeq = page.nextSeq || msgSeq + 1;
+
+    for (const msg of page.messages) {
+      const saved = saveMessage(msg);
+      if (!saved.written) continue;
+      written++;
+      const current = touched.get(saved.conversationId) || { messageCount: 0, lastMsgAt: null, lastSyncSeq: 0 };
+      current.messageCount++;
+      current.lastMsgAt = saved.message.timestamp;
+      current.lastSyncSeq = Math.max(current.lastSyncSeq || 0, Number(saved.message.seq) || 0);
+      touched.set(saved.conversationId, current);
+    }
+
+    if (!page.hasMore || !page.messages.length) break;
+  }
+
+  for (const [conversationId, info] of touched.entries()) {
+    recordGroupSync(conversationId, info);
+  }
+
+  const nextState = {
+    ...state,
+    msgSeq,
+    lastFetched: fetched,
+    lastWritten: written,
+    lastRunAt: new Date().toISOString()
+  };
+  writeState(nextState);
+  return nextState;
+}
+
+async function main() {
+  const guid = String(process.argv.find(arg => arg.startsWith('--guid='))?.slice('--guid='.length) || readQiweiGuid() || '').trim();
+  if (!guid) {
+    console.error('[MessagePolling] Missing guid. Log in first or pass --guid=<device-guid>.');
+    process.exit(1);
+  }
+
+  const ctx = buildContext({ guid });
+  console.log(`[MessagePolling] Started. guid=${guid}, interval=${INTERVAL_MS}ms`);
+
+  let state = readState();
+  if (!state.msgSeq && !IMPORT_HISTORY) {
+    state = await seedCursor(ctx);
+  }
+
+  while (true) {
+    try {
+      state = await runOnce(ctx, state);
+      console.log(`[MessagePolling] fetched=${state.lastFetched}, written=${state.lastWritten}, nextSeq=${state.msgSeq}`);
+    } catch (err) {
+      console.error('[MessagePolling] Poll failed:', err.message);
+    }
+    if (ONCE) break;
+    await new Promise(resolve => setTimeout(resolve, INTERVAL_MS));
+  }
+}
+
+main().catch(err => {
+  console.error('[MessagePolling] Fatal:', err);
+  process.exit(1);
+});

+ 6 - 33
claude-code/claude-code-qiwe-assistant/scripts/smoke-test.js

@@ -55,9 +55,6 @@ const {
 const { qiweiTranscribeVoice } = require('../mcp/src/tools/qiwei-voice-run');
 const {
   qiweiWebhookStatus,
-  qiweiWebhookDiscover,
-  qiweiWebhookServerStart,
-  qiweiWebhookServerStop,
   qiweiWebhookAutoSetup
 } = require('../mcp/src/tools/qiwei-webhook-relay-run');
 
@@ -544,37 +541,13 @@ async function main() {
 
     const statusBefore = await qiweiWebhookStatus({});
     assert.strictEqual(statusBefore.status, 'ok');
-    assert.strictEqual(statusBefore.summary.running, false);
-
-    const started = await qiweiWebhookServerStart({ port: 0 });
-    assert.strictEqual(started.status, 'ok');
-    assert.strictEqual(typeof started.summary.port, 'number');
-
-    const discovered = await qiweiWebhookDiscover({});
-    assert.strictEqual(discovered.status, 'ok');
-    assert.strictEqual(discovered.summary.running, true);
-
-    const autoSetup = await qiweiWebhookAutoSetup({ ...common, guid: 'guid-1' });
-    assert.strictEqual(autoSetup.status, 'ok');
-    assert.strictEqual(gateway.requests.at(-1).body.method, '/client/setCallback');
-    const setCallbackParams = gateway.requests.at(-1).body.params;
-    assert.strictEqual(setCallbackParams.callbackUrl.startsWith('http://127.0.0.1:'), true);
-    assert.strictEqual(setCallbackParams.authSecret && setCallbackParams.authSecret.length > 0, true);
-    assert.strictEqual(setCallbackParams.guid, undefined);
-    assertProviderHidden(autoSetup);
-
-    const localUrl = discovered.data.localUrl;
-    const callbackRes = await fetch(localUrl, {
-      method: 'POST',
-      headers: { 'Content-Type': 'application/json' },
-      body: JSON.stringify({ msgType: 1, content: 'test', fromRoomId: 'r-1' })
-    });
-    assert.strictEqual(callbackRes.status, 200);
+    assert.strictEqual(statusBefore.summary.localMode, 'disabled');
+    assert.strictEqual(statusBefore.summary.relayEnabled, false);
 
-    const stopped = await qiweiWebhookServerStop({});
-    assert.strictEqual(stopped.status, 'ok');
-    assert.strictEqual(stopped.data.stopped, true);
-    console.log('[ok] webhook-relay tools start server, auto-setup and ingest callback');
+    const autoSetupWithoutRelay = await qiweiWebhookAutoSetup({ ...common, guid: 'guid-1' });
+    assert.strictEqual(autoSetupWithoutRelay.status, 'error');
+    assert.strictEqual(autoSetupWithoutRelay.assistantMessage.includes('本地 webhook 模式已禁用'), true);
+    console.log('[ok] webhook-relay tools disabled local mode and require relay');
 
     for (const request of gateway.requests) {
       assert.strictEqual(request.authorization, 'Bearer sk-test-relay-token');

+ 15 - 15
claude-code/claude-code-qiwe-assistant/scripts/start-relay-client.js

@@ -3,7 +3,7 @@
  * Relay 长轮询客户端
  *
  * 独立进程运行,从中央 Relay 拉取属于本租户的加密事件,
- * 用本地 RSA 私钥解密后落盘到 outputs/webhook/
+ * 用本地 RSA 私钥解密后构造 v1 envelope 并交给 processWebhookEvents 处理
  *
  * 启动方式:
  *   node scripts/start-relay-client.js [device-guid]
@@ -11,7 +11,8 @@
  */
 
 const crypto = require('crypto');
-const { saveWebhookEvent } = require('../mcp/src/core/webhook-server');
+const { processWebhookEvents } = require('../mcp/src/core/webhook-server');
+const { readQiweiGuid } = require('../mcp/src/core/credentials');
 const {
   getRelayBaseUrl,
   getTenantApiSecret,
@@ -24,11 +25,6 @@ const POLL_WAIT_MS = 30000;
 const INITIAL_BACKOFF_MS = 1000;
 const MAX_BACKOFF_MS = 60000;
 
-function saveEvent(eventId, payload) {
-  const filePath = saveWebhookEvent({ eventId, ...payload }, 'relay');
-  return filePath;
-}
-
 function decryptPayload(encryptedPayload, privateKeyPem) {
   const key = crypto.createPrivateKey(privateKeyPem);
   const buffer = Buffer.from(encryptedPayload, 'base64');
@@ -75,7 +71,7 @@ async function runPollOnce(baseUrl, apiSecret, guid, privateKey) {
   }
 
   const data = await res.json();
-  if (!data.events || !data.events.length) return;
+  if (!data.events || !data.events.length) return { acked: 0 };
 
   console.log(`[RelayClient] 取回 ${data.events.length} 条事件`);
   const eventIds = [];
@@ -84,17 +80,23 @@ async function runPollOnce(baseUrl, apiSecret, guid, privateKey) {
     try {
       const decrypted = decryptPayload(event.encryptedPayload, privateKey);
       const payload = JSON.parse(decrypted);
-      const filePath = saveEvent(event.eventId, payload);
-      console.log(`[RelayClient] 已解密落盘: ${filePath}`);
+      const envelope = { code: 0, msg: 'from-relay', data: Array.isArray(payload) ? payload : [payload], __rawBody: decrypted };
+      await processWebhookEvents(envelope);
       eventIds.push(event.eventId);
     } catch (err) {
-      console.error(`[RelayClient] 解密/落盘失败 eventId=${event.eventId}:`, err.message);
+      console.error(`[RelayClient] 解密/处理失败 eventId=${event.eventId}:`, err.message);
       // 解密失败也要 ACK,避免 Relay 重复投递
       eventIds.push(event.eventId);
     }
   }
 
   await ackEvents(baseUrl, apiSecret, guid, eventIds);
+  return { acked: eventIds.length };
+}
+
+function resolveGuid() {
+  // 命令行参数 > 环境变量 > relay-config.json > credentials
+  return process.argv[2] || process.env.RELAY_DEVICE_GUID || getRelayDeviceGuid() || readQiweiGuid() || '';
 }
 
 async function main() {
@@ -102,16 +104,14 @@ async function main() {
   const apiSecret = getTenantApiSecret();
   const privateKey = getRelayPrivateKey();
   const tenantId = getTenantId();
-
-  // guid 优先级:命令行参数 > 环境变量 > relay-config.json
-  const guid = process.argv[2] || process.env.RELAY_DEVICE_GUID || getRelayDeviceGuid();
+  const guid = resolveGuid();
 
   if (!apiSecret || !privateKey || !tenantId) {
     console.error('[RelayClient] 缺少配置:请检查 .env.local 中的 TENANT_API_SECRET、RELAY_PRIVATE_KEY、TENANT_ID');
     process.exit(1);
   }
   if (!guid) {
-    console.error('[RelayClient] 缺少 deviceGuid:请通过命令行传入,或配置 RELAY_DEVICE_GUID / relay-config.json');
+    console.error('[RelayClient] 缺少 deviceGuid:请通过命令行传入,或配置 RELAY_DEVICE_GUID / relay-config.json / 完成企微登录');
     process.exit(1);
   }
 

+ 16 - 7
claude-code/claude-code-qiwe-assistant/skills/qiwei-portrait-tags/SKILL.md

@@ -1,6 +1,6 @@
 ---
 name: qiwei-portrait-tags
-description: 迁移自 Qiwei 项目的客户画像与标签能力:准备/更新/保存/批量/导出客户画像、本地客户标签管理、企微个人标签同步与增删改及应用。画像与标签优先回写统一客户主账,并在客户管理页集中展示
+description: 迁移自 Qiwei 项目的客户画像与标签能力:准备/更新/保存/批量/导出客户画像、本地客户标签管理、企微个人标签同步与增删改及应用。画像和标签数据保存在 outputs/portraits/ 和 outputs/tags/
 ---
 
 # 客户画像与标签
@@ -18,15 +18,11 @@ description: 迁移自 Qiwei 项目的客户画像与标签能力:准备/更
 
 ## 数据存储
 
-- 权威客户主账:`outputs/messages/agent-workbench.db` 的 `customer_profiles`;
-- 客户管理脱敏投影:`outputs/customers/index.json`;
 - 画像文件:`outputs/portraits/<externalUserId>.json`
 - 画像上下文:`outputs/portraits/<timestamp>/context-<externalUserId>.json`
 - 画像导出:`outputs/portraits/export-<timestamp>.xlsx`
 - 本地标签:`outputs/tags/<externalUserId>.json`
 
-当 externalUserId 已对应真实企微会话时,画像文件和本地标签写入后必须同步合并到权威客户主账。读取时优先返回主账内容,文件仅作为兼容输出和导出来源。
-
 ## 标准流程
 
 ### 准备客户画像
@@ -40,7 +36,20 @@ description: 迁移自 Qiwei 项目的客户画像与标签能力:准备/更
 
 返回分析上下文,包含消息样本和完整文本预览。分析后调用 `qiwei_save_customer_portrait` 保存。
 
-### 关键词模式更新画像
+### 更新画像
+
+不传 `aiMode` 或传 `ai`/`agent` 时,默认使用 LLM(`deepseek-v4-pro`)自动分析消息并保存画像:
+
+```json
+{
+  "externalUserId": "wx-u-1",
+  "roomIds": ["r-1"]
+}
+```
+
+显式指定 `aiMode: "keyword"` 才会走关键词匹配模式。
+
+### 关键词模式
 
 ```json
 {
@@ -97,7 +106,7 @@ description: 迁移自 Qiwei 项目的客户画像与标签能力:准备/更
 
 ## 迁移说明
 
-原 Qiwei 项目中的 `CustomerPortrait` 和 `Customer.tags` 已合并到 Agent Workbench 客户主账,同时保留文件化兼容输出
+原 Qiwei 项目中的 `CustomerPortrait`、`Customer.tags`、`QiwePersonalLabel` SQLite 表改为文件化
 
 - 画像分析 → `outputs/portraits/`
 - 本地标签 → `outputs/tags/`

+ 13 - 41
claude-code/claude-code-qiwe-assistant/skills/qiwei-webhook-relay/SKILL.md

@@ -1,6 +1,6 @@
 ---
 name: qiwei-webhook-relay
-description: 企微 Webhook 与 Relay:支持本地 webhook 接收、中央 Relay 模式自动注册/配置、Relay 长轮询客户端。
+description: 企微 Webhook 与 Relay:支持中央 Relay 模式自动注册/配置、Relay 长轮询客户端。
 ---
 
 # Webhook 与 Relay
@@ -9,35 +9,31 @@ description: 企微 Webhook 与 Relay:支持本地 webhook 接收、中央 Rel
 
 本 skill 负责:
 
-- 查询本地 webhook server 状态;
-- 启动/停止本地 HTTP server 接收企微回调(开发调试);
+- 查询 Relay 回调配置状态;
 - 在中央 Relay 服务端注册租户、保存凭证;
-- 自动/手动配置企微回调地址到 Relay;
+- 自动配置企微回调地址到 Relay;
 - 管理 Relay 配置。
 
-## 模式说明
-
-### 本地 Webhook 模式
+> **本地 webhook server 模式已禁用。** 所有回调统一通过中央 Relay 接收。
 
-启动本地 HTTP server(默认绑定 `127.0.0.1`),直接把企微事件写入 `outputs/webhook/`。适合本机能被公网访问或本地调试。
+## 模式说明
 
-### 中央 Relay 模式(推荐
+### 中央 Relay 模式(唯一可用
 
-企微平台把事件推送到 Fmode 中央 Relay 服务器(`http://8.138.37.248:4000`),本地 Skill 通过长轮询主动取回属于自己的事件。适合 Skill 运行在本地电脑、内网或无固定公网 IP 的场景。
+企微平台把事件推送到 Fmode 中央 Relay 服务器(默认 `http://8.138.37.248:4000`),本地 Skill 通过长轮询主动取回属于自己的事件。适合 Skill 运行在本地电脑、内网或无固定公网 IP 的场景。
 
 ## 数据存储
 
-- Webhook 配置:`outputs/webhook/webhook-config.json`
 - Relay 配置:`outputs/webhook/relay-config.json`
 - 回调事件:`outputs/webhook/<date>/<time>-<source>/event-...json`
 
 ## 标准流程
 
-### 首次使用自动配置(推荐)
+### 首次使用自动配置
 
 1. 确保 `.env.local` 已配置 `QIWEI_AUTH_TOKEN`。
 2. 调用 `qiwei_login_start` → 用户扫码登录。
-3. 登录成功后,系统会自动
+3. 登录成功后,调用 `qiwei_webhook_auto_setup` 或 `qiwei_relay_connect`
    - 提取并保存设备 `guid`;
    - 在中央 Relay 注册租户(如未注册);
    - 保存租户凭证到 `.env.local`;
@@ -70,28 +66,6 @@ description: 企微 Webhook 与 Relay:支持本地 webhook 接收、中央 Rel
 }
 ```
 
-### 手动配置本地 Webhook
-
-```json
-{
-  "name": "qiwei_webhook_server_start",
-  "arguments": {
-    "port": 8080
-  }
-}
-```
-
-不传 port 时由系统分配。
-
-```json
-{
-  "name": "qiwei_webhook_auto_setup",
-  "arguments": {
-    "callbackUrl": "https://your-server.com/callback"
-  }
-}
-```
-
 ## Relay 客户端持久化运行
 
 ### systemd
@@ -131,13 +105,11 @@ pm2 save
 
 ## MCP 工具
 
-- `qiwei_webhook_status` — 查询 webhook 状态
-- `qiwei_webhook_discover` — 获取本地 webhook 回调地址
-- `qiwei_webhook_server_start` — 启动本地 webhook server
-- `qiwei_webhook_server_stop` — 停止本地 webhook server
-- `qiwei_webhook_auto_setup` — 自动配置企微回调(优先 Relay 模式)
-- `qiwei_webhook_setup` — 手动配置企微回调
+- `qiwei_webhook_status` — 查询 Relay 回调配置状态
+- `qiwei_webhook_auto_setup` — 自动配置企微回调到 Relay(本地模式已禁用)
 - `qiwei_relay_config` — 读取 Relay 配置
 - `qiwei_relay_save_config` — 保存 Relay 公钥/租户配置
 - `qiwei_relay_register` — 注册 Relay 租户并保存凭证
 - `qiwei_relay_connect` — 配置 Relay 回调地址
+
+> 已移除的本地模式工具:`qiwei_webhook_discover`、`qiwei_webhook_server_start`、`qiwei_webhook_server_stop`、`qiwei_webhook_setup`。

Some files were not shown because too many files changed in this diff