docs: 添加 Phase 4 API 文档(已实现部分)
parent
c3261e553d
commit
6a2f9ed061
|
|
@ -0,0 +1,413 @@
|
|||
# Phase 4 API 文档(已实现部分)
|
||||
|
||||
> 状态:截至 2026-04-23,Phase 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_id(5维规格维度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_id(0-1679615),ticket_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 key(HMAC-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`
|
||||
|
||||
---
|
||||
|
||||
## 三、后台管理 API(Admin)
|
||||
|
||||
所有 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 有效期 | 1800s(30min) | `signQrPayload` 中 `exp = iat + 1800` |
|
||||
| 缓存阈值 | 900s(15min) | `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`
|
||||
Loading…
Reference in New Issue