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