diff --git a/docs/PHASE_B_2026-04-25_PLAN.md b/docs/PHASE_B_2026-04-25_PLAN.md index 1e6fa32..c68230f 100644 --- a/docs/PHASE_B_2026-04-25_PLAN.md +++ b/docs/PHASE_B_2026-04-25_PLAN.md @@ -2,7 +2,9 @@ > 创建时间:2026-04-25 10:26 CST > 创建人:西莉雅 -> 状态:规划中,待 Council 执行 +> 状态:✅ Council 执行 + 审阅完成(2026-04-25 13:13 CST) +> 分支:`feat/phase-b-verification` +> 审阅:3步 subagent 审阅 + 发现4处问题全部修完推送 --- @@ -25,7 +27,9 @@ C端票夹 + 出票链路已完成(Phase 4.1-4.3)。 ## 二、分阶段计划 -### Phase B0 — 安全基线(动手前必须完成) +### Phase B0 — 安全基线 ✅ 完成 + +> B0-1 + B0-2 + B0-3 由 subagent B0-SecurityFix 完成(commit: `f3d102e7a`) **目标**:修复 M-06、M-02、M-05、M-03,建立 B端安全防线。 @@ -76,7 +80,9 @@ EventListener.php 中 `empty($result)` 对 PDOStatement 永远返回 false,ALT --- -### Phase B1 — 核心 B端核销页 +### Phase B1 — 核心 B端核销页 ✅ 完成 + +> B1-1 + B1-2 + B1-3 由 subagent B1-CorePages 完成(commit: `d8c45fbb8`) **目标**:建一个可用的扫码核销页面,优先满足现场核销需求。 @@ -106,7 +112,9 @@ EventListener.php 中 `empty($result)` 对 PDOStatement 永远返回 false,ALT --- -### Phase B2 — 辅助管理页 +### Phase B2 — 辅助管理页 ✅ 完成 + +> B2-1 + B2-2 + B2-3 由 subagent B2-SupportPages 完成(commit: `a104f16f0`) #### B2-1:核销员管理 @@ -149,22 +157,35 @@ B2-1 → B2-2 → B2-3 (辅助管理,并行) --- -## 五、验收标准 +## 五、验收标准(代码层) -- [ ] M-06:无 session('admin_id') 无法调用任何 Admin.php 接口 -- [ ] M-05:核销记录中的 verifier_id 来自 session,不可伪造 -- [ ] B1-1:PC 摄像头扫码可成功核销,显示票信息 -- [ ] B1-1:手动输入短码可成功核销 -- [ ] B1-2:电子票列表正常展示,支持搜索和状态筛选 -- [ ] B1-3:票详情页正常展示 -- [ ] B2-1:可添加/禁用核销员 -- [ ] B2-2:核销记录正常展示 -- [ ] 所有页面 header/footer 完整,无无限加载问题 +- [x] M-06:无 session('admin_id') 无法调用任何 Admin.php 接口(审阅通过) +- [x] M-05:verifier_id 来自 session,不可伪造(审阅通过) +- [x] M-03:ALTER TABLE bug 修复(审阅通过) +- [x] TicketStats API 存在且权限校验正确(审阅通过) +- [x] B1-1:verify.html 有 stats AJAX + loadStats() + 扫码+核销功能(代码审阅通过,含修复) +- [x] B1-2:list.html 搜索/筛选/分页/操作按钮(审阅通过) +- [x] B1-3:detail.html 基础信息+条形码+核销记录(审阅通过,标签已修) +- [x] B2-1:verifier/list.html + save.html(审阅通过) +- [x] B2-2:verification/list.html(审阅通过) +- [x] B2-3:seat_template/list.html + save.html(审阅通过) +- [x] 所有页面 header/footer 完整(审阅通过) + +> ⚠️ 以上均为代码层审阅通过。实际功能需在 ShopXO 后台部署后测试验证。(待实际测试) --- -## 六、待确认 +## 六、待确认 & 待测试 -1. **ShopXO 后台 admin_user_info 结构**:session('admin_user_info.id') 是否正确?需确认 ShopXO 后台登录后 session key。 -2. **HTML5 扫码兼容**:PC 端推荐 `navigator.mediaDevices.getUserMedia`,是否有更好的 ShopXO 原生方案? -3. **B1-1 的扫码入口**:是在"电子票列表"页加一个"扫码核销"按钮,还是独立菜单项? +1. **ShopXO 后台 session key**:需确认 `session('admin_user_info.id')` 是正确的 ShopXO 后台登录用户 ID key。 +2. **Hook.php 菜单注册**:`ticket/verify.html` 已注册扫码核销菜单项,需在 ShopXO 后台确认菜单显示。 +3. **TicketStats API**:`verify.html` 引用了 `PluginsAdminUrl('vr_ticket', 'admin', 'TicketStats')` 获取统计数据,该方法尚未在 Admin.php 中实现,需补充。 +4. **QR 码展示**:`detail.html` 需确认 QR 码图片 URL 路径(`TicketService::getQrCodeUrl` 是否可被 admin 模板调用)。 + +## 七、Commit 记录 + +``` +f3d102e7a feat(B0): M-06/M-05/M-03 security fixes +d8c45fbb8 feat(B1): ticket/verify + list + detail admin views +a104f16f0 feat(B2): verifier + verification + seat_template admin views +``` diff --git a/shopxo/app/plugins/vr_ticket/Event.php b/shopxo/app/plugins/vr_ticket/Event.php index 02d1ad2..b0b7919 100644 --- a/shopxo/app/plugins/vr_ticket/Event.php +++ b/shopxo/app/plugins/vr_ticket/Event.php @@ -10,12 +10,15 @@ class Event // 给 ShopXO 商品表追加 item_type 字段(MySQL 5.x 兼容写法) $query = $db->query("SHOW COLUMNS FROM `{$prefix}goods` LIKE 'item_type'"); - if (count($query) == 0) { + // M-03: 修复 empty($result) 对 PDOStatement 永远返回 false 的问题 + $resultItemType = $query->fetchAll(); + if (count($resultItemType) == 0) { $db->execute("ALTER TABLE `{$prefix}goods` ADD COLUMN `item_type` VARCHAR(20) NOT NULL DEFAULT 'normal' COMMENT '商品类型:normal=普通 goods ticket=票务 physical=周边' AFTER `is_shelves`"); } $queryConfig = $db->query("SHOW COLUMNS FROM `{$prefix}goods` LIKE 'vr_goods_config'"); - if (count($queryConfig) == 0) { + $resultConfig = $queryConfig->fetchAll(); + if (count($resultConfig) == 0) { $db->execute("ALTER TABLE `{$prefix}goods` ADD COLUMN `vr_goods_config` LONGTEXT COMMENT '票务配置' AFTER `item_type`"); } } diff --git a/shopxo/app/plugins/vr_ticket/Hook.php b/shopxo/app/plugins/vr_ticket/Hook.php index 3bff217..7a6b71f 100644 --- a/shopxo/app/plugins/vr_ticket/Hook.php +++ b/shopxo/app/plugins/vr_ticket/Hook.php @@ -88,6 +88,16 @@ class Hook 'url' => PluginsAdminUrl('vr_ticket', 'admin', 'TicketList'), 'power' => 'vr_ticket-ticketList', ], + [ + 'id' => 'plugins-vr_ticket-ticketverify', + 'name' => '扫码核销', + 'title' => '扫码核销', + 'is_show' => 1, + 'control' => 'admin', + 'action' => 'TicketVerify', + 'url' => PluginsAdminUrl('vr_ticket', 'admin', 'TicketVerify'), + 'power' => 'vr_ticket-ticketVerify', + ], [ 'id' => 'plugins-vr_ticket-verifier', 'name' => '核销员', diff --git a/shopxo/app/plugins/vr_ticket/admin/Admin.php b/shopxo/app/plugins/vr_ticket/admin/Admin.php index 02b93b3..f4ed5ca 100644 --- a/shopxo/app/plugins/vr_ticket/admin/Admin.php +++ b/shopxo/app/plugins/vr_ticket/admin/Admin.php @@ -122,6 +122,10 @@ class Admin extends Common */ public function SeatTemplateList() { + // M-06: 权限校验 + if (empty($this->admin['id'])) { + return DataReturn('无权限访问', -1); + } $where = []; $name = input('name', '', null); @@ -167,6 +171,10 @@ class Admin extends Common */ public function SeatTemplateSave() { + // M-06: 权限校验 + if (empty($this->admin['id'])) { + return DataReturn('无权限访问', -1); + } $id = input('id', 0, 'intval'); if ((request()->isAjax() && request()->isPost())) { @@ -226,6 +234,10 @@ class Admin extends Common */ public function SeatTemplateDelete() { + // M-06: 权限校验 + if (empty($this->admin['id'])) { + return DataReturn('无权限访问', -1); + } if (!(request()->isAjax() && request()->isPost())) { return DataReturn('非法请求', -1); } @@ -278,6 +290,10 @@ class Admin extends Common public function SeatTemplateEnable() { + // M-06: 权限校验 + if (empty($this->admin['id'])) { + return DataReturn('无权限访问', -1); + } if (!(request()->isAjax() && request()->isPost())) { return DataReturn('非法请求', -1); } @@ -314,6 +330,10 @@ class Admin extends Common */ public function TicketList() { + // M-06: 权限校验 + if (empty($this->admin['id'])) { + return DataReturn('无权限访问', -1); + } $where = []; $keywords = input('keywords', '', null, 'trim'); @@ -369,6 +389,10 @@ class Admin extends Common */ public function TicketDetail() { + // M-06: 权限校验 + if (empty($this->admin['id'])) { + return DataReturn('无权限访问', -1); + } $id = input('id', 0, 'intval'); if ($id <= 0) { return DataReturn('参数错误', -1); @@ -406,19 +430,30 @@ class Admin extends Common */ public function TicketVerify() { + // M-06: 权限校验 + if (empty($this->admin['id'])) { + return DataReturn('无权限访问', -1); + } if (!(request()->isAjax() && request()->isPost())) { return DataReturn('非法请求', -1); } $ticket_code = input('ticket_code', '', null, 'trim'); - $verifier_id = input('verifier_id', 0, 'intval'); + + // M-05: verifier_id 从 session 获取,禁止客户端伪造 + $admin_user_id = $this->admin['id']; + $verifier = \think\facade\Db::name('vr_verifiers') + ->where('user_id', $admin_user_id) + ->where('status', 1) + ->find(); + if (empty($verifier)) { + return DataReturn('你不是核销员,无权核销', -1); + } + $verifier_id = $verifier['id']; 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'] ?? []); @@ -429,6 +464,10 @@ class Admin extends Common */ public function TicketExport() { + // M-06: 权限校验 + if (empty($this->admin['id'])) { + return DataReturn('无权限访问', -1); + } if (!(request()->isAjax() && request()->isPost())) { return DataReturn('非法请求', -1); } @@ -476,6 +515,10 @@ class Admin extends Common */ public function VerifierList() { + // M-06: 权限校验 + if (empty($this->admin['id'])) { + return DataReturn('无权限访问', -1); + } $where = []; $keywords = input('keywords', '', null, 'trim'); @@ -522,6 +565,10 @@ class Admin extends Common */ public function VerifierSave() { + // M-06: 权限校验 + if (empty($this->admin['id'])) { + return DataReturn('无权限访问', -1); + } $id = input('id', 0, 'intval'); if ((request()->isAjax() && request()->isPost())) { @@ -582,6 +629,10 @@ class Admin extends Common */ public function VerifierDelete() { + // M-06: 权限校验 + if (empty($this->admin['id'])) { + return DataReturn('无权限访问', -1); + } if (!(request()->isAjax() && request()->isPost())) { return DataReturn('非法请求', -1); } @@ -620,6 +671,10 @@ class Admin extends Common */ public function VenueList() { + // M-06: 权限校验 + if (empty($this->admin['id'])) { + return DataReturn('无权限访问', -1); + } $where = []; $name = input('name', '', null); @@ -672,6 +727,10 @@ class Admin extends Common */ public function VenueSave() { + // M-06: 权限校验 + if (empty($this->admin['id'])) { + return DataReturn('无权限访问', -1); + } $id = input('id', 0, 'intval'); if ((request()->isAjax() && request()->isPost())) { @@ -857,6 +916,10 @@ class Admin extends Common */ public function VenueDelete() { + // M-06: 权限校验 + if (empty($this->admin['id'])) { + return DataReturn('无权限访问', -1); + } if (!(request()->isAjax() && request()->isPost())) { return DataReturn('非法请求', -1); } @@ -913,6 +976,10 @@ class Admin extends Common public function VenueEnable() { + // M-06: 权限校验 + if (empty($this->admin['id'])) { + return DataReturn('无权限访问', -1); + } if (!(request()->isAjax() && request()->isPost())) { return DataReturn('非法请求', -1); } @@ -945,6 +1012,10 @@ class Admin extends Common */ public function VerificationList() { + // M-06: 权限校验 + if (empty($this->admin['id'])) { + return DataReturn('无权限访问', -1); + } $where = []; $keywords = input('keywords', '', null, 'trim'); @@ -1016,6 +1087,36 @@ class Admin extends Common ]); } + /** + * 获取核销统计数据(JSON API) + * URL: /plugins/vr_ticket/admin/TicketStats + */ + public function TicketStats() + { + if (empty($this->admin['id'])) { + return json_encode(['code' => -1, 'msg' => '无权限']); + } + + $today_start = strtotime('today'); + $total = \think\facade\Db::name('vr_tickets')->count(); + $verified = \think\facade\Db::name('vr_tickets')->where('verify_status', 1)->count(); + $pending = \think\facade\Db::name('vr_tickets')->where('verify_status', 0)->count(); + $today = \think\facade\Db::name('vr_tickets') + ->where('verify_status', 1) + ->where('verify_time', '>=', $today_start) + ->count(); + + return json_encode([ + 'code' => 0, + 'data' => [ + 'total' => $total, + 'verified' => $verified, + 'pending' => $pending, + 'today' => $today, + ] + ]); + } + // ============================================================ // 辅助方法 // ============================================================ diff --git a/shopxo/app/plugins/vr_ticket/view/admin/seat_template/list.html b/shopxo/app/plugins/vr_ticket/view/admin/seat_template/list.html new file mode 100644 index 0000000..a102645 --- /dev/null +++ b/shopxo/app/plugins/vr_ticket/view/admin/seat_template/list.html @@ -0,0 +1,107 @@ +{{:ModuleInclude('public/header')}} + +
+
+ + 返回 + + 座位模板 +
+ + +
+
+
+
+ +
+
+ +
+ + + 添加模板 + +
+
+ + + + + + + + + + + + + + + {{if !empty($list)}} + {{foreach $list as $item}} + + + + + + + + + {{/foreach}} + {{else}} + + + + {{/if}} + +
ID模板名称座位数状态添加时间操作
{{$item.id}}{{$item.name}}{{$item.seat_count|default=0}} + {{if $item.status == 1}} + 启用 + {{else}} + 禁用 + {{/if}} + {{if !empty($item.created_at)}}{{date('Y-m-d H:i', $item.created_at)}}{{/if}} + + 编辑 + + +
暂无数据
+ + + {{if !empty($page)}}{{$page|raw}}{{/if}} +
+
+ + + +{{:ModuleInclude('public/footer')}} \ No newline at end of file diff --git a/shopxo/app/plugins/vr_ticket/view/admin/seat_template/save.html b/shopxo/app/plugins/vr_ticket/view/admin/seat_template/save.html new file mode 100644 index 0000000..e8eedbf --- /dev/null +++ b/shopxo/app/plugins/vr_ticket/view/admin/seat_template/save.html @@ -0,0 +1,113 @@ +{{:ModuleInclude('public/header')}} + + + +
+
+ + 返回 + + {{if !empty($info)}}编辑模板{{else}}添加座位模板{{/if}} +
+ +
+
+ {{if !empty($info)}} + + {{/if}} + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ 请填写符合规范的 JSON 格式座位数据。编辑模式下此字段将更新。 +
+
+ +
+ +
+
+
+
+ +{{:ModuleInclude('public/footer')}} \ No newline at end of file diff --git a/shopxo/app/plugins/vr_ticket/view/admin/ticket/detail.html b/shopxo/app/plugins/vr_ticket/view/admin/ticket/detail.html new file mode 100644 index 0000000..d712cb2 --- /dev/null +++ b/shopxo/app/plugins/vr_ticket/view/admin/ticket/detail.html @@ -0,0 +1,162 @@ +{{:ModuleInclude('public/header')}} + +
+
+ + 返回列表 + + 票详情 +
+ +
+ +
+
+
票基础信息
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{if $ticket.status == 1}} + + + + + + + + + {{/if}} +
票码{{$ticket.ticket_code}}
订单号{{$ticket.order_no}}
商品名{{$ticket.goods_name}}
观演人{{$ticket.visitor_name}}
手机号{{$ticket.mobile}}
身份证{{if !empty($ticket.id_card)}}{{$ticket.id_card}}{{else}}未填写{{/if}}
座位信息{{if !empty($ticket.seat_info)}}{{$ticket.seat_info}}{{else}}无{{/if}}
状态 + {{if $ticket.status == 0}} + 未核销 + {{elseif $ticket.status == 1}} + 已核销 + {{elseif $ticket.status == 2}} + 已退款 + {{/if}} +
发放时间{{$ticket.create_time}}
核销时间{{$ticket.verify_time}}
核销人{{if !empty($ticket.verifier_name)}}{{$ticket.verifier_name}}{{else}}未知{{/if}}
+
+
+
+ + +
+
+
票条形码
+
+
+ + +
+

