docs: 添加 Phase 4 API 文档(已实现部分)

feat/phase4-ticket-wallet
Council 2026-04-23 15:59:45 +08:00
parent c3261e553d
commit 6a2f9ed061
1 changed files with 413 additions and 0 deletions

413
docs/PHASE_4_API.md Normal file
View File

@ -0,0 +1,413 @@
# 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`