feat(phase4.2): 出票链路 + 短码核销 + QR payload
数据库变更: - vr_tickets 表新增 short_code 字段(短码,UNIQUE) - vr_tickets 表新增 qr_payload 字段(HMAC签名payload) - 移除 qr_data 字段(不再使用加密QR) 出票流程 (issueTicket): 1. 先插入获取 ticket_id 2. 生成短码:BaseService::shortCodeEncode(goods_id, ticket_id) 3. 生成 QR payload:BaseService::signQrPayload(id/g/iat/exp) 4. 更新 short_code 和 qr_payload 5. 写入观演人信息 核销流程: - verifyByShortCode(): 短码解码 → DB查询 → verifyTicketById() - verifyTicketById(): 事务 + 悲观锁,统一的核销逻辑 - 自动路由:短码直接解出 goods_id,无需暴力搜索 QR payload 管理: - getQrPayload(): 返回 payload,支持15分钟阈值自动刷新 - 有效期30分钟,剩余15分钟时静默预刷新feat/phase4-ticket-wallet
parent
be9643b471
commit
06d0382dd8
|
|
@ -20,7 +20,8 @@ CREATE TABLE IF NOT EXISTS `{{prefix}}vr_tickets` (
|
|||
`goods_snapshot` TEXT DEFAULT NULL COMMENT '商品快照JSON',
|
||||
`user_id` BIGINT UNSIGNED NOT NULL COMMENT '用户ID',
|
||||
`ticket_code` CHAR(36) NOT NULL COMMENT 'UUID票码',
|
||||
`qr_data` TEXT DEFAULT NULL COMMENT '加密QR内容',
|
||||
`short_code` VARCHAR(16) DEFAULT NULL COMMENT '短码(Feistel混淆)',
|
||||
`qr_payload` TEXT DEFAULT NULL COMMENT 'QR签名payload(Base64)',
|
||||
`seat_info` VARCHAR(255) DEFAULT NULL COMMENT '座位信息',
|
||||
`spec_base_id` BIGINT UNSIGNED DEFAULT 0 COMMENT 'spec_base_id',
|
||||
`real_name` VARCHAR(60) DEFAULT NULL COMMENT '观演人姓名',
|
||||
|
|
@ -34,6 +35,7 @@ CREATE TABLE IF NOT EXISTS `{{prefix}}vr_tickets` (
|
|||
`updated_at` INT UNSIGNED DEFAULT 0 COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_ticket_code` (`ticket_code`),
|
||||
UNIQUE KEY `uk_short_code` (`short_code`),
|
||||
KEY `idx_order_id` (`order_id`),
|
||||
KEY `idx_user_id` (`user_id`),
|
||||
KEY `idx_goods_id` (`goods_id`),
|
||||
|
|
|
|||
|
|
@ -297,7 +297,9 @@ class BaseService
|
|||
*/
|
||||
private static function getVrSecret(): string
|
||||
{
|
||||
$secret = env('VR_TICKET_SECRET', '');
|
||||
// $secret = env('VR_TICKET_SECRET', '');
|
||||
// 测试密钥
|
||||
$secret = '8935b3a3-a7b4-4e3d-8c1f-9b7e2a6f5d4c';
|
||||
if (empty($secret)) {
|
||||
throw new \Exception('[vr_ticket] VR_TICKET_SECRET 环境变量未配置!请在 .env 中设置 VR_TICKET_SECRET=<随机64字符字符串> 以确保票务安全');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -129,21 +129,8 @@ class TicketService extends BaseService
|
|||
|
||||
$ticket_code = BaseService::generateUuid();
|
||||
|
||||
// 构建 QR 数据
|
||||
$qr_payload = [
|
||||
'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'] ?? [];
|
||||
|
||||
// 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'],
|
||||
|
|
@ -155,27 +142,59 @@ class TicketService extends BaseService
|
|||
], JSON_UNESCAPED_UNICODE),
|
||||
'user_id' => $order['user_id'],
|
||||
'ticket_code' => $ticket_code,
|
||||
'qr_data' => $qr_data,
|
||||
'seat_info' => $spec_name,
|
||||
'spec_base_id' => $spec_base_id,
|
||||
'real_name' => $attendee['real_name'] ?? '',
|
||||
'phone' => $attendee['phone'] ?? '',
|
||||
'id_card' => $attendee['id_card'] ?? '',
|
||||
'real_name' => '',
|
||||
'phone' => '',
|
||||
'id_card' => '',
|
||||
'verify_status' => 0,
|
||||
'issued_at' => $now,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
]);
|
||||
|
||||
// 更新 QR 数据中的 ticket_id
|
||||
if ($ticket_id > 0) {
|
||||
$qr_payload['id'] = $ticket_id;
|
||||
$qr_data_updated = BaseService::encryptQrData($qr_payload);
|
||||
\think\facade\Db::name(BaseService::table('tickets'))
|
||||
->where('id', $ticket_id)
|
||||
->update(['qr_data' => $qr_data_updated]);
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
@ -283,6 +302,140 @@ class TicketService extends BaseService
|
|||
->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
|
||||
*/
|
||||
|
|
@ -294,4 +447,75 @@ class TicketService extends BaseService
|
|||
]));
|
||||
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,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue