Files
chazhi_admin_broker/app/common/service/DouyinVerifyService.php
2026-03-11 18:24:59 +08:00

478 lines
13 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace app\common\service;
use think\facade\Db;
use think\facade\Cache;
use think\Exception;
use app\common\model\teastore\TeaStore;
use app\common\model\teastore\TeaStoreGroup;
use app\common\model\user\UserGroup;
use app\common\model\verify\VerifyRecords;
/**
* 抖音核销服务类
*/
class DouyinVerifyService
{
// 抖音配置
protected $appId = 'awyjvrmyjwss6d71';
protected $appSecret = '1a9208f7742e83a91a2a27ccb558849c';
/**
* 核销券码
*/
public function verify($userId, $storeId, $code, $type = 2)
{
try {
// 1. 频率限制
$this->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;
}
}