feat(Phase 2): 完成票务商品前端展示层

- Goods.php: item_type=ticket 时加载 ticket_detail.html 模板
- SeatSkuService.php: 新增 GetGoodsViewData() 供前端模板使用
- TicketService.php: onOrderPaid 改用 sxo_order_detail 表 + JSON spec 解析

关联: Phase 2 前台展示层完成
council/SecurityEngineer
Council 2026-04-20 05:22:07 +08:00
parent dc63cff77c
commit 7bd8967648
3 changed files with 181 additions and 37 deletions

View File

@ -135,6 +135,19 @@ class Goods extends Common
MyViewAssign($assign);
// 钩子
$this->PluginsHook($goods_id, $goods);
// 票务商品:加载自定义模板并注入座位数据
if (($goods['item_type'] ?? '') === 'ticket') {
$viewData = \app\plugins\vr_ticket\service\SeatSkuService::GetGoodsViewData($goods_id);
MyViewAssign([
'vr_seat_template' => $viewData['vr_seat_template'] ?? null,
'goods_spec_data' => $viewData['goods_spec_data'] ?? [],
]);
// 使用绝对路径 + fetch() 方法,让 Think 驱动正确解析 include 路径
$tplFile = ROOT . 'app' . DS . 'plugins' . DS . 'vr_ticket' . DS . 'view' . DS . 'goods' . DS . 'ticket_detail.html';
return \think\facade\View::fetch($tplFile, $assign);
}
return MyView();
}
MyViewAssign('msg', MyLang('goods.goods_no_data_tips'));

View File

@ -348,4 +348,102 @@ class SeatSkuService extends BaseService
return ['code' => 0];
}
/**
* 获取商品前端展示数据(供 ticket_detail.html 模板使用)
*
* @param int $goodsId
* @return array ['vr_seat_template' => [...], 'goods_spec_data' => [...]]
*/
public static function GetGoodsViewData(int $goodsId): array
{
// 读取 vr_goods_config
$goods = \think\facade\Db::name('goods')->find($goodsId);
$vrGoodsConfig = json_decode($goods['vr_goods_config'] ?? '', true);
if (empty($vrGoodsConfig) || !is_array($vrGoodsConfig)) {
return ['vr_seat_template' => null, 'goods_spec_data' => [], 'goods_config' => null];
}
// 取第一个配置块(单模板模式)
$config = $vrGoodsConfig[0];
$templateId = intval($config['template_id'] ?? 0);
if ($templateId <= 0) {
return ['vr_seat_template' => null, 'goods_spec_data' => [], 'goods_config' => null];
}
// 读取座位模板(包含 seat_map 和 spec_base_id_map
$seatTemplate = \think\facade\Db::name(self::table('seat_templates'))
->where('id', $templateId)
->find();
// 解码 seat_map JSON存储时是 JSON 字符串)
if (!empty($seatTemplate['seat_map'])) {
$decoded = json_decode($seatTemplate['seat_map'], true);
if (json_last_error() === JSON_ERROR_NONE) {
$seatTemplate['seat_map'] = $decoded;
}
}
// 解码 spec_base_id_map JSON
if (!empty($seatTemplate['spec_base_id_map'])) {
$decoded = json_decode($seatTemplate['spec_base_id_map'], true);
if (json_last_error() === JSON_ERROR_NONE) {
$seatTemplate['spec_base_id_map'] = $decoded;
}
}
// 构建场次列表goods_spec_data
$sessions = $config['sessions'] ?? [];
$goodsSpecData = [];
foreach ($sessions as $session) {
$start = $session['start'] ?? '';
$end = $session['end'] ?? '';
$timeRange = $start && $end ? "{$start}-{$end}" : ($start ?: $end);
// 查找该场次对应的 spec_base_id
$specValue = \think\facade\Db::name('goods_spec_value')
->alias('sv')
->join('goods_spec_base sb', 'sb.id = sv.goods_spec_base_id')
->where('sv.goods_id', $goodsId)
->where('sv.value', $timeRange)
->where('sb.price', '>', 0)
->find();
$goodsSpecData[] = [
'spec_id' => $specValue['goods_spec_base_id'] ?? 0,
'spec_name' => $timeRange,
'price' => $specValue['price'] ?? floatval($goods['price'] ?? 0),
];
}
// 如果没有从配置读取到场次,尝试从数据库直接读取场次类规格值
if (empty($goodsSpecData)) {
$sessionValues = \think\facade\Db::name('goods_spec_value')
->alias('sv')
->join('goods_spec_base sb', 'sb.id = sv.goods_spec_base_id')
->field('sv.goods_spec_base_id as spec_id, sv.value as spec_name, sb.price')
->where('sv.goods_id', $goodsId)
->where('sb.price', '>', 0)
->order('sb.id asc')
->select()->toArray();
foreach ($sessionValues as $sv) {
if (preg_match('/^\d{2}:\d{2}-\d{2}:\d{2}$/', $sv['spec_name'])) {
$goodsSpecData[] = [
'spec_id' => $sv['spec_id'],
'spec_name' => $sv['spec_name'],
'price' => floatval($sv['price']),
];
}
}
}
return [
'vr_seat_template' => $seatTemplate ?: null,
'goods_spec_data' => $goodsSpecData,
'goods_config' => $config,
];
}
}

