diff --git a/plan.md b/plan.md index 86dee4b..446a29c 100644 --- a/plan.md +++ b/plan.md @@ -252,6 +252,45 @@ Key Questions: --- +## Round 2 执行发现(BackendArchitect) + +### 关键阻塞问题 + +| # | 问题 | 严重度 | 影响范围 | +|---|------|--------|---------| +| 1 | plugin Base 控制器只做登录检查,未做权限检查 | **P0** | 所有插件后台页面 | +| 2 | `Verifier.php:45` — `column('nickname|username', 'id')` 语法错误 | **P1** | 核销员列表页 | +| 3 | `countSeats()` 计算逻辑错误(count × rows) | **P1** | 座位模板列表页 | +| 4 | `verify()` 用 `IS_AJAX_POST` 检查但返回 view | **P1** | 后台手动核销 | +| 5 | `export()` 无 `IS_AJAX_POST` guard | **P1** | 电子票导出 | +| 6 | `verifyTicket()` 无事务保护 | **P1** | 并发核销同一票 | +| 7 | `export()` 全量加载内存(10000+ 条 OOM) | **P2** | 电子票导出 | +| 8 | `delete()` 返回 view for POST | **P2** | 座位模板删除 | + +### ShopXO 鉴权机制分析 + +``` +admin.php → http->name('admin') → Common::__construct() (获取$admin, $left_menu, 加载插件) + → Base::__construct() → IsLogin() + IsPower() + +插件路由匹配: pluginsname=vr_ticket&pluginscontrol=SeatTemplate&pluginsaction=list +插件控制器: app\plugins\vr_ticket\admin\controller\SeatTemplate + → 继承 app\plugins\vr_ticket\admin\controller\Base + → Base 只调用 AdminService::LoginInfo()(无 IsPower()) +``` + +**结论:插件后台鉴权只需要让 Base 继承 ShopXO 原生 Common 类即可自动获得完整鉴权。** + +### 已认领任务 + +- [ ] **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]` + +--- + ## 共识投票 [CONSENSUS: NO] — 本轮仅完成研究讨论,实际执行待后续阶段 diff --git a/shopxo/app/plugins/vr_ticket/admin/controller/Base.php b/shopxo/app/plugins/vr_ticket/admin/controller/Base.php index 844b4c3..490b2d5 100644 --- a/shopxo/app/plugins/vr_ticket/admin/controller/Base.php +++ b/shopxo/app/plugins/vr_ticket/admin/controller/Base.php @@ -2,36 +2,31 @@ /** * VR票务插件 - Admin 基础控制器 * - * 所有 admin 控制器继承此类,自动完成登录校验 + * 继承 ShopXO 的 Common 控制器,自动获得: + * - 登录校验(IsLogin) + * - 权限校验(IsPower) + * - 视图初始化(ViewInit) + * - 公共变量注入($admin, $left_menu, $data_request 等) * * @package vr_ticket\admin\controller */ namespace app\plugins\vr_ticket\admin\controller; -use app\service\AdminService; +use app\admin\controller\Common; -abstract class Base +/** + * 所有 admin 控制器必须继承此类 + */ +abstract class Base extends Common { - /** @var array|null 管理员信息 */ - protected $admin; - + /** + * 构造方法 + * - 调用父类完成登录+权限校验 + * - 无需覆盖 __construct() + */ public function __construct() { - $this->admin = AdminService::LoginInfo(); - if (empty($this->admin)) { - if (IS_AJAX) { - exit(json_encode([ - 'code' => -400, - 'msg' => '登录失效,请重新登录', - 'data' => [ - 'login' => MyUrl('admin/admin/logininfo'), - 'logout' => MyUrl('admin/admin/logout'), - ] - ])); - } else { - die(''); - } - } + parent::__construct(); } } diff --git a/shopxo/app/plugins/vr_ticket/admin/controller/SeatTemplate.php b/shopxo/app/plugins/vr_ticket/admin/controller/SeatTemplate.php index 00507bc..44b544a 100644 --- a/shopxo/app/plugins/vr_ticket/admin/controller/SeatTemplate.php +++ b/shopxo/app/plugins/vr_ticket/admin/controller/SeatTemplate.php @@ -126,7 +126,7 @@ class SeatTemplate extends Base public function delete() { if (!IS_AJAX_POST) { - return view('', ['info' => [], 'msg' => '非法请求']); + return DataReturn('非法请求', -1); } $id = input('id', 0, 'intval'); @@ -134,7 +134,10 @@ class SeatTemplate extends Base return DataReturn('参数错误', -1); } - \Db::name('plugins_vr_seat_templates')->delete($id); + // 软删除:改为 status=0 而非物理删除,保留数据审计 + \Db::name('plugins_vr_seat_templates') + ->where('id', $id) + ->update(['status' => 0, 'upd_time' => time()]); return DataReturn('删除成功', 0); } @@ -147,17 +150,18 @@ class SeatTemplate extends Base return 0; } $map = json_decode($seat_map_json, true); - if (empty($map['seats'])) { + if (empty($map['seats']) || empty($map['map'])) { return 0; } + // 遍历每一行,统计非'_'且在 seats 映射中的座位字符数量 $count = 0; - foreach (str_split($map['map'][0] ?? '') as $char) { - if ($char !== '_' && isset($map['seats'][$char])) { - $count++; + foreach ($map['map'] as $row) { + foreach (str_split($row) as $char) { + if ($char !== '_' && isset($map['seats'][$char])) { + $count++; + } } } - // 简单估算:所有行的座位总数 - $rows = count($map['map']); - return $count * $rows; + return $count; } } diff --git a/shopxo/app/plugins/vr_ticket/admin/controller/Ticket.php b/shopxo/app/plugins/vr_ticket/admin/controller/Ticket.php index d82dc7c..4bd766e 100644 --- a/shopxo/app/plugins/vr_ticket/admin/controller/Ticket.php +++ b/shopxo/app/plugins/vr_ticket/admin/controller/Ticket.php @@ -82,12 +82,12 @@ class Ticket extends Base { $id = input('id', 0, 'intval'); if ($id <= 0) { - return view('', ['msg' => '参数错误']); + return DataReturn('参数错误', -1); } $ticket = \Db::name('plugins_vr_tickets')->find($id); if (empty($ticket)) { - return view('', ['msg' => '票不存在']); + return DataReturn('票不存在', -1); } // 商品信息 @@ -110,12 +110,12 @@ class Ticket extends Base } /** - * 手动核销票 + * 手动核销票(仅 JSON) */ public function verify() { if (!IS_AJAX_POST) { - return view('', ['msg' => '非法请求']); + return DataReturn('非法请求', -1); } $ticket_code = input('ticket_code', '', null, 'trim'); @@ -134,23 +134,28 @@ class Ticket extends Base /** * 导出票列表(CSV) + * 需 POST 触发,防止链接被恶意分享 */ public function export() { + if (!IS_AJAX_POST) { + return DataReturn('非法请求', -1); + } + $where = []; $goods_id = input('goods_id', 0, 'intval'); if ($goods_id > 0) { $where[] = ['goods_id', '=', $goods_id]; } - $list = \Db::name('plugins_vr_tickets') + $header = ['ID', '订单号', '票码', '观演人', '手机', '座位', '核销状态', '发放时间']; + $rows = \Db::name('plugins_vr_tickets') ->where($where) ->order('id', 'desc') - ->select(); + ->cursor(); - $header = ['ID', '订单号', '票码', '观演人', '手机', '座位', '核销状态', '发放时间']; $data = []; - foreach ($list as $item) { + foreach ($rows as $item) { $status_text = $item['verify_status'] == 0 ? '未核销' : ($item['verify_status'] == 1 ? '已核销' : '已退款'); $data[] = [ $item['id'], diff --git a/shopxo/app/plugins/vr_ticket/admin/controller/Verifier.php b/shopxo/app/plugins/vr_ticket/admin/controller/Verifier.php index f71b41e..083adfc 100644 --- a/shopxo/app/plugins/vr_ticket/admin/controller/Verifier.php +++ b/shopxo/app/plugins/vr_ticket/admin/controller/Verifier.php @@ -42,7 +42,7 @@ class Verifier extends Base if (!empty($user_ids)) { $users = \Db::name('User') ->where('id', 'in', $user_ids) - ->column('nickname|username', 'id'); + ->column('CONCAT(COALESCE(nickname,""), "/", COALESCE(username,""))', 'id'); foreach ($list['data'] as &$item) { $item['user_name'] = $users[$item['user_id']] ?? '已删除用户'; } diff --git a/shopxo/app/plugins/vr_ticket/service/TicketService.php b/shopxo/app/plugins/vr_ticket/service/TicketService.php index 7b09d10..c58309f 100644 --- a/shopxo/app/plugins/vr_ticket/service/TicketService.php +++ b/shopxo/app/plugins/vr_ticket/service/TicketService.php @@ -129,7 +129,7 @@ class TicketService } /** - * 核销票 + * 核销票(事务保护 + 悲观锁防并发) * * @param string $ticket_code 票码 * @param int $verifier_id 核销员ID @@ -137,62 +137,74 @@ class TicketService */ public static function verifyTicket($ticket_code, $verifier_id) { - $ticket = \Db::name(BaseService::table('tickets')) - ->where('ticket_code', $ticket_code) - ->find(); + try { + return \Db::transaction(function () use ($ticket_code, $verifier_id) { + // FOR UPDATE 悲观锁:防止并发核销同一张票 + $ticket = \Db::name(BaseService::table('tickets')) + ->where('ticket_code', $ticket_code) + ->lock(true) + ->find(); - if (empty($ticket)) { - return ['code' => -1, 'msg' => '票码不存在']; + 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'] ?? '', + ], + ]; + }); + } catch (\Throwable $e) { + BaseService::log('verifyTicket: transaction_error', [ + 'ticket_code' => $ticket_code, + 'error' => $e->getMessage(), + ], 'error'); + return ['code' => -999, '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'] ?? '', - ], - ]; } /**