vr-shopxo-plugin/docs/PHASE_4_API.md

413 lines
16 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# Phase 4 API 文档(已实现部分)
> 状态:截至 2026-04-23Phase 4.1/4.2 已完成B端核销进行中
> 目的:记录已实现 API避免后续 agent 重复发明轮子
---
## 一、数据库表结构
### 1.1 vr_tickets电子票
**用途**:存储用户已购票,每行 = 一张票。
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | BIGINT UNSIGNED AUTO_INCREMENT | 票ID主键 |
| `order_id` | BIGINT UNSIGNED | 关联订单ID |
| `order_no` | CHAR(60) | 订单号 |
| `goods_id` | BIGINT UNSIGNED | 票务商品ID |
| `goods_snapshot` | TEXT | 商品快照JSON含名称/规格/价格) |
| `user_id` | BIGINT UNSIGNED | 持有用户ID |
| `ticket_code` | CHAR(36) UNIQUE | UUID v4 票码(核销主键) |
| `qr_data` | TEXT | 格式:`短码\|QR_payload`(例:`003a2hgmgety\|eyJ...` |
| `seat_info` | VARCHAR(255) | 座位描述(如 `A区-3排-15座` |
| `spec_base_id` | BIGINT UNSIGNED | spec_base_id5维规格维度ID |
| `real_name` | VARCHAR(60) | 观演人姓名 |
| `phone` | CHAR(15) | 观演人手机 |
| `id_card` | CHAR(18) | 观演人身份证 |
| `verify_status` | TINYINT UNSIGNED | 0=未核销1=已核销2=已退款 |
| `verify_time` | INT UNSIGNED | 核销时间戳 |
| `verifier_id` | BIGINT UNSIGNED | 核销员ID |
| `issued_at` | INT UNSIGNED | 票发放时间 |
| `created_at` | INT UNSIGNED | 创建时间 |
| `updated_at` | INT UNSIGNED | 更新时间 |
**索引**`idx_user_id`、`idx_goods_id`、`idx_verify_status`、`idx_created_at`、`idx_spec_base_id`
**qr_data 格式详解**
```
short_code|base64(payload)
例如003a2hgmgety|eyJpZCI6NDgyODE1LCJnIjoxMTgsImlhdCI6MTc0NTI4...
payload = base64(json({
"id": 482815, // ticket_id
"g": 118, // goods_id
"iat": 1745286000,// 签发时间
"exp": 1745287800,// 过期时间iat+1800s=30min
"sig": "A3F9B2C1" // HMAC-SHA256签名前8字符
}))
```
---
### 1.2 vr_verifiers核销员
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | BIGINT UNSIGNED | 核销员ID |
| `user_id` | BIGINT UNSIGNED | 关联的 ShopXO 用户ID |
| `name` | VARCHAR(60) | 核销员名称 |
| `status` | TINYINT UNSIGNED | 1=启用0=禁用 |
| `created_at` | INT UNSIGNED | 创建时间 |
---
### 1.3 vr_verifications核销记录
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | BIGINT UNSIGNED | 记录ID |
| `ticket_id` | BIGINT UNSIGNED | 关联票ID |
| `ticket_code` | CHAR(36) | 票码(冗余) |
| `verifier_id` | BIGINT UNSIGNED | 核销员ID |
| `verifier_name` | VARCHAR(60) | 核销员名称(冗余) |
| `goods_id` | BIGINT UNSIGNED | 商品ID |
| `created_at` | INT UNSIGNED | 核销时间 |
---
### 1.4 vr_audit_log审计日志
| 字段 | 类型 | 说明 |
|------|------|------|
| `action` | VARCHAR(60) | 操作类型(如 `verify`, `refund`, `export` |
| `operator_id` | BIGINT UNSIGNED | 操作人ID后台 admin_id |
| `operator_name` | VARCHAR(90) | 操作人名称 |
| `target_type` | VARCHAR(60) | 对象类型(`ticket`, `verifier`, `seat_template` |
| `target_id` | BIGINT UNSIGNED | 对象ID |
| `target_desc` | VARCHAR(255) | 对象描述(冗余,人工可读) |
| `client_ip` | VARCHAR(45) | 客户端IP |
| `user_agent` | VARCHAR(512) | User-Agent |
| `request_id` | VARCHAR(64) | 请求追踪ID同一次 HTTP 请求内的多个操作共用) |
| `extra` | LONGTEXT | 附加数据JSON |
| `created_at` | INT UNSIGNED | 操作时间 |
---
## 二、Service 层 API内部调用
所有方法均在 `app\plugins\vr_ticket\service\` 下。
### 2.1 BaseService
**命名空间**`app\plugins\vr_ticket\service\BaseService`
#### 短码编解码
**`shortCodeEncode(int $goods_id, int $ticket_id): string`**
- 输入goods_id0-1679615ticket_id正整数
- 输出:短码(小写字母,格式:`4位goods_id明文 + 变长混淆ticket_id`
- 示例:`shortCodeEncode(118, 1)` → `003a2hgmgety`
**`shortCodeDecode(string $code, ?int $goods_id_hint = null): array`**
- 输入:短码字符串,可选 goods_id_hint校验用可省略
- 输出:`['goods_id' => int, 'ticket_id' => int]`
- 示例:`shortCodeDecode('003a2hgmgety')` → `['goods_id' => 118, 'ticket_id' => 1]`
- 特性:**O(1) 解码**无需暴力搜索前4位直接取 goods_id
#### QR 签名
**`signQrPayload(array $payload): string`**
- 输入:`['id' => int, 'g' => int, 'iat' => int, 'exp' => int]`
- 输出base64 编码字符串payload + `sig` 字段)
- 算法:`sig = substr(HMAC-SHA256("{$id}.{$g}.{$iat}.{$exp}", secret), 0, 8)`
- 密钥:`VR_TICKET_SECRET` 环境变量(或硬编码测试密钥 `8935b3...`
**`verifyQrPayload(string $encoded): array|null`**
- 输入base64 编码字符串
- 输出:验证失败返回 `null`;成功返回 `['id' => int, 'g' => int, 'exp' => int]`
- 验证内容:过期检查(`exp < now`+ 签名校验(HMAC-SHA256
#### 其他工具方法
**`generateUuid(): string`** 生成 UUID v4 票码
**`encryptQrData(array $data, ?int $expire = null): string`** AES-256-CBC 加密
**`decryptQrData(string $encoded): array|null`** 解密
**`isTicketGoods(int $goods_id): bool`** 判断商品是否为票务商品
**`table(string $name): string`** 返回带前缀的表名 `vr_` + $name
**`now(): int`** 当前时间戳
**`getGoodsKey(int $goods_id): string`** 派生 per-goods keyHMAC-SHA256,前16字节)
**`getVrSecret(): string`** 获取主密钥(需配置 `VR_TICKET_SECRET`
**`AdminPowerMenu(): array`** 返回后台权限菜单配置
---
### 2.2 TicketService
**命名空间**`app\plugins\vr_ticket\service\TicketService`(继承 BaseService
#### 出票链路
**`onOrderPaid(array $params): bool`** 核心入口
`plugins_service_order_pay_success_handle_end` Hook 触发。
- 触发时机:微信支付成功回调,`pay_status=1` 已写入 DB
- `$params` 结构:
```php
[
'business_id' => 123, // 订单ID
'business_ids' => [123], // 批量订单ID数组
'user_id' => 456,
'order' => [...], // 订单完整数据(含 extension_data
'order_id' => 123,
]
```
- 处理流程:
1. 判断是否为票务商品(`isTicketGoods`
2. 查询订单明细解析5维 spec`$vr-场次/$vr-场馆/$vr-演播室/$vr-分区/$vr-座位号`
3. 逐行调用 `issueTicket()` 生成票
- 返回成功发放票数0表示跳过
**`issueTicket(array $order, array $og): int`** ⚡ 单票发放
内部方法,供 `onOrderPaid` 调用。
- 参数:`$order`(订单数据),`$og`(订单商品行,已解析 `_parsed_spec_name` 等字段)
- 流程:
1. 幂等保护:同一 `order_id + seat_info` 已有票则跳过
2. 生成 UUID `ticket_code`
3. `INSERT` 占位 `qr_data=''` → 获取 `ticket_id`
4. 生成短码:`shortCodeEncode(goods_id, ticket_id)`
5. 生成 QR payload`signQrPayload(['id' => ticket_id, ...])`
6. `UPDATE qr_data = short_code + '|' + payload`
7. 写入观演人信息(`real_name/phone/id_card`
- 返回ticket_id失败返回 0
#### 核销
**`verifyTicket(string $ticket_code, int $verifier_id): array`** ⚡ UUID核销
- 参数UUID 票码(`vr_tickets.ticket_code`核销员ID
- 内部使用 `FOR UPDATE` 悲观锁
- 返回:`['code' => 0, 'msg' => '核销成功', 'data' => ['seat_info' => ..., 'real_name' => ..., 'goods_name' => ...]]`
- 错误码:`code=-1` 票不存在,`code=-2` 已核销,`code=-3` 已退款,`code=-999` 系统异常
**`verifyByShortCode(string $short_code, int $verifier_id): array`** ⚡ 短码核销
- 参数:短码(如 `003a2hgmgety`核销员ID
- 流程:`shortCodeDecode(code)` → 解析 `{goods_id, ticket_id}` → DB查询 → `verifyTicketById()`
- 特点:**无需知道 goods_id**从短码前4位直接解析
**`verifyTicketById(int $ticket_id, int $verifier_id): array`** — 内部方法ticket_id 核销
#### QR 数据
**`getQrData(int $ticket_id, int $user_id): array`** — 获取用户票的 QR 数据
- 校验:票必须属于该用户(`user_id` 匹配)
- 返回:
```php
// 有效期 > 15分钟900s返回缓存
['code' => 0, 'data' => [
'short_code' => '003a2hgmgety',
'payload' => 'eyJ...',
'cached' => true,
'expires_in' => 1724, // 秒
]]
// 有效期 ≤ 15分钟刷新后返回
['code' => 0, 'data' => [
'short_code' => '003a2hgmgety',
'payload' => 'eyJ...(new)', // 新签名
'cached' => false,
'expires_in' => 1800,
]]
```
- 错误:`code=-1` 票不存在,`code=-2` 已核销,`code=-3` 已退款
**`getQrCodeUrl(string $ticket_code): string`** — 生成 QR 码图片 URL
- 调用 ShopXO 内置 `/?s=index/qrcode/index&content=...&size=8&level=H`
- 内容base64(JSON(`{type: 'vr_ticket', code: UUID}`))
#### 用户票查询
**`getUserTickets(int $user_id, ?int $status = null): array`**
- 返回用户所有票(可筛选核销状态)
---
### 2.3 AuditService
**命名空间**`app\plugins\vr_ticket\service\AuditService`
**`log(string $action, string $targetType, int $targetId, array $extra = [], string $targetDesc = ''): int|false`**
记录审计日志,异常不阻断主流程。
- `$action` 常量:`ACTION_VERIFY` / `ACTION_REFUND` / `ACTION_EXPORT` / `ACTION_DISABLE_VERIFIER` / `ACTION_ENABLE_VERIFIER` / `ACTION_DELETE_VERIFIER` / `ACTION_DELETE_TEMPLATE`
- `$targetType` 常量:`TARGET_TICKET` / `TARGET_VERIFIER` / `TARGET_TEMPLATE` / `TARGET_GOODS`
- 返回日志ID 或 `false`(失败时)
**`logVerify(int $ticketId, string $ticketCode, int $verifierId, string $verifierName, string $result, int $oldStatus)`** — 便捷包装
**`logExport(int $goodsId, array $filter, int $count)`** — 记录导出操作
**`search(array $params): array`** — 查询审计日志(分页)
- 支持:`action`, `operator_id`, `target_type`, `target_id`, `date_from`, `date_to`, `page`, `limit`
---
## 三、后台管理 APIAdmin
所有 Admin 方法在 `app\plugins\vr_ticket\admin\Admin.php`(继承 `Common`)。
### 3.1 电子票管理
| 方法 | URL | 说明 | 鉴权 |
|------|-----|------|------|
| `TicketList()` | `GET /plugins/vr_ticket/admin/ticketList` | 票列表(支持关键词/状态/商品筛选) | Admin |
| `TicketDetail()` | `GET /plugins/vr_ticket/admin/ticketDetail?id=X` | 票详情 | Admin |
| `TicketVerify()` | `POST /plugins/vr_ticket/admin/ticketVerify` | 手动核销JSON API | Admin |
| `TicketExport()` | `POST /plugins/vr_ticket/admin/ticketExport` | 导出票列表 CSV | Admin |
**`TicketVerify` 请求**
```json
POST /plugins/vr_ticket/admin/ticketVerify
Body: { "ticket_code": "uuid-xxx", "verifier_id": 1 }
```
---
### 3.2 核销员管理
| 方法 | URL | 说明 |
|------|-----|------|
| `VerifierList()` | `GET /plugins/vr_ticket/admin/verifierList` | 核销员列表 |
| `VerifierSave()` | `GET/POST /plugins/vr_ticket/admin/verifierSave` | 添加/编辑核销员 |
| `VerifierDelete()` | `POST /plugins/vr_ticket/admin/verifierDelete` | 禁用核销员 |
---
### 3.3 核销记录
| 方法 | URL | 说明 |
|------|-----|------|
| `VerificationList()` | `GET /plugins/vr_ticket/admin/verificationList` | 核销记录列表(支持核销员/日期筛选) |
---
### 3.4 场馆/座位模板
| 方法 | URL | 说明 |
|------|-----|------|
| `VenueList()` | `GET /plugins/vr_ticket/admin/venueList` | 场馆列表seat_map v3 格式) |
| `VenueSave()` | `GET/POST /plugins/vr_ticket/admin/venueSave` | 添加/编辑场馆(支持 Base64 传输 seat_map JSON |
| `VenueDelete()` | `POST /plugins/vr_ticket/admin/venueDelete` | 删除场馆(支持硬删除 `value=hard` |
| `VenueEnable()` | `POST /plugins/vr_ticket/admin/venueEnable` | 启用场馆 |
---
### 3.5 座位模板Phase 2 遗留)
| 方法 | URL | 说明 |
|------|-----|------|
| `SeatTemplateList()` | `GET /plugins/vr_ticket/admin/seatTemplateList` | 座位模板列表 |
| `SeatTemplateSave()` | `GET/POST /plugins/vr_ticket/admin/seatTemplateSave` | 添加/编辑模板 |
| `SeatTemplateDelete()` | `POST /plugins/vr_ticket/admin/seatTemplateDelete` | 删除模板 |
| `SeatTemplateEnable()` | `POST /plugins/vr_ticket/admin/seatTemplateEnable` | 启用模板 |
---
### 3.6 辅助 API
**`SoldSeats()`** `GET /plugins/vr_ticket/admin/soldSeats?goods_id=X&spec_base_id=Y`
- 返回:`{"code":0,"data":{"sold_seats":[]}}`
- 当前版本返回空数组TODO接入真实已售座位查询
---
## 四、Hook 触发点
Hook 在 `app\plugins\vr_ticket\Hook.php` 中注册。
| Hook 名称 | 触发时机 | 处理方法 |
|-----------|---------|---------|
| `plugins_service_order_pay_success_handle_end` | 订单支付成功pay_status=1 | `TicketService::onOrderPaid($params)` |
| `plugins_service_admin_menu_data` | 后台菜单渲染 | `Hook::AdminSidebarInit()` 注入 VR票务菜单 |
---
## 五、QR 验证流程(核销机)
### 5.1 QR 本地验证(离线可行)
```php
// 步骤1解析 base64 payload
$payload = json_decode(base64_decode($qr_string), true);
// 步骤2检查过期
if ($payload['exp'] < time()) {
return '已过期,拒绝';
}
// 步骤3验签防篡改
$sign_str = "{$payload['id']}.{$payload['g']}.{$payload['iat']}.{$payload['exp']}";
$expected_sig = substr(hash_hmac('sha256', $sign_str, $secret), 0, 8);
if (!hash_equals($expected_sig, $payload['sig'])) {
return '伪造,拒绝';
}
// 步骤4联网执行 DB 状态检查(防重放)
// SELECT verify_status FROM vr_tickets WHERE id=? AND ticket_code=? FOR UPDATE
// verify_status == 0 → 放行
// verify_status == 1 → 已核销
// verify_status == 2 → 已退款
```
### 5.2 短码核销流程
```
核销员输入/扫码003a2hgmgety
shortCodeDecode('003a2hgmgety')
→ goods_id=118, ticket_id=1
DB: SELECT * FROM vr_tickets WHERE id=1 AND goods_id=118 FOR UPDATE
→ 命中 → verify_status==0? → 更新 verify_status=1, verifier_id=X
返回:核销成功 + 票面信息
```
---
## 六、关键配置
| 配置项 | 值 | 说明 |
|--------|---|------|
| `VR_TICKET_SECRET` | 环境变量(.env | QR签名 + 短码混淆主密钥 |
| `VR_TICKET_QR_SECRET` | 环境变量(.env | AES-256-CBC QR加密密钥尚未启用 |
| 表前缀 | `vrt_` | ShopXO 配置的表前缀 |
| QR 有效期 | 1800s30min | `signQrPayload``exp = iat + 1800` |
| 缓存阈值 | 900s15min | `getQrData()` 刷新阈值 |
| Feistel 轮数 | 8 | HMAC-XOR 混淆轮数 |
---
## 七、待完成 API避免重复实现
| 功能 | 文件 | 状态 |
|------|------|------|
| C端票列表 API | `api/controller/Ticket.php` | ❌ 待建 |
| C端票夹页面 | `index/Ticket.php` | ❌ 待建 |
| C端票夹 HTML | `view/goods/ticket_wallet.html` | ❌ 待建 |
| B端扫码核销页 | `admin/view/ticket/verify.html` | ⏳ 进行中 |
| B端核销 API含短码路由 | `admin/controller/Ticket.php` | ⏳ 进行中 |
| 票夹聚合查询服务 | `service/WalletService.php` | ❌ 待建 |
---
## 八、已验证可行的方案
- **ShopXO 插件路由**无需手动声明Hooks → 自动映射
- **admin/Admin.php 单文件控制器**:所有后台方法在此文件,统一继承 `Common`(含 IsLogin/IsPower/ViewInit
- **自动建表**Admin 控制器 `initialize()` 检查并从 `install.sql` 执行建表
- **session('admin')**:后台鉴权从 session 读取管理员信息
- **Base64 绕过净化**`VenueSave` 支持 `seat_map_raw` 字段Base64 传输,避免 HTML 净化破坏 JSON
- **JsBarcode**:已内置 `public/static/common/lib/JsBarcode/JsBarcode.all.min.js`v3.11.5
- **QR码**:前端用 `$('.view-qrcode-init').qrcode({text: value})`,后端用 `/?s=index/qrcode/index`