resolve: 保留 fix/ticket-select-refresh 的 GitNexus 索引统计(26682 symbols)

main
Council 2026-06-25 17:28:14 +08:00
commit d704a482c7
19 changed files with 7095 additions and 3370 deletions

View File

@ -1,7 +1,7 @@
<!-- gitnexus:start -->
# GitNexus — Code Intelligence
This project is indexed by GitNexus as **vr-shopxo-plugin** (26564 symbols, 57490 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
This project is indexed by GitNexus as **vr-shopxo-plugin** (26682 symbols, 57680 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.

View File

@ -1,7 +1,7 @@
<!-- gitnexus:start -->
# GitNexus — Code Intelligence
This project is indexed by GitNexus as **vr-shopxo-plugin** (26564 symbols, 57490 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
This project is indexed by GitNexus as **vr-shopxo-plugin** (26682 symbols, 57680 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,86 @@
# VR 票务插件开发日志
> vr-shopxo-plugin 项目全量记录
> 仓库http://xmhome.ow-my.com:3000/sileya-ai/vr-shopxo-plugin
> 最后更新2026-04-15
> 最后更新2026-06-22
---
## 2026-06-22 — 票务 API 优化与查询重置 Bug 修复
### 背景
前端 UniApp 订单模块反馈需要后端支持订单筛选功能。当前实现是前端全量拉取票务列表后在客户端进行 `goods_id` 筛选,效率低下。
### 需求
1. **订单筛选支持**
- `order_id`: 单订单精准筛选
- `order_ids`: 批量订单筛选(逗号分隔)
- `goods_id`: 商品筛选
- `status`: 核销状态筛选
2. **分页支持**
- `page`: 页码控制
- `page_size`: 每页数量1-100
### 实施过程
#### 新增功能
**1. WalletService 新增方法**
- `formatTickets()`: 统一票据格式化逻辑
- `getUserTicketsPaginated()`: 支持订单筛选和分页的查询方法
**2. Ticket API 控制器更新**
- `list` 接口新增 6 个筛选参数
- 响应格式增加 `total`, `page`, `page_size`, `pages` 分页字段
- 票据对象增加 `order_id` 字段
#### Bug 修复
**🐛 Bug #1: 查询条件重置Critical**
- **现象**: 传入筛选参数后,接口返回全表数据,未生效任何筛选条件(包括 `user_id`),存在跨用户数据暴露风险。
- **根因**: `WalletService::getUserTicketsPaginated` 中,构建完 Query Builder 实例后执行 `$db->count()`。在 ThinkPHP ORM 机制下,终端查询方法(如 `count()`)执行完成后会清空当前 query 实例中的 `where` 条件。导致后续 `$db->select()` 在没有任何 where 约束的情况下执行了全表查询。
- **解决**: 放弃复用 Query Builder 实例,改用 `$where` 数组存储条件,在 `count()``select()` 中分别独立传入执行,彻底避免状态污染与越权隐患。
**🐛 Bug #2: page_size 最小值限制**
- **现象**: 传入 `page_size=1` 却被后端强制改为 `page_size=10`
- **根因**: API 控制器中使用了 `max(10, intval(input('page_size', 20)))`,导致最小值被强制锁死为 10。
- **解决**: 将最小值限制修改为 `max(1, ...)`,允许客户端根据实际需求精准获取单条数据。
### 验证测试
通过宿主机执行 `curl` 请求模拟前端调用验证:
```bash
curl -s "http://localhost:10000/api.php?s=plugins/index&pluginsname=vr_ticket&pluginscontrol=ticket&pluginsaction=list&token=xxx&page_size=1&page=1" | python3 -m json.tool
```
**响应结果**
- `page_size` 被正确解析并返回为 `1`
- `tickets` 列表长度为 `1`
- 总数 `total` 正常返回为 `5`,总页数 `pages` 正确计算为 `5`
- 所有查询条件用户ID、筛选等均生效 ✅
### Git Commit
```
[待提交] fix(vr_ticket): 修复查询条件重置Bug + 新增订单筛选和分页功能
```
### 影响范围分析GitNexus
- **`WalletService::getUserTickets`**: 影响范围 `LOW`,仅被 `Ticket.php``list` 接口调用
- **`gitnexus_detect_changes()`**: 变更仅局限于相关文件,风险等级 `low`
### 相关文档
- [API 文档](file:///Users/bigemon/WorkSpace/vr-shopxo-plugin/docs/api/VR_TICKET_WALLET_VERIFY_API.md) v1.2.0(已更新)
- [实施完成报告](file:///Users/bigemon/.gemini/antigravity-ide/brain/84fa0992-c4b7-4511-94e1-99e3ea98793e/walkthrough.md)
---

View File

@ -610,3 +610,124 @@ public function OnSearchListBegin(&$params)
- [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
---

View File

@ -1,7 +1,8 @@
# VR票务插件 C端 API 文档
> **版本**: 1.1.0
> **最后更新**: 2026-05-28
> **版本**: 1.3.0
> **最后更新**: 2026-06-22
> **更新内容**: 新增瀑布流参数支持last_id + limit + order_by
本文档描述了 VR 票务插件(`vr_ticket`)的 C 端 UniApp API涵盖票夹、核销、核销记录三类接口适用于移动端用户及授权核销员使用。
@ -93,23 +94,39 @@ Header Value: {user_token}
## 四、票夹 API
> **基础路由**: `/api.php?s=plugins/index&pluginsname=vr_ticket&pluginscontrol=ticket&pluginsaction={action}`
> **基础路由**: `/api.php?s=plugins/index&pluginsname=vr_ticket&pluginscontrol=ticket&pluginsaction={action}`
> **认证**: C 端登录态(无需核销员身份)
### 4.1 获取用户票列表
### 4.1 获取用户票列表**新增功能**
```
GET ...&pluginsaction=list
GET ...&pluginsaction=tickets (别名)
```
**成功响应 `data`**:
#### 请求参数
| 参数 | 类型 | 必填 | 默认值 | 说明 |
|------|------|------|--------|------|
| `order_id` | int | ❌ | - | **【新增】** 按单个订单 ID 精准筛选 |
| `order_ids` | string | ❌ | - | **【新增】** 按多个订单 ID 批量筛选,逗号分隔,如 `41,42,43` |
| `goods_id` | int | ❌ | - | **【新增】** 按商品 ID 筛选 |
| `status` | int | ❌ | - | **【新增】** 按核销状态筛选:`0`=未核销,`1`=已核销,`2`=已退款 |
| `page` | int | ❌ | `1` | **【新增】** 页码 |
| `page_size` | int | ❌ | `20` | **【新增】** 每页数量(最小 1最大 100 |
> **注意**:
> - 所有筛选参数均为**可选**。不同参数之间是 **AND** 关系。
> - 例如同时传 `order_id=41&goods_id=119` 表示「订单 41 中商品 ID 为 119 的票据」。
#### 成功响应 `data`
```json
{
"tickets": [
{
"id": 123,
"order_id": 41,
"goods_id": 456,
"goods_title": "周杰伦演唱会-北京站",
"goods_image": "https://...jpg",
@ -124,20 +141,306 @@ GET ...&pluginsaction=tickets (别名)
"short_code": "000ca1b2"
}
],
"count": 1
"total": 50,
"page": 1,
"page_size": 20,
"pages": 3
}
```
| 字段 | 类型 | 说明 |
|------|------|------|
| `tickets` | array | 票据列表数组 |
| `total` | int | **【新增】** 总记录数 |
| `page` | int | **【新增】** 当前页码 |
| `page_size` | int | **【新增】** 每页数量 |
| `pages` | int | **【新增】** 总页数 |
| `order_id` | int | **【新增】** 订单 ID票据对象内 |
| `verify_status` | int | `0`=未核销 `1`=已核销 `2`=已退款 |
| `short_code` | string | 9位短码可供核销员扫码 |
| `short_code` | string | 8-9位短码可供核销员扫码 |
| `seat_info` | string | 完整 5 维坐席信息,`场次\|场馆\|演播室\|分区\|座位号` |
#### 使用场景
**场景 1订单详情页 - 查看当前订单的所有票据**
```javascript
// 优化前(低效)- 全量拉取后前端过滤
uni.request({
url: app.globalData.get_request_url('list', 'ticket', 'vr_ticket'),
success: function(res) {
var allTickets = res.data.data.tickets || [];
var orderTickets = allTickets.filter(function(t) {
return goodsIds.indexOf(t.goods_id) !== -1;
});
}
});
// 优化后(高效)- 后端直接筛选
uni.request({
url: app.globalData.get_request_url('list', 'ticket', 'vr_ticket'),
data: {
order_id: orderId, // 直接传订单ID
page_size: 100 // 订单票据通常不会太多
},
success: function(res) {
var orderTickets = res.data.data.tickets || [];
// 无需前端过滤,直接使用
}
});
```
**场景 2票夹页 - 分页加载**
```javascript
uni.request({
url: app.globalData.get_request_url('list', 'ticket', 'vr_ticket'),
data: {
page: 1,
page_size: 20,
status: 0 // 只看未核销的票
},
success: function(res) {
var data = res.data.data;
console.log('总票数:', data.total);
console.log('总页数:', data.pages);
console.log('当前页:', data.page);
}
});
```
**场景 3批量订单查询**
```javascript
uni.request({
url: app.globalData.get_request_url('list', 'ticket', 'vr_ticket'),
data: {
order_ids: '41,42,43', // 一次查询多个订单
page_size: 50
});
```
---
### 4.1.1 瀑布流模式 ⭐ **v1.3.0 新增**
瀑布流模式专为移动端无限滚动场景设计,基于 ID 游标实现高性能连续加载。
#### 触发条件
- 传入 `last_id > 0``limit` 参数时自动启用瀑布流模式
- 否则使用传统分页模式(向后兼容)
#### 新增参数
| 参数 | 类型 | 必填 | 默认值 | 说明 |
|------|------|------|--------|------|
| `last_id` | int | ❌ | `0` | 游标 ID`0` 表示首次加载 |
| `limit` | int | ❌ | `20` | 每次拉取数量(最小 1最大 100 |
| `order_by` | string | ❌ | `desc` | 排序方向:`desc`(降序,历史方向)/ `asc`(升序,新数据方向) |
#### 响应格式
```json
{
"tickets": [
{
"id": 69,
"order_id": 41,
"goods_id": 456,
"goods_title": "周杰伦演唱会-北京站",
"seat_info": "2026-06-01 20:00|国家体育馆|主厅|A区|A1",
"session_time": "2026-06-01 20:00",
"venue_name": "国家体育馆",
"verify_status": 0,
"issued_at": 1716307200,
"short_code": "000ca1b2"
}
],
"has_more": true,
"last_id": 65,
"count": 3
}
```
| 字段 | 类型 | 说明 |
|------|------|------|
| `tickets` | array | 票据列表数组 |
| `has_more` | bool | 是否还有更多数据 |
| `last_id` | int | 本次返回的最后一条 ID下次请求的游标 |
| `count` | int | 本次返回数量 |
#### 使用场景
**场景 1首次加载最新票据**
```bash
GET /list?limit=20&order_by=desc
```
**响应**
```json
{
"tickets": [{id: 100, ...}, {id: 99, ...}, ...],
"has_more": true,
"last_id": 81,
"count": 20
}
```
**场景 2下拉加载更多历史数据**
```bash
GET /list?last_id=81&limit=20&order_by=desc
```
**响应**
```json
{
"tickets": [{id: 80, ...}, {id: 79, ...}, ...],
"has_more": true,
"last_id": 61,
"count": 20
}
```
**场景 3上拉刷新新数据**
```bash
GET /list?last_id=100&limit=20&order_by=asc
```
**响应**
```json
{
"tickets": [{id: 101, ...}, {id: 102, ...}],
"has_more": false,
"last_id": 102,
"count": 2
}
```
#### UniApp 集成示例
```javascript
data() {
return {
tickets: [],
lastId: 0,
hasMore: true,
loading: false
}
},
methods: {
// 首次加载
loadTickets() {
this.loading = true;
uni.request({
url: app.globalData.get_request_url('list', 'ticket', 'vr_ticket'),
data: {
limit: 20,
order_by: 'desc'
},
success: (res) => {
const data = res.data.data;
this.tickets = data.tickets;
this.hasMore = data.has_more;
this.lastId = data.last_id;
},
complete: () => {
this.loading = false;
}
});
},
// 下拉加载更多
loadMore() {
if (!this.hasMore || this.loading) return;
this.loading = true;
uni.request({
url: app.globalData.get_request_url('list', 'ticket', 'vr_ticket'),
data: {
last_id: this.lastId,
limit: 20,
order_by: 'desc'
},
success: (res) => {
const data = res.data.data;
this.tickets = this.tickets.concat(data.tickets);
this.hasMore = data.has_more;
this.lastId = data.last_id;
},
complete: () => {
this.loading = false;
}
});
},
// 上拉刷新(获取新票)
refresh() {
if (this.tickets.length === 0) {
this.loadTickets();
return;
}
const firstId = this.tickets[0].id;
uni.request({
url: app.globalData.get_request_url('list', 'ticket', 'vr_ticket'),
data: {
last_id: firstId,
limit: 20,
order_by: 'asc' // 升序获取新数据
},
success: (res) => {
const data = res.data.data;
if (data.count > 0) {
// 新数据插入到列表前面
this.tickets = data.tickets.reverse().concat(this.tickets);
uni.showToast({ title: `新增 ${data.count} 张票` });
} else {
uni.showToast({ title: '已是最新' });
}
}
});
}
},
onLoad() {
this.loadTickets();
},
onReachBottom() {
this.loadMore();
},
onPullDownRefresh() {
this.refresh();
setTimeout(() => {
uni.stopPullDownRefresh();
}, 1000);
}
```
#### 技术优势
**vs 传统分页**
- ✅ 无缝滚动体验(无页码概念)
- ✅ 支持双向拉取(下拉历史,上拉新数据)
- ✅ 数据变化时不会重复/遗漏(基于 ID 游标)
- ✅ 性能更好(`WHERE id < ?` + `LIMIT``OFFSET` 高效)
**排序说明**
- 使用 `id` 字段排序(主键索引,性能最优)
- `id` 单调递增,确保顺序唯一性
- `issued_at` 已添加索引,支持未来按发放时间筛选
---
### 4.2 获取票详情(含 QR Payload
```
GET ...&pluginsaction=detail&id={ticket_id}
```
@ -154,6 +457,7 @@ GET ...&pluginsaction=detail&id={ticket_id}
{
"ticket": {
"id": 123,
"order_id": 41,
"goods_id": 456,
"goods_title": "周杰伦演唱会-北京站",
"goods_image": "https://...jpg",
@ -237,7 +541,7 @@ GET ...&pluginsaction=checkVerifier
## 五、核销 APIUniApp 授权核销员专用)
> **基础路由**: `/api.php?s=plugins/index&pluginsname=vr_ticket&pluginscontrol=ticket&pluginsaction=verify`
> **基础路由**: `/api.php?s=plugins/index&pluginsname=vr_ticket&pluginscontrol=ticket&pluginsaction=verify`
> **认证**: C 端登录态 **+** 必须是 `vr_verifiers` 表中 `status=1` 的授权核销员
### 5.1 扫码核销
@ -286,7 +590,7 @@ Content-Type: application/x-www-form-urlencoded
## 六、核销记录 APIUniApp 授权核销员专用)
> **基础路由**: `/api.php?s=plugins/index&pluginsname=vr_ticket&pluginscontrol=ticket&pluginsaction=myVerifications`
> **基础路由**: `/api.php?s=plugins/index&pluginsname=vr_ticket&pluginscontrol=ticket&pluginsaction=myVerifications`
> **认证**: C 端登录态 **+** 必须是 `vr_verifiers` 表中 `status=1` 的授权核销员
### 6.1 我的核销记录
@ -300,7 +604,7 @@ GET ...&pluginsaction=myVerifications
| 参数 | 类型 | 必填 | 默认值 | 说明 |
|------|------|------|--------|------|
| `page` | int | ❌ | `1` | 页码 |
| `page_size` | int | ❌ | `20` | 每页条数(最大 100 |
| `page_size` | int | ❌ | `20` | 每页条数(最小 1大 100 |
**成功响应 `data`**:
@ -351,6 +655,41 @@ GET ...&pluginsaction=myVerifications
---
## 八、变更日志与 Bug 修复
### v1.2.0 (2026-06-22)
#### 新增功能
1. **订单筛选支持**
- `order_id`: 单订单精准筛选
- `order_ids`: 批量订单筛选(逗号分隔)
- `goods_id`: 商品筛选
- `status`: 核销状态筛选
2. **分页支持**
- `page`: 页码控制
- `page_size`: 每页数量1-100
- 响应增加 `total`, `page`, `page_size`, `pages` 字段
3. **响应格式优化**
- 票据对象增加 `order_id` 字段,便于前端业务关联
#### Bug 修复
**🐛 Bug #1: 查询条件重置Critical**
- **现象**: 传入筛选参数后,接口返回全表数据,未生效任何筛选条件(包括 `user_id`),存在跨用户数据暴露风险。
- **根因**: `WalletService::getUserTicketsPaginated` 中,构建完 Query Builder 实例后执行 `$db->count()`。在 ThinkPHP ORM 机制下,终端查询方法(如 `count()`)执行完成后会清空当前 query 实例中的 `where` 条件。导致后续 `$db->select()` 在没有任何 where 约束的情况下执行了全表查询。
- **解决**: 放弃复用 Query Builder 实例,改用 `$where` 数组存储条件,在 `count()``select()` 中分别独立传入执行,彻底避免状态污染与越权隐患。
**🐛 Bug #2: page_size 最小值限制**
- **现象**: 传入 `page_size=1` 却被后端强制改为 `page_size=10`
- **根因**: API 控制器中使用了 `max(10, intval(input('page_size', 20)))`,导致最小值被强制锁死为 10。
- **解决**: 将最小值限制修改为 `max(1, ...)`,允许客户端根据实际需求精准获取单条数据。
---
## 附录:数据字典
### vr_tickets 电子票表

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -101,15 +101,15 @@ return array (
array (
0 => 'app\\plugins\\vr_ticket\\Hook',
),
'plugins_service_goods_list_begin' =>
'plugins_service_goods_list_begin' =>
array (
0 => 'app\\plugins\\vr_ticket\\Hook',
),
'plugins_service_search_goods_list_begin' =>
'plugins_service_search_goods_list_begin' =>
array (
0 => 'app\\plugins\\vr_ticket\\Hook',
),
'plugins_service_search_goods_list_result' =>
'plugins_service_search_goods_list_result' =>
array (
0 => 'app\\plugins\\vr_ticket\\Hook',
),

View File

@ -383,6 +383,107 @@ class Hook
}
}
// ──────────────────────────────────────────────────────
// Step 3: 观影人信息校验(新增)
// ──────────────────────────────────────────────────────
// 1. 获取前端提交的 viewer_data
$viewerData = $params['data']['viewer_data'] ?? [];
if (!is_array($viewerData)) {
$viewerData = [];
}
// 2. 获取商品配置中的观影人要求
$viewerConfig = null;
foreach ($goodsItems as $item) {
$gid = intval($item['goods_id'] ?? 0);
if ($gid <= 0) continue;
// 只查询票务商品
$isTicketGood = isset($ticketGoodsMap[$gid]) && $ticketGoodsMap[$gid]['has_config'];
if (!$isTicketGood) continue;
$goods = Db::name('Goods')->where('id', $gid)->field('vr_goods_config')->find();
if (!empty($goods['vr_goods_config'])) {
$config = json_decode($goods['vr_goods_config'], true);
if (is_array($config) && !empty($config[0]['viewer_config'])) {
$viewerConfig = $config[0]['viewer_config'];
break;
}
}
}
// 3. 如果商品要求观影人信息,进行校验
if (!empty($viewerConfig) && !empty($viewerConfig['require_viewer'])) {
// 检查 viewer_data 是否存在且为数组
if (empty($viewerData)) {
return [
'code' => -1,
'msg' => '请填写观影人信息',
];
}
// 阶段1只支持单个观影人require_viewer_per_seat=0
$perSeat = !empty($viewerConfig['require_viewer_per_seat']) ? $viewerConfig['require_viewer_per_seat'] : 0;
if ($perSeat == 0) {
if (count($viewerData) != 1) {
return [
'code' => -1,
'msg' => '当前商品只需填写一个观影人信息',
];
}
} else {
return [
'code' => -1,
'msg' => '暂不支持每个座位独立填写观影人',
];
}
// 4. 遍历校验每个观影人的字段
$viewer = $viewerData[0] ?? [];
// 手机号校验
if (!empty($viewerConfig['require_viewer_mobile'])) {
if (empty($viewer['mobile'])) {
return [
'code' => -1,
'msg' => '请填写观影人手机号',
];
}
if (!preg_match('/^1[3-9]\d{9}$/', $viewer['mobile'])) {
return [
'code' => -1,
'msg' => '手机号格式不正确',
];
}
}
// 姓名校验
if (!empty($viewerConfig['require_viewer_name'])) {
if (empty($viewer['name'])) {
return [
'code' => -1,
'msg' => '请填写观影人姓名',
];
}
}
// 身份证号校验阶段1预留
if (!empty($viewerConfig['require_viewer_idcard'])) {
if (empty($viewer['idcard'])) {
return [
'code' => -1,
'msg' => '请填写观影人身份证号',
];
}
if (!preg_match('/^\d{17}[\dXx]$/', $viewer['idcard'])) {
return [
'code' => -1,
'msg' => '身份证号格式不正确',
];
}
}
}
return ['code' => 0, 'msg' => ''];
}
@ -407,6 +508,7 @@ class Hook
* 触发位置plugins_service_goods_list_begin
*
* 向下兼容:无 user_lng/user_lat 参数时,不添加任何筛选,返回全部商品
* 修复只对票务商品vr_goods_config 非空)应用城市筛选,非票务商品不受影响
*
* @param array &$params 钩子参数引用 ['hook_name','is_backend','params','where','field','order_by','m','n']
*/
@ -439,12 +541,22 @@ class Hook
return;
}
// 注入 produce_region 城市筛选条件
// 修复:只对票务商品应用城市筛选
// 逻辑:(非票务商品 vr_goods_config='') OR (票务商品且城市匹配)
$where = &$params['where'];
if (!is_array($where)) {
$where = [];
}
$where[] = ['g.produce_region', '=', $cityId];
// 使用闭包实现 OR 条件vr_goods_config 为空(非票务) OR produce_region = 城市ID
$where[] = function($query) use ($cityId) {
$query->where(function($q) {
$q->where('g.vr_goods_config', '=', '');
})->whereOr(function($q) use ($cityId) {
$q->where('g.vr_goods_config', '<>', '')->where('g.produce_region', '=', $cityId);
});
};
// @file_put_contents($debugFile, date('Y-m-d H:i:s') . " OnGoodsListBegin: city filter applied to ticket goods only, cityId=$cityId\n", FILE_APPEND);
}
/**
@ -452,6 +564,7 @@ class Hook
* 触发位置plugins_service_search_goods_list_begin
*
* 向下兼容:无 user_lng/user_lat 参数时,不添加任何筛选,返回全部商品
* 修复只对票务商品vr_goods_config 非空)应用城市筛选,非票务商品不受影响
*
* @param array &$params 钩子参数引用 ['hook_name','is_backend','params','where_base','where_keywords','where_screening_price','field','order_by','page','page_start','page_size']
*/
@ -485,13 +598,23 @@ class Hook
return;
}
// 注入 produce_region 城市筛选条件SearchService 使用 where_base 而非 where
// 修复:只对票务商品应用城市筛选
// 逻辑:(非票务商品 vr_goods_config='') OR (票务商品且城市匹配)
// SearchService 使用 where_base 而非 where
$whereBase = &$params['where_base'];
if (!is_array($whereBase)) {
$whereBase = [];
}
$whereBase[] = ['g.produce_region', '=', $cityId];
// [调试代码] @file_put_contents($debugFile, date('Y-m-d H:i:s') . " OnSearchListBegin: city filter applied, cityId=$cityId\n", FILE_APPEND);
// 使用闭包实现 OR 条件vr_goods_config 为空(非票务) OR produce_region = 城市ID
$whereBase[] = function($query) use ($cityId) {
$query->where(function($q) {
$q->where('g.vr_goods_config', '=', '');
})->whereOr(function($q) use ($cityId) {
$q->where('g.vr_goods_config', '<>', '')->where('g.produce_region', '=', $cityId);
});
};
// [调试代码] @file_put_contents($debugFile, date('Y-m-d H:i:s') . " OnSearchListBegin: city filter applied to ticket goods only, cityId=$cityId\n", FILE_APPEND);
}
/**

View File

@ -197,6 +197,18 @@ class Admin extends Common
$goods = \think\facade\Db::name('Goods')->find($ticket['goods_id']);
// 查询订单信息
$order = [];
$user = [];
if ($ticket['order_id'] > 0) {
$order = \think\facade\Db::name('Order')->find($ticket['order_id']);
// 查询用户信息
if (!empty($order) && $order['user_id'] > 0) {
$user = \think\facade\Db::name('User')->find($order['user_id']);
}
}
$verifier = [];
if ($ticket['verifier_id'] > 0) {
$verifier = \think\facade\Db::name('vr_verifiers')->find($ticket['verifier_id']);
@ -204,6 +216,12 @@ class Admin extends Common
$ticket['qr_code_url'] = \app\plugins\vr_ticket\service\TicketService::getQrCodeUrl($ticket['ticket_code']);
// 提取短码(从 qr_data 的前半部分)
$short_code = '';
if (!empty($ticket['qr_data']) && strpos($ticket['qr_data'], '|') !== false) {
$short_code = explode('|', $ticket['qr_data'], 2)[0];
}
$verifiers = \think\facade\Db::name('vr_verifiers')
->where('status', 1)
->order('id', 'asc')
@ -212,8 +230,11 @@ class Admin extends Common
return MyView('../../../plugins/vr_ticket/view/admin/ticket/detail', [
'ticket' => $ticket,
'goods' => $goods,
'order' => $order,
'user' => $user,
'verifier' => $verifier,
'verifiers' => $verifiers,
'short_code'=> $short_code,
]);
}

View File

@ -113,6 +113,23 @@ class Ticket
*
* GET /api.php?s=plugins/index&pluginsname=vr_ticket&pluginscontrol=ticket&pluginsaction=list
*
* 支持两种模式:
*
* 【模式 1:传统分页】
* - page: 页码,默认 1
* - page_size: 每页数量,默认 20,最大 100
*
* 【模式 2瀑布流】last_id > 0 或传入 limit 时启用)
* - last_id: 游标 ID0 表示首次加载
* - limit: 每次拉取数量,默认 20,最大 100
* - order_by: 排序方向desc降序历史 / asc升序新数据默认 desc
*
* 通用筛选参数:
* - order_id: 按单个订单ID筛选
* - order_ids: 按多个订单ID批量筛选逗号分隔 41,42,43
* - goods_id: 按商品ID筛选
* - status: 按核销状态筛选 (0=未核销, 1=已核销, 2=已退款)
*
* @return Json
*/
public function list()
@ -122,15 +139,70 @@ class Ticket
return self::unauthorized();
}
try {
$tickets = WalletService::getUserTickets($userId);
// 解析筛选参数
$orderId = input('order_id', 0, 'intval');
$orderIdsStr = input('order_ids', '', 'trim');
$orderIds = !empty($orderIdsStr) ? array_filter(array_map('intval', explode(',', $orderIdsStr))) : null;
$goodsId = input('goods_id', 0, 'intval');
$status = input('status', null, 'intval');
return self::success([
'tickets' => $tickets,
'count' => count($tickets),
]);
} catch (\Exception $e) {
return self::error('获取票列表失败: ' . $e->getMessage());
// 判断模式:瀑布流 vs 分页
$lastId = input('last_id', 0, 'intval');
$hasLimitParam = input('limit') !== null && input('limit') !== '';
if ($lastId > 0 || $hasLimitParam) {
// 瀑布流模式
$limit = min(100, max(1, intval(input('limit', 20))));
$orderByInput = strtolower(trim(input('order_by', 'desc')));
$orderBy = in_array($orderByInput, ['asc', 'desc']) ? $orderByInput : 'desc';
try {
$result = WalletService::getUserTicketsWaterfall(
$userId,
$lastId,
$orderBy,
$limit,
$orderId,
$orderIds,
$goodsId,
$status
);
return self::success([
'tickets' => $result['list'],
'has_more' => $result['has_more'],
'last_id' => $result['last_id'],
'count' => $result['count'],
]);
} catch (\Exception $e) {
return self::error('获取票列表失败: ' . $e->getMessage());
}
} else {
// 传统分页模式(保持兼容)
$page = max(1, intval(input('page', 1)));
$pageSize = min(100, max(1, intval(input('page_size', 20))));
try {
$result = WalletService::getUserTicketsPaginated(
$userId,
$orderId,
$orderIds,
$goodsId,
$status,
$page,
$pageSize
);
return self::success([
'tickets' => $result['list'],
'total' => $result['total'],
'page' => $result['page'],
'page_size' => $result['page_size'],
'pages' => $result['pages'],
]);
} catch (\Exception $e) {
return self::error('获取票列表失败: ' . $e->getMessage());
}
}
}
@ -309,7 +381,8 @@ class Ticket
->where($where)
->order('id', 'desc')
->page($page, $page_size)
->select();
->select()
->toArray();
$total = \think\facade\Db::name('vr_verifications')
->where($where)
->count();
@ -317,12 +390,28 @@ class Ticket
// 关联票和商品信息
$ticket_ids = array_filter(array_column($list, 'ticket_id'));
$tickets_map = [];
$user_ids = [];
if (!empty($ticket_ids)) {
$tickets_raw = \think\facade\Db::name('vr_tickets')
->where('id', 'in', $ticket_ids)
->select();
->select()
->toArray();
foreach ($tickets_raw as $t) {
$tickets_map[$t['id']] = $t;
if (!empty($t['user_id'])) {
$user_ids[] = $t['user_id'];
}
}
}
$users_map = [];
if (!empty($user_ids)) {
$users_raw = \think\facade\Db::name('User')
->where('id', 'in', array_unique($user_ids))
->select()
->toArray();
foreach ($users_raw as $u) {
$users_map[$u['id']] = !empty($u['nickname']) ? $u['nickname'] : (!empty($u['username']) ? $u['username'] : '用户' . $u['id']);
}
}
@ -338,12 +427,27 @@ class Ticket
$result = [];
foreach ($list as $v) {
$ticket = $tickets_map[$v['ticket_id']] ?? [];
// 提取短码(从 qr_data 的前半部分)
$short_code = '';
if (!empty($ticket['qr_data']) && strpos($ticket['qr_data'], '|') !== false) {
$short_code = explode('|', $ticket['qr_data'], 2)[0];
} else {
$short_code = !empty($ticket['ticket_code']) ? substr($ticket['ticket_code'], 0, 8) : '';
}
$buyer_name = $users_map[$ticket['user_id'] ?? 0] ?? '';
$result[] = [
'id' => $v['id'],
'ticket_id' => $v['ticket_id'],
'ticket_code' => $v['ticket_code'],
'short_code' => $short_code,
'seat_info' => $ticket['seat_info'] ?? '',
'real_name' => $ticket['real_name'] ?? '',
'real_name' => $ticket['real_name'] ?? '',
'phone' => $ticket['phone'] ?? '',
'order_no' => $ticket['order_no'] ?? '',
'buyer_name' => $buyer_name,
'goods_title' => $goods_map[$v['goods_id']] ?? '已下架商品',
'created_at' => $v['created_at'],
];

View File

@ -83,7 +83,7 @@ class AdminGoodsSave
<div v-show="isTicket" style="margin-top:20px; border-top:1px solid #eee; padding-top:15px;" v-cloak>
<div class="am-form-group">
<label>请选择场馆模板(可多选)</label>
<label>请选择场馆模板(可多选)<span style="color:#e00;font-weight:bold;"> v1.0.1</span></label>
<div style="margin-top:10px;">
<label v-for="t in templates" :key="t.id" class="am-checkbox-inline" style="margin-right:20px;">
<input type="checkbox" :value="t.id" v-model="selectedTemplateIds" @change="onTemplateChange" />
@ -123,6 +123,54 @@ class AdminGoodsSave
</div>
</div>
<!-- 观影人信息设置 -->
<div class="am-form-group" style="background: #f0f8ff; padding: 12px; border: 1px solid #b0d4f1; border-radius: 4px; margin-bottom:15px;">
<label style="font-weight:bold;">
观影人信息设置
<span style="color:#999; font-size:12px; font-weight:normal; margin-left:8px;">(票务实名制选项)</span>
</label>
<div style="margin-top:10px;">
<label class="am-checkbox-inline">
<input type="checkbox" v-model="config.viewer_config.require_viewer" />
<span style="margin-left:5px;">需要填写观影人信息</span>
</label>
</div>
<div v-show="config.viewer_config.require_viewer" style="margin-left:20px; margin-top:10px; padding-left:15px; border-left:3px solid #ddd;">
<div style="margin-bottom:8px;">
<label class="am-checkbox-inline">
<input type="checkbox" v-model="config.viewer_config.require_viewer_mobile" checked disabled />
<span style="margin-left:5px;">必填手机号</span>
<small style="color:#999; margin-left:8px;">(核心字段,默认必填)</small>
</label>
</div>
<div style="margin-bottom:8px;">
<label class="am-checkbox-inline">
<input type="checkbox" v-model="config.viewer_config.require_viewer_name" />
<span style="margin-left:5px;">必填姓名</span>
</label>
</div>
<div style="margin-bottom:8px;">
<label class="am-checkbox-inline">
<input type="checkbox" v-model="config.viewer_config.require_viewer_idcard" />
<span style="margin-left:5px;">必填身份证号</span>
</label>
</div>
<!-- 每个座位独立填写观影人阶段2功能暂时隐藏 -->
<div v-if="false" style="margin-bottom:8px;">
<label class="am-checkbox-inline">
<input type="checkbox" v-model="config.viewer_config.require_viewer_per_seat" disabled />
<span style="margin-left:5px;">每个座位独立填写观影人</span>
<small style="color:#999; margin-left:8px;">阶段2功能暂不可用</small>
</label>
</div>
</div>
</div>
<div class="am-form-group">
<label>演播厅选择</label>
<div style="display:flex; flex-wrap:wrap; gap:15px; margin-top:5px;">
@ -220,6 +268,17 @@ class AdminGoodsSave
}
if (!c.selected_sections) c.selected_sections = {};
// 【新增】确保 viewer_config 结构完整(兼容老数据)
if (!c.viewer_config) {
c.viewer_config = {
require_viewer: 0,
require_viewer_per_seat: 0,
require_viewer_mobile: 1,
require_viewer_name: 0,
require_viewer_idcard: 0
};
}
// 【核心清洗】过滤非法 room ID
const validRooms = getRooms(c.template_id);
const validRoomIds = validRooms.map(r => String(r.id));
@ -269,6 +328,14 @@ class AdminGoodsSave
selected_rooms: [],
selected_sections: {},
sessions: defaultSessions(),
// 新增:观影人配置默认值
viewer_config: {
require_viewer: 0,
require_viewer_per_seat: 0,
require_viewer_mobile: 1, // 默认必填
require_viewer_name: 0,
require_viewer_idcard: 0
}
};
});
};

View File

@ -230,6 +230,9 @@ class AdminGoodsSaveHandle
// c) 重新计算 goods.price / goods.inventory
SeatSkuService::refreshGoodsBase($goodsId);
// d) 清除 Tree API 缓存(避免前端看到过期数据)
self::clearTreeCache($goodsId);
}
}
}
@ -237,6 +240,37 @@ class AdminGoodsSaveHandle
return ['code' => 0];
}
return ['code' => 0];
}
/**
* 清除指定商品的 Tree API 缓存
*
* @param int $goodsId 商品ID
* @return void
*/
private static function clearTreeCache($goodsId)
{
if ($goodsId <= 0) {
return;
}
// Tree API 缓存 key 格式vr_tree_v4_{goods_id}_{md5(group_by)}
// 由于 group_by 参数可能有多种组合,清除所有可能的缓存
$possibleGroupBys = [
'venue,session,room,section', // 默认组合
'session,venue,room,section', // 按场次优先
'venue,room,session,section', // 其他可能组合
'session,room,section', // 简化组合
];
foreach ($possibleGroupBys as $groupBy) {
$cacheKey = 'vr_tree_v4_' . $goodsId . '_' . md5($groupBy);
\think\facade\Cache::delete($cacheKey);
}
// 记录日志
\think\facade\Log::info('[VR Ticket] Cleared Tree API cache for goods: ' . $goodsId);
}
}

