docs: 【plan】260514 seatmap API + 轮询库存实时同步方案
parent
9617f5c4c0
commit
ff5e80df22
|
|
@ -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 核心逻辑
|
||||
Loading…
Reference in New Issue