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')}}
+
+
+
+
+
+{{: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}}
+
+
+
+
+
+{{: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')}}
+
+
+
+
+
+
+
+
+
票基础信息
+
+
+
+ 票码
+ {{$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}}
+
+ {{if $ticket.status == 1}}
+
+ 核销时间
+ {{$ticket.verify_time}}
+
+
+ 核销人
+ {{if !empty($ticket.verifier_name)}}{{$ticket.verifier_name}}{{else}}未知{{/if}}
+
+ {{/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"}}
+
+ {{$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}}
+
+
+ 查看详情
+
+
+
+ {{/volist}}
+ {{else}}
+
+ 暂无数据
+
+ {{/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/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')}}
+
+
+
+{{: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')}}
+
+
+
+
+
+{{: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}}
+
+
+
核销员信息
+
+
+ 关联用户 *
+
+ 请选择用户
+ {{if !empty($users)}}
+ {{foreach $users as $u}}
+ {{$u.nickname|default='-'}}/{{$u.username}}
+ {{/foreach}}
+ {{/if}}
+
+
+
+
+ 核销员名称 *
+
+
+
+
+ 状态
+
+ 启用
+ 禁用
+
+
+
+
+
+
+
+ {{if !empty($info)}}保存修改{{else}}添加核销员{{/if}}
+
+
+
+
+
+
+{{:ModuleInclude('public/footer')}}
\ No newline at end of file