# 【plan】260514 — seatmap API + 轮询库存实时同步

> 标签：seatmap-api | realtime | inventory | polling | uniapp
> 日期：2026-05-14
> 状态：**待执行**

---

## 一、背景与目标

**背景**：
- UniApp 票务页面需要实时显示座位是否已售（灰色）
- `vr_goods_config` 有座位图结构（`rooms[].map`），但**没有座位级库存**
- 库存数据在 `vrt_goods_spec_base.inventory`，ShopXO 标准 goods/detail API 不返回
- ShopXO 无 SSE/WebSocket 基础设施，SSE 需改 PHP 基础设施层（成本高）

**目标**：
1. 新增 `seatmap` API，返回 `seatSpecMap`（含每个座位的 inventory）
2. UniApp 每 10 秒轮询，实时灰化已售座位
3. 订单支付成功后 cache 失效，下一次 poll 自动拿到新库存
4. **完全隔离**：不动任何现有 ShopXO 代码，所有改动在插件内部

---

## 二、架构设计

### 2.1 API 接口

```
GET /api.php?s=plugins/index&pluginsname=vr_ticket&pluginscontrol=goods&pluginsaction=seatmap&goods_id=118
```

**响应：**
```json
{
  "code": 0,
  "data": {
    "seatSpecMap": {
      "69e5b802-c71e-4cc2-437f-2f1ef5f6afad_A_1": {
        "inventory": 1,
        "price": 999,
        "spec": [
          { "type": "$vr-场次",   "value": "08:00-23:59" },
          { "type": "$vr-场馆",   "value": "一个测试场馆信息" },
          { "type": "$vr-演播室", "value": "主要展厅" },
          { "type": "$vr-分区",   "value": "一个测试场馆信息-主要展厅-A" },
          { "type": "$vr-座位号", "value": "一个测试场馆信息-主要展厅-A-A1" }
        ],
        "section": { "char": "A", "name": "VIP区", "color": "#ff4d4f" },
        "roomId": "69e5b802-c71e-4cc2-437f-2f1ef5f6afad",
        "roomName": "主要展厅",
        "rowLabel": "A",
        "colNum": 1
      },
      "69e5b802-c71e-4cc2-437f-2f1ef5f6afad_A_2": {
        "inventory": 0,   // 已售！
        "price": 999,
        "spec": [...],
        "section": { "char": "A", "name": "VIP区", "color": "#ff4d4f" },
        ...
      }
    },
    "goods_spec_data": [
      { "spec_name": "08:00-23:59", "price": 299, "min_price": 299 }
    ]
  }
}
```

**seatSpecMap key 格式**：`{roomId}_{rowLabel}_{colNum}`
（与 H5 `ticket_detail.html` 的 `seatKey` 完全一致，保证 UI 一致性）

### 2.2 数据流

```
订单支付成功
    ↓
TicketService::onOrderPaid() 末尾
    → Cache::delete('vr_seatmap_' . goodsId)  ← 1行改动
    ↓
UniApp 端轮询（每10秒）
    → GET seatmap API
    → 用 seatSpecMap 灰化已售座位（inventory ≤ 0）
    → renderSeatMap() 重新渲染
```

### 2.3 缓存策略

| 数据 | 缓存策略 | TTL |
|------|---------|-----|
| `template_snapshot`（座位图结构） | ShopXO Cache（file 或 Redis） | 60s |
| `seatSpecMap`（inventory） | **不缓存**（实时读 DB） | — |
| `goods_spec_data`（场次+最低价） | 从 seatSpecMap 实时合并 | — |

**理由**：
- 模板快照不变，缓存减少 DB 查询
- inventory 必须实时，否则可能出现超售后 UI 仍显示可售

### 2.4 Realtime 推送方案

**最终选择：轮询（轻量，无额外基础设施）**

| 方案 | 结论 |
|------|------|
| SSE（长连接） | ❌ ShopXO 无 Swoole/Workerman，PHP-FPM 不支持长连 |
| Redis pub/sub + daemon | ❌ 需并行跑 daemon 进程，改基础设施 |
| **轮询（推荐）** | ✅ `GET /seatmap` 无 CDN 延迟，每 10 秒刷新 |
| ShopXO Realtime addon | ⚠️ 未装 addon，不依赖 |

**轮询 vs 即时推送的差距 < 10 秒**，用户感知弱。支付时 `FOR UPDATE SKIP LOCKED` 兜底拒超售。

---

## 三、新增文件清单

### 3.1 `service/SeatMapService.php`（新建）

```php
namespace app\plugins\vr_ticket\service;

class SeatMapService
{
    /**
     * 获取座位图完整数据（含实时库存）
     *
     * @param int $goodsId
     * @return array [
     *   'seatSpecMap' => [...],
     *   'goods_spec_data' => [...],
     * ]
     */
    public static function GetSeatMap(int $goodsId): array

    /**
     * 清除座位图缓存
     */
    public static function ClearCache(int $goodsId): void
}
```

**实现要点**：
- 复用 `SeatSkuService::buildSeatSpecMap($goodsId, $seatTemplate)` 的核心逻辑
- 模板快照走 `Cache::get('vr_seatmap_' . $goodsId)`，miss 时从 DB 读并写入
- `seatSpecMap` 不缓存，直接调 `buildSeatSpecMap()`

