'', '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/json;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 { $computerStr = ""; foreach ($data as $k => $v) { $computerStr .= $k . $v; } $computerStr = $this->api['appSecret'] . $computerStr; return md5($computerStr); } 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)); } }