View File

@ -29,7 +29,7 @@ class TicketService extends BaseService
}
// 查询订单
$order = \think\facade\Db::name('Order')->find($order_id);
$order = \think\facade\Db::name('order')->find($order_id);
if (empty($order) || $order['pay_status'] != 1) {
BaseService::log('onOrderPaid: order not paid or not found', ['order_id' => $order_id], 'warning');
return false;
@ -41,16 +41,51 @@ class TicketService extends BaseService
return true; // 不是票务商品,不报错
}
// 查询商品快照(规格信息
$order_goods = \think\facade\Db::name('OrderGoods')
// 查询订单明细(规格信息存储在 spec JSON 字段
$order_goods = \think\facade\Db::name('order_detail')
->where('order_id', $order_id)
->select();
if (empty($order_goods)) {
BaseService::log('onOrderPaid: no order goods', ['order_id' => $order_id], 'error');
BaseService::log('onOrderPaid: no order detail', ['order_id' => $order_id], 'error');
return false;
}
// 逐个生成票(每个规格选项 = 一张票)
// 逐行解析 spec JSON提取座位信息
foreach ($order_goods as &$og) {
$spec_list = json_decode($og['spec'] ?? '[]', true);
$spec_name = '';
$spec_base_id = 0;
if (is_array($spec_list)) {
// 优先取座位号,其次分区名
foreach ($spec_list as $spec_item) {
$type = $spec_item['type'] ?? '';
$value = $spec_item['value'] ?? '';
if ($type === '$vr-座位号') {
$spec_name = $value;
break;
} elseif ($type === '$vr-分区' && !$spec_name) {
$spec_name = $value;
}
}
}
// 尝试通过座位名反向查找 spec_base_id
if ($spec_name) {
$spec_base = \think\facade\Db::name('goods_spec_value')
->where('goods_id', $order['goods_id'])
->where('value', $spec_name)
->find();
$spec_base_id = $spec_base['goods_spec_base_id'] ?? 0;
}
$og['_parsed_spec_name'] = $spec_name;
$og['_parsed_spec_base_id'] = $spec_base_id;
}
unset($og);
// 逐个生成票(每个订单明细行 = 一张票)
$count = 0;
foreach ($order_goods as $og) {
$ticket_id = self::issueTicket($order, $og);
@ -70,21 +105,24 @@ class TicketService extends BaseService
/**
* 发放单张票
*
* @param array $order 订单数据
* @param array $order_goods 订单商品数据(包含 spec_base_id
* @param array $order 订单数据
* @param array $og 订单商品数据(来自 vrt_order_detail已解析 _parsed_spec_name 等字段
* @return int 票ID
*/
public static function issueTicket($order, $order_goods)
public static function issueTicket($order, $og)
{
// P0-1 幂等保护:同一订单+同一规格只发一张票
$spec_name = $og['_parsed_spec_name'] ?? '';
$spec_base_id = $og['_parsed_spec_base_id'] ?? 0;
// P0-1 幂等保护:同一订单+同一座位名只发一张票
$existing = \think\facade\Db::name(BaseService::table('tickets'))
->where('order_id', $order['id'])
->where('spec_base_id', $order_goods['spec_base_id'] ?? 0)
->where('seat_info', $spec_name)
->find();
if (!empty($existing)) {
BaseService::log('issueTicket: idempotent_skip', [
'order_id' => $order['id'],
'spec_base_id'=> $order_goods['spec_base_id'] ?? 0,
'order_id' => $order['id'],
'seat_info' => $spec_name,
], 'info');
return $existing['id'];
}
@ -93,14 +131,14 @@ class TicketService extends BaseService
// 构建 QR 数据
$qr_payload = [
'id' => 0, // 写入后再更新
'code' => $ticket_code,
'event' => $order['goods_id'],
'seat' => $order_goods['spec_name'] ?? '', // 规格名=座位信息
'id' => 0, // 写入后再更新
'code' => $ticket_code,
'event'=> $order['goods_id'],
'seat' => $spec_name,
];
$qr_data = BaseService::encryptQrData($qr_payload);
// 观演人信息(从订单扩展字段读取,由购票页表单写入)
// 观演人信息:优先从 order.extension_data 读取(购票页表单写入)
$extension_data = json_decode($order['extension_data'] ?? '{}', true);
$attendee = $extension_data['attendee'] ?? [];
@ -111,19 +149,19 @@ class TicketService extends BaseService
'order_no' => $order['order_no'],
'goods_id' => $order['goods_id'],
'goods_snapshot' => json_encode([
'goods_name' => $order['goods_name'] ?? '',
'spec_name' => $order_goods['spec_name'] ?? '',
'price' => $order_goods['goods_price'] ?? 0,
'goods_name' => $og['title'] ?? '',
'spec_name' => $spec_name,
'price' => $og['price'] ?? 0,
], JSON_UNESCAPED_UNICODE),
'user_id' => $order['user_id'],
'ticket_code' => $ticket_code,
'qr_data' => $qr_data,
'seat_info' => $order_goods['spec_name'] ?? '',
'spec_base_id' => $order_goods['spec_base_id'] ?? 0,
'seat_info' => $spec_name,
'spec_base_id' => $spec_base_id,
'real_name' => $attendee['real_name'] ?? '',
'phone' => $attendee['phone'] ?? '',
'id_card' => $attendee['id_card'] ?? '',
'verify_status' => 0, // 0=未核销
'verify_status' => 0,
'issued_at' => $now,
'created_at' => $now,
'updated_at' => $now,
@ -145,7 +183,7 @@ class TicketService extends BaseService
* 核销票(事务保护 + 悲观锁防并发)
*
* @param string $ticket_code 票码
* @param int $verifier_id 核销员ID
* @param int $verifier_id 核销员ID
* @return array [code, msg]
*/
public static function verifyTicket($ticket_code, $verifier_id)
@ -178,8 +216,8 @@ class TicketService extends BaseService
->update([
'verify_status' => 1,
'verify_time' => $now,
'verifier_id' => $verifier_id,
'updated_at' => $now,
'verifier_id' => $verifier_id,
'updated_at' => $now,
]);
// 写入核销记录
@ -201,20 +239,20 @@ class TicketService extends BaseService
'verifier_id' => $verifier_id,
]);
// 审计日志(失败也记录,便于追溯异常)
// 审计日志
AuditService::logVerify(
$ticket['id'],
$ticket_code,
$verifier_id,
$verifier['name'] ?? '',
'success',
0 // 原状态(核销前一定是 0
0
);
return [
'code' => 0,
'msg' => '核销成功',
'data' => [
'code' => 0,
'msg' => '核销成功',
'data' => [
'seat_info' => $ticket['seat_info'],
'real_name' => $ticket['real_name'],
'goods_name' => json_decode($ticket['goods_snapshot'] ?? '{}', true)['goods_name'] ?? '',
@ -239,7 +277,6 @@ class TicketService extends BaseService
if ($status !== null) {
$where['verify_status'] = $status;
}
return \think\facade\Db::name(BaseService::table('tickets'))
->where($where)
->order('created_at', 'desc')
@ -248,9 +285,6 @@ class TicketService extends BaseService
/**
* 生成 QR 码图片 URL
*
* @param string $ticket_code
* @return string QR码图片URL
*/
public static function getQrCodeUrl($ticket_code)
{
@ -258,7 +292,6 @@ class TicketService extends BaseService
'type' => 'vr_ticket',
'code' => $ticket_code,
]));
return request()->domain().request()->root() . '?s=index/qrcode/index&content=' . urlencode($content) . '&size=8&level=H';
return request()->domain() . request()->root() . '?s=index/qrcode/index&content=' . urlencode($content) . '&size=8&level=H';
}
}