### 3.2 `api/Goods.php`（已有空壳，加 action）

```php
// 新增 action
public function seatmap()
{
    $goodsId = input('goods_id', 0, 'intval');
    if ($goodsId <= 0) {
        return self::error('参数错误');
    }
    $data = SeatMapService::GetSeatMap($goodsId);
    return self::success($data);
}
```

### 3.3 Hook 改动（1行，TicketService.php）

```php
// TicketService::onOrderPaid() 末尾
// 清除缓存，触发下一次 poll 拿到新库存
SeatMapService::ClearCache($goodsId);
```

---

## 四、UniApp 接入（vr-shopxo-uniapp）

### 4.1 轮询时机

```
购票弹窗打开（openTicketPopup）
    ↓ 立即调一次 seatmap API（拿到初始状态）
    ↓ 每 10 秒 setInterval
        → 调 seatmap API
        → 比对本地 seatSpecMap，找出 inventory=0 的座位
        → renderSeatMap() 灰化已售
购票弹窗关闭（closePopup）
    ↓ clearInterval，停止轮询
```

### 4.2 UniApp 端改动（goods-vr-ticket.vue）

```javascript
data() {
  return {
    seatSpecMap: {},        // ← 新增
    seatmapTimer: null,     // ← 新增
  }
},

methods: {
  openTicketPopup() {
    // 现有逻辑...
    this.loadSeatMap();          // ← 新增：立即加载
    this.seatmapTimer = setInterval(() => {
      this.loadSeatMap();
    }, 10000);                   // ← 新增：10秒轮询
  },

  closePopup() {
    if (this.seatmapTimer) {
      clearInterval(this.seatmapTimer);
      this.seatmapTimer = null;
    }
    // 现有逻辑...
  },

  loadSeatMap() {
    uni.request({
      url: 'http://shopxo.test/api.php?s=plugins/index&pluginsname=vr_ticket&pluginscontrol=goods&pluginsaction=seatmap&goods_id=' + this.goodsId,
      method: 'GET',
      success: (res) => {
        if (res.data.code === 0) {
          this.seatSpecMap = res.data.data.seatSpecMap;
          this.renderSeatMap();   // 用 seatSpecMap 灰化已售座位
        }
      }
    });
  },

  renderSeatMap() {
    // 参考 H5 ticket_detail.html 的 renderSeatMap 逻辑
    // 遍历 mapData，对每字符：
    //   - char === '_' → 空白
    //   - seatSpecMap[seatKey].inventory > 0 → 可选（带颜色）
    //   - seatSpecMap[seatKey].inventory <= 0 → 已售（灰色）
  }
}
```

### 4.3 API URL 配置

ShopXO 部署到不同环境时，`shopxo.test` 需要换成实际域名。
建议在 `main.js` 或全局配置里统一管理：
```javascript
// main.js
Vue.prototype.$shopxoBaseUrl = 'http://shopxo.test';
```

---

## 五、执行步骤

### Step 1：SeatMapService.php

1. 创建 `service/SeatMapService.php`
2. 复用 `SeatSkuService::buildSeatSpecMap()` 逻辑
3. 模板快照走 Cache（TTL 60s）
4. seatSpecMap 实时读 DB
5. `ClearCache($goodsId)` 实现

**验证**：`curl` 访问 API，返回正确的 seatSpecMap JSON

### Step 2：api/Goods.php 加 action

1. 在 `api/Goods.php`（已有空壳）加 `seatmap()` action
2. 调用 `SeatMapService::GetSeatMap()`
3. 返回格式 `{ code: 0, data: {...} }`

**验证**：浏览器直接访问 seatmap URL

### Step 3：TicketService Hook 加 1 行

1. 在 `TicketService::onOrderPaid()` 末尾加一行：
   ```php
   SeatMapService::ClearCache($goodsId);
   ```

**验证**：购买一张票后，确认下一次 poll 拿到新库存

### Step 4：UniApp 接入

1. 在 `goods-vr-ticket.vue` 实现 `loadSeatMap()`、`renderSeatMap()`
2. `openTicketPopup` 时启动轮询，弹窗关闭时停止
3. 本地开发测试（localhost），联调时配置实际 API URL

**验证**：
1. seatmap API 响应正确
2. 轮询后已售座位变灰
3. 购买后 poll 自动刷新

---

## 六、风险与备选

| 风险 | 概率 | 应对 |
|------|------|------|
| 轮询间隔内用户选到刚被买的座位 | 低 | `FOR UPDATE SKIP LOCKED` 支付时兜底拒单 |
| 多 PHP-FPM 实例 file cache 不一致 | 低（单服）| 未来迁移 Redis |
| API 响应慢影响轮询体验 | 低 | 设置 5 秒 timeout，超时跳过 |

---

## 七、相关文档

- `docs/VR_GOODS_CONFIG_SPEC.md` — vr_goods_config v3.0 协议
- `docs/PLAN_5DIM_REFACTOR.md` — 5维 SPEC_DIMS 说明
- `docs/09_SHOPXO_HOOKS_REFERENCE.md` — ShopXO Hook 钩子参考
- `service/SeatSkuService.php` — buildSeatSpecMap 核心逻辑