vr-shopxo-plugin/docs/EXPERIENCES.md

734 lines
25 KiB
Markdown
Raw Blame History

This file contains invisible Unicode characters!

This file contains invisible Unicode characters that may be processed differently from what appears below. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to reveal hidden characters.

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# VR票务插件 — 踩坑经验文档
> 本文档源自 2026-04-16 一整夜的重构调试,汇集了所有关键教训。
> **任何接手本项目的 agent请先阅读本文档。**
>
> 原始日志:`refactoring_log_vrticket_2026.md`4644行
> 提炼源:`refactoring_learnings.md`
---
## 🔴 P0 — 致命陷阱(必读)
### 1. `public/footer` 缺失 → 无限加载(最反直觉)
**现象**:列表页秒开,点击"新建/编辑"后页面永远转圈。
**假线索**后端死循环、数据库慢查询、unpkg.com CDN 阻断。
**真实根因**`save.html` 漏掉了 `{{:ModuleInclude('public/footer')}}`。AmazeUI 后台在页面跳转时显示全屏 Loading Spinner关闭动画的 JS 逻辑在 `footer` 的库文件里。没有 `footer` → Spinner 永不消失。
```html
<!-- ❌ 错误:漏 footer -->
{{:ModuleInclude('public/header')}}
<div>页面内容</div>
<!-- ✅ 正确:必须成对 -->
{{:ModuleInclude('public/header')}}
<div>页面内容</div>
{{:ModuleInclude('public/footer')}}
```
**教训**:无限加载 ≠ 后端死循环。AmazeUI Loading Spinner 遮罩是更常见的原因,优先检查 header/footer 是否完整。
---
### 2. Vue 3 `[[ ]]` 插值禁止用于 `<textarea>`
**现象**Base64 大字符串场景下页面完全无响应(浏览器卡死)。
**根因**`<textarea>[[ compiledJsonRaw ]]</textarea>` 使用双花括号插值绑定 Text NodeVue 3 在大数据动态赋值时触发虚拟 DOM 补丁机制无限死循环。
```html
<!-- ❌ 错误 -->
<textarea>[[ compiledJsonRaw ]]</textarea>
<!-- ✅ 正确:用隐藏 input -->
<input type="hidden" name="seat_map_raw" :value="compiledJsonRaw" />
```
**教训**Vue 3 插值语法 `[[ ]]` 只用于文本节点,禁止用于 `<textarea>` 的 value 属性。
---
### 3. 字段名不能猜,必须查源码
**现象**`is_delete_time` 报错,第一轮凭经验改字段名后仍然报错。
**根因**GoodsCategory 表根本没有软删除字段ShopXO 的软删除用的是 `is_enable` 而非 `is_delete_time`)。
```php
// ❌ 错误:凭经验猜字段名
if (!empty($category['is_delete_time']))
// ✅ 正确:查 GoodsCategoryService.php 源码确认字段
if (!empty($category['is_enable']))
```
**教训**ShopXO 部分表没有软删除字段。改字段名之前必须查对应 Service 层源码或实际表结构。
---
## 🟡 P1 — 严重问题
### 4. 插件视图路径:`../../../plugins/插件名/view/{group}/controller/action`
ShopXO 插件控制器继承 `app\admin\controller\Common` 后,模板引擎默认去找 `app/admin/view/default/` 而非插件目录。
```php
// ❌ 错误:引擎截断路径
return MyView('venue/list');
// ❌ 错误路径片段顺序颠倒admin/view/ vs view/admin/
return MyView('../../../plugins/vr_ticket/admin/view/seat_template/list');
// ↑ admin/view/ 是错的
// 正确view/admin/ ↓
return MyView('../../../plugins/vr_ticket/view/admin/seat_template/list');
```
**ShopXO 插件视图目录结构**(必须严格遵守):
```
app/plugins/vr_ticket/
├── admin/ ← 控制器目录PHP 类),不是视图目录!
│ └── Admin.php
├── view/ ← 视图文件根目录
│ ├── admin/ ← 后台视图group=admin
│ │ ├── seat_template/list.html
│ │ ├── seat_template/save.html
│ │ ├── ticket/list.html
│ │ ├── verifier/list.html
│ │ └── verification/list.html
│ ├── goods/ ← C端商品页视图
│ └── venue/ ← C端场馆视图
```
**完整路径公式**`../../../plugins/vr_ticket/view/{group}/{controller}/{action}`
| 场景 | group | 示例路径 |
|------|-------|---------|
| 后台管理 | `admin` | `../../../plugins/vr_ticket/view/admin/seat_template/list` |
| C端前台 | `goods` | `../../../plugins/vr_ticket/view/goods/ticket_detail` |
| C端场馆 | `venue` | `../../../plugins/vr_ticket/view/venue/list` |
**教训**:插件视图放在插件根目录的 `view/` 下(不是 `admin/view/`),路径公式 `../../../plugins/插件名/view/{group}/controller/action` 中的 `view/{group}/` 顺序不能颠倒。
---
### 5. Hook.php 返回值必须完整
ShopXO 的菜单渲染引擎要求 Hook 返回数组包含 `id`、`url`、`name`、`is_show` 完整字段。缺失任何一项都会导致侧边栏报错(`Undefined array key url` 等)。
```php
// ❌ 错误:缺少字段
return [
'title' => '场馆管理',
'control' => 'Venue',
'action' => 'list',
];
// ✅ 正确:完整字段
return [
'id' => 'venue-list',
'name' => '场馆管理',
'url' => MyUrl('vr_ticket/admin/venue-list'),
'is_show' => 1,
'control' => 'Venue',
'action' => 'list',
];
```
---
### 6. CDN 国内阻断:`unpkg.com` 不可用
**现象**Vue 3 编辑器完全失效,控制台有 `[Intervention] Slow network is detected`
**根因**`unpkg.com` 在中国大陆常发生静默阻断挂起。
```html
<!-- ❌ 错误:国内阻断 -->
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
<!-- ✅ 正确 -->
<script src="https://cdn.staticfile.net/vue/3.x.x/vue.global.prod.js"></script>
```
**教训**:国内项目 CDN 必须用 `cdn.staticfile.net``cdn.bootcdn.net`,禁止 `unpkg.com`/`cdnjs.cloudflare.com`。
**更优方案**:优先使用 ShopXO 源码自带的本地文件(见第 18 条)。
---
### 7. PHP 注释块污染:未闭合 `/*` 导致语法错误
**现象**:整个 Admin 控制器报 `syntax error, unexpected token "public"`
**根因**:调试期间遗留的未闭合 `/*` 注释块吞噬了后面的方法定义。
```php
/* 这是调试代码...
// 忘记闭合public 关键字被吞掉
public function index() { ... }
```
**教训**:隔离测试时清理调试代码;用 `php -l` 做语法检查。
---
### 8. ShopXO 路由 → `PluginsAdminUrl()` 而非硬编码
```php
// ❌ 错误:硬编码 URL
$url = '/adminwatekc.php?s=Plugins/VrTicket/Admin/index';
// ✅ 正确
$url = PluginsAdminUrl('vr_ticket', 'admin', 'index');
```
---
### 9. Admin.php `initialize()` 每次请求执行 `SHOW TABLES`
**现象**`save.html` 等页面加载极其缓慢。
**根因**Admin.php 的 `initialize()` 构造中每次请求都在执行完整的 `SHOW TABLES` 和重建引用脚本,巨大 I/O 负担。
**解法**引入基于时间跨度的缓存锁Cache Lock将高昂的表检查操作降低至 1 小时 1 次。
```php
protected function initialize()
{
parent::initialize();
$lockKey = 'vr_ticket_init_lock';
$cache = cache($lockKey);
if ($cache === false) {
// 执行表检查和初始化
$this->checkTables();
cache($lockKey, 1, 3600); // 锁1小时
}
}
```
---
### 10. ThinkPHP 6 分页:`->render()` 而非 `->toArray()` 后取 `page`
```php
// ❌ 错误
$list = $model->paginate(10)->toArray();
return $list['page']; // undefined
// ✅ 正确
$list = $model->paginate(10);
return $list->render();
```
---
## 🟢 P2 — 重要经验
### 11. ShopXO 插件目录结构(正确模式)
```
vr_ticket/
├── Admin.php ← 根目录,继承 think\Controller不是 admin/controller/
├── Hook.php ← 根目录
├── config.json
├── service/
│ └── BaseService.php
└── view/ ← 不是 admin/view/,是根目录 view/
└── venue/
├── list.html
└── save.html
```
**教训**:参考 `freightfee`/`answers` 插件结构,不要用 `admin/controller/` 子目录模式(会导致 strtolower+ucfirst 类名不匹配)。
---
### 12. JSON 传给 JS用 `<script type="text/json">` + `textContent`
```html
<!-- ❌ 错误:模板插值,含特殊字符时 JS 解析失败 -->
<script>var data = {{$seat_map | json_encode | raw}};</script>
<!-- ✅ 正确:隔绝特殊字符 -->
<script type="text/json" id="seat-map-data">{{$seat_map | json_encode | raw}}</script>
<script>var data = JSON.parse(document.getElementById('seat-map-data').textContent);</script>
```
---
### 13. AmazeUI 必用类名速查
| 功能 | AmazeUI 类 |
|------|-----------|
| 表格 | `am-table am-table-striped am-table-hover am-text-middle` |
| 按钮 | `am-btn am-btn-default` / `-primary` / `-danger` + `am-btn-xs am-radius` |
| 表单验证 | `class="am-form form-validation"`(没有这个 class 不走 AJAX |
| 字段校验提示 | `data-validation-message` 属性 |
| 徽章 | `am-badge am-badge-success` / `-danger` |
| 搜索栏 | `am-input-group am-input-group-sm am-fl so` |
| 分页 | `{{$page\|raw}}` |
| 图标 | `am-icon-plus` / `am-icon-edit` / `am-icon-trash-o` |
---
### 14. URL 截断Base64 编码兜底
ShopXO 框架内部有多层正则扫描"净化"资源路径CDN URL 会被截断。
```javascript
// 前端:提交前 Base64 编码
formData.seat_map = btoa(unescape(encodeURIComponent(jsonString)));
// 后端:解码还原
$seatMap = json_decode(base64_decode($_POST['seat_map']), true);
```
---
### 15. 搜索字段 = 列表主标题(字段一致性原则)
- 搜索条件固定为数据库可索引字段(`name` 列)
- 列表展示为三行式层级:`大字简名` → `(完整场馆名称)``📍 地址`
- JSON 内字段只做展示,不做搜索条件
```sql
-- ✅ 好:直接索引字段检索
WHERE name LIKE '%keyword%'
-- ❌ 坏JSON 字段模糊查询,效率低
WHERE JSON_EXTRACT(venue_data, '$.full_name') LIKE '%keyword%'
```
---
### 16. char 匹配需 `toUpperCase()` 归一化
座位标识字符比较时,务必归一化大小写:
```javascript
// ✅ 正确
const char = seatChar.toUpperCase();
const zone = zones.find(z => z.char.toUpperCase() === char);
// ❌ 错误:大小写不一致导致匹配失败
const zone = zones.find(z => z.char === seatChar);
```
---
### 17. ShopXO 插件静态文件双目录陷阱 → 详见 [DEBUG_STATIC_FILE_SYNC.md](DEBUG_STATIC_FILE_SYNC.md)
> ⚠️ **高危**:插件 JS/CSS 文件在 `app/`PHP runtime和 `public/`Nginx webroot各有一份副本。修改后必须同步两边并验证 MD5。详情见专项文档。
---
## 🟢 P2 — 重要经验
### 18. 插件模板静态文件引用:`$public_host` 最佳实践
**背景**`ticket_wallet.html` 的 JsBarcode 条形码不显示,原因是 `cdn.jsdelivr.net` 在大陆阻断。
**案例**:票夹页面用 `<script src="https://cdn.jsdelivr.net/npm/jsbarcode@3.11.0/...">` → JsBarcode 加载失败 → `typeof JsBarcode === 'undefined'` → 条形码静默不渲染。
**ShopXO 源码已自带 JsBarcode v3.11.5**
```
public/static/common/lib/JsBarcode/JsBarcode.all.min.js
```
**根本原因一CDN 阻断)**jsdelivr.net 在大陆不可用。应优先使用 ShopXO 自带库或国内 CDN`cdn.staticfile.net`)。
**根本原因二(`$public_host` 不可用)**ShopXO 官方模板用 `{{$public_host}}` 引用静态文件,但插件控制器不继承 `index/Common.php``$public_host` 不会自动赋值。
```php
// ❌ 错误:插件控制器不继承 Common模板里 $public_host 是空的
// 模板中用 {{$public_host}}static/... → 变成 /static/...(丢域名)
// ✅ 正确:在控制器显式传递 public_host
class Index
{
public function wallet()
{
return MyView('../../../plugins/vr_ticket/view/goods/ticket_wallet', [
'user' => $user,
'public_host' => \think\facade\Config::get('shopxo.host_url'),
]);
}
}
// 模板中用 {{$public_host}}static/...
```
**静态文件引用优先级**
1. **ShopXO 自带文件**(如 JsBarcode直接引用 `{{$public_host}}static/common/lib/...`,无需下载
2. **国内 CDN**`cdn.staticfile.net` / `cdn.bootcdn.net`ShopXO 无自带时)
3. **国际 CDN**`unpkg.com` / `jsdelivr.net` — 禁止在国内项目使用
**教训**:引用 JS/CSS 前先查 ShopXO 源码是否已自带(`public/static/common/lib/`),优先使用本地文件。
**规范写法(插件模板)**
- 控制器:`MyView('path', ['public_host' => \think\facade\Config::get('shopxo.host_url'), ...])`
- 模板:`{{$public_host}}static/...`
> ⚠️ **高危**:插件 JS/CSS 文件在 `app/`PHP runtime和 `public/`Nginx webroot各有一份副本。修改后必须同步两边并验证 MD5。详情见专项文档。
---
### 19. AdminSidebarInit 子菜单:`items`(复数)不是 `item`(单数)
**症状**sidebar 显示"VR票务"顶级菜单,但子菜单项(场馆配置/座位模板/电子票等)全部丢失,控制台无报错。
**根因**`Hook.php` 的 `AdminSidebarInit` 里,子菜单数组的 key 写成 `item`
```php
// ❌ 错误ShopXO 渲染器不认识 `item`
$params[] = [
'id' => 'plugins-vr_ticket',
'name' => 'VR票务',
'item' => [ // ← 单数ShopXO sidebar 不认
['name' => '场馆配置', 'url' => ..., 'is_show' => 1],
['name' => '座位模板', 'url' => ..., 'is_show' => 1],
]
];
```
ShopXO 后台 sidebar 模板(`app/admin/view/default/public/menu.html`)渲染逻辑:
```html
{{foreach $left_menu as $v}}
{{if empty($v['items'])}} <!-- 无子菜单,渲染为顶级单项 -->
{{else /}} <!-- 有子菜单,渲染为可折叠父项 -->
<ul class="admin-sidebar-sub">
{{foreach $v.items as $vs}} ... {{/foreach}}
</ul>
{{/if}}
{{/foreach}}
```
ShopXO 渲染器检查的是 **`items`(复数)**,不是 `item`(单数)。`item` 被当作普通属性忽略,所有子菜单项静默丢失。
**修复**
```php
// ✅ 正确:`items` 复数
$params[] = [
'id' => 'plugins-vr_ticket',
'name' => 'VR票务',
'items' => [ // ← 复数ShopXO sidebar 渲染器认识
['name' => '场馆配置', 'url' => ..., 'is_show' => 1],
['name' => '座位模板', 'url' => ..., 'is_show' => 1],
]
];
```
**调试方法**:在浏览器控制台执行:
```javascript
// 提取 sidebar 中所有菜单项 URL
const items = document.querySelectorAll('a.menu-item');
const vr = [];
items.forEach(a => {
const t = a.textContent.trim();
if(t.includes('VR')||t.includes('票务')||t.includes('场馆')||t.includes('座位'))
vr.push(a.href + ' => ' + t);
});
console.log(vr.join('\n'));
```
对比 `Hook.php` 中注册的所有 URL即可发现哪些子菜单项未出现在 DOM 中。
**教训**ShopXO 大量使用复数形式约定(`items`、`menus`、`params`),遇到"数据明明传了但不渲染"的问题时,优先检查属性名的单复数是否与渲染器约定一致。
---
## 📌 开发前检查清单
接手本插件时,逐项确认以下内容:
- [ ] `save.html` 有完整的 `{{:ModuleInclude('public/header')}}``{{:ModuleInclude('public/footer')}}`
- [ ] Vue 3 的 `<textarea>` 没有使用 `[[ ]]` 插值绑定 value
- [ ] 引用 JS/CSS 前先查 ShopXO 是否已自带(`public/static/common/lib/`),优先本地文件而非 CDN
- [ ] 插件模板使用 `$public_host` 时,控制器已显式传递(不依赖框架自动赋值)
- [ ] Hook.php 返回数组包含 `id`、`url`、`name`、`is_show`
- [ ] Hook.php `AdminSidebarInit` 子菜单数组的 key 是 `items`(复数),不是 `item`(单数)
- [ ] Admin.php 有缓存锁机制保护 `initialize()` 免于每次请求检查表
- [ ] 改字段名之前查过 Service 层源码或实际表结构
- [ ] 插件视图路径使用 `../../../plugins/vr_ticket/view/...` 前缀
- [ ] `php -l` 语法检查通过后再提交
---
## 🟣 P3 — ShopXO Service 层 Hook 机制2026-06-11 新增)
### 20. 插件 Hook 注册config.json + event.php 缺一不可
**问题**:搜索 API `search/datalist` 调用后,城市筛选不生效。
**排查过程**
1. 先在 `event.php` 注册了 `plugins_service_search_goods_list_begin`
2.`Hook.php` 实现了 `OnSearchListBegin()` 方法
3. 测试时 Hook 始终不被触发
4. 添加文件调试日志后确认:`switch: plugins_service_search_goods_list_begin hit!` 永远不出现
**根因**ShopXO 插件的 Hook 注册采用**双注册机制**
- `app/event.php` — 全局事件注册ShopXO 合并所有插件配置的地方)
- `config.json``hook` 部分 — **插件独立配置,事件系统实际读取这里**
漏了 `config.json` 中的注册,事件系统找不到插件的 Hook 实现。
```json
// config.json 中的 hook 部分(漏了会导致 Hook 不触发)
"hook": {
"plugins_service_goods_list_begin": [
"app\\plugins\\vr_ticket\\Hook"
],
"plugins_service_search_goods_list_begin": [ // ← 必须注册!
"app\\plugins\\vr_ticket\\Hook"
]
}
```
**教训**:新增 Hook 时,必须在**两处**同时注册:
1. `app/event.php` — 全局事件注册(便于其他模块调用)
2. `config.json``hook` 部分 — 插件独立配置(事件系统实际读取)
---
### 21. ShopXO Service 层硬编码 `is_backend = true`
**问题**:即使正确注册了 Hook筛选仍然不生效。
**排查过程**
1. 确认 Hook 被触发switch 日志出现)
2. 发现 `OnSearchListBegin` 被调用后立即 return
3. 原因:`$params['is_backend'] === true` 条件成立,触发提前返回
**根因**`SearchService::GoodsList()` 和 `GoodsService::GoodsList()` 在触发 Hook 时**硬编码** `is_backend = true`
```php
// SearchService.php:271
MyEventTrigger($hook_name, [
'hook_name' => $hook_name,
'is_backend' => true, // ← 硬编码,无论前端后台都是 true
'params' => &$params,
'where_base' => &$where_base,
...
]);
// GoodsService.php:1294
MyEventTrigger($hook_name, [
'hook_name' => $hook_name,
'is_backend' => true, // ← 硬编码
'params' => &$params,
'where' => &$where,
...
]);
```
这意味着ShopXO 的 Service 层不区分前端/后台请求,统一标记为 `is_backend = true`
**错误写法(原来)**
```php
// ❌ 错误:根据 is_backend 过滤前端请求
public function OnSearchListBegin(&$params)
{
if (($params['is_backend'] ?? false) === true) {
return; // ← 把所有请求都过滤掉了!
}
// ...
}
```
**正确写法(现在)**
```php
// ✅ 正确:不依赖 is_backend直接根据参数决定是否筛选
public function OnSearchListBegin(&$params)
{
$paramsArr = $params['params'] ?? [];
$cityId = isset($paramsArr['city_id']) ? intval($paramsArr['city_id']) : 0;
// 无坐标无city_id → 不筛选(向下兼容)
if ($cityId <= 0) {
$userLng = isset($paramsArr['user_lng']) ? floatval($paramsArr['user_lng']) : 0;
$userLat = isset($paramsArr['user_lat']) ? floatval($paramsArr['user_lat']) : 0;
if ($userLng != 0 && $userLat != 0) {
$cityId = \app\plugins\vr_ticket\service\GeoCityService::FindNearestCityId($userLng, $userLat);
}
}
if ($cityId <= 0) {
return; // 无城市信息,不筛选
}
// 注入城市筛选条件
$whereBase = &$params['where_base'];
if (!is_array($whereBase)) {
$whereBase = [];
}
$whereBase[] = ['g.produce_region', '=', $cityId];
}
```
**教训**
- `is_backend` 是 ShopXO 内部标记,不应用于区分前端/后台
- 应该根据请求参数(`city_id`、`user_lng`、`user_lat`)来决定是否筛选
- 无坐标时**不筛选**是正确行为(向下兼容)
---
### 22. ShopXO Service 层查询变量名不同
| Service | Hook 传入变量 | 说明 |
|---------|--------------|------|
| `GoodsService::GoodsList()` | `$params['where']` | 普通商品列表 |
| `SearchService::GoodsList()` | `$params['where_base']` | 搜索列表 |
**教训**:新增 Hook 时,必须检查目标 Service 的 Hook 触发代码,确认传入的变量名。
---
### 23. 调试代码保留策略
调试日志已注释保留,格式如下:
```php
// [调试代码] 如需调试此钩子,可取消下方注释:
// $debugFile = defined('ROOT') ? ROOT . 'runtime/debug_vr.log' : '/tmp/debug_vr.log';
// @file_put_contents($debugFile, date('Y-m-d H:i:s') . " OnSearchListBegin called\n", FILE_APPEND);
```
日志文件位置:
- Docker 环境:`/var/www/html/runtime/debug_vr.log`
- 本地:`/tmp/debug_vr.log`
**教训**ShopXO 的日志系统(`log_info`/`MyEventTrigger`)静默失败,改用 `file_put_contents` 直接写文件更可靠。
---
## 📌 搜索城市筛选 Hook 实施清单
- [x] `event.php` 注册 `plugins_service_search_goods_list_begin`
- [x] `config.json` 注册 `plugins_service_search_goods_list_begin`
- [x] `Hook.php` 实现 `OnSearchListBegin()` 方法
- [x] 移除 `is_backend === true` 检查
- [x] 使用 `where_base`(非 `where`)注入筛选条件
- [x] 使用 `ROOT`(非 `ROOT_PATH`)定位日志文件
- [x] 保留调试代码(注释状态)
---
### 24. ThinkPHP Query Builder 查询条件重置Critical
**发现日期**2026-06-22
**现象**:传入筛选参数(如 `order_id`、`page_size=1`API 返回全表数据,未生效任何筛选条件(包括 `user_id`),存在跨用户数据暴露的安全风险。
**根因**ThinkPHP ORM 的 Query Builder 实例在执行终端查询方法(如 `count()`、`find()`、`select()`)后,会自动清空当前实例中的 `where` 条件。
**错误代码示例**
```php
// ❌ 错误:复用 Query Builder 实例
public function getUserTicketsPaginated(int $userId, ?int $orderId = null) {
$db = Db::name('vr_tickets');
// 构建查询条件
$db->where('user_id', $userId);
if ($orderId > 0) {
$db->where('order_id', $orderId);
}
// 执行 count() —— 此时 where 条件被清空!
$total = $db->count();
// 再执行 select() —— 实际上是全表查询,没有任何 where 条件!
$tickets = $db->select()->toArray();
return ['total' => $total, 'list' => $tickets];
}
```
**执行过程分析**
1. `$db->where('user_id', $userId)` → 查询条件:`WHERE user_id = 1`
2. `$db->where('order_id', $orderId)` → 查询条件:`WHERE user_id = 1 AND order_id = 41`
3. `$db->count()` → 执行查询并返回结果,**同时清空 $db 实例中的 where 条件**
4. `$db->select()` → 实际执行的是 `SELECT * FROM vr_tickets`**无任何 where 条件**
**安全风险**
- 跨用户数据泄露user_id 筛选失效)
- 订单数据越权访问order_id 筛选失效)
- 分页失效(返回全表数据)
**正确写法 1使用 where 条件数组(推荐)**
```php
// ✅ 正确:使用 where 条件数组,避免状态污染
public function getUserTicketsPaginated(int $userId, ?int $orderId = null) {
$where = [
['user_id', '=', $userId]
];
if ($orderId > 0) {
$where[] = ['order_id', '=', $orderId];
}
// 分别独立执行,每次都传入完整的 where 条件
$total = Db::name('vr_tickets')->where($where)->count();
$tickets = Db::name('vr_tickets')->where($where)->select()->toArray();
return ['total' => $total, 'list' => $tickets];
}
```
**正确写法 2克隆 Query Builder 实例**
```php
// ✅ 正确克隆实例但不推荐性能和可读性不如方法1
public function getUserTicketsPaginated(int $userId, ?int $orderId = null) {
$db = Db::name('vr_tickets');
$db->where('user_id', $userId);
if ($orderId > 0) {
$db->where('order_id', $orderId);
}
// 克隆实例用于 count
$dbClone = clone $db;
$total = $dbClone->count();
// 原实例用于 select
$tickets = $db->select()->toArray();
return ['total' => $total, 'list' => $tickets];
}
```
**为什么会出现这个问题**
ThinkPHP Query Builder 采用**链式调用 + 状态复用**的设计:
- 每次调用 `where()` 都会修改实例内部的 `$options['where']` 数组
- 执行 `count()`、`select()` 等终端方法时,会根据 `$options` 构建 SQL 并执行
- 执行完毕后,为了允许实例被复用,会调用 `$this->removeOption()` 清空查询条件
- 这个设计在单次查询时没问题,但在"先 count 后 select"的场景下就会出现条件丢失
**教训**
1. **禁止复用 Query Builder 实例**进行多次查询count + select
2. 推荐使用 **where 条件数组**,在每次查询时独立传入
3. 这个问题在 ShopXO 的其他 Service 层代码中也很常见,需要全面排查
4. 这是一个 **Critical 级别的安全问题**,可能导致数据越权访问
**影响范围**
- 本次发现:`vr_ticket` 插件的 `WalletService::getUserTicketsPaginated` 方法
- 潜在风险:所有使用 Query Builder 复用模式的 Service 层代码
**验证方法**
```bash
# 测试是否存在问题
curl "http://localhost/api.php?s=plugins/index&pluginsname=vr_ticket&pluginscontrol=ticket&pluginsaction=list&token=xxx&order_id=41&page_size=1"
# 检查响应:
# - total 应该等于 tickets 数组长度(如果不等,说明 count 和 select 用了不同的条件)
# - 如果返回了其他用户的数据,说明 user_id 筛选失效
```
**相关 Issue**:修复 Query Builder 查询条件重置 Bug2026-06-22
---