View File

@ -39,7 +39,8 @@ CREATE TABLE IF NOT EXISTS `{{prefix}}vr_tickets` (
KEY `idx_goods_id` (`goods_id`),
KEY `idx_verify_status` (`verify_status`),
KEY `idx_created_at` (`created_at`),
KEY `idx_spec_base_id` (`spec_base_id`)
KEY `idx_spec_base_id` (`spec_base_id`),
KEY `idx_issued_at` (`issued_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='VR票务电子票';
CREATE TABLE IF NOT EXISTS `{{prefix}}vr_verifiers` (

View File

@ -270,4 +270,247 @@ class WalletService extends BaseService
return substr($phone, 0, 3) . '****' . substr($phone, -4);
}
/**
* 格式化票据列表数据(统一格式化逻辑,供 getUserTicketsPaginated 复用)
*
* @param array $tickets 原始票据数组
* @return array 格式化后的票据列表
*/
private static function formatTickets(array $tickets): array
{
if (empty($tickets)) {
return [];
}
// 批量获取商品信息(减少 N+1 查询)
$goodsIds = array_filter(array_column($tickets, 'goods_id'));
$goodsMap = [];
$goodsImageMap = [];
if (!empty($goodsIds)) {
$goodsList = \think\facade\Db::name('Goods')
->where('id', 'in', $goodsIds)
->field('id, title, images')
->select()
->toArray();
foreach ($goodsList as $goods) {
$goodsMap[$goods['id']] = $goods['title'] ?? '';
$goodsImageMap[$goods['id']] = $goods['images'] ?? '';
}
}
$result = [];
foreach ($tickets as $ticket) {
// 生成短码
$shortCode = self::shortCodeEncode($ticket['goods_id'], $ticket['id']);
// 优先从 seat_info 解析5维 pipe 格式),兜底从 goods_snapshot 解析
$seatInfo = self::parseSeatInfo($ticket['seat_info'] ?? '');
$snapshot = json_decode($ticket['goods_snapshot'] ?? '{}', true);
$snapshotKeys = array_filter([
'session' => $snapshot['session'] ?? '',
'venue' => $snapshot['venue'] ?? '',
'studio' => $snapshot['studio'] ?? '',
'section' => $snapshot['section'] ?? '',
'seat' => $snapshot['seat'] ?? ''
]);
if (empty($seatInfo['session']) && !empty($snapshotKeys)) {
$seatInfo = array_merge($seatInfo, $snapshotKeys);
}
// goods_snapshot 里没有 session/venue 时,从商品表补全
if (empty($seatInfo['session']) || empty($seatInfo['venue'])) {
$goodsTitle = $goodsMap[$ticket['goods_id']] ?? '已下架商品';
$goods = \think\facade\Db::name('Goods')->where('id', $ticket['goods_id'])->find();
$vrConfig = json_decode($goods['vr_goods_config'] ?? '', true);
if (!empty($vrConfig[0]['template_id'])) {
$template = \think\facade\Db::name('vr_seat_templates')
->where('id', $vrConfig[0]['template_id'])->find();
if (empty($seatInfo['venue'])) $seatInfo['venue'] = $template['name'] ?? '';
if (empty($seatInfo['session'])) {
$sessions = $vrConfig[0]['sessions'] ?? [];
$seatInfo['session'] = !empty($sessions[0]['start']) && !empty($sessions[0]['end'])
? ($sessions[0]['start'] . '-' . $sessions[0]['end']) : '';
}
}
}
if (empty($seatInfo['venue'])) $seatInfo['venue'] = $snapshot['venue'] ?? '';
if (empty($seatInfo['session'])) $seatInfo['session'] = $snapshot['session'] ?? '';
$result[] = [
'id' => $ticket['id'],
'order_id' => $ticket['order_id'], // 新增:便于前端按订单筛选
'goods_id' => $ticket['goods_id'],
'goods_title' => $goodsMap[$ticket['goods_id']] ?? '已下架商品',
'goods_image' => $goodsImageMap[$ticket['goods_id']] ?? '',
'seat_info' => $ticket['seat_info'] ?? '',
'seat_number' => self::parseSeatNumber($ticket['seat_info'] ?? ''),
'session_time' => $seatInfo['session'] ?? '',
'venue_name' => $seatInfo['venue'] ?? '',
'real_name' => $ticket['real_name'] ?? '',
'phone' => self::maskPhone($ticket['phone'] ?? ''),
'verify_status' => $ticket['verify_status'],
'issued_at' => $ticket['issued_at'],
'short_code' => $shortCode,
];
}
return $result;
}
/**
* 获取用户票列表(支持分页 + 订单/商品筛选)
*
* @param int $userId 用户ID
* @param int|null $orderId 单个订单ID
* @param array|null $orderIds 多个订单ID数组
* @param int|null $goodsId 商品ID
* @param int|null $status 核销状态 (0=未核销, 1=已核销, 2=已退款)
* @param int $page 页码
* @param int $pageSize 每页数量
* @return array ['list' => [], 'total' => int, 'page' => int, 'page_size' => int, 'pages' => int]
*/
public static function getUserTicketsPaginated(
int $userId,
?int $orderId = null,
?array $orderIds = null,
?int $goodsId = null,
?int $status = null,
int $page = 1,
int $pageSize = 20
): array {
$where = [
['user_id', '=', $userId]
];
// 按单个订单ID筛选
if ($orderId > 0) {
$where[] = ['order_id', '=', $orderId];
}
// 按多个订单ID批量筛选
if (!empty($orderIds) && is_array($orderIds)) {
$where[] = ['order_id', 'in', $orderIds];
}
// 按商品ID筛选
if ($goodsId > 0) {
$where[] = ['goods_id', '=', $goodsId];
}
// 按核销状态筛选
if ($status !== null) {
$where[] = ['verify_status', '=', $status];
}
// 计算总数
$total = \think\facade\Db::name('vr_tickets')->where($where)->count();
$pages = $pageSize > 0 ? ceil($total / $pageSize) : 1;
// 分页查询
$offset = ($page - 1) * $pageSize;
$tickets = \think\facade\Db::name('vr_tickets')
->where($where)
->order('issued_at', 'desc')
->limit($offset, $pageSize)
->select()
->toArray();
// 格式化返回数据
$formatted = self::formatTickets($tickets);
return [
'list' => $formatted,
'total' => $total,
'page' => $page,
'page_size' => $pageSize,
'pages' => $pages,
];
}
/**
* 获取用户票列表(瀑布流模式)
*
* @param int $userId 用户ID
* @param int $lastId 游标ID0 表示首次加载
* @param string $orderBy 排序方向:'desc' 降序(历史)/ 'asc' 升序(新数据)
* @param int $limit 每次拉取数量
* @param int|null $orderId 单个订单ID
* @param array|null $orderIds 多个订单ID数组
* @param int|null $goodsId 商品ID
* @param int|null $status 核销状态
* @return array ['list' => [], 'has_more' => bool, 'last_id' => int, 'count' => int]
*/
public static function getUserTicketsWaterfall(
int $userId,
int $lastId = 0,
string $orderBy = 'desc',
int $limit = 20,
?int $orderId = null,
?array $orderIds = null,
?int $goodsId = null,
?int $status = null
): array {
$where = [
['user_id', '=', $userId]
];
// 按单个订单ID筛选
if ($orderId > 0) {
$where[] = ['order_id', '=', $orderId];
}
// 按多个订单ID批量筛选
if (!empty($orderIds) && is_array($orderIds)) {
$where[] = ['order_id', 'in', $orderIds];
}
// 按商品ID筛选
if ($goodsId > 0) {
$where[] = ['goods_id', '=', $goodsId];
}
// 按核销状态筛选
if ($status !== null) {
$where[] = ['verify_status', '=', $status];
}
// 游标筛选
if ($lastId > 0) {
if ($orderBy === 'asc') {
// 升序:获取 ID > last_id 的数据(新数据)
$where[] = ['id', '>', $lastId];
} else {
// 降序:获取 ID < last_id 的数据(历史数据)
$where[] = ['id', '<', $lastId];
}
}
// 查询数据limit + 1 用于判断 has_more
$tickets = \think\facade\Db::name('vr_tickets')
->where($where)
->order('id', $orderBy === 'asc' ? 'asc' : 'desc')
->limit($limit + 1)
->select()
->toArray();
// 判断是否还有更多数据
$hasMore = count($tickets) > $limit;
if ($hasMore) {
array_pop($tickets); // 移除多查询的一条
}
// 获取本次返回的最后一条 ID
$newLastId = !empty($tickets) ? end($tickets)['id'] : $lastId;
// 格式化返回数据
$formatted = self::formatTickets($tickets);
return [
'list' => $formatted,
'has_more' => $hasMore,
'last_id' => $newLastId,
'count' => count($formatted),
];
}
}

View File

@ -19,21 +19,25 @@
<td width="100" class="am-text-gray">票码</td>
<td>{{$ticket.ticket_code}}</td>
</tr>
<tr>
<td class="am-text-gray">短码</td>
<td><strong style="font-size: 16px; color: #0e90d2; font-family: monospace; letter-spacing: 1px;">{{if !empty($short_code)}}{{$short_code}}{{else}}-{{/if}}</strong></td>
</tr>
<tr>
<td class="am-text-gray">订单号</td>
<td>{{$ticket.order_no}}</td>
</tr>
<tr>
<td class="am-text-gray">商品名</td>
<td>{{$ticket.goods_name}}</td>
<td>{{if !empty($goods)}}{{$goods.title}}{{else}}已删除商品{{/if}}</td>
</tr>
<tr>
<td class="am-text-gray">观演人</td>
<td>{{$ticket.visitor_name}}</td>
<td>{{if !empty($ticket.real_name)}}{{$ticket.real_name}}{{else}}-{{/if}}</td>
</tr>
<tr>
<td class="am-text-gray">手机号</td>
<td>{{$ticket.mobile}}</td>
<td>{{if !empty($ticket.phone)}}{{$ticket.phone}}{{else}}-{{/if}}</td>
</tr>
<tr>
<td class="am-text-gray">身份证</td>
@ -46,27 +50,27 @@
<tr>
<td class="am-text-gray">状态</td>
<td>
{{if $ticket.status == 0}}
{{if $ticket.verify_status == 0}}
<span class="am-badge am-badge-warning">未核销</span>
{{elseif $ticket.status == 1}}
{{elseif $ticket.verify_status == 1}}
<span class="am-badge am-badge-success">已核销</span>
{{elseif $ticket.status == 2}}
{{elseif $ticket.verify_status == 2}}
<span class="am-badge am-badge-danger">已退款</span>
{{/if}}
</td>
</tr>
<tr>
<td class="am-text-gray">发放时间</td>
<td>{{$ticket.create_time}}</td>
<td>{{if !empty($ticket.issued_at)}}{{:date('Y-m-d H:i:s', $ticket.issued_at)}}{{else}}-{{/if}}</td>
</tr>
{{if $ticket.status == 1}}
{{if $ticket.verify_status == 1}}
<tr>
<td class="am-text-gray">核销时间</td>
<td>{{$ticket.verify_time}}</td>
<td>{{if !empty($ticket.verify_time)}}{{:date('Y-m-d H:i:s', $ticket.verify_time)}}{{else}}-{{/if}}</td>
</tr>
<tr>
<td class="am-text-gray">核销人</td>
<td>{{if !empty($ticket.verifier_name)}}{{$ticket.verifier_name}}{{else}}未知{{/if}}</td>
<td>{{if !empty($verifier)}}{{$verifier.name}}{{else}}未知{{/if}}</td>
</tr>
{{/if}}
</table>
@ -74,89 +78,89 @@
</div>
</div>
<!-- 右侧:二维码 -->
<!-- 右侧:订单信息和核销操作 -->
<div class="am-u-sm-6">
<!-- 订单信息 -->
<div class="am-panel am-panel-default">
<div class="am-panel-hd">票条形码</div>
<div class="am-panel-bd am-text-center">
<div id="qrcode-container" class="am-margin-bottom">
<!-- QR 码将由 JsBarcode 生成 -->
<svg id="qrcode-svg"></svg>
</div>
<p class="am-text-gray am-text-sm">票码:{{$ticket.ticket_code}}</p>
<!-- 条形码 -->
<div class="am-margin-top">
<svg id="barcode"></svg>
</div>
</div>
</div>
<!-- 核销操作(仅未核销状态显示) -->
{{if $ticket.status == 0}}
<div class="am-panel am-panel-default am-margin-top">
<div class="am-panel-hd">核销操作</div>
<div class="am-panel-hd">订单信息</div>
<div class="am-panel-bd">
<form class="am-form form-validation" id="verify-form">
<input type="hidden" name="ticket_code" value="{{$ticket.ticket_code}}" />
<div class="am-form-group">
<button type="submit" class="am-btn am-btn-primary am-btn-block am-radius" data-am-loading="{spinner: 'circle-o-notch', loadingText: '核销中...'}">
<i class="am-icon-check"></i> 立即核销
</button>
</div>
</form>
{{if !empty($order)}}
<table class="am-table am-text-sm">
<tr>
<td width="100" class="am-text-gray">订单号</td>
<td>{{$order.order_no}}</td>
</tr>
<tr>
<td class="am-text-gray">用户昵称</td>
<td>{{if !empty($user)}}{{$user.nickname|default=$user.username|default='未设置'}}{{else}}-{{/if}}</td>
</tr>
<tr>
<td class="am-text-gray">订单状态</td>
<td>
{{if $order.status == 0}}
<span class="am-badge am-badge-secondary">待确认</span>
{{elseif $order.status == 1}}
<span class="am-badge am-badge-warning">待支付</span>
{{elseif $order.status == 2}}
<span class="am-badge am-badge-primary">待发货</span>
{{elseif $order.status == 3}}
<span class="am-badge am-badge-primary">待收货</span>
{{elseif $order.status == 4}}
<span class="am-badge am-badge-success">已完成</span>
{{elseif $order.status == 5}}
<span class="am-badge am-badge-danger">已取消</span>
{{elseif $order.status == 6}}
<span class="am-badge am-badge-danger">已关闭</span>
{{else}}
<span class="am-badge">未知</span>
{{/if}}
</td>
</tr>
<tr>
<td class="am-text-gray">支付状态</td>
<td>
{{if isset($order.pay_status) && $order.pay_status == 1}}
<span class="am-badge am-badge-success">已支付</span>
{{else}}
<span class="am-badge am-badge-warning">未支付</span>
{{/if}}
</td>
</tr>
<tr>
<td class="am-text-gray">订单金额</td>
<td class="am-text-danger"><strong>¥{{$order.total_price|default='0.00'}}</strong></td>
</tr>
<tr>
<td class="am-text-gray">购买数量</td>
<td><strong>{{$order.buy_number_count|default='0'}}</strong> 张票</td>
</tr>
<tr>
<td class="am-text-gray">支付方式</td>
<td>{{if !empty($order.payment_name)}}{{$order.payment_name}}{{else}}-{{/if}}</td>
</tr>
<tr>
<td class="am-text-gray">支付时间</td>
<td>{{if !empty($order.pay_time) && $order.pay_time > 0}}{{:date('Y-m-d H:i:s', $order.pay_time)}}{{else}}<span class="am-text-warning">未支付</span>{{/if}}</td>
</tr>
<tr>
<td class="am-text-gray">下单时间</td>
<td>{{if !empty($order.add_time)}}{{:date('Y-m-d H:i:s', $order.add_time)}}{{else}}-{{/if}}</td>
</tr>
<tr>
<td class="am-text-gray">用户备注</td>
<td>{{if !empty($order.user_note)}}{{$order.user_note}}{{else}}<span class="am-text-gray"></span>{{/if}}</td>
</tr>
</table>
{{else}}
<p class="am-text-center am-text-gray am-padding">订单信息不存在或已删除</p>
{{/if}}
</div>
</div>
{{/if}}
</div>
</div>
</div>
<script src="{{$public_host}}static/common/lib/JsBarcode/JsBarcode.all.min.js"></script>
<script>
$(function() {
// 生成条形码
var ticketCode = '{{$ticket.ticket_code}}';
if (typeof JsBarcode !== 'undefined') {
JsBarcode('#barcode', ticketCode, {
format: 'CODE128',
width: 2,
height: 60,
displayValue: true,
fontSize: 14
});
}
// 核销表单提交
$('#verify-form').on('submit', function(e) {
e.preventDefault();
var $btn = $(this).find('button[type="submit"]');
$btn.button('loading');
$.ajax({
url: '{{:PluginsAdminUrl("vr_ticket", "admin", "ticketverify")}}',
type: 'POST',
data: { ticket_code: ticketCode },
dataType: 'json',
success: function(res) {
$btn.button('reset');
if (res.code === 0) {
alert('核销成功');
location.reload();
} else {
alert(res.msg || '核销失败');
}
},
error: function() {
$btn.button('reset');
alert('网络请求失败');
}
});
});
});
</script>
{{:ModuleInclude('public/footer')}}

View File

@ -9,6 +9,20 @@
border-bottom: 2px solid #e8e8e8;
margin-bottom: 20px;
}
/* 列宽优化 */
.ticket-list-table th:nth-child(4), /* 手机号 */
.ticket-list-table td:nth-child(4) {
width: 12%; /* 增加100% */
min-width: 110px;
}
.ticket-list-table th:nth-child(5), /* 座位信息 */
.ticket-list-table td:nth-child(5) {
width: 8%; /* 缩减30% */
max-width: 100px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>
<div class="right-content">
@ -89,11 +103,13 @@
<div class="am-panel am-panel-default">
<div class="am-panel-hd">电子票列表 <span class="am-text-xs am-padding-left">共 {{$count|default=0}} 条</span></div>
<div class="am-panel-bd">
<table class="am-table am-table-striped am-table-hover am-text-middle">
<table class="am-table am-table-striped am-table-hover am-text-middle ticket-list-table">
<thead>
<tr>
<th>订单号</th>
<th>票码</th>
<th>观演人</th>
<th>手机号</th>
<th>座位信息</th>
<th>商品名</th>
<th>状态</th>
@ -105,8 +121,10 @@
{{if !empty($list)}}
{{volist name="list" id="ticket"}}
<tr>
<td>{{$ticket.order_no|default='—'}}</td>
<td>{{$ticket.ticket_code}}</td>
<td>{{$ticket.real_name|default='—'}}</td>
<td>{{$ticket.phone|default='—'}}</td>
<td>{{if !empty($ticket.seat_info)}}{{$ticket.seat_info}}{{else}}无{{/if}}</td>
<td>{{$ticket.goods_title|default='已删除商品'}}</td>
<td>
@ -121,7 +139,7 @@
<td>{{if !empty($ticket.issued_at)}}{{:date('Y-m-d H:i', $ticket.issued_at)}}{{else}}—{{/if}}
</td>
<td>
<a href="{{:PluginsAdminUrl('vr_ticket', 'admin', 'ticketdetail')}}?id={{$ticket.id}}"
<a href="{{:PluginsAdminUrl('vr_ticket', 'admin', 'TicketDetail', ['id' => $ticket.id])}}"
class="am-btn am-btn-default am-btn-xs am-radius">
<i class="am-icon-eye"></i> 查看详情
</a>
@ -130,7 +148,7 @@
{{/volist}}
{{else}}
<tr>
<td colspan="7" class="am-text-center">暂无数据</td>
<td colspan="9" class="am-text-center">暂无数据</td>
</tr>
{{/if}}
</tbody>

15
test_php81_warning.php Normal file
View File

@ -0,0 +1,15 @@
<?php
/**
* 测试 PHP 8.1.4 是否会产生 exit(header(...)) 废弃警告
*/
echo "PHP Version: " . phpversion() . "\n";
echo "Error Reporting: " . error_reporting() . "\n";
echo str_repeat("-", 50) . "\n\n";
// 测试 1: exit(false) - 应该产生警告
echo "Test 1: exit(false)\n";
@exit(false);
// 注意exit() 会终止脚本,所以下面的代码不会执行
echo "This line should not be printed\n";