resolve: 保留 fix/ticket-select-refresh 的 GitNexus 索引统计(26682 symbols)
commit
d704a482c7
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
2543
ag-stop-probe.json
2543
ag-stop-probe.json
File diff suppressed because it is too large
Load Diff
|
|
@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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 查询条件重置 Bug(2026-06-22)
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -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
|
|||
|
||||
## 五、核销 API(UniApp 授权核销员专用)
|
||||
|
||||
> **基础路由**: `/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
|
|||
|
||||
## 六、核销记录 API(UniApp 授权核销员专用)
|
||||
|
||||
> **基础路由**: `/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 电子票表
|
||||
|
|
|
|||
3903
docs/test.json
3903
docs/test.json
File diff suppressed because it is too large
Load Diff
2606
docs/test2.json
2606
docs/test2.json
File diff suppressed because it is too large
Load Diff
|
|
@ -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',
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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: 游标 ID,0 表示首次加载
|
||||
* - 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'],
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
};
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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` (
|
||||
|
|
|
|||
|
|
@ -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 游标ID,0 表示首次加载
|
||||
* @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),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')}}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
Loading…
Reference in New Issue