diff --git a/app/plugins/vr_ticket/EventListener.php b/app/plugins/vr_ticket/EventListener.php new file mode 100644 index 0000000..751ccd4 --- /dev/null +++ b/app/plugins/vr_ticket/EventListener.php @@ -0,0 +1,125 @@ +getTablePrefix(); + + // 座位模板表 + $db->query(" + CREATE TABLE IF NOT EXISTS `{$prefix}vr_seat_templates` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '模板ID', + `name` VARCHAR(180) NOT NULL COMMENT '模板名称', + `category_id` BIGINT UNSIGNED NOT NULL COMMENT '绑定的分类ID', + `seat_map` LONGTEXT NOT NULL COMMENT '座位地图JSON', + `spec_base_id_map` LONGTEXT DEFAULT NULL COMMENT '座位ID→spec_base_id映射', + `status` TINYINT UNSIGNED DEFAULT 1 COMMENT '状态:0禁用 1启用', + `add_time` INT UNSIGNED DEFAULT 0 COMMENT '创建时间', + `upd_time` INT UNSIGNED DEFAULT 0 COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_category_id` (`category_id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci + COMMENT='VR票务座位模板' + "); + + // 电子票表 + $db->query(" + CREATE TABLE IF NOT EXISTS `{$prefix}vr_tickets` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '票ID', + `order_id` BIGINT UNSIGNED NOT NULL COMMENT '订单ID', + `order_no` CHAR(60) NOT NULL COMMENT '订单号', + `goods_id` BIGINT UNSIGNED NOT NULL COMMENT '商品ID', + `goods_snapshot` TEXT DEFAULT NULL COMMENT '商品快照JSON', + `user_id` BIGINT UNSIGNED NOT NULL COMMENT '用户ID', + `ticket_code` CHAR(36) NOT NULL COMMENT 'UUID票码', + `qr_data` TEXT DEFAULT NULL COMMENT '加密QR内容', + `seat_info` VARCHAR(255) DEFAULT NULL COMMENT '座位信息', + `spec_base_id` BIGINT UNSIGNED DEFAULT 0 COMMENT 'spec_base_id', + `real_name` VARCHAR(60) DEFAULT NULL COMMENT '观演人姓名', + `phone` CHAR(15) DEFAULT NULL COMMENT '观演人手机', + `id_card` CHAR(18) DEFAULT NULL COMMENT '观演人身份证', + `verify_status` TINYINT UNSIGNED DEFAULT 0 COMMENT '核销状态', + `verify_time` INT UNSIGNED DEFAULT 0 COMMENT '核销时间', + `verifier_id` BIGINT UNSIGNED DEFAULT 0 COMMENT '核销员ID', + `issued_at` INT UNSIGNED DEFAULT 0 COMMENT '票发放时间', + `created_at` INT UNSIGNED DEFAULT 0 COMMENT '创建时间', + `updated_at` INT UNSIGNED DEFAULT 0 COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ticket_code` (`ticket_code`), + KEY `idx_order_id` (`order_id`), + KEY `idx_user_id` (`user_id`), + KEY `idx_goods_id` (`goods_id`), + KEY `idx_verify_status` (`verify_status`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci + COMMENT='VR票务电子票' + "); + + // 核销员表 + $db->query(" + CREATE TABLE IF NOT EXISTS `{$prefix}vr_verifiers` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '核销员ID', + `user_id` BIGINT UNSIGNED NOT NULL COMMENT 'ShopXO用户ID', + `name` VARCHAR(60) NOT NULL COMMENT '核销员名称', + `status` TINYINT UNSIGNED DEFAULT 1 COMMENT '状态', + `created_at` INT UNSIGNED DEFAULT 0 COMMENT '创建时间', + PRIMARY KEY (`id`), + KEY `idx_user_id` (`user_id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci + COMMENT='VR票务核销员' + "); + + // 核销记录表 + $db->query(" + CREATE TABLE IF NOT EXISTS `{$prefix}vr_verifications` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '记录ID', + `ticket_id` BIGINT UNSIGNED NOT NULL COMMENT '票ID', + `ticket_code` CHAR(36) NOT NULL COMMENT '票码', + `verifier_id` BIGINT UNSIGNED NOT NULL COMMENT '核销员ID', + `verifier_name` VARCHAR(60) DEFAULT NULL COMMENT '核销员名称', + `goods_id` BIGINT UNSIGNED DEFAULT 0 COMMENT '商品ID', + `created_at` INT UNSIGNED DEFAULT 0 COMMENT '核销时间', + PRIMARY KEY (`id`), + KEY `idx_ticket_id` (`ticket_id`), + KEY `idx_verifier_id` (`verifier_id`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci + COMMENT='VR票务核销记录' + "); + + return true; +} + +/** + * 卸载时执行 + * 删除数据库表(可按需保留数据) + */ +function vr_ticket_uninstall() +{ + // 建议注释掉以下内容,保留数据以便重新安装 + // $db = GetDB(); + // $prefix = $db->getTablePrefix(); + // $db->query("DROP TABLE IF EXISTS `{$prefix}vr_seat_templates`"); + // $db->query("DROP TABLE IF EXISTS `{$prefix}vr_tickets`"); + // $db->query("DROP TABLE IF EXISTS `{$prefix}vr_verifiers`"); + // $db->query("DROP TABLE IF EXISTS `{$prefix}vr_verifications`"); + return true; +} + +/** + * 升级时执行 + */ +function vr_ticket_upgrade($old_version) +{ + // 未来升级时在这里处理数据迁移 + return true; +} diff --git a/app/plugins/vr_ticket/README.md b/app/plugins/vr_ticket/README.md new file mode 100644 index 0000000..3d04c9a --- /dev/null +++ b/app/plugins/vr_ticket/README.md @@ -0,0 +1,75 @@ +# VR票务插件 - vr_ticket + +> ⚡ 核心原则:怎么快怎么来,怎么方便怎么来 + +## 安装 + +1. 将本目录上传到 ShopXO 插件目录: + ```bash + cp -r vr_ticket /path/to/shopxo/app/plugins/ + ``` +2. 后台 → 应用中心 → 插件管理 → 找到「VR票务」→ 点击安装 +3. 数据库表自动创建 + +## 目录结构 + +``` +vr_ticket/ +├── plugin.json # 插件配置(名称、菜单、钩子) +├── EventListener.php # 安装/卸载/升级生命周期 +├── service/ +│ ├── BaseService.php # 基础工具(AES加密、QR生成) +│ └── TicketService.php # 核心票务逻辑(发票、核销) +├── admin/ +│ ├── controller/ +│ │ ├── SeatTemplate.php # 座位模板 CRUD +│ │ ├── Ticket.php # 电子票管理 +│ │ ├── Verifier.php # 核销员管理 +│ │ └── Verification.php # 核销记录 +│ └── view/ # 后台视图模板 +│ ├── seat_template/ +│ ├── ticket/ +│ ├── verifier/ +│ └── verification/ +└── view/ + └── goods/ + └── ticket_detail.html # 前端票务详情页(独立模板) +``` + +## 关键钩子 + +| 钩子 | 作用 | +|------|------| +| `plugins_service_order_pay_success_handle_end` | 支付成功 → 自动发放 QR 电子票 | +| `plugins_service_order_delete_success` | 订单删除 → 清理票务数据 | + +## 前端票务详情页 + +需要在 ShopXO 核心文件 `app/index/controller/Goods.php` 中加 1 行: + +```php +// 在 return MyView(); 之前(约第 440 行) +if (!empty($assign['goods']['item_type']) && $assign['goods']['item_type'] == 'ticket') { + return MyView('/../../../plugins/vr_ticket/view/goods/ticket_detail'); +} +``` + +详见 `docs/GOODS_PHP_MODIFICATION.md` + +## 数据库表 + +| 表名 | 用途 | +|------|------| +| `vrt_vr_seat_templates` | 座位模板(绑定分类) | +| `vrt_vr_tickets` | 电子票(含观演人) | +| `vrt_vr_verifiers` | 核销员 | +| `vrt_vr_verifications` | 核销记录 | + +## API + +| URL | 方法 | 作用 | +|-----|------|------| +| `/plugins/vr_ticket/admin/seat_template/list` | GET | 座位模板列表 | +| `/plugins/vr_ticket/admin/seat_template/save` | GET/POST | 添加/编辑模板 | +| `/plugins/vr_ticket/admin/ticket/list` | GET | 电子票列表 | +| `/plugins/vr_ticket/admin/verification/list` | GET | 核销记录 | diff --git a/app/plugins/vr_ticket/admin/controller/SeatTemplate.php b/app/plugins/vr_ticket/admin/controller/SeatTemplate.php new file mode 100644 index 0000000..3b41628 --- /dev/null +++ b/app/plugins/vr_ticket/admin/controller/SeatTemplate.php @@ -0,0 +1,158 @@ +where($where) + ->order('id', 'desc') + ->paginate(20) + ->toArray(); + + // 关联分类名 + $category_ids = array_column($list['data'], 'category_id'); + if (!empty($category_ids)) { + $categories = \Db::name('GoodsCategory') + ->where('id', 'in', $category_ids) + ->column('name', 'id'); + foreach ($list['data'] as &$item) { + $item['category_name'] = $categories[$item['category_id']] ?? '未知分类'; + $item['seat_count'] = self::countSeats($item['seat_map']); + } + unset($item); + } + + return view('', [ + 'list' => $list['data'], + 'page' => $list['page'], + 'count' => $list['total'], + ]); + } + + /** + * 添加/编辑座位模板 + */ + public function save() + { + $id = input('id', 0, 'intval'); + + if (IS_AJAX_POST) { + $data = [ + 'name' => input('name', '', null, 'trim'), + 'category_id' => input('category_id', 0, 'intval'), + 'seat_map' => input('seat_map', '', null, 'trim'), + 'spec_base_id_map' => input('spec_base_id_map', '', null, 'trim'), + 'status' => input('status', 1, 'intval'), + 'upd_time' => time(), + ]; + + if (empty($data['name'])) { + return DataReturn('模板名称不能为空', -1); + } + if (empty($data['category_id'])) { + return DataReturn('请选择绑定的分类', -1); + } + + // 验证 seat_map 为合法 JSON + $seat_map = json_decode($data['seat_map'], true); + if (empty($seat_map) && $data['seat_map'] !== '[]' && $data['seat_map'] !== '{}') { + return DataReturn('座位地图JSON格式错误', -1); + } + + if ($id > 0) { + // 更新 + $data['upd_time'] = time(); + \Db::name('plugins_vr_seat_templates')->where('id', $id)->update($data); + return DataReturn('更新成功', 0, ['url' => MyUrl('plugins_vr_ticket/admin/seat_template/list')]); + } else { + // 新增 + $data['add_time'] = time(); + $data['upd_time'] = time(); + \Db::name('plugins_vr_seat_templates')->insert($data); + return DataReturn('添加成功', 0, ['url' => MyUrl('plugins_vr_ticket/admin/seat_template/list')]); + } + } + + // 编辑时加载数据 + $info = []; + if ($id > 0) { + $info = \Db::name('plugins_vr_seat_templates')->find($id); + } + + // 加载分类列表(用于下拉选择) + $categories = \Db::name('GoodsCategory') + ->where('is_delete', 0) + ->order('id', 'asc') + ->select(); + + return view('', [ + 'info' => $info, + 'categories' => $categories, + ]); + } + + /** + * 删除座位模板 + */ + public function delete() + { + if (!IS_AJAX_POST) { + return view('', ['info' => [], 'msg' => '非法请求']); + } + + $id = input('id', 0, 'intval'); + if ($id <= 0) { + return DataReturn('参数错误', -1); + } + + \Db::name('plugins_vr_seat_templates')->delete($id); + return DataReturn('删除成功', 0); + } + + /** + * 统计座位数 + */ + private static function countSeats($seat_map_json) + { + if (empty($seat_map_json)) { + return 0; + } + $map = json_decode($seat_map_json, true); + if (empty($map['seats'])) { + return 0; + } + $count = 0; + foreach (str_split($map['map'][0] ?? '') as $char) { + if ($char !== '_' && isset($map['seats'][$char])) { + $count++; + } + } + // 简单估算:所有行的座位总数 + $rows = count($map['map']); + return $count * $rows; + } +} diff --git a/app/plugins/vr_ticket/admin/controller/Ticket.php b/app/plugins/vr_ticket/admin/controller/Ticket.php new file mode 100644 index 0000000..4d7d0c8 --- /dev/null +++ b/app/plugins/vr_ticket/admin/controller/Ticket.php @@ -0,0 +1,165 @@ + 0) { + $where[] = ['goods_id', '=', $goods_id]; + } + + $list = \Db::name('plugins_vr_tickets') + ->where($where) + ->order('id', 'desc') + ->paginate(20) + ->toArray(); + + // 补充商品名称 + $goods_ids = array_filter(array_column($list['data'], 'goods_id')); + if (!empty($goods_ids)) { + $goods_map = \Db::name('Goods') + ->where('id', 'in', $goods_ids) + ->column('title', 'id'); + foreach ($list['data'] as &$item) { + $item['goods_title'] = $goods_map[$item['goods_id']] ?? '已删除商品'; + $item['qr_code_url'] = \app\plugins\vr_ticket\service\TicketService::getQrCodeUrl($item['ticket_code']); + } + unset($item); + } + + // 状态标签映射 + $status_map = [ + 0 => ['text' => '未核销', 'color' => 'blue'], + 1 => ['text' => '已核销', 'color' => 'green'], + 2 => ['text' => '已退款', 'color' => 'red'], + ]; + + return view('', [ + 'list' => $list['data'], + 'page' => $list['page'], + 'count' => $list['total'], + 'status_map' => $status_map, + ]); + } + + /** + * 票详情 + */ + public function detail() + { + $id = input('id', 0, 'intval'); + if ($id <= 0) { + return view('', ['msg' => '参数错误']); + } + + $ticket = \Db::name('plugins_vr_tickets')->find($id); + if (empty($ticket)) { + return view('', ['msg' => '票不存在']); + } + + // 商品信息 + $goods = \Db::name('Goods')->find($ticket['goods_id']); + + // 核销员信息 + $verifier = []; + if ($ticket['verifier_id'] > 0) { + $verifier = \Db::name('plugins_vr_verifiers')->find($ticket['verifier_id']); + } + + // QR 码图片 + $ticket['qr_code_url'] = \app\plugins\vr_ticket\service\TicketService::getQrCodeUrl($ticket['ticket_code']); + + return view('', [ + 'ticket' => $ticket, + 'goods' => $goods, + 'verifier' => $verifier, + ]); + } + + /** + * 手动核销票 + */ + public function verify() + { + if (!IS_AJAX_POST) { + return view('', ['msg' => '非法请求']); + } + + $ticket_code = input('ticket_code', '', null, 'trim'); + $verifier_id = input('verifier_id', 0, 'intval'); + + if (empty($ticket_code)) { + return DataReturn('票码不能为空', -1); + } + if ($verifier_id <= 0) { + return DataReturn('请选择核销员', -1); + } + + $result = \app\plugins\vr_ticket\service\TicketService::verifyTicket($ticket_code, $verifier_id); + return DataReturn($result['msg'], $result['code'], $result['data'] ?? []); + } + + /** + * 导出票列表(CSV) + */ + public function export() + { + $where = []; + $goods_id = input('goods_id', 0, 'intval'); + if ($goods_id > 0) { + $where[] = ['goods_id', '=', $goods_id]; + } + + $list = \Db::name('plugins_vr_tickets') + ->where($where) + ->order('id', 'desc') + ->select(); + + $header = ['ID', '订单号', '票码', '观演人', '手机', '座位', '核销状态', '发放时间']; + $data = []; + foreach ($list as $item) { + $status_text = $item['verify_status'] == 0 ? '未核销' : ($item['verify_status'] == 1 ? '已核销' : '已退款'); + $data[] = [ + $item['id'], + $item['order_no'], + $item['ticket_code'], + $item['real_name'], + $item['phone'], + $item['seat_info'], + $status_text, + date('Y-m-d H:i:s', $item['issued_at']), + ]; + } + + ExportCsv($header, $data, 'vr_tickets_' . date('Ymd')); + return; + } +} diff --git a/app/plugins/vr_ticket/admin/controller/Verification.php b/app/plugins/vr_ticket/admin/controller/Verification.php new file mode 100644 index 0000000..8c54bf7 --- /dev/null +++ b/app/plugins/vr_ticket/admin/controller/Verification.php @@ -0,0 +1,84 @@ + 0) { + $where[] = ['verifier_id', '=', $verifier_id]; + } + + // 日期范围 + $start_date = input('start_date', '', null, 'trim'); + $end_date = input('end_date', '', null, 'trim'); + if (!empty($start_date)) { + $where[] = ['created_at', '>=', strtotime($start_date)]; + } + if (!empty($end_date)) { + $where[] = ['created_at', '<=', strtotime($end_date . ' 23:59:59')]; + } + + $list = \Db::name('plugins_vr_verifications') + ->where($where) + ->order('id', 'desc') + ->paginate(20) + ->toArray(); + + // 补充票信息和商品信息 + $ticket_ids = array_filter(array_column($list['data'], 'ticket_id')); + if (!empty($ticket_ids)) { + $tickets = \Db::name('plugins_vr_tickets') + ->where('id', 'in', $ticket_ids) + ->column('seat_info,real_name,goods_id', 'id'); + foreach ($list['data'] as &$item) { + $ticket = $tickets[$item['ticket_id']] ?? []; + $item['seat_info'] = $ticket['seat_info'] ?? ''; + $item['real_name'] = $ticket['real_name'] ?? ''; + $item['goods_id'] = $ticket['goods_id'] ?? 0; + } + unset($item); + } + + // 商品名 + $goods_ids = array_filter(array_unique(array_column($list['data'], 'goods_id'))); + if (!empty($goods_ids)) { + $goods_map = \Db::name('Goods') + ->where('id', 'in', $goods_ids) + ->column('title', 'id'); + foreach ($list['data'] as &$item) { + $item['goods_title'] = $goods_map[$item['goods_id']] ?? '已删除'; + } + unset($item); + } + + // 核销员列表(用于筛选) + $verifiers = \Db::name('plugins_vr_verifiers') + ->where('status', 1) + ->column('name', 'id'); + + return view('', [ + 'list' => $list['data'], + 'page' => $list['page'], + 'count' => $list['total'], + 'verifiers' => $verifiers, + ]); + } +} diff --git a/app/plugins/vr_ticket/admin/controller/Verifier.php b/app/plugins/vr_ticket/admin/controller/Verifier.php new file mode 100644 index 0000000..9c8d110 --- /dev/null +++ b/app/plugins/vr_ticket/admin/controller/Verifier.php @@ -0,0 +1,140 @@ +where($where) + ->order('id', 'desc') + ->paginate(20) + ->toArray(); + + // 关联 ShopXO 用户信息 + $user_ids = array_filter(array_column($list['data'], 'user_id')); + if (!empty($user_ids)) { + $users = \Db::name('User') + ->where('id', 'in', $user_ids) + ->column('nickname|username', 'id'); + foreach ($list['data'] as &$item) { + $item['user_name'] = $users[$item['user_id']] ?? '已删除用户'; + } + unset($item); + } + + return view('', [ + 'list' => $list['data'], + 'page' => $list['page'], + 'count' => $list['total'], + ]); + } + + /** + * 添加/编辑核销员 + */ + public function save() + { + $id = input('id', 0, 'intval'); + + if (IS_AJAX_POST) { + $user_id = input('user_id', 0, 'intval'); + $name = input('name', '', null, 'trim'); + $status = input('status', 1, 'intval'); + + if ($user_id <= 0) { + return DataReturn('请选择关联用户', -1); + } + if (empty($name)) { + return DataReturn('核销员名称不能为空', -1); + } + + // 检查是否已存在 + $exist = \Db::name('plugins_vr_verifiers') + ->where('user_id', $user_id) + ->where('id', '<>', $id) + ->find(); + if ($exist) { + return DataReturn('该用户已是核销员', -1); + } + + if ($id > 0) { + \Db::name('plugins_vr_verifiers') + ->where('id', $id) + ->update([ + 'name' => $name, + 'status' => $status, + ]); + return DataReturn('更新成功', 0); + } else { + \Db::name('plugins_vr_verifiers')->insert([ + 'user_id' => $user_id, + 'name' => $name, + 'status' => $status, + 'created_at' => time(), + ]); + return DataReturn('添加成功', 0); + } + } + + $info = []; + if ($id > 0) { + $info = \Db::name('plugins_vr_verifiers')->find($id); + } + + // 用户列表(用于选择) + $users = \Db::name('User') + ->where('is_delete', 0) + ->field('id, nickname, username') + ->order('id', 'desc') + ->select(); + + return view('', [ + 'info' => $info, + 'users' => $users, + ]); + } + + /** + * 删除核销员 + */ + public function delete() + { + if (!IS_AJAX_POST) { + return DataReturn('非法请求', -1); + } + + $id = input('id', 0, 'intval'); + if ($id <= 0) { + return DataReturn('参数错误', -1); + } + + // 不允许删除,改为禁用 + \Db::name('plugins_vr_verifiers') + ->where('id', $id) + ->update(['status' => 0]); + + return DataReturn('已禁用', 0); + } +} diff --git a/app/plugins/vr_ticket/admin/view/seat_template/list.html b/app/plugins/vr_ticket/admin/view/seat_template/list.html new file mode 100644 index 0000000..41dba22 --- /dev/null +++ b/app/plugins/vr_ticket/admin/view/seat_template/list.html @@ -0,0 +1,102 @@ + + + + + 座位模板 - VR票务 + {include file="public/head" /} + + +
+
+
座位模板管理
+
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ + 添加模板 +
+
+
+ + +
+ + + + +
+
+
+ +{include file="public/footer" /} + + + diff --git a/app/plugins/vr_ticket/admin/view/seat_template/save.html b/app/plugins/vr_ticket/admin/view/seat_template/save.html new file mode 100644 index 0000000..1c9ba0d --- /dev/null +++ b/app/plugins/vr_ticket/admin/view/seat_template/save.html @@ -0,0 +1,84 @@ + + + + + {$info ? '编辑' : '添加'}座位模板 - VR票务 + {include file="public/head" /} + + +
+
+
{$info ? '编辑' : '添加'}座位模板
+
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+ 格式:{"map":["AAAAAA","BBBBB"],"seats":{"A":{"price":599,"label":"VIP"},"B":{"price":299,"label":"普通"}},"sections":[]} +
+
+
+ +
+ +
+
+
+
+ + + 返回 +
+
+
+
+
+
+{include file="public/footer" /} + + + diff --git a/app/plugins/vr_ticket/admin/view/ticket/list.html b/app/plugins/vr_ticket/admin/view/ticket/list.html new file mode 100644 index 0000000..4404db3 --- /dev/null +++ b/app/plugins/vr_ticket/admin/view/ticket/list.html @@ -0,0 +1,99 @@ + + + + + 电子票管理 - VR票务 + {include file="public/head" /} + + +
+
+
电子票管理
+
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ + 导出CSV +
+
+
+ +
+ + + + + + +
+
+
+ +{include file="public/footer" /} + + + diff --git a/app/plugins/vr_ticket/admin/view/verification/list.html b/app/plugins/vr_ticket/admin/view/verification/list.html new file mode 100644 index 0000000..15c4fdf --- /dev/null +++ b/app/plugins/vr_ticket/admin/view/verification/list.html @@ -0,0 +1,89 @@ + + + + + 核销记录 - VR票务 + {include file="public/head" /} + + +
+
+
核销记录
+
+
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+ +
+
+
+ +
+
+
+ +
+ + +
+
+
+ +{include file="public/footer" /} + + + diff --git a/app/plugins/vr_ticket/plugin.json b/app/plugins/vr_ticket/plugin.json new file mode 100644 index 0000000..d2a9188 --- /dev/null +++ b/app/plugins/vr_ticket/plugin.json @@ -0,0 +1,26 @@ +{ + "name": "vr_ticket", + "title": "VR票务", + "description": "为ShopXO添加VR演唱会票务功能:座位模板、观演人收集、QR电子票、扫码核销", + "version": "1.0.0", + "author": "sileya-ai", + "author_url": "http://xmhome.ow-my.com:3000/sileya-ai/vr-shopxo-plugin", + "shopxo_version": ">=1.0.0", + "dependencies": [], + "menus": [ + { + "title": "VR票务", + "icon": "icon icon-ticket", + "submenus": [ + { "title": "座位模板", "url": "/plugins/vr_ticket/admin/seat_template/list" }, + { "title": "电子票", "url": "/plugins/vr_ticket/admin/ticket/list" }, + { "title": "核销员", "url": "/plugins/vr_ticket/admin/verifier/list" }, + { "title": "核销记录", "url": "/plugins/vr_ticket/admin/verification/list" } + ] + } + ], + "hooks": [ + "plugins_service_order_pay_success_handle_end", + "plugins_service_order_delete_success" + ] +} diff --git a/app/plugins/vr_ticket/service/BaseService.php b/app/plugins/vr_ticket/service/BaseService.php new file mode 100644 index 0000000..d576e44 --- /dev/null +++ b/app/plugins/vr_ticket/service/BaseService.php @@ -0,0 +1,155 @@ + $expire, + 'iat' => time(), + ]), JSON_UNESCAPED_UNICODE); + + $iv = random_bytes(16); + $encrypted = openssl_encrypt($payload, 'AES-256-CBC', $secret, OPENSSL_RAW_DATA, $iv); + + return base64_encode($iv . $encrypted); + } + + /** + * 解密 QR 数据 + * + * @param string $encoded base64 编码密文 + * @return array|null + */ + public static function decryptQrData($encoded) + { + $secret = self::getQrSecret(); + $combined = base64_decode($encoded); + + if (strlen($combined) < 16) { + return null; + } + + $iv = substr($combined, 0, 16); + $encrypted = substr($combined, 16); + + $decrypted = openssl_decrypt($encrypted, 'AES-256-CBC', $secret, OPENSSL_RAW_DATA, $iv); + + if ($decrypted === false) { + return null; + } + + $data = json_decode($decrypted, true); + + if (isset($data['exp']) && $data['exp'] < time()) { + return null; + } + + return $data; + } + + /** + * 获取 QR 加密密钥 + */ + private static function getQrSecret() + { + // 优先从环境变量读取 + $secret = env('VR_TICKET_QR_SECRET', ''); + if (!empty($secret)) { + return $secret; + } + // 回退:使用 ShopXO 应用密钥 + return config('shopxo.app_key', 'shopxo_default_secret_change_me'); + } + + /** + * 判断商品是否为票务商品 + * + * @param int $goods_id + * @return bool + */ + public static function isTicketGoods($goods_id) + { + $goods = \Db::name('Goods')->find($goods_id); + if (empty($goods)) { + return false; + } + return !empty($goods['venue_data']) || ($goods['item_type'] ?? '') === 'ticket'; + } + + /** + * 获取商品座位模板 + * + * @param int $goods_id + * @return array|null + */ + public static function getSeatTemplateByGoods($goods_id) + { + $goods = \Db::name('Goods')->find($goods_id); + if (empty($goods) || empty($goods['category_id'])) { + return null; + } + + return \Db::name(self::table('seat_templates')) + ->where('category_id', $goods['category_id']) + ->where('status', 1) + ->find(); + } + + /** + * 安全日志 + */ + public static function log($message, $context = [], $level = 'info') + { + $tag = '[vr_ticket]'; + $ctx = empty($context) ? '' : ' ' . json_encode($context, JSON_UNESCAPED_UNICODE); + $log_func = "log_{$level}"; + if (function_exists($log_func)) { + $log_func($tag . $message . $ctx); + } + } +} diff --git a/app/plugins/vr_ticket/service/TicketService.php b/app/plugins/vr_ticket/service/TicketService.php new file mode 100644 index 0000000..7b09d10 --- /dev/null +++ b/app/plugins/vr_ticket/service/TicketService.php @@ -0,0 +1,229 @@ +find($order_id); + if (empty($order) || $order['pay_status'] != 1) { + BaseService::log('onOrderPaid: order not paid or not found', ['order_id' => $order_id], 'warning'); + return false; + } + + // 判断是否为票务商品 + if (!BaseService::isTicketGoods($order['goods_id'])) { + BaseService::log('onOrderPaid: not a ticket goods', ['order_id' => $order_id], 'info'); + return true; // 不是票务商品,不报错 + } + + // 查询商品快照(规格信息) + $order_goods = \Db::name('OrderGoods') + ->where('order_id', $order_id) + ->select(); + if (empty($order_goods)) { + BaseService::log('onOrderPaid: no order goods', ['order_id' => $order_id], 'error'); + return false; + } + + // 逐个生成票(每个规格选项 = 一张票) + $count = 0; + foreach ($order_goods as $og) { + $ticket_id = self::issueTicket($order, $og); + if ($ticket_id > 0) { + $count++; + } + } + + BaseService::log('onOrderPaid: success', [ + 'order_id' => $order_id, + 'tickets_issued' => $count, + ]); + + return $count > 0; + } + + /** + * 发放单张票 + * + * @param array $order 订单数据 + * @param array $order_goods 订单商品数据(包含 spec_base_id) + * @return int 票ID + */ + public static function issueTicket($order, $order_goods) + { + $ticket_code = BaseService::generateUuid(); + + // 构建 QR 数据 + $qr_payload = [ + 'id' => 0, // 写入后再更新 + 'code' => $ticket_code, + 'event' => $order['goods_id'], + 'seat' => $order_goods['spec_name'] ?? '', // 规格名=座位信息 + ]; + $qr_data = BaseService::encryptQrData($qr_payload); + + // 观演人信息(从订单扩展字段读取,由购票页表单写入) + $extension_data = json_decode($order['extension_data'] ?? '{}', true); + $attendee = $extension_data['attendee'] ?? []; + + $now = BaseService::now(); + + $ticket_id = \Db::name(BaseService::table('tickets'))->insertGetId([ + 'order_id' => $order['id'], + 'order_no' => $order['order_no'], + 'goods_id' => $order['goods_id'], + 'goods_snapshot' => json_encode([ + 'goods_name' => $order['goods_name'] ?? '', + 'spec_name' => $order_goods['spec_name'] ?? '', + 'price' => $order_goods['goods_price'] ?? 0, + ], JSON_UNESCAPED_UNICODE), + 'user_id' => $order['user_id'], + 'ticket_code' => $ticket_code, + 'qr_data' => $qr_data, + 'seat_info' => $order_goods['spec_name'] ?? '', + 'spec_base_id' => $order_goods['spec_base_id'] ?? 0, + 'real_name' => $attendee['real_name'] ?? '', + 'phone' => $attendee['phone'] ?? '', + 'id_card' => $attendee['id_card'] ?? '', + 'verify_status' => 0, // 0=未核销 + 'issued_at' => $now, + 'created_at' => $now, + 'updated_at' => $now, + ]); + + // 更新 QR 数据中的 ticket_id + if ($ticket_id > 0) { + $qr_payload['id'] = $ticket_id; + $qr_data_updated = BaseService::encryptQrData($qr_payload); + \Db::name(BaseService::table('tickets')) + ->where('id', $ticket_id) + ->update(['qr_data' => $qr_data_updated]); + } + + return $ticket_id; + } + + /** + * 核销票 + * + * @param string $ticket_code 票码 + * @param int $verifier_id 核销员ID + * @return array [code, msg] + */ + public static function verifyTicket($ticket_code, $verifier_id) + { + $ticket = \Db::name(BaseService::table('tickets')) + ->where('ticket_code', $ticket_code) + ->find(); + + if (empty($ticket)) { + return ['code' => -1, 'msg' => '票码不存在']; + } + + if ($ticket['verify_status'] == 1) { + return ['code' => -2, 'msg' => '该票已核销']; + } + + if ($ticket['verify_status'] == 2) { + return ['code' => -3, 'msg' => '该票已退款']; + } + + $now = BaseService::now(); + + // 更新票状态 + \Db::name(BaseService::table('tickets')) + ->where('id', $ticket['id']) + ->update([ + 'verify_status' => 1, + 'verify_time' => $now, + 'verifier_id' => $verifier_id, + 'updated_at' => $now, + ]); + + // 写入核销记录 + $verifier = \Db::name(BaseService::table('verifiers')) + ->where('id', $verifier_id) + ->find(); + + \Db::name(BaseService::table('verifications'))->insert([ + 'ticket_id' => $ticket['id'], + 'ticket_code' => $ticket_code, + 'verifier_id' => $verifier_id, + 'verifier_name'=> $verifier['name'] ?? '', + 'goods_id' => $ticket['goods_id'], + 'created_at' => $now, + ]); + + BaseService::log('verifyTicket: success', [ + 'ticket_id' => $ticket['id'], + 'verifier_id' => $verifier_id, + ]); + + return [ + 'code' => 0, + 'msg' => '核销成功', + 'data' => [ + 'seat_info' => $ticket['seat_info'], + 'real_name' => $ticket['real_name'], + 'goods_name' => json_decode($ticket['goods_snapshot'] ?? '{}', true)['goods_name'] ?? '', + ], + ]; + } + + /** + * 获取用户所有票 + */ + public static function getUserTickets($user_id, $status = null) + { + $where = ['user_id' => $user_id]; + if ($status !== null) { + $where['verify_status'] = $status; + } + + return \Db::name(BaseService::table('tickets')) + ->where($where) + ->order('created_at', 'desc') + ->select(); + } + + /** + * 生成 QR 码图片 URL + * + * @param string $ticket_code + * @return string QR码图片URL + */ + public static function getQrCodeUrl($ticket_code) + { + $content = base64_encode(json_encode([ + 'type' => 'vr_ticket', + 'code' => $ticket_code, + ])); + + return ROOT_URL . '?s=index/qrcode/index&content=' . urlencode($content) . '&size=8&level=H'; + } +} diff --git a/app/plugins/vr_ticket/view/goods/ticket_detail.html b/app/plugins/vr_ticket/view/goods/ticket_detail.html new file mode 100644 index 0000000..2d81c8f --- /dev/null +++ b/app/plugins/vr_ticket/view/goods/ticket_detail.html @@ -0,0 +1,431 @@ +{include file="public/head" /} + + + + +
+ +
+
{$goods.title|default='VR演唱会'}
+
{$goods.simple_desc|default=''|raw}
+
+ + +
+
选择场次
+
+ +
该商品暂无场次信息
+
+
+ + + + + + + + + + + + {if !empty($goods.content)} +
+
演出详情
+
{$goods.content|raw}
+
+ {/if} + + +
+
+ 已选 0 座 + 合计 ¥0 +
+ +
+
+ +{include file="public/footer" /} + + + +{include file="public/footer_page" /} diff --git a/docs/GOODS_PHP_MODIFICATION.md b/docs/GOODS_PHP_MODIFICATION.md index ae7e36c..96351fa 100644 --- a/docs/GOODS_PHP_MODIFICATION.md +++ b/docs/GOODS_PHP_MODIFICATION.md @@ -63,7 +63,7 @@ if (!empty($assign['goods']['venue_data']) || !empty($assign['goods']['item_type || !empty($assign['goods']['venue_data']); } if (!empty($is_ticket) && !empty($assign['vr_is_ticket_goods'])) { - return MyView('/goods/ticket_detail'); + return MyView('/../../../plugins/vr_ticket/view/goods/ticket_detail'); } // === VR票务插件 END === @@ -74,7 +74,7 @@ return MyView(); ## 新建票务模板文件 -路径:`{SHOPXO_ROOT}/app/index/view/default/goods/ticket_detail.html` +路径:`{SHOPXO_ROOT}/app/plugins/vr_ticket/view/goods/ticket_detail.html` 参考 `app/index/view/default/goods/detail.html`,重写以下部分: