478 lines
13 KiB
PHP
478 lines
13 KiB
PHP
<?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;
|
||
}
|
||
} |