From 6a2f9ed061ed114a50dfa395ed6cd7d4adb85026 Mon Sep 17 00:00:00 2001 From: Council Date: Thu, 23 Apr 2026 15:59:45 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E6=B7=BB=E5=8A=A0=20Phase=204=20API=20?= =?UTF-8?q?=E6=96=87=E6=A1=A3=EF=BC=88=E5=B7=B2=E5=AE=9E=E7=8E=B0=E9=83=A8?= =?UTF-8?q?=E5=88=86=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/PHASE_4_API.md | 413 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 413 insertions(+) create mode 100644 docs/PHASE_4_API.md diff --git a/docs/PHASE_4_API.md b/docs/PHASE_4_API.md new file mode 100644 index 0000000..97bf8b5 --- /dev/null +++ b/docs/PHASE_4_API.md @@ -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` \ No newline at end of file