# 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`