8.5 KiB
8.5 KiB
【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 基础设施层(成本高)
目标:
- 新增
seatmapAPI,返回seatSpecMap(含每个座位的 inventory) - UniApp 每 10 秒轮询,实时灰化已售座位
- 订单支付成功后 cache 失效,下一次 poll 自动拿到新库存
- 完全隔离:不动任何现有 ShopXO 代码,所有改动在插件内部
二、架构设计
2.1 API 接口
GET /api.php?s=plugins/index&pluginsname=vr_ticket&pluginscontrol=goods&pluginsaction=seatmap&goods_id=118
响应:
{
"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(新建)
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)
// 新增 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)
// 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)
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 或全局配置里统一管理:
// main.js
Vue.prototype.$shopxoBaseUrl = 'http://shopxo.test';
五、执行步骤
Step 1:SeatMapService.php
- 创建
service/SeatMapService.php - 复用
SeatSkuService::buildSeatSpecMap()逻辑 - 模板快照走 Cache(TTL 60s)
- seatSpecMap 实时读 DB
ClearCache($goodsId)实现
验证:curl 访问 API,返回正确的 seatSpecMap JSON
Step 2:api/Goods.php 加 action
- 在
api/Goods.php(已有空壳)加seatmap()action - 调用
SeatMapService::GetSeatMap() - 返回格式
{ code: 0, data: {...} }
验证:浏览器直接访问 seatmap URL
Step 3:TicketService Hook 加 1 行
- 在
TicketService::onOrderPaid()末尾加一行:SeatMapService::ClearCache($goodsId);
验证:购买一张票后,确认下一次 poll 拿到新库存
Step 4:UniApp 接入
- 在
goods-vr-ticket.vue实现loadSeatMap()、renderSeatMap() openTicketPopup时启动轮询,弹窗关闭时停止- 本地开发测试(localhost),联调时配置实际 API URL
验证:
- seatmap API 响应正确
- 轮询后已售座位变灰
- 购买后 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 核心逻辑