- 11 个已跟踪文件的在途改动此前只存在于工作区,生产已在运行 - 补入两个从未入库的文件:InternalController.php(app_03/box_app 内部接口)、 config/internal.php(内部通信 key,值从 .env 读取,不含硬编码密钥) - 逐文件 md5 比对过生产,内容一致 Co-authored-by: Cursor <cursoragent@cursor.com>
360 lines
13 KiB
PHP
360 lines
13 KiB
PHP
<?php
|
|
|
|
namespace app\haichao;
|
|
|
|
use think\exception\ValidateException;
|
|
use think\facade\Log;
|
|
|
|
abstract class HaichaoBridge
|
|
{
|
|
private array $api = [
|
|
'appid' => '',
|
|
'appSecret' => '',
|
|
'api' => 'https://openapi.jushuitan.com/',
|
|
'access_token' => '',
|
|
'shopId' => '',
|
|
];
|
|
|
|
private string $tokenFile = 'runtime/jstdata/jst_token_data.json';
|
|
|
|
public function __construct()
|
|
{
|
|
$this->api['appid'] = env('JST_APP_KEY', '');
|
|
$this->api['appSecret'] = env('JST_APP_SECRET', '');
|
|
$this->api['api'] = env('JST_API', 'https://openapi.jushuitan.com/');
|
|
$this->api['access_token'] = $this->getToken();
|
|
$this->api['shopId'] = (string)env('JST_SHOP_ID', '');
|
|
}
|
|
|
|
public function getShopId(): string
|
|
{
|
|
return $this->api['shopId'];
|
|
}
|
|
|
|
/**
|
|
* 聚水潭开放平台请求封装
|
|
* @param string $api
|
|
* @param array $data
|
|
* @param array $addonBody
|
|
* @return array
|
|
*/
|
|
protected function post(string $api, array $data = [], array $addonBody = []): array
|
|
{
|
|
return $this->postInternal($api, $data, $addonBody);
|
|
}
|
|
|
|
private function postInternal(string $api, array $data = [], array $addonBody = [], int $attempt = 0, bool $tokenRefreshed = false): array
|
|
{
|
|
if (!$this->api['appid'] || !$this->api['appSecret'] || !$this->api['access_token']) {
|
|
throw new ValidateException('JST config missing');
|
|
}
|
|
$apiName = ltrim($api, '/');
|
|
if ($apiName === 'open/inventory/query') {
|
|
$skuIdsRaw = $data['sku_ids'] ?? '';
|
|
$skuIdsList = [];
|
|
if (is_array($skuIdsRaw)) {
|
|
$skuIdsList = array_values(array_filter(array_map('trim', $skuIdsRaw), static fn($v) => $v !== ''));
|
|
$skuIdsRaw = implode(',', $skuIdsList);
|
|
} else {
|
|
$skuIdsRaw = (string)$skuIdsRaw;
|
|
$skuIdsList = array_values(array_filter(array_map('trim', explode(',', trim($skuIdsRaw, ", \t\n\r\0\x0B"))), static fn($v) => $v !== ''));
|
|
}
|
|
Log::info('jst.api.inventory.query.request ' . json_encode([
|
|
'api' => $apiName,
|
|
'attempt' => $attempt,
|
|
'page_index' => $data['page_index'] ?? null,
|
|
'page_num' => $data['page_num'] ?? null,
|
|
'page_size' => $data['page_size'] ?? null,
|
|
'sku_ids_count' => count($skuIdsList),
|
|
'sku_ids' => $skuIdsList,
|
|
'sku_ids_raw' => $skuIdsRaw,
|
|
], JSON_UNESCAPED_UNICODE));
|
|
}
|
|
$this->throttle();
|
|
$time = time();
|
|
$baseBody = [
|
|
'access_token' => $this->api['access_token'],
|
|
'app_key' => $this->api['appid'],
|
|
];
|
|
$baseBody = array_merge($baseBody, $addonBody);
|
|
$baseBody['biz'] = json_encode($data, JSON_UNESCAPED_UNICODE);
|
|
$baseBody['charset'] = 'utf-8';
|
|
$baseBody['timestamp'] = $time;
|
|
$baseBody['version'] = '2';
|
|
$baseBody['sign'] = $this->sign($baseBody);
|
|
$options['http'] = [
|
|
'timeout' => 60,
|
|
'method' => 'POST',
|
|
'header' => 'Content-Type: application/x-www-form-urlencoded;charset=UTF-8',
|
|
'content' => http_build_query($baseBody)
|
|
];
|
|
$raw = file_get_contents("{$this->api['api']}$api", false, stream_context_create($options));
|
|
$rt = json_decode((string)$raw, true);
|
|
$respPreview = null;
|
|
try {
|
|
$respPreview = mb_substr((string)$raw, 0, 2000);
|
|
} catch (\Throwable $e) {
|
|
}
|
|
Log::info('jst.api.response ' . json_encode([
|
|
'api' => $api,
|
|
'code' => $rt['code'] ?? null,
|
|
'msg' => $rt['msg'] ?? null,
|
|
'preview' => $respPreview,
|
|
], JSON_UNESCAPED_UNICODE));
|
|
if (!is_array($rt) || !array_key_exists('code', $rt)) {
|
|
Log::error('JST response invalid ' . json_encode([
|
|
'api' => $api,
|
|
], JSON_UNESCAPED_UNICODE));
|
|
throw new ValidateException('JST response invalid');
|
|
}
|
|
if ($rt['code'] != 0) {
|
|
$code = (int)$rt['code'];
|
|
if (!$tokenRefreshed && $this->isTokenError($rt)) {
|
|
$this->api['access_token'] = $this->getToken(true);
|
|
return $this->postInternal($api, $data, $addonBody, $attempt + 1, true);
|
|
}
|
|
if ($this->isRateLimitError($code) && $attempt < 6) {
|
|
$sleepMs = $this->getRateLimitSleepMs($code, $attempt);
|
|
usleep($sleepMs * 1000);
|
|
return $this->postInternal($api, $data, $addonBody, $attempt + 1, $tokenRefreshed);
|
|
}
|
|
Log::error('JST request failed ' . json_encode([
|
|
'api' => $api,
|
|
'code' => $code,
|
|
'msg' => (string)($rt['msg'] ?? ''),
|
|
], JSON_UNESCAPED_UNICODE));
|
|
throw new ValidateException($rt['msg'] ?? 'JST error');
|
|
}
|
|
return $rt;
|
|
}
|
|
|
|
private function throttle(): void
|
|
{
|
|
static $lastAt = 0.0;
|
|
static $recent = [];
|
|
|
|
$minGap = 0.65;
|
|
while (true) {
|
|
$now = microtime(true);
|
|
$gap = $now - $lastAt;
|
|
if ($gap < $minGap) {
|
|
usleep((int)(($minGap - $gap) * 1000000));
|
|
continue;
|
|
}
|
|
|
|
$recent = array_values(array_filter($recent, static fn($t) => ($now - $t) < 60));
|
|
if (count($recent) >= 95) {
|
|
$oldest = $recent[0] ?? $now;
|
|
$wait = 60 - ($now - $oldest) + 0.2;
|
|
if ($wait > 0) {
|
|
usleep((int)($wait * 1000000));
|
|
continue;
|
|
}
|
|
}
|
|
|
|
$lastAt = microtime(true);
|
|
$recent[] = $lastAt;
|
|
return;
|
|
}
|
|
}
|
|
|
|
private function isRateLimitError(int $code): bool
|
|
{
|
|
return $code === 199 || $code === 200;
|
|
}
|
|
|
|
private function getRateLimitSleepMs(int $code, int $attempt): int
|
|
{
|
|
$jitter = random_int(0, 300);
|
|
if ($code === 200) {
|
|
return min(60000, (int)(6000 * (1 + $attempt)) + $jitter);
|
|
}
|
|
return min(10000, (int)(900 * (1 + $attempt)) + $jitter);
|
|
}
|
|
|
|
private function sign(array $data): string
|
|
{
|
|
if (!$data) {
|
|
return '';
|
|
}
|
|
|
|
ksort($data);
|
|
$str = '';
|
|
foreach ($data as $k => $v) {
|
|
if ($k === null || $k === '' || $k === 'sign' || $v === '' || $v === null) {
|
|
continue;
|
|
}
|
|
if (is_array($v) || is_object($v)) {
|
|
$v = json_encode($v, JSON_UNESCAPED_UNICODE);
|
|
}
|
|
$str .= $k . $v;
|
|
}
|
|
|
|
$str = $this->api['appSecret'] . $str;
|
|
return bin2hex(md5($str, true));
|
|
}
|
|
|
|
private function isTokenError(array $rt): bool
|
|
{
|
|
$msg = (string)($rt['msg'] ?? '');
|
|
if (!$msg) return false;
|
|
$msgLower = strtolower($msg);
|
|
return strpos($msgLower, 'access_token') !== false || strpos($msgLower, 'token') !== false;
|
|
}
|
|
|
|
private function getToken(bool $forceRefresh = false): string
|
|
{
|
|
$tokenInfo = $this->readTokenInfo();
|
|
if ($tokenInfo === null) {
|
|
$tokenInfo = $this->autoInitTokenFile();
|
|
if ($tokenInfo === null) {
|
|
throw new ValidateException('JST token missing, please check .env JST_ACCESS_TOKEN config');
|
|
}
|
|
}
|
|
if (!$forceRefresh && $tokenInfo) {
|
|
$accessToken = (string)($tokenInfo['access_token'] ?? '');
|
|
$expiresIn = (int)($tokenInfo['expires_in'] ?? 0);
|
|
$tokenTime = (int)($tokenInfo['time'] ?? 0);
|
|
if ($accessToken && $expiresIn > 0 && $tokenTime > 0) {
|
|
if (time() < ($tokenTime + $expiresIn - 3600)) {
|
|
return $accessToken;
|
|
}
|
|
}
|
|
}
|
|
|
|
$refreshToken = (string)($tokenInfo['refresh_token'] ?? '');
|
|
if (!$refreshToken) {
|
|
$refreshToken = trim((string)env('JST_REFRESH_TOKEN', ''));
|
|
}
|
|
if ($refreshToken) {
|
|
$newTokenInfo = $this->refreshAccessToken($refreshToken);
|
|
$this->writeTokenInfo($newTokenInfo);
|
|
return (string)$newTokenInfo['access_token'];
|
|
}
|
|
|
|
$code = (string)env('JST_AUTH_CODE', '');
|
|
if ($code) {
|
|
$newTokenInfo = $this->code2accessToken($code);
|
|
$this->writeTokenInfo($newTokenInfo);
|
|
return (string)$newTokenInfo['access_token'];
|
|
}
|
|
|
|
throw new ValidateException('JST token missing');
|
|
}
|
|
|
|
private function autoInitTokenFile(): ?array
|
|
{
|
|
$accessToken = (string)env('JST_ACCESS_TOKEN', '');
|
|
$refreshToken = (string)env('JST_REFRESH_TOKEN', '');
|
|
$expiresIn = (int)env('JST_EXPIRES_IN', 2592000);
|
|
|
|
if (empty($accessToken)) {
|
|
return null;
|
|
}
|
|
|
|
$tokenInfo = [
|
|
'access_token' => $accessToken,
|
|
'expires_in' => $expiresIn,
|
|
'refresh_token' => $refreshToken,
|
|
'time' => time(),
|
|
];
|
|
|
|
$this->writeTokenInfo($tokenInfo);
|
|
return $tokenInfo;
|
|
}
|
|
|
|
private function refreshAccessToken(string $refreshToken): array
|
|
{
|
|
$params = [];
|
|
$params['app_key'] = $this->api['appid'];
|
|
$params['timestamp'] = time();
|
|
$params['grant_type'] = 'refresh_token';
|
|
$params['charset'] = 'utf-8';
|
|
$params['refresh_token'] = $refreshToken;
|
|
$params['scope'] = 'all';
|
|
$params['sign'] = $this->sign($params);
|
|
|
|
$url = "{$this->api['api']}openWeb/auth/refreshToken";
|
|
$raw = file_get_contents($url, false, stream_context_create([
|
|
'http' => [
|
|
'timeout' => 60,
|
|
'method' => 'POST',
|
|
'header' => 'Content-Type: application/x-www-form-urlencoded;charset=UTF-8',
|
|
'content' => http_build_query($params),
|
|
],
|
|
]));
|
|
$rt = json_decode((string)$raw, true);
|
|
if (!is_array($rt) || !array_key_exists('code', $rt)) {
|
|
throw new ValidateException('JST response invalid');
|
|
}
|
|
if ((int)$rt['code'] !== 0) {
|
|
throw new ValidateException((string)($rt['msg'] ?? 'JST error'));
|
|
}
|
|
$data = $rt['data'] ?? [];
|
|
if (!is_array($data) || empty($data['access_token'])) {
|
|
throw new ValidateException('JST token invalid');
|
|
}
|
|
return $data;
|
|
}
|
|
|
|
private function code2accessToken(string $code): array
|
|
{
|
|
$params = [];
|
|
$params['app_key'] = $this->api['appid'];
|
|
$params['timestamp'] = time();
|
|
$params['grant_type'] = 'authorization_code';
|
|
$params['charset'] = 'utf-8';
|
|
$params['code'] = $code;
|
|
$params['sign'] = $this->sign($params);
|
|
|
|
$url = "{$this->api['api']}openWeb/auth/accessToken";
|
|
$raw = file_get_contents($url, false, stream_context_create([
|
|
'http' => [
|
|
'timeout' => 60,
|
|
'method' => 'POST',
|
|
'header' => 'Content-Type: application/x-www-form-urlencoded;charset=UTF-8',
|
|
'content' => http_build_query($params),
|
|
],
|
|
]));
|
|
$rt = json_decode((string)$raw, true);
|
|
if (!is_array($rt) || !array_key_exists('code', $rt)) {
|
|
throw new ValidateException('JST response invalid');
|
|
}
|
|
if ((int)$rt['code'] !== 0) {
|
|
throw new ValidateException((string)($rt['msg'] ?? 'JST error'));
|
|
}
|
|
$data = $rt['data'] ?? [];
|
|
if (!is_array($data) || empty($data['access_token'])) {
|
|
throw new ValidateException('JST token invalid');
|
|
}
|
|
return $data;
|
|
}
|
|
|
|
private function readTokenInfo(): ?array
|
|
{
|
|
$file = root_path() . $this->tokenFile;
|
|
if (!is_file($file)) {
|
|
Log::info('JST token file not found, will try to auto init from .env');
|
|
return null;
|
|
}
|
|
$raw = file_get_contents($file);
|
|
if (!$raw) return null;
|
|
$arr = json_decode((string)$raw, true);
|
|
if (!is_array($arr) || empty($arr['access_token'])) {
|
|
Log::warning('JST token file is invalid, will try to auto init from .env');
|
|
return null;
|
|
}
|
|
return $arr;
|
|
}
|
|
|
|
private function writeTokenInfo(array $tokenInfo): void
|
|
{
|
|
$dir = root_path() . 'runtime/jstdata';
|
|
if (!is_dir($dir)) {
|
|
mkdir($dir, 0777, true);
|
|
}
|
|
$tokenInfo['time'] = time();
|
|
file_put_contents(root_path() . $this->tokenFile, json_encode($tokenInfo, JSON_UNESCAPED_UNICODE));
|
|
}
|
|
}
|