diff --git a/docs/【plan】260514-seatmap-api-polling-realtime.md b/docs/【plan】260514-seatmap-api-polling-realtime.md new file mode 100644 index 0000000..38c87e0 --- /dev/null +++ b/docs/【plan】260514-seatmap-api-polling-realtime.md @@ -0,0 +1,304 @@ +# 【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 核心逻辑 \ No newline at end of file