vr-shopxo-plugin/shopxo/app/plugins/vr_ticket/service/TicketService.php

559 lines
20 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters!

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

<?php
/**
* VR票务插件 - 票务服务
*
* 核心业务:订单支付成功 → 生成电子票
*
* @package vr_ticket\service
*/
namespace app\plugins\vr_ticket\service;
require_once __DIR__ . '/BaseService.php';
class TicketService extends BaseService
{
/**
* 订单支付成功回调
*
* 从 plugin.json 的 hook 触发:
* plugins_service_order_pay_success_handle_end
*
* @param array $params 钩子参数,含 business_data, user_id, business_ids(order_ids)
* @return bool
*/
public static function onOrderPaid($params = [])
{
$order_id = $params['order_id'] ?? $params['business_id'] ?? ($params['business_ids'][0] ?? 0);
BaseService::log('onOrderPaid: called', ['order_id' => $order_id, 'params_keys' => array_keys($params)], 'info');
if (empty($order_id)) {
BaseService::log('onOrderPaid: empty order_id', $params, 'warning');
return false;
}
// 查询订单
$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;
}
// 查询订单明细(规格信息存储在 spec JSON 字段)
$order_goods = \think\facade\Db::name('order_detail')
->where('order_id', $order_id)
->select()->toArray();
if (empty($order_goods)) {
BaseService::log('onOrderPaid: no order detail', ['order_id' => $order_id], 'error');
return false;
}
// 逐行解析 spec JSON提取座位信息
foreach ($order_goods as &$og) {
// 判断是否为票务商品goods_id 在 order_detail 而非 order 主表)
if (!BaseService::isTicketGoods($og['goods_id'])) {
BaseService::log('onOrderPaid: not a ticket goods', ['order_id' => $order_id, 'goods_id' => $og['goods_id']], 'info');
continue;
}
$spec_list = json_decode($og['spec'] ?? '[]', true);
$spec_name = '';
$spec_base_id = 0;
// 完整解析 5 维规格
$parsed = [
'session' => '',
'venue' => '',
'studio' => '',
'section' => '',
'seat' => '',
];
if (is_array($spec_list)) {
foreach ($spec_list as $spec_item) {
$type = $spec_item['type'] ?? '';
$value = $spec_item['value'] ?? '';
switch ($type) {
case '$vr-场次': $parsed['session'] = $value; break;
case '$vr-场馆': $parsed['venue'] = $value; break;
case '$vr-演播室': $parsed['studio'] = $value; break;
case '$vr-分区': $parsed['section'] = $value; break;
case '$vr-座位号': $parsed['seat'] = $value; break;
}
}
}
// seat_info 格式:场次|场馆|演播室|分区|座位号WalletService::parseSeatInfo 依赖此格式)
$seat_info = implode('|', [
$parsed['session'],
$parsed['venue'],
$parsed['studio'],
$parsed['section'],
$parsed['seat'],
]);
// goods_snapshot 完整快照(含全部 5 维)
$goods_snapshot = json_encode([
'goods_name' => $og['title'] ?? '',
'item_type' => 'ticket',
'session' => $parsed['session'],
'venue' => $parsed['venue'],
'studio' => $parsed['studio'],
'section' => $parsed['section'],
'seat' => $parsed['seat'],
'price' => $og['price'] ?? 0,
], JSON_UNESCAPED_UNICODE);
$og['_parsed_spec_name'] = $parsed['seat'] ?: $parsed['section'];
$og['_parsed_seat_info'] = $seat_info;
$og['_parsed_goods_snapshot'] = $goods_snapshot;
}
unset($og);
// 逐个生成票(每个订单明细行 = 一张票)
$count = 0;
foreach ($order_goods as $og) {
$ticket_id = self::issueTicket($order, $og);
if ($ticket_id > 0) {
$count++;
}
}
BaseService::log('onOrderPaid: success', [
'order_id' => $order_id,
'tickets_issued' => $count,
]);
return $count > 0;
}
/**
* 发放单张票
*
* @param array $order 订单数据
* @param array $og 订单商品数据(来自 vrt_order_detail已解析 _parsed_spec_name 等字段)
* @return int 票ID
*/
public static function issueTicket($order, $og)
{
$spec_name = $og['_parsed_spec_name'] ?? '';
$spec_base_id = $og['_parsed_spec_base_id'] ?? 0;
$seat_info = $og['_parsed_seat_info'] ?? $spec_name;
$goods_snapshot = $og['_parsed_goods_snapshot'] ?? json_encode([
'goods_name' => $og['title'] ?? '',
'spec_name' => $spec_name,
'price' => $og['price'] ?? 0,
], JSON_UNESCAPED_UNICODE);
// P0-1 幂等保护:同一订单+同一座位名只发一张票
$existing = \think\facade\Db::name(BaseService::table('tickets'))
->where('order_id', $order['id'])
->where('seat_info', $spec_name)
->find();
if (!empty($existing)) {
BaseService::log('issueTicket: idempotent_skip', [
'order_id' => $order['id'],
'seat_info' => $spec_name,
], 'info');
return $existing['id'];
}
$ticket_code = BaseService::generateUuid();
$now = BaseService::now();
// Step 1: 先插入获取 ticket_id用于 short_code 生成)
$ticket_id = \think\facade\Db::name(BaseService::table('tickets'))->insertGetId([
'order_id' => $order['id'],
'order_no' => $order['order_no'],
'goods_id' => $og['goods_id'],
'goods_snapshot' => $goods_snapshot,
'user_id' => $order['user_id'],
'ticket_code' => $ticket_code,
'qr_data' => '', // 占位,生成后更新
'seat_info' => $seat_info,
'spec_base_id' => $spec_base_id,
'real_name' => '',
'phone' => '',
'id_card' => '',
'verify_status' => 0,
'issued_at' => $now,
'created_at' => $now,
'updated_at' => $now,
]);
if ($ticket_id <= 0) {
BaseService::log('issueTicket: insert_failed', ['order_id' => $order['id']], 'error');
return 0;
}
// Step 2: 生成短码goods_id 明文 + ticket_id 混淆)
// 短码存储在 qr_data 中,供前端展示
$short_code = BaseService::shortCodeEncode($og['goods_id'], $ticket_id);
// Step 3: 生成 QR payloadHMAC-SHA256 签名30分钟有效
$qr_payload = BaseService::signQrPayload([
'id' => $ticket_id,
'g' => $og['goods_id'],
'iat' => $now,
'exp' => $now + 1800, // 30分钟
]);
// qr_data 格式:短码|QR_payload竖线分隔
$qr_data = $short_code . '|' . $qr_payload;
// Step 4: 更新 qr_data
\think\facade\Db::name(BaseService::table('tickets'))
->where('id', $ticket_id)
->update(['qr_data' => $qr_data]);
// Step 5: 写入观演人信息
$extension_data = json_decode($order['extension_data'] ?? '{}', true);
$attendee = $extension_data['attendee'] ?? [];
\think\facade\Db::name(BaseService::table('tickets'))
->where('id', $ticket_id)
->update([
'real_name' => $attendee['real_name'] ?? '',
'phone' => $attendee['phone'] ?? '',
'id_card' => $attendee['id_card'] ?? '',
]);
BaseService::log('issueTicket: success', [
'ticket_id' => $ticket_id,
'short_code' => $short_code,
'goods_id' => $og['goods_id'],
]);
return $ticket_id;
}
/**
* 核销票(事务保护 + 悲观锁防并发)
*
* @param string $ticket_code 票码
* @param int $verifier_id 核销员ID
* @return array [code, msg]
*/
public static function verifyTicket($ticket_code, $verifier_id)
{
try {
return \think\facade\Db::transaction(function () use ($ticket_code, $verifier_id) {
// FOR UPDATE 悲观锁:防止并发核销同一张票
$ticket = \think\facade\Db::name(BaseService::table('tickets'))
->where('ticket_code', $ticket_code)
->lock(true)
->find();
if (empty($ticket)) {
return ['code' => -1, 'msg' => '票码不存在'];
}
if ($ticket['verify_status'] == 1) {
return ['code' => -2, 'msg' => '该票已核销'];
}
if ($ticket['verify_status'] == 2) {
return ['code' => -3, 'msg' => '该票已退款'];
}
$now = BaseService::now();
// 更新票状态
\think\facade\Db::name(BaseService::table('tickets'))
->where('id', $ticket['id'])
->update([
'verify_status' => 1,
'verify_time' => $now,
'verifier_id' => $verifier_id,
'updated_at' => $now,
]);
// 写入核销记录
$verifier = \think\facade\Db::name(BaseService::table('verifiers'))
->where('id', $verifier_id)
->find();
\think\facade\Db::name(BaseService::table('verifications'))->insert([
'ticket_id' => $ticket['id'],
'ticket_code' => $ticket_code,
'verifier_id' => $verifier_id,
'verifier_name'=> $verifier['name'] ?? '',
'goods_id' => $ticket['goods_id'],
'created_at' => $now,
]);
BaseService::log('verifyTicket: success', [
'ticket_id' => $ticket['id'],
'verifier_id' => $verifier_id,
]);
// 审计日志
AuditService::logVerify(
$ticket['id'],
$ticket_code,
$verifier_id,
$verifier['name'] ?? '',
'success',
0
);
return [
'code' => 0,
'msg' => '核销成功',
'data' => [
'seat_info' => $ticket['seat_info'],
'real_name' => $ticket['real_name'],
'goods_name' => json_decode($ticket['goods_snapshot'] ?? '{}', true)['goods_name'] ?? '',
],
];
});
} catch (\Throwable $e) {
BaseService::log('verifyTicket: transaction_error', [
'ticket_code' => $ticket_code,
'error' => $e->getMessage(),
], 'error');
return ['code' => -999, 'msg' => '核销失败,请重试'];
}
}
/**
* 获取用户所有票
*/
public static function getUserTickets($user_id, $status = null)
{
$where = ['user_id' => $user_id];
if ($status !== null) {
$where['verify_status'] = $status;
}
return \think\facade\Db::name(BaseService::table('tickets'))
->where($where)
->order('created_at', 'desc')
->select();
}
/**
* 通过短码核销票(自动路由)
*
* 短码结构:【明文 goods_id(4位)】【混淆 ticket_id(5位)】
* 解码 O(1)直接读前4位=goods_idFeistel解密后5位=ticket_id
*
* @param string $short_code 短码
* @param int $verifier_id 核销员ID
* @return array [code, msg]
*/
public static function verifyByShortCode($short_code, $verifier_id)
{
try {
// Step 1: 解码短码
$decoded = BaseService::shortCodeDecode($short_code);
$goods_id = $decoded['goods_id'];
$ticket_id = $decoded['ticket_id'];
// Step 2: DB 查询
$ticket = \think\facade\Db::name(BaseService::table('tickets'))
->where('id', $ticket_id)
->where('goods_id', $goods_id)
->find();
if (empty($ticket)) {
BaseService::log('verifyByShortCode: ticket_not_found', [
'short_code' => $short_code,
'goods_id' => $goods_id,
'ticket_id' => $ticket_id,
], 'warning');
return ['code' => -1, 'msg' => '票不存在'];
}
// Step 3: 委托给 verifyTicket统一核销逻辑 + 事务 + 悲观锁)
return self::verifyTicketById($ticket['id'], $verifier_id);
} catch (\Throwable $e) {
BaseService::log('verifyByShortCode: error', [
'short_code' => $short_code,
'error' => $e->getMessage(),
], 'error');
return ['code' => -999, 'msg' => '核销失败,请重试'];
}
}
/**
* 通过 ticket_id 核销票(内部方法)
*
* @param int $ticket_id 票ID
* @param int $verifier_id 核销员ID
* @return array [code, msg]
*/
private static function verifyTicketById($ticket_id, $verifier_id)
{
try {
return \think\facade\Db::transaction(function () use ($ticket_id, $verifier_id) {
// FOR UPDATE 悲观锁:防止并发核销同一张票
$ticket = \think\facade\Db::name(BaseService::table('tickets'))
->where('id', $ticket_id)
->lock(true)
->find();
if (empty($ticket)) {
return ['code' => -1, 'msg' => '票不存在'];
}
if ($ticket['verify_status'] == 1) {
return ['code' => -2, 'msg' => '该票已核销'];
}
if ($ticket['verify_status'] == 2) {
return ['code' => -3, 'msg' => '该票已退款'];
}
$now = BaseService::now();
// 更新票状态
\think\facade\Db::name(BaseService::table('tickets'))
->where('id', $ticket_id)
->update([
'verify_status' => 1,
'verify_time' => $now,
'verifier_id' => $verifier_id,
'updated_at' => $now,
]);
// 写入核销记录
$verifier = \think\facade\Db::name(BaseService::table('verifiers'))
->where('id', $verifier_id)
->find();
\think\facade\Db::name(BaseService::table('verifications'))->insert([
'ticket_id' => $ticket_id,
'ticket_code' => $ticket['ticket_code'],
'verifier_id' => $verifier_id,
'verifier_name'=> $verifier['name'] ?? '',
'goods_id' => $ticket['goods_id'],
'created_at' => $now,
]);
BaseService::log('verifyTicketById: success', [
'ticket_id' => $ticket_id,
'verifier_id' => $verifier_id,
]);
// 审计日志
AuditService::logVerify(
$ticket_id,
$ticket['ticket_code'],
$verifier_id,
$verifier['name'] ?? '',
'success',
0
);
return [
'code' => 0,
'msg' => '核销成功',
'data' => [
'seat_info' => $ticket['seat_info'],
'real_name' => $ticket['real_name'],
'goods_name' => json_decode($ticket['goods_snapshot'] ?? '{}', true)['goods_name'] ?? '',
],
];
});
} catch (\Throwable $e) {
BaseService::log('verifyTicketById: transaction_error', [
'ticket_id' => $ticket_id,
'error' => $e->getMessage(),
], 'error');
return ['code' => -999, 'msg' => '核销失败,请重试'];
}
}
/**
* 生成 QR 码图片 URL
*/
public static function getQrCodeUrl($ticket_code)
{
$content = base64_encode(json_encode([
'type' => 'vr_ticket',
'code' => $ticket_code,
]));
return request()->domain() . request()->root() . '?s=index/qrcode/index&content=' . urlencode($content) . '&size=8&level=H';
}
/**
* 获取票的 QR 数据(短码 + payload
*
* qr_data 格式:短码|payload
*
* @param int $ticket_id 票ID
* @param int $user_id 用户ID校验归属
* @return array [code, data]
*/
public static function getQrData($ticket_id, $user_id)
{
$ticket = \think\facade\Db::name(BaseService::table('tickets'))
->where('id', $ticket_id)
->where('user_id', $user_id)
->find();
if (empty($ticket)) {
return ['code' => -1, 'msg' => '票不存在'];
}
// 已核销的票不返回 QR
if ($ticket['verify_status'] == 1) {
return ['code' => -2, 'msg' => '该票已核销'];
}
// 已退款的票不返回 QR
if ($ticket['verify_status'] == 2) {
return ['code' => -3, 'msg' => '该票已退款'];
}
$qr_data = $ticket['qr_data'] ?? '';
if (empty($qr_data) || strpos($qr_data, '|') === false) {
return ['code' => -4, 'msg' => 'QR数据异常'];
}
[$short_code, $payload] = explode('|', $qr_data, 2);
// 检查是否需要刷新 QR剩余有效期 < 15分钟
if (!empty($payload)) {
$decoded = BaseService::verifyQrPayload($payload);
if ($decoded !== null && $decoded['exp'] - time() > 900) {
// 有效期 > 15分钟返回缓存
return [
'code' => 0,
'msg' => 'success',
'data' => [
'short_code' => $short_code,
'payload' => $payload,
'cached' => true,
'expires_in' => $decoded['exp'] - time(),
],
];
}
}
// 需要刷新 QR过期或即将过期
$now = time();
$new_payload = BaseService::signQrPayload([
'id' => $ticket_id,
'g' => $ticket['goods_id'],
'iat' => $now,
'exp' => $now + 1800, // 30分钟
]);
// 更新缓存
$new_qr_data = $short_code . '|' . $new_payload;
\think\facade\Db::name(BaseService::table('tickets'))
->where('id', $ticket_id)
->update(['qr_data' => $new_qr_data, 'updated_at' => $now]);
return [
'code' => 0,
'msg' => 'success',
'data' => [
'short_code' => $short_code,
'payload' => $new_payload,
'cached' => false,
'expires_in' => 1800,
],
];
}
}