提交其他文件
This commit is contained in:
478
app/common/service/DouyinVerifyService.php
Normal file
478
app/common/service/DouyinVerifyService.php
Normal file
@ -0,0 +1,478 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user