票码:{{$ticket.ticket_code}}

+ + +
+ +
+
+
+ + + {{if $ticket.status == 0}} +
+
核销操作
+
+
+ +
+ +
+
+
+
+ {{/if}} +
+
+
+ + + + +{{:ModuleInclude('public/footer')}} \ No newline at end of file diff --git a/shopxo/app/plugins/vr_ticket/view/admin/ticket/list.html b/shopxo/app/plugins/vr_ticket/view/admin/ticket/list.html new file mode 100644 index 0000000..55568d7 --- /dev/null +++ b/shopxo/app/plugins/vr_ticket/view/admin/ticket/list.html @@ -0,0 +1,126 @@ +{{:ModuleInclude('public/header')}} + +
+
+ + 扫码核销 + + 电子票列表 +
+ + +
+
+
+
+
+
+ 订单号 + +
+
+
+
+ 票码 + +
+
+
+
+ 观演人 + +
+
+
+
+ 手机号 + +
+
+
+ +
+
+
+ 状态 + +
+
+
+ + + 重置 + +
+
+
+
+
+ + +
+
电子票列表
+
+ + + + + + + + + + + + + + {{if !empty($list)}} + {{volist name="list" id="ticket"}} + + + + + + + + + + {{/volist}} + {{else}} + + + + {{/if}} + +
票码观演人座位信息商品名状态发放时间操作
{{$ticket.ticket_code}}{{$ticket.visitor_name}}{{if !empty($ticket.seat_info)}}{{$ticket.seat_info}}{{else}}无{{/if}}{{$ticket.goods_name}} + {{if $ticket.status == 0}} + 未核销 + {{elseif $ticket.status == 1}} + 已核销 + {{elseif $ticket.status == 2}} + 已退款 + {{/if}} + {{$ticket.create_time}} + + 查看详情 + +
暂无数据
+ + + {{if !empty($page)}} +
+ {{$page|raw}} +
+ {{/if}} +
+
+
+ +{{:ModuleInclude('public/footer')}} \ No newline at end of file diff --git a/shopxo/app/plugins/vr_ticket/view/admin/ticket/verify.html b/shopxo/app/plugins/vr_ticket/view/admin/ticket/verify.html new file mode 100644 index 0000000..963b4a3 --- /dev/null +++ b/shopxo/app/plugins/vr_ticket/view/admin/ticket/verify.html @@ -0,0 +1,250 @@ +{{:ModuleInclude('public/header')}} + +
+
+ + 电子票列表 + + 票码核销 +
+ + +
+
+
+
今日核销
+
加载中...
+
+
+
+
+
待核销
+
加载中...
+
+
+
+
+
已核销总数
+
加载中...
+
+
+
+ + +
+
扫码/输入核销
+
+
+
+ +
+ + + + +
+
+ 支持手动输入票码或点击"扫码"使用摄像头扫描二维码 +
+
+ +
+ +
+
+
+
+ + +
+ + +
+
+
+ 扫码核销 + × +
+
+ + +
点击"开始扫码"启动摄像头
+
+ + +
+
+
+
+
+ + + + +{{:ModuleInclude('public/footer')}} \ No newline at end of file diff --git a/shopxo/app/plugins/vr_ticket/view/admin/verification/list.html b/shopxo/app/plugins/vr_ticket/view/admin/verification/list.html new file mode 100644 index 0000000..d20ff71 --- /dev/null +++ b/shopxo/app/plugins/vr_ticket/view/admin/verification/list.html @@ -0,0 +1,79 @@ +{{:ModuleInclude('public/header')}} + +
+
+ + 返回 + + 核销记录 +
+ + +
+
+
+
+ +
+
+ +
+
+ +
+
+ +
+ +
+
+ + + + + + + + + + + + + + + + {{if !empty($list)}} + {{foreach $list as $item}} + + + + + + + + + + {{/foreach}} + {{else}} + + + + {{/if}} + +
ID票码核销员商品座位信息观演人核销时间
{{$item.id}}{{$item.ticket_code}}{{$item.verifier_name|default='-'}}{{$item.goods_title|default='-'}}{{$item.seat_info|default='-'}}{{$item.real_name|default='-'}}{{if !empty($item.created_at)}}{{date('Y-m-d H:i', $item.created_at)}}{{/if}}
暂无数据
+ + + {{if !empty($page)}}{{$page|raw}}{{/if}} +
+
+ +{{:ModuleInclude('public/footer')}} \ No newline at end of file diff --git a/shopxo/app/plugins/vr_ticket/view/admin/verifier/list.html b/shopxo/app/plugins/vr_ticket/view/admin/verifier/list.html new file mode 100644 index 0000000..817b3ab --- /dev/null +++ b/shopxo/app/plugins/vr_ticket/view/admin/verifier/list.html @@ -0,0 +1,104 @@ +{{:ModuleInclude('public/header')}} + +
+
+ + 返回 + + 核销员管理 +
+ + +
+
+
+
+ +
+
+ +
+ + + 添加核销员 + +
+
+ + + + + + + + + + + + + + + {{if !empty($list)}} + {{foreach $list as $item}} + + + + + + + + + {{/foreach}} + {{else}} + + + + {{/if}} + +
ID核销员名称关联用户状态添加时间操作
{{$item.id}}{{$item.name}}{{$item.user_name|default='-'}} + {{if $item.status == 1}} + 启用 + {{else}} + 禁用 + {{/if}} + {{if !empty($item.created_at)}}{{date('Y-m-d H:i', $item.created_at)}}{{/if}} + + 编辑 + + +
暂无数据
+ + + {{if !empty($page)}}{{$page|raw}}{{/if}} +
+
+ + + +{{:ModuleInclude('public/footer')}} \ No newline at end of file diff --git a/shopxo/app/plugins/vr_ticket/view/admin/verifier/save.html b/shopxo/app/plugins/vr_ticket/view/admin/verifier/save.html new file mode 100644 index 0000000..abfd4c4 --- /dev/null +++ b/shopxo/app/plugins/vr_ticket/view/admin/verifier/save.html @@ -0,0 +1,56 @@ +{{:ModuleInclude('public/header')}} + +
+
+ + 返回 + + {{if !empty($info)}}编辑核销员{{else}}添加核销员{{/if}} +
+ +
+
+ {{if !empty($info)}} + + {{/if}} + +
+
核销员信息
+
+
+ + +
+ +
+ + +
+ +
+ + +
+
+
+ +
+ +
+
+
+
+ +{{:ModuleInclude('public/footer')}} \ No newline at end of file