# MCP 实时数据架构 v2.0 实战笔记（2026-07-26）

## 核心架构

```
后端: MCP API → 缓存(TTL) → JSON备份 → HTTP API
前端: API优先 → 三级降级 → 本地JSON
```

## 后端关键发现

### MQL 语法三坑

| 坑 | 错误 | 正确 |
|----|------|------|
| 表名 | `kh`/`xsdd`/`sj` (api_name) | 完整 type_key: `65ae1e403c87b152f3365ca6` |
| SELECT | `SELECT *` | 显式列举所有字段 |
| 响应格式 | 期望扁平 JSON | 实际是 `moql_field_list` 嵌套 |

### MQL type_key 速查表

| 工作项 | type_key |
|--------|----------|
| 客户 | `65ae1e403c87b152f3365ca6` |
| 活动 | `65ae1e5d44338dbe7c39a29a` |
| 销售订单 | `662612aea6bb7089fea044ef` |
| 商机 | `story`（OK） |
| 缺陷(客诉) | `issue`（OK） |

### `moql_field_list` 展开模板

```python
for gid, items in data_dict.items():
    for item in items:
        flat = {}
        for f in item.get('moql_field_list', []):
            k = f['key']
            v = f.get('value')
            # key_label_value 嵌套 → 取 label
            if isinstance(v, dict) and 'key_label_value' in v:
                flat[k] = v['key_label_value'].get('label', '')
            elif isinstance(v, dict) and len(v) == 1:
                flat[k] = list(v.values())[0]
            else:
                flat[k] = v
```

### 翻页机制

```python
session_id = None  # 首次查询获取，后续复用
for page in range(1, max_pages + 1):
    args = {
        'project_key': '6593cd71471290e3cc6be6e6',
        'mql': mql_query,
        'session_id': session_id,
        'group_pagination_list': [{'page_num': page, 'group_id': 'default'}]
    }
    result = mcp_call('search_by_mql', args, timeout=60)
    # 检查是否为最后一页
    if len(items) < 50 or len(all_items) >= list[0].count:
        break
```

### 缓存策略

| 数据 | TTL | 原因 |
|------|-----|------|
| 客户 | 30分钟 | 变化少，查询慢(33页) |
| 订单 | 5分钟 | 高频变化 |
| 活动 | 5分钟 | 高频变化 |
| 客诉 | 15分钟 | 中等频率 |

### 字段权限限制

MCP Token 对部分自定义字段无读取权限。标错字段 → `attribute key or value error: no permission`。修复：从 MQL 移除该字段。

## 前端关键发现

### ENDPOINTS 升级模式

```javascript
// 旧：`[key, localPath]`
// 新：`[key, [apiPath, localPath]]`  支持 API 优先 + 本地降级

var API_BASE_URLS = [
  'https://{tunnel}.trycloudflare.com',
  'http://127.0.0.1:8765'
];

var ENDPOINTS = [
  ['customers',    ['/api/mcp/customers',    '../数据/pricing/_key_customers.json']],
  ['orders',       ['/api/mcp/orders',       '../数据/orders_dashboard.json']],
  ['myActivities', ['/api/mcp/activities',   '../数据/my_activities.json']],
  // 仍未 MCP 化的数据：API 路径留空
  ['kpiDashboard', ['',                      '../数据/kpi_dashboard.json']],
];
```

### 三级降级 fetcher

```javascript
function fetchWithFallback(key, apiPath, localPath) {
  if (apiPath) {
    return tryApiUrls(apiPath)
      .then(function(data) {
        var items = data.items || data;
        return adaptMcpFields(key, items);  // 字段名映射
      })
      .catch(function() {
        if (localPath) return fetch(localPath + '?v=' + Date.now()).then(r => r.json());
        throw new Error(key + ': 所有数据源不可用');
      });
  }
  // 无 API: 直读本地
  if (!localPath) return Promise.resolve(null);
  return fetch(localPath + '?v=' + Date.now()).then(r => r.json());
}
```

### MCP 字段名 → 旧格式映射

MCP 返回 `field_5ed7ab`（所属人部门），旧代码期望 `dept`。需 `adaptMcpFields()`：

```javascript
function adaptMcpFields(key, items) {
  if (key === 'customers') {
    return items.map(function(item) { return {
      name: item.name,
      dept: item.field_5ed7ab || '',      // 所属人部门/区域
      level: item.field_17186c || '',     // 客户等级
      location: item.field_6415cf || '',  // 所在地
      ctype: item.field_2d5b6a || '',     // 客户类型
      group: item.field_c3224b || '',     // 所属集团客户
      // MCP 无计算字段，兼容旧格式
      total: 0, sales_2024: 0, sales_2025: 0, sales_2026: 0,
    };});
  }
  // orders / activities 同理...
}
```

## 搜索框修复模式

### 问题
`oninput="orderState.filterText=this.value;render()"` 每次按键全页重渲染 → 输入框被销毁重建 → 焦点丢失

### 修复：debounce + render-first-then-focus

```javascript
// debounce 函数（已在 utils 中）
var debounce = function(fn, ms) {
  var t; return function() {
    var args = arguments; clearTimeout(t);
    t = setTimeout(function() { fn.apply(null, args); }, ms);
  };
};

// 搜索框 oninput
oninput="var t=this,v=t.value,p=t.selectionStart;
  orderState.filterText=v;
  debounce(function(){
    render();                           // 1. 先重建 DOM
    var el=document.getElementById('orderSearchInput');
    if(el){ el.focus();                 // 2. 再 focus 新元素
      el.selectionStart=el.selectionEnd=p; }  // 3. 恢复光标
  }, 300)()"
```

**关键顺序**：render() → getElementById → focus。反了（focus → render）会导致在旧元素上操作，新元素无焦点。

## 重复函数声明陷阱

**症状**：页面一直加载中，无 JS 报错。

**原因**：patch 追加代码时保留了旧 `function refreshData()`（634行），又新增了同名函数（726行）。`grep -n "function refreshData"` 可发现。

**预防**：长文件用 `write_file` 重写。追加函数前先 `grep` 检查是否已存在。
