522 lines
18 KiB
PHP
522 lines
18 KiB
PHP
<?php
|
||
/**
|
||
* VR票务插件 - 票务服务
|
||
*
|
||
* 核心业务:订单支付成功 → 生成电子票
|
||
*
|
||
* @package vr_ticket\service
|
||
*/
|
||
|
||
namespace app\plugins\vr_ticket\service;
|
||
|
||
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['business_id'] ?? ($params['business_ids'][0] ?? 0);
|
||
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;
|
||
}
|
||
|
||
// 判断是否为票务商品
|
||
if (!BaseService::isTicketGoods($order['goods_id'])) {
|
||
BaseService::log('onOrderPaid: not a ticket goods', ['order_id' => $order_id], 'info');
|
||
return true; // 不是票务商品,不报错
|
||
}
|
||
|
||
// 查询订单明细(规格信息存储在 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 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);
|
||
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;
|
||
|
||
// 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();
|
||
|
||
// Step 1: 先插入获取 ticket_id(用于 short_code 生成)
|
||
$now = BaseService::now();
|
||
$ticket_id = \think\facade\Db::name(BaseService::table('tickets'))->insertGetId([
|
||
'order_id' => $order['id'],
|
||
'order_no' => $order['order_no'],
|
||
'goods_id' => $order['goods_id'],
|
||
'goods_snapshot' => json_encode([
|
||
'goods_name' => $og['title'] ?? '',
|
||
'spec_name' => $spec_name,
|
||
'price' => $og['price'] ?? 0,
|
||
], JSON_UNESCAPED_UNICODE),
|
||
'user_id' => $order['user_id'],
|
||
'ticket_code' => $ticket_code,
|
||
'seat_info' => $spec_name,
|
||
'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 混淆)
|
||
$short_code = BaseService::shortCodeEncode($order['goods_id'], $ticket_id);
|
||
|
||
// Step 3: 生成 QR payload(HMAC-SHA256 签名,30分钟有效)
|
||
$qr_payload = BaseService::signQrPayload([
|
||
'id' => $ticket_id,
|
||
'g' => $order['goods_id'],
|
||
'iat' => $now,
|
||
'exp' => $now + 1800, // 30分钟
|
||
]);
|
||
|
||
// Step 4: 更新 short_code 和 qr_payload
|
||
\think\facade\Db::name(BaseService::table('tickets'))
|
||
->where('id', $ticket_id)
|
||
->update([
|
||
'short_code' => $short_code,
|
||
'qr_payload' => $qr_payload,
|
||
]);
|
||
|
||
// 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' => $order['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_id,Feistel解密后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(带动态刷新)
|
||
*
|
||
* @param int $ticket_id 票ID
|
||
* @param int $user_id 用户ID(校验归属)
|
||
* @return array [code, data]
|
||
*/
|
||
public static function getQrPayload($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(剩余有效期 < 15分钟)
|
||
$qr_payload = $ticket['qr_payload'];
|
||
if (!empty($qr_payload)) {
|
||
$decoded = BaseService::verifyQrPayload($qr_payload);
|
||
if ($decoded !== null && $decoded['exp'] - time() > 900) {
|
||
// 有效期 > 15分钟,返回缓存
|
||
return [
|
||
'code' => 0,
|
||
'msg' => 'success',
|
||
'data' => [
|
||
'payload' => $qr_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分钟
|
||
]);
|
||
|
||
// 更新缓存
|
||
\think\facade\Db::name(BaseService::table('tickets'))
|
||
->where('id', $ticket_id)
|
||
->update(['qr_payload' => $new_payload, 'updated_at' => $now]);
|
||
|
||
return [
|
||
'code' => 0,
|
||
'msg' => 'success',
|
||
'data' => [
|
||
'payload' => $new_payload,
|
||
'cached' => false,
|
||
'expires_in'=> 1800,
|
||
],
|
||
];
|
||
}
|
||
}
|