council(execute): BackendArchitect - add missing verifier views + fix CONCAT bug in Verifier.php
Round 3 completed: - NEW: verifier/list.html (Layui table + search + enable/disable) - NEW: verifier/save.html (user select + name + status form) - NEW: ticket/detail.html (QR code + manual verify form) - FIX: Verifier.php CONCAT column() → select() + PHP concat (P1) - FIX: Ticket.php detail() adds $verifiers list for detail.html - UPDATE: plan.md marks B1~B5 Done, S1~S5 pending SecurityEngineer Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>refactor/vr-ticket-20260416
parent
b768d34dff
commit
6f49b8355c
63
plan.md
63
plan.md
|
|
@ -31,31 +31,31 @@ Phase 2 目标:完成后台管理页面开发,涵盖座位模板管理、电
|
|||
## 任务清单
|
||||
|
||||
### 座位模板管理
|
||||
- [ ] 座位模板列表页(seat_template_list.html)
|
||||
- [ ] 座位模板新增/编辑页(seat_template_save.html)
|
||||
- [x] 座位模板列表页(seat_template/list.html)— 已完成
|
||||
- [x] 座位模板新增/编辑页(seat_template/save.html)— 已完成
|
||||
- [ ] 座位图可视化编辑器集成
|
||||
- [ ] 分类绑定功能
|
||||
- [x] 分类绑定功能 — 已完成
|
||||
|
||||
### 电子票管理
|
||||
- [ ] 电子票列表页(ticket_list.html)
|
||||
- [ ] 票详情页(ticket_detail.html)
|
||||
- [ ] 批量导出功能(CSV/Excel)
|
||||
- [ ] 票状态筛选(未核销/已核销/已退款)
|
||||
- [x] 电子票列表页(ticket/list.html)— 已完成
|
||||
- [x] 票详情页(ticket/detail.html)— 已完成
|
||||
- [x] 批量导出功能(CSV/Excel)— 已完成
|
||||
- [x] 票状态筛选(未核销/已核销/已退款)— 已完成
|
||||
|
||||
### 核销员管理
|
||||
- [ ] 核销员列表页
|
||||
- [ ] 核销员新增/编辑/删除
|
||||
- [x] 核销员列表页(verifier/list.html)— 已完成
|
||||
- [x] 核销员新增/编辑/删除(verifier/save.html)— 已完成
|
||||
- [ ] 核销员绑定店铺/场次
|
||||
|
||||
### 核销记录
|
||||
- [ ] 核销记录列表页
|
||||
- [ ] 多条件查询(时间/核销员/场次)
|
||||
- [x] 核销记录列表页(verification/list.html)— 已完成
|
||||
- [x] 多条件查询(时间/核销员/场次)— 已完成
|
||||
- [ ] 核销统计看板
|
||||
|
||||
### Admin 鉴权(P1 安全)
|
||||
- [ ] 所有 Admin 控制器继承 Base controller
|
||||
- [ ] 鉴权中间件验证
|
||||
- [ ] 敏感操作日志审计
|
||||
- [x] 所有 Admin 控制器继承 Base controller — 已完成(Base extends Common)
|
||||
- [ ] 鉴权中间件验证 — `[Claimed: council/SecurityEngineer]`
|
||||
- [ ] 敏感操作日志审计 — `[Pending]`
|
||||
|
||||
### 安全任务
|
||||
- [ ] **Task S1** — 审查 ShopXO 后台鉴权机制,确认 Phase 2 Base 控制器鉴权覆盖完整性 `[Pending]`
|
||||
|
|
@ -224,14 +224,37 @@ admin.php → http->name('admin') → Common::__construct() (获取$admin, $left
|
|||
|
||||
### 已认领任务
|
||||
|
||||
- [ ] **Task B1** — 座位模板管理 CRUD — `[Claimed: council/BackendArchitect]`
|
||||
- [ ] **Task B2** — 电子票列表/详情/导出 — `[Claimed: council/BackendArchitect]`
|
||||
- [ ] **Task B3** — 核销员管理(增删改查)— `[Claimed: council/BackendArchitect]`
|
||||
- [ ] **Task B4** — 核销记录查询 — `[Pending]`
|
||||
- [ ] **Task B5** — Base 控制器鉴权修复 — `[Claimed: council/BackendArchitect]`
|
||||
- [x] **Task B1** — 座位模板管理 CRUD — `[Done: council/BackendArchitect]`
|
||||
- [x] **Task B2** — 电子票列表/详情/导出 — `[Done: council/BackendArchitect]`
|
||||
- [x] **Task B3** — 核销员管理(增删改查)— `[Done: council/BackendArchitect]`
|
||||
- [x] **Task B4** — 核销记录查询 — `[Done: council/BackendArchitect]`
|
||||
- [x] **Task B5** — Base 控制器鉴权修复 — `[Done: council/BackendArchitect]`
|
||||
|
||||
---
|
||||
|
||||
## Round 3 执行结果(BackendArchitect)
|
||||
|
||||
### 本轮完成内容
|
||||
|
||||
| # | 文件 | 操作 | 说明 |
|
||||
|---|------|------|------|
|
||||
| 1 | `admin/view/verifier/list.html` | 新建 | 核销员列表页(Layui table + 搜索栏 + 编辑/禁用按钮) |
|
||||
| 2 | `admin/view/verifier/save.html` | 新建 | 核销员新增/编辑页(用户选择 + 名称 + 状态) |
|
||||
| 3 | `admin/view/ticket/detail.html` | 新建 | 票详情页(基本信息 + QR码 + 手动核销表单) |
|
||||
| 4 | `admin/controller/Verifier.php` | 修复 | `column(CONCAT(...))` 语法错误 → 改为先 select 再 PHP 拼接 |
|
||||
| 5 | `admin/controller/Ticket.php` | 修复 | `detail()` 补充 `verifiers` 变量传给 detail.html |
|
||||
|
||||
### 遗留 P0/P1 阻塞(需 SecurityEngineer 处理)
|
||||
|
||||
| # | 问题 | 严重度 | 备注 |
|
||||
|---|------|--------|------|
|
||||
| S1 | Admin 控制器鉴权覆盖完整性 | P0 | `[Pending: council/SecurityEngineer]` |
|
||||
| S2 | SQL 注入风险审计 | P1 | `[Pending: council/SecurityEngineer]` |
|
||||
| S3 | XSS / CSRF 防护检查 | P1 | `[Pending: council/SecurityEngineer]` |
|
||||
| S5 | IDOR 越权测试 | P1 | `[Pending: council/SecurityEngineer]` |
|
||||
|
||||
---
|
||||
|
||||
## 共识投票
|
||||
|
||||
[CONSENSUS: NO] — 本轮仅完成研究讨论,实际执行待后续阶段
|
||||
[CONSENSUS: NO] — 等待 SecurityEngineer 完成 Task S1~S5 后可结束 Phase 2
|
||||
|
|
|
|||
|
|
@ -102,10 +102,17 @@ class Ticket extends Base
|
|||
// QR 码图片
|
||||
$ticket['qr_code_url'] = \app\plugins\vr_ticket\service\TicketService::getQrCodeUrl($ticket['ticket_code']);
|
||||
|
||||
// 核销员列表(用于手动核销)
|
||||
$verifiers = \Db::name('plugins_vr_verifiers')
|
||||
->where('status', 1)
|
||||
->order('id', 'asc')
|
||||
->select();
|
||||
|
||||
return view('', [
|
||||
'ticket' => $ticket,
|
||||
'goods' => $goods,
|
||||
'verifier' => $verifier,
|
||||
'verifiers' => $verifiers,
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -37,14 +37,20 @@ class Verifier extends Base
|
|||
->paginate(20)
|
||||
->toArray();
|
||||
|
||||
// 关联 ShopXO 用户信息
|
||||
// 关联 ShopXO 用户信息(先查再 PHP 拼接,避免 column() 不支持 CONCAT 表达式)
|
||||
$user_ids = array_filter(array_column($list['data'], 'user_id'));
|
||||
if (!empty($user_ids)) {
|
||||
$users = \Db::name('User')
|
||||
->where('id', 'in', $user_ids)
|
||||
->column('CONCAT(COALESCE(nickname,""), "/", COALESCE(username,""))', 'id');
|
||||
->select();
|
||||
$user_map = [];
|
||||
foreach ($users as $u) {
|
||||
$nickname = $u['nickname'] ?? '';
|
||||
$username = $u['username'] ?? '';
|
||||
$user_map[$u['id']] = ($nickname ?: '') . ($username ? '/' . $username : '');
|
||||
}
|
||||
foreach ($list['data'] as &$item) {
|
||||
$item['user_name'] = $users[$item['user_id']] ?? '已删除用户';
|
||||
$item['user_name'] = $user_map[$item['user_id']] ?? '已删除用户';
|
||||
}
|
||||
unset($item);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,130 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>票详情 - VR票务</title>
|
||||
{include file="public/head" /}
|
||||
<style>
|
||||
.ticket-detail-card {max-width:800px;}
|
||||
.detail-row {padding:12px 0;border-bottom:1px solid #f0f0f0;}
|
||||
.detail-label {color:#999;font-size:13px;margin-bottom:4px;}
|
||||
.detail-value {font-size:14px;}
|
||||
.qr-box {text-align:center;padding:20px;background:#fafafa;border-radius:4px;}
|
||||
.qr-box img {max-width:200px;}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="layui-fluid">
|
||||
<div class="layui-card ticket-detail-card">
|
||||
<div class="layui-card-header">
|
||||
票详情
|
||||
<span class="layui-badge layui-bg-{{$ticket['verify_status']==1 ? 'green' : ($ticket['verify_status']==2 ? 'red' : 'blue')}}">
|
||||
{{$ticket['verify_status']==1 ? '已核销' : ($ticket['verify_status']==2 ? '已退款' : '未核销')}}
|
||||
</span>
|
||||
<span class="layui-btn layui-btn-xs layui-btn-primary" style="float:right" onclick="history.back()">返回</span>
|
||||
</div>
|
||||
<div class="layui-card-body">
|
||||
<!-- 票码 -->
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">票码</div>
|
||||
<div class="detail-value" style="font-family:monospace;font-size:16px;color:#1e9fff">{$ticket.ticket_code}</div>
|
||||
</div>
|
||||
|
||||
<!-- 二维码 -->
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">二维码</div>
|
||||
<div class="qr-box">
|
||||
<img src="{$ticket.qr_code_url}" alt="票二维码">
|
||||
<div style="margin-top:10px;color:#999;font-size:12px">扫描核销</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 基本信息 -->
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">关联商品</div>
|
||||
<div class="detail-value">{$goods['title']|default='已删除商品'}</div>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">订单号</div>
|
||||
<div class="detail-value">{$ticket.order_no}</div>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">座位信息</div>
|
||||
<div class="detail-value">{$ticket.seat_info|default='无'}</div>
|
||||
</div>
|
||||
|
||||
<!-- 观演人 -->
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">观演人</div>
|
||||
<div class="detail-value">{$ticket.real_name|default='-'}</div>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">手机号</div>
|
||||
<div class="detail-value">{$ticket.phone|default='-'}</div>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">身份证</div>
|
||||
<div class="detail-value">{$ticket.id_card|default='-'}</div>
|
||||
</div>
|
||||
|
||||
<!-- 时间 -->
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">发放时间</div>
|
||||
<div class="detail-value">{$ticket.issued_at > 0 ? date('Y-m-d H:i:s', $ticket.issued_at) : '-'}</div>
|
||||
</div>
|
||||
{if $ticket['verify_status'] == 1}
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">核销时间</div>
|
||||
<div class="detail-value">{$ticket.verify_time > 0 ? date('Y-m-d H:i:s', $ticket.verify_time) : '-'}</div>
|
||||
</div>
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">核销员</div>
|
||||
<div class="detail-value">{$verifier['name']|default='-'}</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- 操作 -->
|
||||
{if $ticket['verify_status'] == 0}
|
||||
<div class="detail-row">
|
||||
<div class="detail-label">手动核销</div>
|
||||
<div class="detail-value">
|
||||
<form class="layui-form" style="display:inline" id="verify-form">
|
||||
<select name="verifier_id" required lay-verify="required" style="width:200px;display:inline">
|
||||
<option value="">选择核销员</option>
|
||||
{foreach $verifiers as $v}
|
||||
<option value="{$v.id}">{$v.name}</option>
|
||||
{/foreach}
|
||||
</select>
|
||||
<button class="layui-btn layui-btn-sm layui-btn-danger" lay-submit lay-filter="do-verify">确认核销</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{include file="public/footer" /}
|
||||
<script>
|
||||
layui.use('form', function() {
|
||||
var form = layui.form;
|
||||
form.on('submit(do-verify)', function(data) {
|
||||
if (!data.field.verifier_id) {
|
||||
layer.msg('请选择核销员');
|
||||
return false;
|
||||
}
|
||||
$.post('{:MyUrl("plugins_vr_ticket/admin/ticket/verify")}', {
|
||||
ticket_code: '{$ticket.ticket_code}',
|
||||
verifier_id: data.field.verifier_id
|
||||
}, function(res) {
|
||||
if (res.code == 0) {
|
||||
layer.msg('核销成功', function() { location.reload(); });
|
||||
} else {
|
||||
layer.msg(res.msg || '核销失败');
|
||||
}
|
||||
});
|
||||
return false;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>核销员管理 - VR票务</title>
|
||||
{include file="public/head" /}
|
||||
</head>
|
||||
<body>
|
||||
<div class="layui-fluid">
|
||||
<div class="layui-card">
|
||||
<div class="layui-card-header">核销员管理</div>
|
||||
<div class="layui-card-body">
|
||||
<!-- 搜索栏 -->
|
||||
<div class="layui-form layui-form-pane">
|
||||
<div class="layui-form-item">
|
||||
<div class="layui-inline">
|
||||
<label class="layui-form-label">关键词</label>
|
||||
<div class="layui-input-inline">
|
||||
<input type="text" name="keywords" value="" placeholder="姓名/用户ID" class="layui-input">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-inline">
|
||||
<label class="layui-form-label">状态</label>
|
||||
<div class="layui-input-inline">
|
||||
<select name="status" lay-search>
|
||||
<option value="">全部</option>
|
||||
<option value="1">启用</option>
|
||||
<option value="0">禁用</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-inline">
|
||||
<button class="layui-btn" lay-submit lay-filter="search">搜索</button>
|
||||
<a href="{:MyUrl('plugins_vr_ticket/admin/verifier/save')}" class="layui-btn layui-btn-normal">添加核销员</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 数据表格 -->
|
||||
<table class="layui-hide" id="table" lay-filter="table"></table>
|
||||
|
||||
<script type="text/template" id="statusTpl">
|
||||
{{# if (d.status == 1) { }}
|
||||
<span class="layui-badge layui-bg-green">启用</span>
|
||||
{{# } else { }}
|
||||
<span class="layui-badge layui-bg-gray">禁用</span>
|
||||
{{# } }}
|
||||
</script>
|
||||
|
||||
<script type="text/template" id="actionTpl">
|
||||
<a href="{:MyUrl('plugins_vr_ticket/admin/verifier/save')}?id={{d.id}}" class="layui-btn layui-btn-xs">编辑</a>
|
||||
<a href="javascript:;" class="layui-btn layui-btn-danger layui-btn-xs" lay-fn="del" data-id="{{d.id}}">禁用</a>
|
||||
</script>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{include file="public/footer" /}
|
||||
<script>
|
||||
layui.use('table', function() {
|
||||
var table = layui.table;
|
||||
var form = layui.form;
|
||||
|
||||
table.render({
|
||||
elem: '#table',
|
||||
url: '{:MyUrl("plugins_vr_ticket/admin/verifier/list")}',
|
||||
cols: [[
|
||||
{field: 'id', title: 'ID', width: 80},
|
||||
{field: 'name', title: '核销员名称', minWidth: 120},
|
||||
{field: 'user_id', title: '用户ID', width: 100},
|
||||
{field: 'user_name', title: '关联用户', minWidth: 150},
|
||||
{field: 'status', title: '状态', width: 100, templet: '#statusTpl'},
|
||||
{field: 'created_at', title: '创建时间', width: 180, templet: function(d) {
|
||||
return d.created_at > 0 ? layui.util.toDateString(d.created_at * 1000) : '-';
|
||||
}},
|
||||
{field: 'action', title: '操作', width: 150, templet: '#actionTpl'},
|
||||
]]
|
||||
});
|
||||
|
||||
form.on('submit(search)', function(data) {
|
||||
table.reload('table', {where: data.field, page: {curr: 1}});
|
||||
return false;
|
||||
});
|
||||
|
||||
$(document).on('click', '[lay-fn="del"]', function() {
|
||||
var id = $(this).data('id');
|
||||
layer.confirm('确认禁用该核销员?', function(index) {
|
||||
$.post('{:MyUrl("plugins_vr_ticket/admin/verifier/delete")}', {id: id}, function(res) {
|
||||
if (res.code == 0) {
|
||||
layer.msg('操作成功');
|
||||
table.reload('table');
|
||||
} else {
|
||||
layer.msg(res.msg || '操作失败');
|
||||
}
|
||||
});
|
||||
layer.close(index);
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>{$info ? '编辑' : '添加'}核销员 - VR票务</title>
|
||||
{include file="public/head" /}
|
||||
</head>
|
||||
<body>
|
||||
<div class="layui-fluid">
|
||||
<div class="layui-card">
|
||||
<div class="layui-card-header">{$info ? '编辑' : '添加'}核销员</div>
|
||||
<div class="layui-card-body">
|
||||
<form class="layui-form" lay-filter="form">
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">关联用户</label>
|
||||
<div class="layui-input-block">
|
||||
<select name="user_id" lay-search required lay-verify="required" {if isset($info.id) && $info.id > 0}disabled{/if}>
|
||||
<option value="">请选择用户</option>
|
||||
{foreach $users as $u}
|
||||
<option value="{$u.id}" {if isset($info.user_id) && $info.user_id == $u.id}selected{/if}>
|
||||
{$u.nickname|default=$u.username|default='用户'}{$u.username ? ' / '.$u.username : ''} (ID:{$u.id})
|
||||
</option>
|
||||
{/foreach}
|
||||
</select>
|
||||
</div>
|
||||
{if isset($info.id) && $info.id > 0}
|
||||
<input type="hidden" name="user_id" value="{$info.user_id}">
|
||||
<div class="layui-form-mid layui-word-aux">用户关联后不可修改</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">核销员名称</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="text" name="name" value="{$info.name|default=''}" required lay-verify="required" placeholder="如:张三(检票员)" class="layui-input" style="width:400px">
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<label class="layui-form-label">状态</label>
|
||||
<div class="layui-input-block">
|
||||
<input type="checkbox" name="status" value="1" lay-skin="switch" lay-text="启用|禁用" {if !isset($info.status) || $info.status == 1}checked{/if}>
|
||||
</div>
|
||||
</div>
|
||||
<div class="layui-form-item">
|
||||
<div class="layui-input-block">
|
||||
<input type="hidden" name="id" value="{$info.id|default=0}">
|
||||
<button class="layui-btn" lay-submit lay-filter="submit">保存</button>
|
||||
<a href="{:MyUrl('plugins_vr_ticket/admin/verifier/list')}" class="layui-btn layui-btn-primary">返回</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{include file="public/footer" /}
|
||||
<script>
|
||||
layui.use('form', function() {
|
||||
var form = layui.form;
|
||||
form.on('submit(submit)', function(data) {
|
||||
$.post(window.location.href, data.field, function(res) {
|
||||
if (res.code == 0) {
|
||||
layer.msg(res.msg || '保存成功', function() {
|
||||
location.href = '{:MyUrl("plugins_vr_ticket/admin/verifier/list")}';
|
||||
});
|
||||
} else {
|
||||
layer.msg(res.msg || '保存失败');
|
||||
}
|
||||
});
|
||||
return false;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Reference in New Issue