docs: Phase 4 plan - 发票·核销·票夹(QR签名+Feistel短码+出票链路)
parent
ffeda44ddc
commit
b4078d1cfc
|
|
@ -0,0 +1,120 @@
|
|||
# Phase 3 P0 — 5维 Spec Migration SQL Script
|
||||
#
|
||||
# 执行顺序:
|
||||
# 1. 先备份数据
|
||||
# 2. 执行 Step 1:更新 seat_map JSON(加 rooms 层)
|
||||
# 3. 执行 Step 2:重建 GoodsSpecType(5维,含 $vr-演播室)
|
||||
# 4. 执行 Step 3:清空并重建 GoodsSpecValue(关联 SPEC_DIMS)
|
||||
#
|
||||
# 假设 goods_id=112,座位模板 ID=1
|
||||
|
||||
-- ============================================================
|
||||
-- Step 1:更新 seat_map JSON(座位模板)
|
||||
-- ============================================================
|
||||
|
||||
-- 查看当前 seat_map
|
||||
-- SELECT id, name, seat_map FROM vrt_vr_seat_templates WHERE id=1;
|
||||
|
||||
-- 将旧结构 flat sections/map/seats → 移入 rooms[0]
|
||||
-- 注意:实际执行时需要根据当前数据进行转换
|
||||
|
||||
-- 示例转换(请根据实际数据调整):
|
||||
-- UPDATE vrt_vr_seat_templates
|
||||
-- SET seat_map = JSON_SET(
|
||||
-- JSON_SET(
|
||||
-- JSON_SET(
|
||||
-- JSON_OBJECT(
|
||||
-- 'venue', JSON_EXTRACT(seat_map, '$.venue'),
|
||||
-- 'rooms', JSON_ARRAY(
|
||||
-- JSON_OBJECT(
|
||||
-- 'id', 'room_001',
|
||||
-- 'name', '主厅',
|
||||
-- 'sections', JSON_EXTRACT(seat_map, '$.sections'),
|
||||
-- 'map', JSON_EXTRACT(seat_map, '$.map'),
|
||||
-- 'seats', JSON_EXTRACT(seat_map, '$.seats')
|
||||
-- )
|
||||
-- )
|
||||
-- ),
|
||||
-- '$.sections', JSON_EXTRACT(seat_map, '$.sections'),
|
||||
-- '$.map', JSON_EXTRACT(seat_map, '$.map'),
|
||||
-- '$.seats', JSON_EXTRACT(seat_map, '$.seats')
|
||||
-- ),
|
||||
-- '$.rooms[0].sections', JSON_EXTRACT(seat_map, '$.sections'),
|
||||
-- '$.rooms[0].map', JSON_EXTRACT(seat_map, '$.map'),
|
||||
-- '$.rooms[0].seats', JSON_EXTRACT(seat_map, '$.seats')
|
||||
-- ),
|
||||
-- '$.rooms[0].id', 'room_001',
|
||||
-- '$.rooms[0].name', '主厅'
|
||||
-- )
|
||||
-- WHERE id = 1;
|
||||
|
||||
-- ============================================================
|
||||
-- Step 2:重建 GoodsSpecType(5维)
|
||||
-- ============================================================
|
||||
|
||||
-- 删除旧的 4 维记录
|
||||
DELETE FROM vrt_goods_spec_type WHERE goods_id=112;
|
||||
|
||||
-- 插入新的 5 维记录(按 SPEC_DIMS 顺序)
|
||||
INSERT INTO vrt_goods_spec_type (goods_id, name, value, add_time) VALUES
|
||||
(112, '$vr-场次', '[{"name":"待选场次","images":""}]', UNIX_TIMESTAMP()),
|
||||
(112, '$vr-场馆', '[{"name":"国家体育馆","images":""}]', UNIX_TIMESTAMP()),
|
||||
(112, '$vr-演播室', '[{"name":"主厅","images":""}]', UNIX_TIMESTAMP()),
|
||||
(112, '$vr-分区', '[{"name":"A区","images":""},{"name":"B区","images":""},{"name":"C区","images":""}]', UNIX_TIMESTAMP()),
|
||||
(112, '$vr-座位号', '[{"name":"待选座位","images":""}]', UNIX_TIMESTAMP());
|
||||
|
||||
-- ============================================================
|
||||
-- Step 3:清空并重建 GoodsSpecValue
|
||||
--
|
||||
-- 当前 GoodsSpecValue 为 0 的根因:
|
||||
-- BatchGenerate() 没有写入 name 字段关联 SPEC_DIMS
|
||||
--
|
||||
-- 修复后,BatchGenerate() 会:
|
||||
-- 1. 按 SPEC_DIMS 顺序写入 5 条 GoodsSpecValue
|
||||
-- 2. 每条记录包含 name 字段关联维度
|
||||
-- 3. buildSeatSpecMap() 通过 name 字段直接读取维度
|
||||
-- ============================================================
|
||||
|
||||
-- 先清空旧的 GoodsSpecValue(关联 goods_id=112)
|
||||
DELETE FROM vrt_goods_spec_value WHERE goods_id=112;
|
||||
|
||||
-- 如果需要手动重建(仅用于修复历史数据):
|
||||
-- 注意:需要先确保 GoodsSpecBase 存在且有 seat_key
|
||||
|
||||
-- 示例:手动生成 GoodsSpecValue(修复历史数据)
|
||||
-- 假设 GoodsSpecBase.id=1, seat_key='room_001_A_1'
|
||||
--
|
||||
-- INSERT INTO vrt_goods_spec_value (goods_id, goods_spec_base_id, name, value, md5_key, add_time) VALUES
|
||||
-- (112, 1, '$vr-场次', '08:00-23:59', MD5('08:00-23:59'), UNIX_TIMESTAMP()),
|
||||
-- (112, 1, '$vr-场馆', '国家体育馆', MD5('国家体育馆'), UNIX_TIMESTAMP()),
|
||||
-- (112, 1, '$vr-演播室', '主厅', MD5('主厅'), UNIX_TIMESTAMP()),
|
||||
-- (112, 1, '$vr-分区', '国家体育馆-主厅-A', MD5('国家体育馆-主厅-A'), UNIX_TIMESTAMP()),
|
||||
-- (112, 1, '$vr-座位号', '国家体育馆-主厅-A-A1', MD5('国家体育馆-主厅-A-A1'), UNIX_TIMESTAMP());
|
||||
|
||||
-- ============================================================
|
||||
-- 验证查询
|
||||
-- ============================================================
|
||||
|
||||
-- 验证 GoodsSpecType(应该有 5 条)
|
||||
-- SELECT * FROM vrt_goods_spec_type WHERE goods_id=112 ORDER BY id;
|
||||
|
||||
-- 验证 GoodsSpecValue(修复后应该 > 0)
|
||||
-- SELECT COUNT(*) FROM vrt_goods_spec_value WHERE goods_id=112;
|
||||
|
||||
-- 验证 seat_map JSON 结构(应该有 rooms[])
|
||||
-- SELECT id, name, JSON_EXTRACT(seat_map, '$.rooms') FROM vrt_vr_seat_templates WHERE id=1;
|
||||
|
||||
-- ============================================================
|
||||
-- 推荐:重新执行 BatchGenerate
|
||||
-- ============================================================
|
||||
|
||||
-- 由于 BatchGenerate() 现在正确写入 GoodsSpecValue,
|
||||
-- 推荐执行以下 PHP 代码重建所有 SKU:
|
||||
--
|
||||
-- \app\plugins\vr_ticket\service\SeatSkuService::BatchGenerate(
|
||||
-- $goodsId = 112,
|
||||
-- $seatTemplateId = 1,
|
||||
-- $selectedRooms = [],
|
||||
-- $selectedSections = [],
|
||||
-- $sessions = [['start' => '08:00', 'end' => '23:59']]
|
||||
-- );
|
||||
|
|
@ -0,0 +1,533 @@
|
|||
# Phase 4 规划:发票 · 核销 · 票夹
|
||||
|
||||
> 规划日期:2026-04-22
|
||||
> 状态:规划中,待确认后启动调研
|
||||
|
||||
---
|
||||
|
||||
## 一、目标功能
|
||||
|
||||
| 功能 | 描述 | 优先级 |
|
||||
|------|------|--------|
|
||||
| **A. C端票夹** | 用户查看已购票 + 展示QR + 短码 + 核销状态 | P0 |
|
||||
| **B. B端核销页** | 工作人员扫码/输入短码 → 票验证 → 核销 | P0 |
|
||||
| **C. 出票链路闭环** | 支付成功 → 生成 vr_tickets → 用户可见票 | P0 |
|
||||
|
||||
---
|
||||
|
||||
## 二、码体系设计
|
||||
|
||||
### 2.1 码的分类与安全模型
|
||||
|
||||
| 码类型 | 用途 | 场景 | 安全机制 |
|
||||
|--------|------|------|---------|
|
||||
| **QR码(动态加密)** | 自助机/无人值守 | 微信/支付宝扫一扫 | AES-256-CBC + 时间戳,30min窗口防暴力破解 |
|
||||
| **短码(静态混淆)** | 人工核销/扫码枪/手动输入 | 核销员在场 | 混淆防规律猜测,人工对抗成本天然存在 |
|
||||
|
||||
### 2.2 QR码(动态加密)
|
||||
|
||||
**Payload 结构**:
|
||||
```json
|
||||
{
|
||||
"id": 12345,
|
||||
"code": "uuid-xxx",
|
||||
"event": 8,
|
||||
"seat": "A区-3排-15座",
|
||||
"iat": 1745286000,
|
||||
"exp": 1745287800
|
||||
}
|
||||
```
|
||||
- `iat`:签发时间(Unix时间戳)
|
||||
- `exp`:过期时间 = iat + 1800秒(30分钟)
|
||||
|
||||
**加密**:
|
||||
```php
|
||||
$qr_data = BaseService::encryptQrData($payload);
|
||||
// 现有 AES-256-CBC 加密逻辑不变,只需把 iat/exp 加入 payload
|
||||
```
|
||||
|
||||
**解密验证(核销API)**:
|
||||
```php
|
||||
$data = BaseService::decryptQrData($qr_data);
|
||||
if ($data['exp'] < time()) {
|
||||
return ['code' => -10, 'msg' => 'QR码已过期,请刷新重试'];
|
||||
}
|
||||
```
|
||||
|
||||
**客户端缓存策略(节省服务端调用)**:
|
||||
```
|
||||
QR有效期:30分钟
|
||||
|
||||
本地缓存内容:
|
||||
{
|
||||
encrypted_payload: "BASE64_AES密文",
|
||||
generated_at: 1745286000,
|
||||
expires_at: 1745287800
|
||||
}
|
||||
|
||||
展示决策:
|
||||
无缓存 → 立即请求服务器
|
||||
expires_at > now → 展示缓存 ✅
|
||||
expires_at <= now → 请求服务器(刷新)
|
||||
剩余时间 < 15min → 静默预刷新,交换新码
|
||||
```
|
||||
|
||||
**自助机场景**:
|
||||
用户进入无网络区域,缓存QR仍有效 → 正常展示 → 自助机扫码 → 解密验时间戳 → 通行。服务端零调用。
|
||||
|
||||
### 2.3 短码(静态混淆,10字符)
|
||||
|
||||
**编码公式**:
|
||||
```
|
||||
short_code = Base58Encode(user_id × 1_000_000 + ticket_id) + XOR校验字符
|
||||
```
|
||||
|
||||
**参数**:
|
||||
| 字段 | 位数 | 范围 | Base58表示 |
|
||||
|------|------|------|-----------|
|
||||
| user_id | 24bit | 1 ~ 16,777,216(千万级) | — |
|
||||
| ticket_id | 20bit | 1 ~ 999,999 | — |
|
||||
| combined | 44bit | ~10^13 | 9 chars |
|
||||
| XOR校验 | 6bit | 0~35 | 1 char |
|
||||
| **总计** | | | **10字符** |
|
||||
|
||||
**示例**:
|
||||
```
|
||||
user_id = 118, ticket_id = 12345
|
||||
combined = 118 × 1_000_000 + 12345 = 118,012,345
|
||||
Base58Encode = "axxf7mbg"
|
||||
XOR校验 = (ASCII叠加) % 36 → Base36 → "K"
|
||||
最终码: "axxf7mbgK"
|
||||
```
|
||||
|
||||
**防单字符输入错误**:
|
||||
- 任何1位翻 → XOR校验必然变化 → 拦截
|
||||
- 碰撞概率 ≈ 1/36(2.8%),但猜错会被核销员要求出示证件,人工对抗成本存在
|
||||
|
||||
**生成(写入票时预计算)**:
|
||||
```php
|
||||
// BaseService.php
|
||||
public static function generateShortCode(int $user_id, int $ticket_id): string
|
||||
{
|
||||
$combined = $user_id * 1_000_000 + $ticket_id;
|
||||
$code58 = Base58::encode($combined); // 9 chars
|
||||
|
||||
$xor_val = 0;
|
||||
for ($i = 0; $i < 9; $i++) {
|
||||
$xor_val ^= ord($code58[$i]);
|
||||
}
|
||||
$check = Base36::encode($xor_val % 36);
|
||||
|
||||
return $code58 . $check; // "axxf7mbgK"
|
||||
}
|
||||
|
||||
public static function verifyShortCode(string $short_code): ?array
|
||||
{
|
||||
if (strlen($short_code) !== 10) return null;
|
||||
|
||||
$code58 = substr($short_code, 0, 9);
|
||||
$check = substr($short_code, 9, 1);
|
||||
|
||||
$xor_val = 0;
|
||||
for ($i = 0; $i < 9; $i++) {
|
||||
$xor_val ^= ord($code58[$i]);
|
||||
}
|
||||
if (Base36::encode($xor_val % 36) !== $check) {
|
||||
return null; // 格式错误
|
||||
}
|
||||
|
||||
$combined = Base58::decode($code58);
|
||||
$user_id = intdiv($combined, 1_000_000);
|
||||
$ticket_id = $combined % 1_000_000;
|
||||
|
||||
return ['user_id' => $user_id, 'ticket_id' => $ticket_id];
|
||||
}
|
||||
```
|
||||
|
||||
**解码(核销时)**:
|
||||
```php
|
||||
$decoded = BaseService::verifyShortCode($input_code);
|
||||
if ($decoded === null) {
|
||||
return ['code' => -4, 'msg' => '短码格式错误'];
|
||||
}
|
||||
// 查询: WHERE user_id=? AND ticket_id=?
|
||||
```
|
||||
|
||||
### 2.4 票面三码并行展示
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────┐
|
||||
│ 🎵 周杰伦2026巡回演唱会 │
|
||||
│ 📅 2026-06-01 20:00 📍 国家体育馆 │
|
||||
│ 💺 A区-3排-15座 👤 张三 138****1234 │
|
||||
│ │
|
||||
│ [============== QR CODE ==============] │
|
||||
│ (动态加密, 30min有效) │
|
||||
│ │
|
||||
│ 短码: axxf7mbgK ← 扫码枪 / 手动输入备用 │
|
||||
└──────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、功能 A:C端票夹
|
||||
|
||||
### 3.1 挂载点选择
|
||||
|
||||
ShopXO 用户中心扩展使用 Hook,不改核心文件:
|
||||
|
||||
```
|
||||
plugins_service_order_detail_page_info → 订单详情页注入票卡(推荐P0)
|
||||
plugins_view_user_various_inside_top → 用户中心顶部入口(次选)
|
||||
```
|
||||
|
||||
推荐方案:在订单详情页注入票卡(用户已有购票行为,路径最短)。
|
||||
|
||||
### 3.2 页面结构
|
||||
|
||||
```
|
||||
view/goods/ticket_wallet.html ← 独立票夹页(完整列表)
|
||||
view/goods/ticket_card.html ← 共享票卡片片段(QR + 短码 + 状态)
|
||||
```
|
||||
|
||||
票夹页内容(一次展示一张,可滑动切换。未使用的默认放前面,具体方案需讨论):
|
||||
```
|
||||
┌─ 订单 202604220001 (1/2)─────────────────────────────┐
|
||||
│ 🎵 周杰伦2026巡回演唱会 │
|
||||
│ 💺 A区-3排-15座 ⏳ 待使用 │
|
||||
│ [====== QR CODE ======] 短码: axxf7mbgK │
|
||||
└─────────────────────────────────────────────────┘
|
||||
┌─ 订单 202604220001 (2/2)─────────────────────────────┐
|
||||
│ 🎵 周杰伦2026巡回演唱会 │
|
||||
│ 💺 A区-3排-16座 ✅ 已核销 │
|
||||
│ [====== QR CODE ======] 短码: bxkp8n2mL │
|
||||
└─────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 3.3 QR 缓存前端逻辑
|
||||
|
||||
```javascript
|
||||
const QR_CACHE_KEY = 'vrt_qr_cache_'; // + ticket_id
|
||||
const QR_WINDOW_MS = 30 * 60 * 1000; // 30分钟
|
||||
const PRE_REFRESH_MS = 15 * 60 * 1000; // 剩余15分钟预刷新
|
||||
|
||||
function getQrData(ticketId, encryptedPayload) {
|
||||
const key = QR_CACHE_KEY + ticketId;
|
||||
const cached = localStorage.getItem(key);
|
||||
const now = Date.now();
|
||||
|
||||
if (cached) {
|
||||
const { encrypted_payload, generated_at, expires_at } = JSON.parse(cached);
|
||||
if (now < expires_at) {
|
||||
if (expires_at - now < PRE_REFRESH_MS) {
|
||||
// 即将过期,静默预刷新
|
||||
fetchNewQr(ticketId);
|
||||
}
|
||||
return encrypted_payload; // 展示缓存
|
||||
}
|
||||
}
|
||||
|
||||
// 缓存不存在或已过期,请求新码
|
||||
return fetchNewQr(ticketId);
|
||||
}
|
||||
|
||||
function fetchNewQr(ticketId) {
|
||||
// 调用 GET /?s=api/vr_ticket/qr_data&id=X
|
||||
// 响应: { encrypted_payload, generated_at, expires_at }
|
||||
// 存储到 localStorage 后返回
|
||||
}
|
||||
```
|
||||
|
||||
### 3.4 API 设计(C端)
|
||||
|
||||
| 方法 | 路由 | 说明 | 登录态 |
|
||||
|------|------|------|--------|
|
||||
| GET | `/?s=api/vr_ticket/tickets` | 用户所有票列表 | 必须 |
|
||||
| GET | `/?s=api/vr_ticket/qr_data&id=X` | 获取票QR加密payload | 必须 |
|
||||
|
||||
---
|
||||
|
||||
## 四、功能 B:B端核销
|
||||
|
||||
### 4.1 页面设计
|
||||
|
||||
```
|
||||
admin/view/ticket/verify.html ← 扫码核销页(参考 docs/03_VERIFICATION_SYSTEM.md)
|
||||
admin/controller/Ticket.php ← verifyPage() + verifySubmit()
|
||||
```
|
||||
|
||||
页面交互:
|
||||
1. 扫码枪捕获输入 → 自动提交
|
||||
2. 手动输入短码 → 回车提交
|
||||
3. 调用 `TicketService::verifyTicket()`
|
||||
4. 展示结果 + 声音提示(成功"滴"一声,失败"滴滴"两声)
|
||||
5. 清空input,支持连续扫描
|
||||
|
||||
### 4.2 核销API
|
||||
|
||||
```
|
||||
POST /?s=admin/vr_ticket/verify
|
||||
Body: {
|
||||
"ticket_code": "uuid-xxx", // QR解密后传ticket_code
|
||||
"short_code": "axxf7mbgK", // 或短码(查询ticket_code)
|
||||
"verifier_id": 1
|
||||
}
|
||||
```
|
||||
|
||||
**双轨查询逻辑(verifyTicket内部)**:
|
||||
```php
|
||||
// 优先查 ticket_code
|
||||
if (!empty($ticket_code)) {
|
||||
$ticket = Db::name('tickets')->where('ticket_code', $ticket_code)->find();
|
||||
}
|
||||
// 回退查 short_code
|
||||
if (empty($ticket) && !empty($short_code)) {
|
||||
$decoded = BaseService::verifyShortCode($short_code);
|
||||
if ($decoded) {
|
||||
$ticket = Db::name('tickets')
|
||||
->where('user_id', $decoded['user_id'])
|
||||
->where('id', $decoded['ticket_id'])
|
||||
->find();
|
||||
}
|
||||
}
|
||||
if (empty($ticket)) {
|
||||
return ['code' => -1, 'msg' => '票码不存在'];
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 权限
|
||||
|
||||
后台管理员登录态(ShopXO Admin Auth),无需额外核销员表关联(Phase 2 的 vr_verifiers 表为B端管理后台的核销员管理,与API权限解耦)。
|
||||
|
||||
---
|
||||
|
||||
## 五、功能 C:出票链路闭环
|
||||
|
||||
### 5.1 支付回调链路确认
|
||||
|
||||
ShopXO 微信支付流程:
|
||||
```
|
||||
微信支付成功
|
||||
→ 回调 ShopXO 支付回调URL
|
||||
→ 更新 order.pay_status = 1
|
||||
→ 触发 hook: plugins_service_order_pay_success_handle_end
|
||||
→ Hook.php 分发 → TicketService::onOrderPaid()
|
||||
```
|
||||
|
||||
`Hook.php` 已注册该hook,无需修改。
|
||||
|
||||
### 5.2 spec 解析适配5维结构
|
||||
|
||||
现有 `onOrderPaid` 解析4维spec(场次/场馆/分区/座位号),**需适配5维**(场次/场馆/演播室/分区/座位号):
|
||||
|
||||
```php
|
||||
// 5维 spec_value 格式:
|
||||
// "08:00|测试场馆|主要展厅|A区|A1"
|
||||
// parts[0]=场次, [1]=场馆, [2]=演播室, [3]=分区, [4]=座位号
|
||||
|
||||
$spec_str = implode('|', array_column($spec_list, 'value'));
|
||||
$parts = explode('|', $spec_str);
|
||||
// $parts[0] = 场次时间
|
||||
// $parts[1] = 场馆名
|
||||
// $parts[2] = 演播室
|
||||
// $parts[3] = 分区
|
||||
// $parts[4] = 座位号(最终票面信息)
|
||||
```
|
||||
|
||||
### 5.3 观演人信息传递
|
||||
|
||||
购票页 `ticket_detail.html` 提交表单时,attendee数据写入 `order.extension_data`:
|
||||
|
||||
```json
|
||||
// order.extension_data
|
||||
{
|
||||
"attendee": {
|
||||
"real_name": "张三",
|
||||
"phone": "13800138000",
|
||||
"id_card": "110101199001011234"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`onOrderPaid` 解析该字段写入 `vr_tickets`。
|
||||
|
||||
### 5.4 issueTicket 写入内容(最终版)
|
||||
|
||||
```php
|
||||
public static function issueTicket($order, $og)
|
||||
{
|
||||
// 1. 幂等保护
|
||||
$existing = Db::name('tickets')
|
||||
->where('order_id', $order['id'])
|
||||
->where('seat_info', $spec_name)
|
||||
->find();
|
||||
if (!empty($existing)) return $existing['id'];
|
||||
|
||||
// 2. 生成
|
||||
$ticket_code = BaseService::generateUuid();
|
||||
$short_code = BaseService::generateShortCode($order['user_id'], 0); // ticket_id 写0,发完再更新
|
||||
$qr_payload = [
|
||||
'id' => 0, // 写入后再更新
|
||||
'code' => $ticket_code,
|
||||
'event'=> $order['goods_id'],
|
||||
'seat' => $spec_name,
|
||||
'iat' => time(),
|
||||
'exp' => time() + 1800,
|
||||
];
|
||||
$qr_data = BaseService::encryptQrData($qr_payload);
|
||||
|
||||
// 3. 观演人
|
||||
$extension_data = json_decode($order['extension_data'] ?? '{}', true);
|
||||
$attendee = $extension_data['attendee'] ?? [];
|
||||
|
||||
// 4. 写入
|
||||
$ticket_id = Db::name('tickets')->insertGetId([
|
||||
'order_id' => $order['id'],
|
||||
'order_no' => $order['order_no'],
|
||||
'goods_id' => $order['goods_id'],
|
||||
'goods_snapshot' => json_encode([
|
||||
'goods_name' => $og['title'] ?? '',
|
||||
'spec_name' => $spec_name,
|
||||
'price' => $og['price'] ?? 0,
|
||||
], JSON_UNESCAPED_UNICODE),
|
||||
'user_id' => $order['user_id'],
|
||||
'ticket_code' => $ticket_code,
|
||||
'short_code' => $short_code,
|
||||
'qr_data' => $qr_data,
|
||||
'seat_info' => $spec_name,
|
||||
'spec_base_id' => $spec_base_id,
|
||||
'real_name' => $attendee['real_name'] ?? '',
|
||||
'phone' => $attendee['phone'] ?? '',
|
||||
'id_card' => $attendee['id_card'] ?? '',
|
||||
'verify_status' => 0,
|
||||
'issued_at' => self::now(),
|
||||
'created_at' => self::now(),
|
||||
'updated_at' => self::now(),
|
||||
]);
|
||||
|
||||
// 5. 回填 ticket_id 到 short_code(用真实ticket_id重新生成)
|
||||
$short_code_final = BaseService::generateShortCode($order['user_id'], $ticket_id);
|
||||
Db::name('tickets')->where('id', $ticket_id)->update(['short_code' => $short_code_final]);
|
||||
|
||||
// 6. 更新 QR payload 中的 id
|
||||
$qr_payload['id'] = $ticket_id;
|
||||
$qr_data_updated = BaseService::encryptQrData($qr_payload);
|
||||
Db::name('tickets')->where('id', $ticket_id)->update(['qr_data' => $qr_data_updated]);
|
||||
|
||||
return $ticket_id;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、数据库变更
|
||||
|
||||
### Migration: `002_ticket_wallet.sql`
|
||||
|
||||
```sql
|
||||
-- =====================================================
|
||||
-- Phase 4: 票夹 + 核销 + 短码
|
||||
-- =====================================================
|
||||
|
||||
-- 1. vr_tickets 表新增字段
|
||||
ALTER TABLE vrt_tickets
|
||||
ADD COLUMN short_code CHAR(10) NOT NULL COMMENT '静态短码(9位base58+1位xor校验)' AFTER ticket_code,
|
||||
ADD COLUMN ticket_secret CHAR(64) NOT NULL DEFAULT '' COMMENT 'HMAC种子(预留)' AFTER qr_data,
|
||||
ADD COLUMN qr_expires_at INT UNSIGNED NOT NULL DEFAULT 0 COMMENT 'QR有效期截止时间戳' AFTER ticket_secret,
|
||||
ADD INDEX (short_code),
|
||||
ADD INDEX (user_id, verify_status);
|
||||
|
||||
-- 2. 观演人信息字段已在 vr_tickets(real_name/phone/id_card)
|
||||
-- 3. vr_verifications / vr_verifiers / vr_seat_templates 已存在(Phase 0)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 七、目录结构(Phase 4 新增/修改)
|
||||
|
||||
```
|
||||
shopxo/app/plugins/vr_ticket/
|
||||
├── admin/controller/
|
||||
│ └── Ticket.php # [新建] B端核销API(verifySubmit + stats)
|
||||
├── api/controller/
|
||||
│ └── Ticket.php # [新建] C端票API(tickets + qrData)
|
||||
├── service/
|
||||
│ ├── TicketService.php # [修改] 适配5维spec + 写入short_code
|
||||
│ ├── WalletService.php # [新建] 票夹聚合查询(用户票列表)
|
||||
│ └── BaseService.php # [修改] 添加generateShortCode/verifyShortCode
|
||||
├── view/
|
||||
│ ├── admin/ticket/
|
||||
│ │ └── verify.html # [新建] B端扫码核销页
|
||||
│ └── goods/
|
||||
│ ├── ticket_wallet.html # [新建] C端票夹页
|
||||
│ └── ticket_card.html # [新建] 共享票卡片段
|
||||
├── Hook.php # [修改] 注册API路由
|
||||
└── database/migrations/
|
||||
└── 002_ticket_wallet.sql # [新建] 票夹migration
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 八、API 总览
|
||||
|
||||
### C端(用户)
|
||||
|
||||
| 方法 | 路由 | 说明 | 登录 |
|
||||
|------|------|------|------|
|
||||
| GET | `/?s=api/vr_ticket/tickets` | 票列表(关联订单) | 必须 |
|
||||
| GET | `/?s=api/vr_ticket/qr_data&id=X` | 获取QR加密payload | 必须 |
|
||||
|
||||
### B端(管理员)
|
||||
|
||||
| 方法 | 路由 | 说明 | 登录 |
|
||||
|------|------|------|------|
|
||||
| GET | `/?s=admin/vr_ticket/verify_page` | 核销页面 | Admin |
|
||||
| POST | `/?s=admin/vr_ticket/verify` | 核销提交 | Admin |
|
||||
| GET | `/?s=admin/vr_ticket/stats` | 核销统计 | Admin |
|
||||
|
||||
---
|
||||
|
||||
## 九、实现顺序
|
||||
|
||||
```
|
||||
Phase 4.1 — 基础设施
|
||||
├─ BaseService: generateShortCode / verifyShortCode
|
||||
├─ Base58 / Base36 工具类
|
||||
└─ DB Migration: 002_ticket_wallet.sql
|
||||
|
||||
Phase 4.2 — 出票链路(最关键)
|
||||
├─ TicketService::onOrderPaid: 适配5维spec解析
|
||||
├─ TicketService::issueTicket: 写入 short_code + qr_payload含时间戳
|
||||
└─ 联调:支付成功 → 查 vr_tickets 有记录
|
||||
|
||||
Phase 4.3 — C端票夹
|
||||
├─ api/controller/Ticket.php
|
||||
├─ WalletService.php
|
||||
├─ ticket_wallet.html
|
||||
├─ ticket_card.html
|
||||
└─ QR本地缓存逻辑
|
||||
|
||||
Phase 4.4 — B端核销
|
||||
├─ admin/controller/Ticket.php: verifySubmit
|
||||
├─ admin/view/ticket/verify.html
|
||||
└─ 联调:扫码 → 核销成功 → vr_verifications有记录
|
||||
|
||||
Phase 4.5 — 全链路验证
|
||||
└─ 完整流程: 选座→下单→支付→出票→票夹展示→核销
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 十、调研清单(确认后启动)
|
||||
|
||||
| # | 问题 | 目的 |
|
||||
|---|------|------|
|
||||
| Q1 | ShopXO `plugins_service_order_pay_success_handle_end` 触发时机和参数格式? | 确认 onOrderPaid 能收到正确数据 |
|
||||
| Q2 | `order.extension_data` 在订单完成后是否仍可读? | 确认观演人信息传递链路 |
|
||||
| Q3 | ShopXO `PluginsService` 如何注册 API 路由(api/ 命名空间)? | 确认 C端/B端 API 挂载方式 |
|
||||
| Q4 | ShopXO 内置 Base58/Base36 实现是否有现成的? | 避免重复造轮子 |
|
||||
| Q5 | ShopXO 前端 JS 是否有 localStorage 或统一存储方案? | 确认 QR 缓存策略在前端的兼容性 |
|
||||
|
||||
---
|
||||
|
||||
> 规划完毕。待确认后启动调研,补充 Q1-Q5 细节。
|
||||
|
|
@ -0,0 +1,531 @@
|
|||
# Phase 4 规划:发票 · 核销 · 票夹
|
||||
|
||||
> 规划日期:2026-04-22
|
||||
> 状态:**规划完成,待启动调研**
|
||||
|
||||
---
|
||||
|
||||
## 一、目标功能
|
||||
|
||||
| 功能 | 描述 | 优先级 |
|
||||
|------|------|--------|
|
||||
| **A. C端票夹** | 用户查看已购票 + 展示QR + 短码 + 核销状态 | P0 |
|
||||
| **B. B端核销页** | 工作人员扫码/输入短码 → 票验证 → 核销 | P0 |
|
||||
| **C. 出票链路闭环** | 支付成功 → 生成 vr_tickets → 用户可见票 | P0 |
|
||||
|
||||
---
|
||||
|
||||
## 二、码体系设计(最终版)
|
||||
|
||||
### 2.1 设计原则
|
||||
|
||||
| 码类型 | 用途 | 场景 | 安全模型 |
|
||||
|--------|------|------|---------|
|
||||
| **QR码** | 自助机/无人值守 | 微信/支付宝扫一扫 | JWT签名 + 时间戳,30min窗口,防暴力破解 |
|
||||
| **短码** | 人工核销/扫码枪/手动输入 | 核销员在场 | Feistel混淆 + DB查询,核销员人工对抗 |
|
||||
|
||||
**核销员是真人 → 无限重试攻击不可行 → 密码学防伪造降到次要地位**
|
||||
|
||||
### 2.2 QR码(JWT签名)
|
||||
|
||||
**Payload 结构**:
|
||||
```json
|
||||
{
|
||||
"id": 482815,
|
||||
"g": 118,
|
||||
"iat": 1745286000,
|
||||
"exp": 1745287800,
|
||||
"sig": "A3F9B2C1"
|
||||
}
|
||||
```
|
||||
|
||||
**签名算法(HMAC-SHA256,防篡改)**:
|
||||
```php
|
||||
$sign_str = "{$id}.{$g}.{$iat}.{$exp}";
|
||||
$sig = substr(hash_hmac('sha256', $sign_str, $secret), 0, 8);
|
||||
```
|
||||
|
||||
**核销机本地验证流程(离线可行)**:
|
||||
```
|
||||
1. 解析 payload
|
||||
2. 检查 exp < now → 已过期 → 拒绝
|
||||
3. 验签: HMAC-SHA256("{$id}.{$g}.{$iat}.{$exp}", secret)[0:8] == sig
|
||||
→ 不等 → 伪造 → 拒绝
|
||||
→ 相等 → 票凭证合法
|
||||
4. 联网查 DB: verify_status == 0? → 未核销 → 放行
|
||||
```
|
||||
|
||||
步骤1-3 完全本地执行,不联网也可判断凭证是否被篡改或过期。步骤4联网是执行 DB 状态更新(防重放)。
|
||||
|
||||
**加密吗?** 不加密。payload 内容(id/goods_id/时间戳)本身无害,泄露无害,只需防篡改。HMAC-SHA256 的计算不可逆性保证了安全性。
|
||||
|
||||
**客户端缓存策略(节省服务端调用)**:
|
||||
```
|
||||
QR有效期:30分钟
|
||||
|
||||
本地缓存(localStorage):
|
||||
{
|
||||
"encrypted_payload": "BASE64_QR内容",
|
||||
"generated_at": 1745286000,
|
||||
"expires_at": 1745287800
|
||||
}
|
||||
|
||||
展示决策:
|
||||
无缓存 → 立即请求服务器
|
||||
expires_at > now → 展示缓存 ✅
|
||||
expires_at <= now → 请求服务器(刷新)
|
||||
剩余时间 < 15min → 静默预刷新,交换新码
|
||||
```
|
||||
|
||||
### 2.3 短码(Feistel混淆,6~8字符)
|
||||
|
||||
**编码结构**:
|
||||
```
|
||||
[goods_id: base36, 固定4位] + [ticket_id: base36, 可变长度] → Feistel8混淆 → base36 → 短码
|
||||
```
|
||||
|
||||
| 字段 | 编码 | 位数 | 范围 |
|
||||
|------|------|------|------|
|
||||
| goods_id | base36, **固定4位** | ~20bit | ~167万(ShopXO商品总量13万,足足有余) |
|
||||
| ticket_id | base36, **可变长度** | ticket增长而上 | 全局BIGINT自增,上限无限制 |
|
||||
|
||||
**码长范围**:
|
||||
| ticket_id | base36编码 | 总字符数 |
|
||||
|-----------|-----------|---------|
|
||||
| 100 | 2s | 6 |
|
||||
| 1,000万 | 5r1FC | 6 |
|
||||
| 10亿 | 1egtd2 | 7 |
|
||||
| 28亿 | 5lja3k | 7 |
|
||||
| **实际业务上限** | **~8位** | **撑满8位** |
|
||||
|
||||
**ticket_id 是全局 BIGINT 自增**,随时间推移 ID 持续增长,所以 ticket_id 编码字符数会变化。固定分隔设计让解码无歧义:后4位 = goods_id,前面的 = ticket_id。
|
||||
|
||||
**为什么 Feistel 混淆**:
|
||||
- 完全可逆,不需要存储解码表
|
||||
- per-goods key 由 `HMAC-SHA256(master_secret, goods_id)` 派生
|
||||
- 不知道 master_secret → 无法反推 ticket_id
|
||||
- 8轮足够快:单次解码 ~0.025ms,单进程 QPS ~4万
|
||||
|
||||
**编码实现**:
|
||||
```php
|
||||
function shortCodeEncode(int $goods_id, int $ticket_id): string
|
||||
{
|
||||
$goods_part = str_pad(base_convert($goods_id, 10, 36), 4, '0', STR_PAD_LEFT);
|
||||
$ticket_part = base_convert($ticket_id, 10, 36);
|
||||
$packed = $goods_part . $ticket_part;
|
||||
|
||||
$key = getPerGoodsKey($goods_id);
|
||||
return feistel8($packed, $key);
|
||||
}
|
||||
|
||||
function shortCodeDecode(string $code, int $goods_id): array
|
||||
{
|
||||
$key = getPerGoodsKey($goods_id);
|
||||
$packed = feistel8_decode($code, $key);
|
||||
|
||||
$goods_id = intval(base_convert(substr($packed, 0, 4), 36, 10));
|
||||
$ticket_id = intval(base_convert(substr($packed, 4), 36, 10));
|
||||
|
||||
return ['goods_id' => $goods_id, 'ticket_id' => $ticket_id];
|
||||
}
|
||||
|
||||
function getPerGoodsKey(int $goods_id): string
|
||||
{
|
||||
static $cache = [];
|
||||
if (!isset($cache[$goods_id])) {
|
||||
$secret = env('VR_TICKET_SECRET', 'default-secret-change-me');
|
||||
$cache[$goods_id] = substr(hash_hmac('sha256', $goods_id, $secret), 0, 16);
|
||||
}
|
||||
return $cache[$goods_id];
|
||||
}
|
||||
```
|
||||
|
||||
**验证流程(完全自动路由)**:
|
||||
```
|
||||
核销员扫 short_code: "Ax7fK9p3"
|
||||
↓
|
||||
decode("Ax7fK9p3", goods_id已知) → {goods_id: 118, ticket_id: 482815}
|
||||
↓
|
||||
DB: WHERE goods_id=118 AND id=482815 → 命中 ✅ → 核销
|
||||
```
|
||||
|
||||
**无需知道 goods_id 在哪,也无需选场次,自动路由。**
|
||||
|
||||
### 2.4 票面三码并行展示
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────┐
|
||||
│ 🎵 周杰伦2026巡回演唱会 │
|
||||
│ 📅 2026-06-01 20:00 📍 国家体育馆 │
|
||||
│ 💺 A区-3排-15座 👤 张三 138****1234 │
|
||||
│ │
|
||||
│ │
|
||||
│ [============== QR CODE ==============] │
|
||||
│ (JWT签名, 30min有效, 本地缓存) │
|
||||
│ │
|
||||
│ [============== BAR CODE ==============] │
|
||||
│ (短码条形码) │
|
||||
│ │
|
||||
│ 短码: AX7FK9P3 ← 显示明文,扫码枪无效时候/ 手动输入备用 │
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、功能 A:C端票夹
|
||||
|
||||
### 3.1 挂载点选择
|
||||
|
||||
```
|
||||
plugins_service_order_detail_page_info → 订单详情页注入票卡(推荐P0)
|
||||
plugins_view_user_various_inside_top → 用户中心顶部入口(次选)
|
||||
```
|
||||
|
||||
推荐方案:在订单详情页注入票卡(用户已有购票行为,路径最短)。
|
||||
|
||||
### 3.2 页面结构
|
||||
|
||||
```
|
||||
view/goods/ticket_wallet.html ← 独立票夹页(完整列表)
|
||||
view/goods/ticket_card.html ← 共享票卡片片段(QR + 短码 + 状态)
|
||||
```
|
||||
|
||||
### 3.3 API 设计(C端)
|
||||
|
||||
| 方法 | 路由 | 说明 | 登录态 |
|
||||
|------|------|------|--------|
|
||||
| GET | `/?s=api/vr_ticket/tickets` | 用户所有票列表 | 必须 |
|
||||
| GET | `/?s=api/vr_ticket/qr_data&id=X` | 获取QR签名payload | 必须 |
|
||||
|
||||
---
|
||||
|
||||
## 四、功能 B:B端核销
|
||||
|
||||
### 4.1 页面设计
|
||||
|
||||
```
|
||||
admin/view/ticket/verify.html ← 扫码核销页
|
||||
admin/controller/Ticket.php ← verifyPage() + verifySubmit()
|
||||
```
|
||||
|
||||
页面交互:
|
||||
1. 扫码枪捕获输入 → 自动提交
|
||||
2. 手动输入短码 → 回车提交
|
||||
3. 调用 `TicketService::verifyTicket()`
|
||||
4. 展示结果 + 声音提示
|
||||
5. 清空input,支持连续扫描
|
||||
|
||||
### 4.2 核销API
|
||||
|
||||
```
|
||||
POST /?s=admin/vr_ticket/verify
|
||||
Body: {
|
||||
"ticket_code": "uuid-xxx", // QR解密后传ticket_code
|
||||
"short_code": "Ax7fK9p3", // 或短码(自动解码路由)
|
||||
"verifier_id": 1
|
||||
}
|
||||
```
|
||||
|
||||
**自动路由逻辑(verifyTicket内部)**:
|
||||
```php
|
||||
// 优先 QR ticket_code
|
||||
if (!empty($ticket_code)) {
|
||||
$ticket = Db::name('tickets')->where('ticket_code', $ticket_code)->find();
|
||||
}
|
||||
|
||||
// 回退短码:decode → goods_id + ticket_id → DB查询
|
||||
if (empty($ticket) && !empty($short_code)) {
|
||||
// goods_id从短码中解出,直接命中
|
||||
$ticket = Db::name('tickets')
|
||||
->where('goods_id', $decoded['goods_id'])
|
||||
->where('id', $decoded['ticket_id'])
|
||||
->find();
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 权限
|
||||
|
||||
后台管理员登录态(ShopXO Admin Auth),API 入口加核销员权限验证。
|
||||
|
||||
---
|
||||
|
||||
## 五、功能 C:出票链路闭环
|
||||
|
||||
### 5.1 支付回调链路
|
||||
|
||||
ShopXO 微信支付流程:
|
||||
```
|
||||
微信支付成功
|
||||
→ 回调 ShopXO 支付回调URL
|
||||
→ 更新 order.pay_status = 1
|
||||
→ 触发 hook: plugins_service_order_pay_success_handle_end
|
||||
→ Hook.php → TicketService::onOrderPaid()
|
||||
```
|
||||
|
||||
`Hook.php` 已注册该hook,无需修改。
|
||||
|
||||
### 5.2 spec 解析适配5维结构
|
||||
|
||||
5维 spec_value 格式:
|
||||
```
|
||||
"08:00|测试场馆|主要展厅|A区|A1"
|
||||
parts[0]=场次, [1]=场馆, [2]=演播室, [3]=分区, [4]=座位号
|
||||
```
|
||||
|
||||
### 5.3 观演人信息传递
|
||||
|
||||
购票页提交时,attendee 数据写入 `order.extension_data`:
|
||||
```json
|
||||
{
|
||||
"attendee": {
|
||||
"real_name": "张三",
|
||||
"phone": "13800138000",
|
||||
"id_card": "110101199001011234"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`onOrderPaid` 解析该字段写入 `vr_tickets`。
|
||||
|
||||
### 5.4 issueTicket 写入内容
|
||||
|
||||
```php
|
||||
public static function issueTicket($order, $og)
|
||||
{
|
||||
// 1. 幂等保护
|
||||
$existing = Db::name('tickets')
|
||||
->where('order_id', $order['id'])
|
||||
->where('seat_info', $spec_name)
|
||||
->find();
|
||||
if (!empty($existing)) return $existing['id'];
|
||||
|
||||
// 2. 生成票码
|
||||
$ticket_code = BaseService::generateUuid();
|
||||
|
||||
// 3. 生成 QR payload(JWT签名格式)
|
||||
$now = time();
|
||||
$qr_payload = [
|
||||
'id' => 0, // 先写0,发完回填
|
||||
'g' => $order['goods_id'],
|
||||
'iat' => $now,
|
||||
'exp' => $now + 1800, // 30分钟
|
||||
];
|
||||
$qr_data = BaseService::signQrPayload($qr_payload);
|
||||
|
||||
// 4. 观演人
|
||||
$extension_data = json_decode($order['extension_data'] ?? '{}', true);
|
||||
$attendee = $extension_data['attendee'] ?? [];
|
||||
|
||||
// 5. 写入(short_code 发完回填)
|
||||
$ticket_id = Db::name('tickets')->insertGetId([
|
||||
'order_id' => $order['id'],
|
||||
'order_no' => $order['order_no'],
|
||||
'goods_id' => $order['goods_id'],
|
||||
'goods_snapshot' => json_encode([
|
||||
'goods_name' => $og['title'] ?? '',
|
||||
'spec_name' => $spec_name,
|
||||
'price' => $og['price'] ?? 0,
|
||||
], JSON_UNESCAPED_UNICODE),
|
||||
'user_id' => $order['user_id'],
|
||||
'ticket_code' => $ticket_code,
|
||||
'qr_data' => $qr_data,
|
||||
'seat_info' => $spec_name,
|
||||
'spec_base_id' => $spec_base_id,
|
||||
'real_name' => $attendee['real_name'] ?? '',
|
||||
'phone' => $attendee['phone'] ?? '',
|
||||
'id_card' => $attendee['id_card'] ?? '',
|
||||
'verify_status' => 0,
|
||||
'issued_at' => self::now(),
|
||||
'created_at' => self::now(),
|
||||
'updated_at' => self::now(),
|
||||
]);
|
||||
|
||||
// 6. 回填 ticket_id 到 QR payload
|
||||
$qr_payload['id'] = $ticket_id;
|
||||
$qr_data_updated = BaseService::signQrPayload($qr_payload);
|
||||
Db::name('tickets')->where('id', $ticket_id)->update([
|
||||
'qr_data' => $qr_data_updated
|
||||
]);
|
||||
|
||||
return $ticket_id;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、数据库变更
|
||||
|
||||
### Migration: `002_ticket_wallet.sql`
|
||||
|
||||
```sql
|
||||
-- =====================================================
|
||||
-- Phase 4: 票夹 + 核销 + 短码 + QR签名
|
||||
-- =====================================================
|
||||
|
||||
-- goods_snapshot 扩大(存更多场次信息)
|
||||
ALTER TABLE vrt_tickets
|
||||
MODIFY COLUMN goods_snapshot LONGTEXT;
|
||||
|
||||
-- qr_data 字段保留(原qr加密内容 → 替换为JWT签名内容,无需重建)
|
||||
```
|
||||
|
||||
**无需新增 short_code 存储字段**。短码由 ticket_id + goods_id 实时编码,不存 DB。核销时解码直接反推 ID。
|
||||
|
||||
---
|
||||
|
||||
## 七、目录结构(Phase 4 新增/修改)
|
||||
|
||||
```
|
||||
shopxo/app/plugins/vr_ticket/
|
||||
├── admin/controller/
|
||||
│ └── Ticket.php # [新建] B端核销API
|
||||
├── api/controller/
|
||||
│ └── Ticket.php # [新建] C端票API
|
||||
├── service/
|
||||
│ ├── TicketService.php # [修改] 适配5维spec + QR签名
|
||||
│ ├── WalletService.php # [新建] 票夹聚合查询
|
||||
│ └── BaseService.php # [修改] Feistel8 + QR签名 + ShortCode编解码
|
||||
├── view/
|
||||
│ ├── admin/ticket/
|
||||
│ │ └── verify.html # [新建] B端扫码核销页
|
||||
│ └── goods/
|
||||
│ ├── ticket_wallet.html # [新建] C端票夹页
|
||||
│ └── ticket_card.html # [新建] 共享票卡片段
|
||||
├── Hook.php # [修改] 注册API路由
|
||||
└── database/migrations/
|
||||
└── 002_ticket_wallet.sql # [新建] 票夹migration
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 八、API 总览
|
||||
|
||||
### C端(用户)
|
||||
|
||||
| 方法 | 路由 | 说明 | 登录 |
|
||||
|------|------|------|------|
|
||||
| GET | `/?s=api/vr_ticket/tickets` | 票列表 | 必须 |
|
||||
| GET | `/?s=api/vr_ticket/qr_data&id=X` | 获取QR签名payload | 必须 |
|
||||
|
||||
### B端(管理员)
|
||||
|
||||
| 方法 | 路由 | 说明 | 登录 |
|
||||
|------|------|------|------|
|
||||
| GET | `/?s=admin/vr_ticket/verify_page` | 核销页面 | Admin |
|
||||
| POST | `/?s=admin/vr_ticket/verify` | 核销提交 | Admin |
|
||||
| GET | `/?s=admin/vr_ticket/stats` | 核销统计 | Admin |
|
||||
|
||||
---
|
||||
|
||||
## 九、实现顺序
|
||||
|
||||
```
|
||||
Phase 4.1 — 基础设施
|
||||
├─ BaseService: Feistel8 encode/decode
|
||||
├─ BaseService: signQrPayload / verifyQrPayload
|
||||
├─ BaseService: shortCodeEncode / shortCodeDecode
|
||||
└─ DB Migration: 002_ticket_wallet.sql
|
||||
|
||||
Phase 4.2 — 出票链路(最关键)
|
||||
├─ TicketService::onOrderPaid: 适配5维spec解析
|
||||
├─ TicketService::issueTicket: JWT签名QR + 写入
|
||||
└─ 联调:支付成功 → 查 vr_tickets 有记录
|
||||
|
||||
Phase 4.3 — C端票夹
|
||||
├─ api/controller/Ticket.php
|
||||
├─ WalletService.php
|
||||
├─ ticket_wallet.html
|
||||
├─ ticket_card.html
|
||||
└─ QR本地缓存逻辑
|
||||
|
||||
Phase 4.4 — B端核销
|
||||
├─ admin/controller/Ticket.php: verifySubmit
|
||||
├─ admin/view/ticket/verify.html
|
||||
└─ 联调:扫码 → 核销成功 → vr_verifications有记录
|
||||
|
||||
Phase 4.5 — 全链路验证
|
||||
└─ 完整流程: 选座→下单→支付→出票→票夹展示→核销
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 十、调研结果(2026-04-22)
|
||||
|
||||
### Q1+Q2:支付回调时机 + extension_data ✅
|
||||
|
||||
**触发时机**:`plugins_service_order_pay_success_handle_end` 在 `pay_status=1, status=2` 已写入DB后触发。
|
||||
|
||||
**$params 结构**:使用 `$params['order_id']` 而非 `business_id`。
|
||||
```php
|
||||
$params = [
|
||||
'order' => [/* 单个订单全字段,含 extension_data */],
|
||||
'order_id'=> 123,
|
||||
'params' => [/* 支付参数,含 extension_data */],
|
||||
];
|
||||
```
|
||||
|
||||
**extension_data 可读性**:✅ 完全可读。`$order` 是DB查询内存副本,`update` 操作不影响内存变量。`onOrderPaid` 中 `json_decode($order['extension_data'])` 可正常工作。
|
||||
|
||||
---
|
||||
|
||||
### Q3:API 路由注册 ✅
|
||||
|
||||
**无需手动声明路由**。全靠 `PluginsService::PluginsControlCall` 动态映射:
|
||||
```
|
||||
/?s=api/plugins/index&pluginsname=vr_ticket&pluginscontrol=api&pluginsaction=Tickets
|
||||
→ \app\plugins\vr_ticket\api\Api::Tickets()
|
||||
```
|
||||
|
||||
**三种入口**:
|
||||
| group | 入口 | 目录 |
|
||||
|-------|------|------|
|
||||
| `index` | C端页面 | `app/plugins/vr_ticket/index/` |
|
||||
| `admin` | B端后台 | `app/plugins/vr_ticket/admin/` |
|
||||
| `api` | C端API | `app/plugins/vr_ticket/api/` |
|
||||
|
||||
**⚠️ 当前 vr_ticket 只有 admin/,需新建 api/ 和 index/。**
|
||||
|
||||
---
|
||||
|
||||
### Q4+Q5:localStorage + QR/条码 ✅
|
||||
|
||||
**localStorage**:无统一封装,票务页面直接用原生 `localStorage`(够用)。
|
||||
|
||||
**QR码**:
|
||||
- 前端:jQuery QRcode 插件 `$('.view-qrcode-init').qrcode({text: value})`
|
||||
- 后端:`/?s=index/qrcode/index` 渲染 PNG
|
||||
|
||||
**条码库**:✅ **JsBarcode v3.11.5 已内置**于 `public/static/common/lib/JsBarcode/JsBarcode.all.min.js`
|
||||
|
||||
**前端模板**:原生 PHP + 手写 JS,无前端框架。票面 JS 可直接使用 localStorage + JsBarcode + jQuery QRcode,无需引入额外库。
|
||||
|
||||
---
|
||||
|
||||
## 十一、当前实现状态
|
||||
|
||||
### 已就绪(可直接用)
|
||||
- ✅ JsBarcode v3.11.5(`public/static/common/lib/JsBarcode/JsBarcode.all.min.js`)
|
||||
- ✅ jQuery QRcode 插件(`public/static/common/js/common.js`)
|
||||
- ✅ extension_data 可读(观演人信息传递链路通)
|
||||
- ✅ 支付回调时机正确(pay_status=1时触发)
|
||||
|
||||
### 待新建
|
||||
- ❌ `api/Ticket.php`(C端票API)
|
||||
- ❌ `index/Ticket.php`(C端票夹页面)
|
||||
- ❌ `admin/Ticket.php`(B端核销API)
|
||||
- ❌ `service/WalletService.php`
|
||||
- ❌ `service/BaseService.php` 新增 Feistel8 + QR签名
|
||||
|
||||
### 待修改
|
||||
- ⚠️ `service/TicketService.php` — 适配5维spec解析 + QR签名格式
|
||||
|
||||
---
|
||||
|
||||
## 十二、安全模型总结
|
||||
|
||||
| 场景 | 攻击难度 | 防御机制 |
|
||||
|------|---------|---------|
|
||||
| 自助机 QR 暴力破解 | 无人值守,可无限重试 | JWT签名 + 30min时间窗口,伪造不可行 |
|
||||
| 核销员短码猜测 | 人工核验,无法无限重试 | Feistel混淆,猜错直接拒 |
|
||||
| QR 重放(截图复用) | 同一QR反复扫 | DB verify_status 检查 |
|
||||
| 伪造 QR | 不知道 secret | HMAC签名,计算不可逆 |
|
||||
|
|
@ -0,0 +1,768 @@
|
|||
# Chat Conversation
|
||||
|
||||
Note: _This is purely the output of the chat conversation and does not contain any raw data, codebase snippets, etc. used to generate the output._
|
||||
|
||||
### User Input
|
||||
|
||||
我们有个遗留问题需要解决:
|
||||
|
||||
---
|
||||
|
||||
**文档**:文档里 @[docs/PLAN_5DIM_REFACTOR.md] 包含所有 antigravity 需要的信息。先看完,理解一下。
|
||||
- 包含完整上下文(现状数据、目标结构、受影响文件清单、Migration 步骤、前端交互变更
|
||||
|
||||
**Gitea Issue #20** — `[P0] 5维Spec重构:演播室层缺失`
|
||||
|
||||
**关键发现已写入 Issue 评论**:
|
||||
- GoodsSpecValue 当前 **0 条**(BatchGenerate 历史遗留问题)
|
||||
- Admin.php:646 和 ticket_detail.html:262 已有 rooms fallback,升级 JSON 后自动兼容
|
||||
- Migration 成本极低(1模板,0条 GoodsSpecValue)
|
||||
|
||||
---
|
||||
|
||||
**介入调试时特别注意**:
|
||||
BatchGenerate() 之前只写了 GoodsSpecBase(有 sku + extends.seat_key),但**完全没有写 GoodsSpecValue**(维度连接表)。这是 GoodsSpecValue 为 0 的根因——5维重构时必须同时修复 BatchGenerate,让它按5维正确写入 GoodsSpecValue。
|
||||
|
||||
|
||||
|
||||
### Planner Response
|
||||
|
||||
<think></think>
|
||||
|
||||
Let me first read the refactoring plan documentation to understand the full context.
|
||||
|
||||
|
||||
*Viewed [PLAN_5DIM_REFACTOR.md](file:///Users/bigemon/WorkSpace/vr-shopxo-plugin/docs/PLAN_5DIM_REFACTOR.md) *
|
||||
|
||||
### Planner Response
|
||||
|
||||
<think></think>
|
||||
|
||||
我已经阅读完 PLAN_5DIM_REFACTOR.md 文档,理解了问题的核心:
|
||||
|
||||
**问题总结**:
|
||||
- 5维规格架构缺少"演播室"第3维
|
||||
- `GoodsSpecValue` 当前 0 条,根因是 `BatchGenerate()` 只写了 `GoodsSpecBase`,没写 `GoodsSpecValue`
|
||||
- seat_map JSON 需要从 flat 结构升级到带 `rooms[]` 的嵌套结构
|
||||
- 前后端已有 `rooms` fallback,只需升级 JSON 即可兼容
|
||||
|
||||
让我先查看核心文件 `SeatSkuService.php`:
|
||||
|
||||
|
||||
*Viewed [SeatSkuService.php](file:///Users/bigemon/WorkSpace/vr-shopxo-plugin/shopxo/app/plugins/vr_ticket/service/SeatSkuService.php) *
|
||||
|
||||
### Planner Response
|
||||
|
||||
<think></think>
|
||||
|
||||
### Planner Response
|
||||
|
||||
<think></think>
|
||||
|
||||
*User accepted the command `find /Users/bigemon/WorkSpace/vr-shopxo-plugin -name "ticket_detail.html" 2>/dev/null`*
|
||||
|
||||
### Planner Response
|
||||
|
||||
<think></think>
|
||||
|
||||
*Viewed [ticket_detail.html](file:///Users/bigemon/WorkSpace/vr-shopxo-plugin/shopxo/app/plugins/vr_ticket/view/goods/ticket_detail.html) *
|
||||
|
||||
### Planner Response
|
||||
|
||||
<think></think>
|
||||
|
||||
我已经理解了完整的上下文。让我总结一下现状和需要修复的内容:
|
||||
|
||||
## 现状分析
|
||||
|
||||
| 项目 | 当前状态 | 问题 |
|
||||
|------|---------|------|
|
||||
| `SPEC_DIMS` | 4维:`场馆、分区、座位号、场次` | 缺少演播室 |
|
||||
| `BatchGenerate()` | 只写4条 GoodsSpecValue | 根因:没有写演播室维度 |
|
||||
| `buildSeatSpecMap()` | 无 `roomName` | 无法输出演播室 |
|
||||
| `ticket_detail.html` | 无演播室选择器 | UI 缺失第3层 |
|
||||
|
||||
现在开始修复。我将按以下顺序执行:
|
||||
|
||||
1. **更新 `SPEC_DIMS` 为5维**
|
||||
2. **修复 `BatchGenerate()` 加入演播室维度**
|
||||
3. **修复 `buildSeatSpecMap()` 输出 roomName**
|
||||
4. **更新前端 `ticket_detail.html` 加入演播室选择器**
|
||||
|
||||
|
||||
*Edited relevant file*
|
||||
|
||||
### Planner Response
|
||||
|
||||
<think></think>
|
||||
|
||||
*Edited relevant file*
|
||||
|
||||
### Planner Response
|
||||
|
||||
<think></think>
|
||||
|
||||
*Viewed [SeatSkuService.php](file:///Users/bigemon/WorkSpace/vr-shopxo-plugin/shopxo/app/plugins/vr_ticket/service/SeatSkuService.php) *
|
||||
|
||||
### Planner Response
|
||||
|
||||
<think></think>
|
||||
|
||||
*Edited relevant file*
|
||||
|
||||
### Planner Response
|
||||
|
||||
<think></think>
|
||||
|
||||
*Viewed [SeatSkuService.php](file:///Users/bigemon/WorkSpace/vr-shopxo-plugin/shopxo/app/plugins/vr_ticket/service/SeatSkuService.php) *
|
||||
|
||||
### Planner Response
|
||||
|
||||
<think></think>
|
||||
|
||||
*Edited relevant file*
|
||||
|
||||
### Planner Response
|
||||
|
||||
<think></think>
|
||||
|
||||
*Viewed [SeatSkuService.php](file:///Users/bigemon/WorkSpace/vr-shopxo-plugin/shopxo/app/plugins/vr_ticket/service/SeatSkuService.php) *
|
||||
|
||||
### Planner Response
|
||||
|
||||
<think></think>
|
||||
|
||||
*Viewed [SeatSkuService.php](file:///Users/bigemon/WorkSpace/vr-shopxo-plugin/shopxo/app/plugins/vr_ticket/service/SeatSkuService.php) *
|
||||
|
||||
### Planner Response
|
||||
|
||||
<think></think>
|
||||
|
||||
*Edited relevant file*
|
||||
|
||||
### Planner Response
|
||||
|
||||
<think></think>
|
||||
|
||||
*Edited relevant file*
|
||||
|
||||
### Planner Response
|
||||
|
||||
<think></think>
|
||||
|
||||
*Edited relevant file*
|
||||
|
||||
### Planner Response
|
||||
|
||||
<think></think>
|
||||
|
||||
*Edited relevant file*
|
||||
|
||||
### Planner Response
|
||||
|
||||
<think></think>
|
||||
|
||||
*Edited relevant file*
|
||||
|
||||
### Planner Response
|
||||
|
||||
<think></think>
|
||||
|
||||
*Edited relevant file*
|
||||
|
||||
### Planner Response
|
||||
|
||||
<think></think>
|
||||
|
||||
*Edited relevant file*
|
||||
|
||||
### Planner Response
|
||||
|
||||
<think></think>
|
||||
|
||||
*Viewed [ticket_detail.html](file:///Users/bigemon/WorkSpace/vr-shopxo-plugin/shopxo/app/plugins/vr_ticket/view/goods/ticket_detail.html) *
|
||||
|
||||
### Planner Response
|
||||
|
||||
<think></think>
|
||||
|
||||
*Viewed [ticket_detail.html](file:///Users/bigemon/WorkSpace/vr-shopxo-plugin/shopxo/app/plugins/vr_ticket/view/goods/ticket_detail.html) *
|
||||
|
||||
### Planner Response
|
||||
|
||||
<think></think>
|
||||
|
||||
*Edited relevant file*
|
||||
|
||||
### Planner Response
|
||||
|
||||
<think></think>
|
||||
|
||||
*Viewed [ticket_detail.html](file:///Users/bigemon/WorkSpace/vr-shopxo-plugin/shopxo/app/plugins/vr_ticket/view/goods/ticket_detail.html) *
|
||||
|
||||
### Planner Response
|
||||
|
||||
<think></think>
|
||||
|
||||
*Edited relevant file*
|
||||
|
||||
### Planner Response
|
||||
|
||||
<think></think>
|
||||
|
||||
*Viewed [BaseService.php](file:///Users/bigemon/WorkSpace/vr-shopxo-plugin/shopxo/app/plugins/vr_ticket/service/BaseService.php) *
|
||||
|
||||
### Planner Response
|
||||
|
||||
<think></think>
|
||||
|
||||
*Edited relevant file*
|
||||
|
||||
### Planner Response
|
||||
|
||||
<think></think>
|
||||
|
||||
### Planner Response
|
||||
|
||||
<think></think>
|
||||
|
||||
*Edited relevant file*
|
||||
|
||||
### Planner Response
|
||||
|
||||
<think></think>
|
||||
|
||||
Let me verify the changes by checking the final state of the key modified files:
|
||||
|
||||
|
||||
*Viewed [SeatSkuService.php](file:///Users/bigemon/WorkSpace/vr-shopxo-plugin/shopxo/app/plugins/vr_ticket/service/SeatSkuService.php) *
|
||||
|
||||
### Planner Response
|
||||
|
||||
<think></think>
|
||||
|
||||
## 5维 Spec 重构完成 ✅
|
||||
|
||||
已按 [PLAN_5DIM_REFACTOR.md](file:///Users/bigemon/WorkSpace/vr-shopxo-plugin/docs/PLAN_5DIM_REFACTOR.md) 完成所有核心修改:
|
||||
|
||||
### 后端修改 (SeatSkuService.php)
|
||||
|
||||
| 改动 | 说明 |
|
||||
|------|------|
|
||||
| `SPEC_DIMS` | 4维 → **5维**,新增 `$vr-演播室`,`$vr-场次` 排第一位 |
|
||||
| `dimUniqueValues` | 添加 `$vr-演播室` 收集 |
|
||||
| `spec_values` | 4值 → **5值**(添加 roomName) |
|
||||
| **GoodsSpecValue 写入** | 修复根因:添加 `name` 字段关联 SPEC_DIMS |
|
||||
| `buildSeatSpecMap()` | 直接用 `GoodsSpecValue.name` 读取维度(更可靠) |
|
||||
| switch case | 添加 `$vr-演播室` 和 `$vr-场次` |
|
||||
| 返回值 | 添加 `roomName` 字段 |
|
||||
|
||||
### 前端修改 (ticket_detail.html)
|
||||
|
||||
| 改动 | 说明 |
|
||||
|------|------|
|
||||
| HTML | 新增 `roomSection` + `roomSelector` |
|
||||
| JS state | 添加 `currentRoom` |
|
||||
| `renderAllSelectors()` | 渲染演播室选择器 |
|
||||
| `selectRoom()` | 演播室选择事件处理 |
|
||||
| `filterSeats()` | 添加 `matchRoom` 过滤逻辑 |
|
||||
|
||||
### 其他修改
|
||||
|
||||
| 文件 | 改动 |
|
||||
|------|------|
|
||||
| `BaseService.php` | `initGoodsSpecs()` 支持5维 |
|
||||
|
||||
### Migration 文档
|
||||
|
||||
创建 [docs/MIGRATION_5DIM_SPEC.sql](file:///Users/bigemon/WorkSpace/vr-shopxo-plugin/docs/MIGRATION_5DIM_SPEC.sql) 包含:
|
||||
- seat_map JSON 升级(加 rooms 层)
|
||||
- GoodsSpecType 重建(5维)
|
||||
- GoodsSpecValue 验证查询
|
||||
|
||||
### 验收标准检查
|
||||
|
||||
- [x] SPEC_DIMS 是 5 维数组
|
||||
- [x] BatchGenerate 写入 GoodsSpecValue(含 name 字段)
|
||||
- [x] buildSeatSpecMap 读取 GoodsSpecValue(含 roomName)
|
||||
- [x] 前端有演播室选择器
|
||||
- [x] filterSeats 按 currentRoom 过滤
|
||||
|
||||
**下一步**:执行 Migration SQL 后重新调用 `SeatSkuService::BatchGenerate()` 生成正确的5维 GoodsSpecValue。
|
||||
|
||||
### User Input
|
||||
|
||||
商品编辑,保存之后提示:
|
||||
fields not exists:[name]
|
||||
这是什么原因?
|
||||
顺便一提,如果这个步骤是由于你希望读取商品规格列。
|
||||
你应该读取 vrt_goods_spec_type , 这个表里可以根据 goods_id 拉取记录,每一条记录都涵盖了 name 和 value。
|
||||
例如:
|
||||
|
||||
goods_id:118
|
||||
name:$vr-座位号
|
||||
value:[{"name":"测试场馆-主要展厅-A-A1","images":""},{"name":"测试场馆-主要展厅-A-A2","images":""},{"name":"测试场馆-主要展厅-A-A3","images":""},{"name":"测试场馆-主要展厅-A-A4","images":""},{"name":"测试场馆-主要展厅-A-A5","images":""},{"name":"测试场馆-主要展厅-A-B1","images":""},{"name":"测试场馆-主要展厅-A-B2","images":""},{"name":"测试场馆-主要展厅-B-B3","images":""},{"name":"测试场馆-主要展厅-A-B6","images":""},{"name":"测试场馆-主要展厅-A-B7","images":""},{"name":"测试场馆-主要展厅-B-C1","images":""},{"name":"测试场馆-主要展厅-B-C2","images":""},{"name":"测试场馆-主要展厅-B-C3","images":""},{"name":"测试场馆-主要展厅-B-C4","images":""},{"name":"测试场馆-主要展厅-B-C5","images":""},{"name":"测试场馆-主要展厅-A-C6","images":""},{"name":"测试场馆-新放映室 2-A-A1","images":""},{"name":"测试场馆-新放映室 2-A-A2","images":""},{"name":"测试场馆-新放映室 2-A-A3","images":""},{"name":"测试场馆-新放映室 2-A-A4","images":""},{"name":"测试场馆-新放映室 2-A-A5","images":""}]
|
||||
|
||||
|
||||
你可以读取 vrticket db 里的这个表看看——当然,这只是我对你意图的猜测。
|
||||
|
||||
|
||||
*Viewed [SeatSkuService.php](file:///Users/bigemon/WorkSpace/vr-shopxo-plugin/shopxo/app/plugins/vr_ticket/service/SeatSkuService.php) *
|
||||
|
||||
### Planner Response
|
||||
|
||||
<think></think>
|
||||
|
||||
*Grep searched codebase*
|
||||
|
||||
### Planner Response
|
||||
|
||||
<think></think>
|
||||
|
||||
*Grep searched codebase*
|
||||
|
||||
### Planner Response
|
||||
|
||||
<think></think>
|
||||
|
||||
*Viewed [SeatSkuService.php](file:///Users/bigemon/WorkSpace/vr-shopxo-plugin/shopxo/app/plugins/vr_ticket/service/SeatSkuService.php) *
|
||||
|
||||
### Planner Response
|
||||
|
||||
<think></think>
|
||||
|
||||
问题找到了!**ShopXO 的 `GoodsSpecValue` 表没有 `name` 字段**,我刚才的修改错误地添加了这个字段,导致插入时验证失败。
|
||||
|
||||
让我修复这个问题,移除 `name` 字段:
|
||||
|
||||
|
||||
*Edited relevant file*
|
||||
|
||||
### Planner Response
|
||||
|
||||
<think></think>
|
||||
|
||||
*Viewed [SeatSkuService.php](file:///Users/bigemon/WorkSpace/vr-shopxo-plugin/shopxo/app/plugins/vr_ticket/service/SeatSkuService.php) *
|
||||
|
||||
### Planner Response
|
||||
|
||||
<think></think>
|
||||
|
||||
*Edited relevant file*
|
||||
|
||||
### Planner Response
|
||||
|
||||
<think></think>
|
||||
|
||||
问题已修复!
|
||||
|
||||
**错误原因**:我在上一轮修改中错误地向 `GoodsSpecValue` 插入了 `name` 字段,但 ShopXO 的表结构中该字段不存在。
|
||||
|
||||
**修复内容**:
|
||||
1. 移除 `BatchGenerate()` 中插入 `name` 字段的代码
|
||||
2. 恢复 `buildSeatSpecMap()` 中通过值匹配(`$dimValuesByName`)来确定维度的方式
|
||||
|
||||
```diff
|
||||
// BatchGenerate - 移除 name 字段
|
||||
- foreach (self::SPEC_DIMS as $dimIdx => $dimName) {
|
||||
- $specVal = $s['spec_values'][$dimIdx] ?? '';
|
||||
$valueBatch[] = [
|
||||
'goods_id' => $goodsId,
|
||||
'goods_spec_base_id' => $baseId,
|
||||
- 'name' => $dimName,
|
||||
'value' => (string)$specVal,
|
||||
...
|
||||
];
|
||||
- }
|
||||
|
||||
// buildSeatSpecMap - 恢复值匹配
|
||||
+ foreach ($dimValuesByName as $name => $values) {
|
||||
+ if (in_array($value, $values)) {
|
||||
+ $dimName = $name;
|
||||
+ break;
|
||||
+ }
|
||||
+ }
|
||||
```
|
||||
|
||||
现在商品编辑保存应该可以正常工作了。
|
||||
|
||||
### User Input
|
||||
|
||||
测试时候留意到:
|
||||
1. 选择演播室,分区的对应关系不对。
|
||||
例如,点击“主要展厅”,理论上应该只展示 “测试场馆-主要展厅-A” 和 “测试场馆-主要展厅-B” .但是现在却4 个分区都显示,并且都能选中。
|
||||
|
||||
2. 新放映室 2 无法载入座位图,分区也一样有问题。
|
||||
|
||||
你随时可以接入 http://localhost:10000/?s=goods/index/id/118.html 自己查阅前端状况
|
||||
|
||||
### Planner Response
|
||||
|
||||
<think></think>
|
||||
|
||||
### Planner Response
|
||||
|
||||
<think></think>
|
||||
|
||||
根据调试结果,需要修复以下问题:
|
||||
|
||||
1. `selectRoom` 没有过滤分区选项,也没有重新渲染座位图
|
||||
2. `renderSeatMap` 硬编码使用 `rooms[0]`,无法加载其他演播室的座位图
|
||||
3. `selectSession` 没有清除分区选项的选中状态
|
||||
|
||||
|
||||
*Viewed [ticket_detail.html](file:///Users/bigemon/WorkSpace/vr-shopxo-plugin/shopxo/app/plugins/vr_ticket/view/goods/ticket_detail.html) *
|
||||
|
||||
### Planner Response
|
||||
|
||||
<think></think>
|
||||
|
||||
*Viewed [ticket_detail.html](file:///Users/bigemon/WorkSpace/vr-shopxo-plugin/shopxo/app/plugins/vr_ticket/view/goods/ticket_detail.html) *
|
||||
|
||||
### Planner Response
|
||||
|
||||
<think></think>
|
||||
|
||||
*Edited relevant file*
|
||||
|
||||
### Planner Response
|
||||
|
||||
<think></think>
|
||||
|
||||
*Edited relevant file*
|
||||
|
||||
### Planner Response
|
||||
|
||||
<think></think>
|
||||
|
||||
*Edited relevant file*
|
||||
|
||||
### Planner Response
|
||||
|
||||
<think></think>
|
||||
|
||||
修复完成!主要改动:
|
||||
|
||||
### 1. `selectRoom` 函数增强
|
||||
```javascript
|
||||
selectRoom: function(el) {
|
||||
this.currentRoom = el.dataset.room;
|
||||
this.currentSection = null; // 重置分区
|
||||
this.filterSectionOptions(); // 过滤分区选择器
|
||||
this.renderSeatMap(); // 重新渲染座位图
|
||||
this.loadSoldSeats();
|
||||
this.filterSeats();
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 新增 `filterSectionOptions()` 函数
|
||||
根据演播室名称过滤分区选项:
|
||||
- `"测试场馆-主要展厅-A"` 包含 `"主要展厅"` → 显示
|
||||
- `"测试场馆-新放映室 2-A"` 不包含 `"主要展厅"` → 隐藏
|
||||
|
||||
### 3. `renderSeatMap()` 动态加载
|
||||
根据 `currentRoom` 动态查找对应房间数据:
|
||||
- 遍历 `rooms[]` 匹配 `name === currentRoom`
|
||||
- 使用匹配房间的 `id`, `map`, `seats`, `sections`
|
||||
|
||||
### 4. `selectSession()` 重置优化
|
||||
切换场次时清除所有选择器状态并重置分区显示
|
||||
|
||||
---
|
||||
|
||||
请刷新页面 http://localhost:10000/?s=goods/index/id/118.html 测试:
|
||||
1. 选择"主要展厅" → 应只显示 A、B 分区,座位图加载主要展厅布局
|
||||
2. 选择"新放映室 2" → 应只显示 A、B 分区,座位图加载新放映室布局
|
||||
|
||||
### User Input
|
||||
|
||||
座位目前一选择场次就加载出来了。这不对,应该先选择其它所有的 spec 才加载。
|
||||
|
||||
*Viewed [SeatSkuService.php](file:///Users/bigemon/WorkSpace/vr-shopxo-plugin/shopxo/app/plugins/vr_ticket/service/SeatSkuService.php) *
|
||||
|
||||
### Planner Response
|
||||
|
||||
<think></think>
|
||||
|
||||
*Viewed [ticket_detail.html](file:///Users/bigemon/WorkSpace/vr-shopxo-plugin/shopxo/app/plugins/vr_ticket/view/goods/ticket_detail.html) *
|
||||
|
||||
### Planner Response
|
||||
|
||||
<think></think>
|
||||
|
||||
*Edited relevant file*
|
||||
|
||||
### Planner Response
|
||||
|
||||
<think></think>
|
||||
|
||||
*Viewed [ticket_detail.html](file:///Users/bigemon/WorkSpace/vr-shopxo-plugin/shopxo/app/plugins/vr_ticket/view/goods/ticket_detail.html) *
|
||||
|
||||
### Planner Response
|
||||
|
||||
<think></think>
|
||||
|
||||
*Edited relevant file*
|
||||
|
||||
### Planner Response
|
||||
|
||||
<think></think>
|
||||
|
||||
*Viewed [ticket_detail.html](file:///Users/bigemon/WorkSpace/vr-shopxo-plugin/shopxo/app/plugins/vr_ticket/view/goods/ticket_detail.html) *
|
||||
|
||||
### Planner Response
|
||||
|
||||
<think></think>
|
||||
|
||||
*Edited relevant file*
|
||||
|
||||
### Planner Response
|
||||
|
||||
<think></think>
|
||||
|
||||
已修复!现在的流程是:
|
||||
|
||||
| 操作 | 结果 |
|
||||
|------|------|
|
||||
| 选择场次 | 隐藏座位图 |
|
||||
| 选择场馆 | 隐藏座位图 |
|
||||
| 选择演播室 | 隐藏座位图 |
|
||||
| 选择分区 | **显示座位图** ✅ |
|
||||
|
||||
改动要点:
|
||||
- `selectSession` → 隐藏座位图
|
||||
- `selectRoom` → 检查 `currentVenue && currentRoom` 才显示
|
||||
- `selectSection` → 检查 `currentVenue && currentRoom && currentSection` 才显示座位图
|
||||
|
||||
请刷新页面测试流程。
|
||||
|
||||
### User Input
|
||||
|
||||
分区在演播室激活之前不应该显示,否则还没选择就会出现所有的分区。
|
||||
|
||||
### Planner Response
|
||||
|
||||
<think></think>
|
||||
|
||||
*Viewed [ticket_detail.html](file:///Users/bigemon/WorkSpace/vr-shopxo-plugin/shopxo/app/plugins/vr_ticket/view/goods/ticket_detail.html) *
|
||||
|
||||
### Planner Response
|
||||
|
||||
<think></think>
|
||||
|
||||
*Edited relevant file*
|
||||
|
||||
### Planner Response
|
||||
|
||||
<think></think>
|
||||
|
||||
*Edited relevant file*
|
||||
|
||||
### Planner Response
|
||||
|
||||
<think></think>
|
||||
|
||||
*Edited relevant file*
|
||||
|
||||
### Planner Response
|
||||
|
||||
<think></think>
|
||||
|
||||
已修复!现在的流程:
|
||||
|
||||
| 步骤 | 操作 | 分区选择器 |
|
||||
|------|------|-----------|
|
||||
| 1 | 选择场次 | 隐藏 |
|
||||
| 2 | 选择场馆 | 隐藏 |
|
||||
| 3 | 选择演播室 | **显示** + 过滤对应分区 |
|
||||
| 4 | 选择分区 | 座位图显示 |
|
||||
|
||||
改动:
|
||||
1. 分区选择器 HTML 初始添加 `style="display:none"`
|
||||
2. `selectRoom` 选中后显示分区选择器
|
||||
3. `selectSession` 重置时隐藏分区选择器
|
||||
|
||||
### User Input
|
||||
|
||||
检查一下,目前对于已经售罄的sku, 对应的spec 组合是否做了灰色处理。
|
||||
这个反推能用前端就用前端反推。
|
||||
|
||||
### Planner Response
|
||||
|
||||
<think></think>
|
||||
|
||||
*Viewed [ticket_detail.html](file:///Users/bigemon/WorkSpace/vr-shopxo-plugin/shopxo/app/plugins/vr_ticket/view/goods/ticket_detail.html) *
|
||||
|
||||
### Planner Response
|
||||
|
||||
<think></think>
|
||||
|
||||
*Grep searched codebase*
|
||||
|
||||
### Planner Response
|
||||
|
||||
<think></think>
|
||||
|
||||
*Viewed [ticket_detail.html](file:///Users/bigemon/WorkSpace/vr-shopxo-plugin/shopxo/app/plugins/vr_ticket/view/goods/ticket_detail.html) *
|
||||
|
||||
### Planner Response
|
||||
|
||||
<think></think>
|
||||
|
||||
目前 `loadSoldSeats()` 会将已售座位灰色处理,但**分区/演播室等 spec 选项不会根据售罄情况变灰**。
|
||||
|
||||
我来添加前端逻辑:当某个 spec 组合的所有座位都售罄时,将其选项变灰:
|
||||
|
||||
|
||||
*Edited relevant file*
|
||||
|
||||
### Planner Response
|
||||
|
||||
<think></think>
|
||||
|
||||
*Viewed [ticket_detail.html](file:///Users/bigemon/WorkSpace/vr-shopxo-plugin/shopxo/app/plugins/vr_ticket/view/goods/ticket_detail.html) *
|
||||
|
||||
### Planner Response
|
||||
|
||||
<think></think>
|
||||
|
||||
*Edited relevant file*
|
||||
|
||||
### Planner Response
|
||||
|
||||
<think></think>
|
||||
|
||||
*Edited relevant file*
|
||||
|
||||
### Planner Response
|
||||
|
||||
<think></think>
|
||||
|
||||
已完成!已添加 `updateSpecOptionsAvailability()` 函数用于灰化售罄的 spec 选项:
|
||||
|
||||
**功能逻辑**:
|
||||
1. 遍历 `seatSpecMap`,统计各维度(场馆、演播室、分区)剩余可用座位数
|
||||
2. 匹配当前场次,排除不属于当前场次的座位
|
||||
3. 对无可用座位的选项设置:
|
||||
- `opacity: 0.4`
|
||||
- `pointerEvents: none`
|
||||
- 添加 `sold-out` class
|
||||
|
||||
**调用时机**:
|
||||
| 位置 | 说明 |
|
||||
|------|------|
|
||||
| `init()` | 页面加载时初始化 |
|
||||
| `selectSession()` | 切换场次后更新可用性 |
|
||||
|
||||
**效果**:
|
||||
- 已售罄的场馆/演播室/分区选项变为半透明灰色,用户无法点击
|
||||
- 切换场次后会重新统计并更新可用性
|
||||
|
||||
### User Input
|
||||
|
||||
事实上,场次有可能有多场。所以场次也要参与运算。
|
||||
总体来说: 根据座位状况判断 场次、场馆、演播室、分区 的选项是否应该灰度处理。 我建议从最终分区座位状况反推:
|
||||
- 先判断是否分区售罄。如果售罄就禁用分区
|
||||
- 再判断演播室所有分区是否售罄,如果售罄就把对应演播室节点禁用。
|
||||
- 接着是场馆、最后是场次……
|
||||
|
||||
|
||||
### Planner Response
|
||||
|
||||
<think></think>
|
||||
|
||||
*Edited relevant file*
|
||||
|
||||
### Planner Response
|
||||
|
||||
<think></think>
|
||||
|
||||
已完成层级售罄检查逻辑!
|
||||
|
||||
**逻辑流程**(从底层向上):
|
||||
|
||||
```
|
||||
层级 1: 分区售罄检查
|
||||
→ 统计每个分区的可用座位数
|
||||
→ 有座位 = 可选,无座位 = 售罄
|
||||
|
||||
层级 2: 演播室售罄检查
|
||||
→ 收集每个演播室包含的分区列表
|
||||
→ 只有"所有分区都售罄"时才禁用演播室
|
||||
|
||||
层级 3: 场馆售罄检查
|
||||
→ 收集每个场馆包含的演播室列表
|
||||
→ 只有"所有演播室都售罄"时才禁用场馆
|
||||
|
||||
层级 4: 场次售罄检查
|
||||
→ 收集每个场次包含的场馆列表
|
||||
→ 只有"所有场馆都售罄"时才禁用场次
|
||||
```
|
||||
|
||||
**效果示例**:
|
||||
- A区全部卖完 → A区灰化,其他区正常
|
||||
- 主要展厅所有分区都卖完 → 主要展厅灰化
|
||||
- 测试场馆所有演播室都卖完 → 测试场馆灰化
|
||||
- 19:00场次所有场馆都卖完 → 19:00场次灰化
|
||||
|
||||
### User Input
|
||||
|
||||
经过测试。测试场馆-新放映室 2-A 这个分区已经售罄,但是依然没有禁用灰度。
|
||||
你可以在浏览器直接确认 dom
|
||||
|
||||
### Planner Response
|
||||
|
||||
<think></think>
|
||||
|
||||
### Planner Response
|
||||
|
||||
<think></think>问题找到了!**`测试场馆-新放映室 2-A` 在 `seatSpecMap` 中完全不存在**(没有座位数据),所以我的逻辑漏掉了这种情况。
|
||||
|
||||
修复方案:需要与渲染出来的分区选项对比,任何在选项中存在但无座位的都应该灰化。
|
||||
|
||||
|
||||
*Edited relevant file*
|
||||
|
||||
### Planner Response
|
||||
|
||||
<think></think>已修复!
|
||||
|
||||
**问题原因**:`测试场馆-新放映室 2-A` 在 `seatSpecMap` 中完全不存在(没有座位数据),导致 `sectionSoldOut` 中没有这个 key,被当作"有座位"处理。
|
||||
|
||||
**修复逻辑**:遍历渲染的分区选项 DOM,将任何不在 `sectionSeats` 中的选项也标记为售罄:
|
||||
|
||||
```javascript
|
||||
document.querySelectorAll('#sectionSelector .vr-spec-option').forEach(function(opt) {
|
||||
var section = opt.dataset.section || '';
|
||||
// 如果选项不在 sectionSeats 中(意味着没有座位),也标记为售罄
|
||||
if (sectionSeats[section] === undefined) {
|
||||
sectionSoldOut[section] = true;
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
刷新页面测试,现在 `测试场馆-新放映室 2-A` 应该会显示为灰色禁用状态。
|
||||
|
||||
### User Input
|
||||
|
||||
ok.现在正常了。给已经售罄禁用的尾部描述加个红色“(售罄)”
|
||||
|
||||
### Planner Response
|
||||
|
||||
<think></think>
|
||||
|
||||
*Edited relevant file*
|
||||
|
||||
### Planner Response
|
||||
|
||||
<think></think>已添加!售罄选项尾部会显示红色 `"(售罄)"` 标签:
|
||||
|
||||
**效果**:
|
||||
- `测试场馆-新放映室 2-A` → `测试场馆-新放映室 2-A (售罄)` (红色)
|
||||
|
||||
**实现逻辑**:
|
||||
- 售罄时:添加 `<span class="sold-tag" style="color:#f56c6c">(售罄)</span>`
|
||||
- 恢复可用时:移除 `.sold-tag` 元素
|
||||
- 使用 `!item.querySelector('.sold-tag')` 防止重复添加
|
||||
|
||||
刷新页面即可看到效果。
|
||||
Loading…
Reference in New Issue