checkRateLimit($userId); // 2. 获取分布式锁 if (!$this->getLock($code)) { throw new Exception('系统正在处理该券码,请稍候'); } // 3. 获取抖音Token $token = $this->getToken(); // 4. 准备核销凭证 $prepared = $this->prepare($token, $code, $type); // 5. 检查是否已核销 $this->checkUsed($prepared['order_id']); // 6. 获取门店pro ID $poiId = $this->getStorePoiId($storeId); // 7. 执行抖音核销 $this->executeVerify($prepared['verify_token'], $token, $poiId, [$prepared['encrypted_code']]); // 8. 保存核销记录 $this->saveRecord($userId, $storeId, $code, $prepared, 1); // 9. 标记为已使用 $this->markUsed($prepared['order_id'], $storeId, $prepared['third_sku_id'], $userId); return [ 'success' => true, 'message' => '核销成功', 'order_id' => $prepared['order_id'] ]; } catch (Exception $e) { // 记录错误日志 $this->saveRecord( $userId, $storeId, $code, $prepared ?? [], 2, $e->getMessage() ); return [ 'success' => false, 'message' => $e->getMessage() ]; } finally { // 释放锁 if (isset($code)) { $this->releaseLock($code); } } } /** * 频率限制检查 */ protected function checkRateLimit($userId) { $key = 'verify:rate:' . $userId . ':' . date('Hi'); try { // 使用Cache门面的store方法指定使用redis驱动 $cache = Cache::store('redis'); $count = $cache->inc($key); $cache->set($key, $count, 60); if ($count > 20) { throw new Exception('操作过于频繁,请稍后再试'); } } catch (\Exception $e) { // Redis不可用时跳过频率限制 return; } } /** * 获取分布式锁 */ protected function getLock($code) { $lockKey = 'verify:lock:' . md5($code); $lockValue = uniqid(); try { // 使用Cache门面获取Redis实例 $redis = Cache::store('redis')->handler(); // 使用SET NX EX实现分布式锁 $result = $redis->set($lockKey, $lockValue, ['nx', 'ex' => 10]); return (bool)$result; } catch (\Exception $e) { // Redis不可用时不使用锁 return true; } } /** * 释放锁 */ protected function releaseLock($code) { $lockKey = 'verify:lock:' . md5($code); try { $redis = Cache::store('redis')->handler(); $redis->del($lockKey); } catch (\Exception $e) { // 忽略释放锁错误 } } /** * 获取抖音Token */ protected function getToken() { $cacheKey = 'douyin:token'; // try { // // 从Redis缓存获取 // $token = Cache::store('redis')->get($cacheKey); // if ($token) { // return $token; // } // } catch (\Exception $e) { // // Redis不可用时使用文件缓存 // $token = Cache::get($cacheKey); // if ($token) { // return $token; // } // } $url = 'https://open.douyin.com/oauth/client_token/'; $params = [ 'client_key' => $this->appId, 'client_secret' => $this->appSecret, 'grant_type' => 'client_credential' ]; $response = $this->httpPost($url, $params, false); $result = json_decode($response, true); if (isset($result['data']['error_code']) && $result['data']['error_code'] === 0) { $token = $result['data']['access_token']; // // 存入Redis缓存 // try { // Cache::store('redis')->set($cacheKey, $token, $expire - 300); // } catch (\Exception $e) { // // Redis不可用时使用文件缓存 // Cache::set($cacheKey, $token, $expire - 300); // } return $token; } throw new Exception('获取抖音Token失败'); } /** * 准备核销凭证 */ protected function prepare($token, $code, $type) { $url = 'https://open.douyin.com/goodlife/v1/fulfilment/certificate/prepare/'; if ($type == 1) { $params = ['code' => $code]; } else { $longUrl = $this->followRedirect($code); $objectId = $this->parseObjectId($longUrl); $params = ['encrypted_data' => $objectId]; } $response = $this->httpGet($url . '?' . http_build_query($params), $token); $result = json_decode($response, true); if (!isset($result['data']['error_code']) || $result['data']['error_code'] !== 0 || !isset($result['data']['certificates'][0])) { throw new Exception('券码无效或已使用'); } return [ 'order_id' => $result['data']['order_id'] ?? '', 'encrypted_code' => $result['data']['certificates'][0]['encrypted_code'] ?? '', 'verify_token' => $result['data']['verify_token'] ?? '', 'third_sku_id' => $result['data']['certificates'][0]['sku']['third_sku_id'] ?? '' ]; } /** * 检查是否已核销 */ protected function checkUsed($orderId) { $cacheKey = 'verify:used:' . $orderId; // 先检查Redis缓存 try { if (Cache::store('redis')->has($cacheKey)) { throw new Exception('该券码已被核销'); } } catch (\Exception $e) { // Redis不可用,跳过缓存检查 } // 检查数据库 $exists = UserGroup::where('code', $orderId)->find(); if ($exists) { // 更新Redis缓存 try { Cache::store('redis')->set($cacheKey, 1, 86400); } catch (\Exception $e) { // Redis不可用时忽略 } throw new Exception('该券码已被核销'); } } /** * 获取门店POI ID */ protected function getStorePoiId($storeId) { $store = TeaStore::where('id', $storeId)->field('pro_id')->find(); if (!$store) { throw new Exception('门店不存在'); } if ($store['pro_id'] == 0) { throw new Exception('门店未配置核销权限'); } return $store['pro_id']; } /** * 执行核销 */ protected function executeVerify($verifyToken, $accessToken, $poiId, $codes) { $url = 'https://open.douyin.com/goodlife/v1/fulfilment/certificate/verify/'; $data = [ 'verify_token' => $verifyToken, 'poi_id' => $poiId, 'encrypted_codes' => $codes ]; $response = $this->httpPost($url, $data, $accessToken); $result = json_decode($response, true); if (!isset($result['data']['error_code']) || $result['data']['error_code'] !== 0) { throw new Exception('抖音核销失败'); } return $result; } /** * 保存核销记录 */ protected function saveRecord($userId, $storeId, $code, $data, $status = 1, $error = '') { $record = [ 'user_id' => $userId, 'dy_order_id' => $data['order_id'] ?? '', 'third_sku_id' => $data['third_sku_id'] ?? '', 'qr_code' => $code, 'encrypted_code' => $data['encrypted_code'] ?? '', 'verify_result' => json_encode($data, JSON_UNESCAPED_UNICODE), 'error_log' => $error, 'verify_time' => date('Y-m-d H:i:s'), 'status' => $status ]; VerifyRecords::insert($record); } /** * 标记为已使用 */ protected function markUsed($orderId, $storeId, $skuId, $userId) { // 获取商品ID $store_group = TeaStoreGroup::where([ 'store_id' => $storeId, 'sku_id' => $skuId ])->field('id')->find(); if ($store_group) { // 保存到UserGroup表 $usergroupData = [ 'group_id' => $store_group['id'], 'user_id' => $userId, 'store_id'=>$storeId, 'type' => 2, 'dtime' => date('Y-m-d H:i:s'), 'code' => $orderId ]; // 保存到UserGroup表 UserGroup::insert($usergroupData); } Db::startTrans(); try { // 更新Redis缓存 try { Cache::store('redis')->set('verify:used:' . $orderId, 1, 86400); } catch (\Exception $e) { // Redis不可用时忽略 } Db::commit(); } catch (\Exception $e) { Db::rollback(); throw new Exception('保存核销记录失败'); } } /** * 跟踪重定向 */ protected function followRedirect($url) { // 先尝试解码Base64 $url = $this->autoDecodeBase64($url); $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => true, CURLOPT_MAXREDIRS => 10, CURLOPT_HEADER => true, CURLOPT_TIMEOUT => 10, CURLOPT_SSL_VERIFYPEER => false, CURLOPT_SSL_VERIFYHOST => false, CURLOPT_USERAGENT => 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', ]); $response = curl_exec($ch); if ($response === false) { $error = curl_error($ch); curl_close($ch); throw new Exception('获取重定向失败: ' . $error); } $redirectUrl = curl_getinfo($ch, CURLINFO_EFFECTIVE_URL); curl_close($ch); return $redirectUrl; } /** * 自动解码Base64 */ protected function autoDecodeBase64($str) { // 检查是否是有效的Base64字符串 if (preg_match('/^[A-Za-z0-9+\/=]+$/', $str)) { $decoded = @base64_decode($str, true); if ($decoded !== false && base64_encode($decoded) === $str) { // 再次检查解码后是否是URL if (filter_var($decoded, FILTER_VALIDATE_URL)) { \think\facade\Log::info('自动解码Base64', [ 'original' => $str, 'decoded' => $decoded ]); return $decoded; } } } return $str; } /** * 解析object_id */ protected function parseObjectId($url) { parse_str(parse_url($url, PHP_URL_QUERY), $params); return $params['object_id'] ?? ''; } /** * HTTP GET请求 */ protected function httpGet($url, $token = '') { $ch = curl_init(); $headers = ['Content-Type: application/json']; if ($token) { $headers[] = 'access-token: ' . $token; } curl_setopt_array($ch, [ CURLOPT_URL => $url, CURLOPT_RETURNTRANSFER => true, CURLOPT_HTTPHEADER => $headers, CURLOPT_TIMEOUT => 10, CURLOPT_SSL_VERIFYPEER => false, CURLOPT_SSL_VERIFYHOST => false, ]); $response = curl_exec($ch); curl_close($ch); return $response; } /** * HTTP POST请求 */ protected function httpPost($url, $data, $token = '') { $ch = curl_init(); $headers = ['Content-Type: application/json']; if ($token) { $headers[] = 'access-token: ' . $token; } curl_setopt_array($ch, [ CURLOPT_URL => $url, CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_POSTFIELDS => json_encode($data), CURLOPT_HTTPHEADER => $headers, CURLOPT_TIMEOUT => 10, CURLOPT_SSL_VERIFYPEER => false, CURLOPT_SSL_VERIFYHOST => false, ]); $response = curl_exec($ch); curl_close($ch); return $response; } }