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

535 lines
19 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;
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();
$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' => $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,
'qr_data' => '', // 占位,生成后更新
'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 混淆)
// 短码存储在 qr_data 中,供前端展示
$short_code = BaseService::shortCodeEncode($order['goods_id'], $ticket_id);
// Step 3: 生成 QR payloadHMAC-SHA256 签名30分钟有效
$qr_payload = BaseService::signQrPayload([
'id' => $ticket_id,
'g' => $order['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' => $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_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,
],
];
}
}