fix: use official jushuitan worklog contract

This commit is contained in:
WuYongJie 2026-09-15 09:49:31 +08:00
parent 18c63d6abf
commit 730522d1a5
7 changed files with 113 additions and 115 deletions

View File

@ -12,7 +12,7 @@ class SyncJushuitanCostPriceHistory extends Command
{
protected $signature = 'business-mirror:sync-jushuitan-cost-price-history
{--full : 强制启动或继续首次全量同步}
{--page-size= : 聚水潭接口每页读取条数,最大 2000}
{--page-size= : 聚水潭接口每页读取条数,最大 50}
{--max-pages= : 本轮最多读取页数,0 表示直到完成}';
protected $description = '按时间窗和页码同步聚水潭商品操作日志中的采购价变更到共享业务镜像';

View File

@ -59,16 +59,18 @@ return [
'jushuitan' => [
// 聚水潭开放平台凭据只从私有 runtime/.env 读取,不进入代码仓库。
'base_url' => env('JUSHUITAN_OPEN_API_URL', 'https://openapi.jushuitan.com'),
'app_key' => env('JUSHUITAN_APP_KEY'),
'app_secret' => env('JUSHUITAN_APP_SECRET'),
'access_token' => env('JUSHUITAN_ACCESS_TOKEN'),
// 商品操作日志仍使用经典开放接口;兼容现有 APP_* 环境变量名,
// 实际含义依次为 partnerid、partnerkey、token。
'base_url' => env('JUSHUITAN_OPEN_API_URL', 'https://open.erp321.com/api/open/query.aspx'),
'partner_id' => env('JUSHUITAN_PARTNER_ID', env('JUSHUITAN_APP_KEY')),
'partner_key' => env('JUSHUITAN_PARTNER_KEY', env('JUSHUITAN_APP_SECRET')),
'token' => env('JUSHUITAN_TOKEN', env('JUSHUITAN_ACCESS_TOKEN')),
'timeout' => (int) env('JUSHUITAN_OPEN_API_TIMEOUT', 20),
'product_operation_history_path' => '/open/webapi/itemapi/itemsku/queryitemskuopshistory',
'product_operation_history_method' => 'worklog.query',
'cost_price_history_start' => env('JUSHUITAN_COST_PRICE_HISTORY_START', '2000-01-01 00:00:00'),
'cost_price_history_window_days' => (int) env('JUSHUITAN_COST_PRICE_HISTORY_WINDOW_DAYS', 30),
'cost_price_history_window_days' => (int) env('JUSHUITAN_COST_PRICE_HISTORY_WINDOW_DAYS', 7),
'cost_price_history_overlap_hours' => (int) env('JUSHUITAN_COST_PRICE_HISTORY_OVERLAP_HOURS', 48),
'cost_price_history_page_size' => (int) env('JUSHUITAN_COST_PRICE_HISTORY_PAGE_SIZE', 2000),
'cost_price_history_page_size' => (int) env('JUSHUITAN_COST_PRICE_HISTORY_PAGE_SIZE', 50),
'cost_price_history_max_pages' => (int) env('JUSHUITAN_COST_PRICE_HISTORY_MAX_PAGES', 30),
],

View File

@ -28,7 +28,7 @@
| 已完成首次导入 | 提现管理的每日店铺余额读取聚水潭“平台原始账单[新] → 店铺余额表”接口 JSON/XLSX 及多平台“平台资金账户余额”导出,进入 `jushuitan_shop_balance_snapshots`;已导入 2026-09-11 的淘系、拼多多和抖音共 164 条余额。页面按店铺名称精确匹配并强制使用平台消歧,未匹配和多匹配不会显示为零。 |
| 已接入待首次同步 | 店铺营销账户余额的京东账户读取 `js_data_middle_platform.dbo.jd_jzt_finance_records` 镜像 `jsm_jd_jzt_finance_records`;按 `id` 稳定断点、`updated_at` 重叠增量,页面不直连上游。 |
| 本次接入 | 普通商品资料的工作平台采购价格接入 `jiansun.dbo.pm_priceAdjustment`(镜像 `js_pm_price_adjustment`):按 `skuCode` 关联 `jst_sku.sku_id`,取 `effectTime` 不晚于当前时间的最新 `purchasePrice`,并在页面和导出中标记“工作平台价格”;源表没有可靠更新时间,按 `Id` 稳定键追加,首次全量完成前只显示普通商品自身采购价。 |
| 本次接入 | 普通商品成本价变更读取聚水潭开放平台“商品及库存管理”的商品操作日志接口 `/open/webapi/itemapi/itemsku/queryitemskuopshistory`,只镜像操作内容含“采购价”的记录到 `jushuitan_cost_price_history_rows`;审计历史按 `operation_id` 只追加或更新,全量回补不删除旧记录;页面按商品编码分别展示聚水潭成本价记录和工作平台价格记录,不混用来源。 |
| 本次接入 | 普通商品成本价变更读取聚水潭经典开放平台“商品操作日志查询”接口 `worklog.query`,只镜像操作内容含“采购价”的记录到 `jushuitan_cost_price_history_rows`;审计历史按稳定记录键只追加或更新,全量回补不删除旧记录;页面按商品编码分别展示聚水潭成本价记录和工作平台价格记录,不混用来源。 |
## 经营分析副本库(operations_replica)
@ -96,7 +96,7 @@
10. `operations_replica.rpt.jst_originalBillInfo` 是供多个业务功能复用的原始账单镜像。首次同步由 Codex 备份后自主安排在生产同步节点低峰期分批续传,无需另行授权;页面请求和普通开发机不启动刷新 worker。同步完成后,开发者通过固定 Repository 查询 `ops_jst_original_bill_info`,不得各自创建整表快照。 淘系支付宝账户账单当前仍读取此 MariaDB 镜像,统一调度须持续增量,不因 ClickHouse 尚未启用而暂停。账单冲减则复用“订单数据 → 原始数据 → 订单售后费用明细”,不按该表费用名称推断 500112。
11. 拼多多货款账户账单使用 `jiansun.dbo.jst_originalBillInfo.pinduoduo_receiving` 数据集。同步器必须以参数绑定固定 `channel = PinduoduoImport`,只复制该渠道切片;页面 Repository 只读 `js_jst_pdd_receiving_bill_rows`。首次全量按 `Id` 断点推进,后续按 `updateTime` 保留重叠窗口,不得切回落后一天的 `operations_replica` 副本。
12. 三方仓进销存首次全量按头表 `Id` 区间小批断点续传,头表只保存 `io_date >= 2026-08-01` 的目标仓数据,不激活半份数据;日常增量以头表 `(modified, Id)` 作稳定游标并保留 48 小时时间重叠,由命中头单驱动整单明细替换。全量和增量都先写待发布代次,库存抓取完成后再把流水追平到库存抓取之后的固定源时间边界,三部分成功后才一次切换;运行中或失败时页面始终读取上一成功代次。增量扫描不先限定仓库或业务日期,确保单据移出目标仓或日期改到起始日前时可从镜像撤回,重载当前版本时再固定仓库和起始日期。页面只统计 `Confirmed` 的 12 类进销存流水,数量按上游 `qty * qty_type` 保留符号。库存同时保存上游 `source_modified_at` 和实际 `captured_at`,历史余额以每个 SKU 的抓取时间为回推锚点;页面始终按“日期 + 商品”输出,日期区间固定为 `[当天 00:00, 次日 00:00)`。每天 `00:00` 执行增量同步,失败时继续展示上一成功代次。
13. 聚水潭商品操作日志按最多 30 天的固定时间窗和接口页码稳定断点同步,首次全量从 `2000-01-01` 起扫描;只保留操作内容含“采购价”的记录,并从“采购价:旧值->新值”解析前后价格和差额。日常同步回看 48 小时,按操作日志 `autoid` 幂等更新;接口失败时保留上一版镜像。页面不得把这些聚水潭记录与 `jiansun.dbo.pm_priceAdjustment` 的工作平台价格记录混为同一来源。
13. 聚水潭商品操作日志按最多 7 天的固定时间窗和接口页码稳定断点同步,每页最多 50 条,首次全量从 `2000-01-01` 起扫描;只保留备注内容含“采购价”的记录,并从“采购价:旧值->新值”解析前后价格和差额。日常同步回看 48 小时,按接口记录字段生成稳定键幂等更新;接口失败时保留上一版镜像。页面不得把这些聚水潭记录与 `jiansun.dbo.pm_priceAdjustment` 的工作平台价格记录混为同一来源。
工作平台采购价格首次全量需在备份和负责人授权后单独执行:

View File

@ -42,11 +42,11 @@ class JushuitanCostPriceHistorySynchronizer
$until = Carbon::parse((string) $state['window_ends_at']);
} elseif ($full) {
$since = $this->historyStart();
$until = now()->addSecond();
$until = now();
} else {
$overlap = max((int) config('services.jushuitan.cost_price_history_overlap_hours', 48), 1);
$since = Carbon::parse((string) $state['last_succeeded_at'])->subHours($overlap);
$until = now()->addSecond();
$until = now();
}
$state = $this->mirror->startRun($mode, $generation, $since, $until, $resume);
@ -55,9 +55,9 @@ class JushuitanCostPriceHistorySynchronizer
: Carbon::parse((string) $state['cursor_window_started_at']);
$page = max((int) ($state['cursor_page'] ?? 1), 1);
$pageSize = min(max(
$pageSize ?? (int) config('services.jushuitan.cost_price_history_page_size', 2000),
$pageSize ?? (int) config('services.jushuitan.cost_price_history_page_size', 50),
1,
), 2000);
), 50);
$maxPages = max(
$maxPages ?? (int) config('services.jushuitan.cost_price_history_max_pages', 30),
0,
@ -66,9 +66,9 @@ class JushuitanCostPriceHistorySynchronizer
$processedSourceRows = (int) ($state['last_processed_source_rows'] ?? 0);
$upsertedRows = (int) ($state['last_upserted_rows'] ?? 0);
$windowDays = min(max(
(int) config('services.jushuitan.cost_price_history_window_days', 30),
(int) config('services.jushuitan.cost_price_history_window_days', 7),
1,
), 30);
), 7);
try {
while ($windowStartedAt->lt($until)) {
@ -88,7 +88,7 @@ class JushuitanCostPriceHistorySynchronizer
$upsertedRows += count($records);
$pages++;
$hasNextPage = $result['total_pages'] > 0 && $page < $result['total_pages'];
$hasNextPage = $result['has_next'];
// 相邻窗口在边界秒保留一次重叠,避免接口时间精度高于秒时漏掉记录;
// operation_id 的幂等 upsert 会消除边界重复。
$nextWindowStartedAt = $hasNextPage
@ -133,7 +133,11 @@ class JushuitanCostPriceHistorySynchronizer
'rows' => $upsertedRows,
];
} catch (Throwable $exception) {
$this->mirror->fail($exception::class);
$message = trim($exception->getMessage());
$errorCode = preg_match('/^[a-z0-9_]+$/', $message) === 1
? $message
: $exception::class;
$this->mirror->fail($errorCode);
throw $exception;
}
}

View File

@ -10,7 +10,7 @@ use RuntimeException;
class JushuitanOpenApiClient
{
/**
* @return array{records:list<array<string,mixed>>,source_rows:int,current_page:int,total_pages:int,total_count:int}
* @return array{records:list<array<string,mixed>>,source_rows:int,current_page:int,has_next:bool}
*/
public function getCostPriceOperationHistory(
Carbon $start,
@ -19,56 +19,57 @@ class JushuitanOpenApiClient
int $pageSize,
): array {
$payload = $this->request([
'current_page' => max($page, 1),
'page_size' => min(max($pageSize, 1), 2000),
'start_time' => $start->format('Y-m-d H:i:s'),
'end_time' => $end->format('Y-m-d H:i:s'),
'page_index' => max($page, 1),
'page_size' => min(max($pageSize, 1), 50),
'modified_begin' => $start->format('Y-m-d H:i:s'),
'modified_end' => $end->format('Y-m-d H:i:s'),
]);
$data = is_array($payload['data'] ?? null) ? $payload['data'] : [];
$pageInfo = is_array($data['page'] ?? null) ? $data['page'] : [];
$records = is_array($payload['datas'] ?? null) ? $payload['datas'] : [];
return [
'records' => $this->normalizeCostPriceOperations($data['list'] ?? []),
'source_rows' => is_array($data['list'] ?? null) ? count($data['list']) : 0,
'current_page' => max((int) ($pageInfo['current_page'] ?? $page), 1),
'total_pages' => max((int) ($pageInfo['pages'] ?? 0), 0),
'total_count' => max((int) ($pageInfo['count'] ?? 0), 0),
'records' => $this->normalizeCostPriceOperations($records),
'source_rows' => count($records),
'current_page' => max((int) ($payload['page_index'] ?? $page), 1),
'has_next' => (bool) ($payload['has_next'] ?? false),
];
}
/** @param array<string,mixed> $biz @return array<string,mixed> */
private function request(array $biz): array
{
$appKey = trim((string) config('services.jushuitan.app_key'));
$appSecret = trim((string) config('services.jushuitan.app_secret'));
$accessToken = trim((string) config('services.jushuitan.access_token'));
if ($appKey === '' || $appSecret === '' || $accessToken === '') {
$partnerId = trim((string) config('services.jushuitan.partner_id'));
$partnerKey = trim((string) config('services.jushuitan.partner_key'));
$token = trim((string) config('services.jushuitan.token'));
if ($partnerId === '' || $partnerKey === '' || $token === '') {
throw new RuntimeException('jushuitan_api_credentials_missing');
}
$method = (string) config(
'services.jushuitan.product_operation_history_method',
'worklog.query',
);
$timestamp = (string) now()->timestamp;
// URL parameter order is part of the classic API signature contract.
$params = [
'access_token' => $accessToken,
'app_key' => $appKey,
'biz' => json_encode(
$biz,
JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR,
),
'charset' => 'utf-8',
'timestamp' => (string) now()->timestamp,
'version' => '2',
'method' => $method,
'partnerid' => $partnerId,
'token' => $token,
'ts' => $timestamp,
];
$params['sign'] = $this->sign($params, $appSecret);
$params['sign'] = $this->sign($method, $partnerId, $token, $timestamp, $partnerKey);
$baseUrl = rtrim((string) config('services.jushuitan.base_url'), '/');
$path = (string) config(
'services.jushuitan.product_operation_history_path',
'/open/webapi/itemapi/itemsku/queryitemskuopshistory',
$baseUrl = (string) config(
'services.jushuitan.base_url',
'https://open.erp321.com/api/open/query.aspx',
);
/** @var PendingRequest $request */
$request = Http::asForm()
$request = Http::asJson()
->acceptJson()
->timeout(max((int) config('services.jushuitan.timeout', 20), 1));
$response = $request->post($baseUrl . '/' . ltrim($path, '/'), $params);
$response = $request->post(
$baseUrl . '?' . http_build_query($params, '', '&', PHP_QUERY_RFC3986),
$biz,
);
if (! $response->successful()) {
throw new RuntimeException('jushuitan_api_http_' . $response->status());
@ -86,19 +87,15 @@ class JushuitanOpenApiClient
return $payload;
}
/** @param array<string,mixed> $params */
private function sign(array $params, string $appSecret): string
private function sign(
string $method,
string $partnerId,
string $token,
string $timestamp,
string $partnerKey,
): string
{
ksort($params, SORT_STRING);
$plain = $appSecret;
foreach ($params as $key => $value) {
if ($key === 'sign' || $value === null || $value === '') {
continue;
}
$plain .= $key . (string) $value;
}
return md5($plain);
return md5($method . $partnerId . 'token' . $token . 'ts' . $timestamp . $partnerKey);
}
/** @param mixed $records @return list<array<string,mixed>> */
@ -114,7 +111,7 @@ class JushuitanOpenApiClient
continue;
}
$skuId = trim((string) ($record['sku_id'] ?? ''));
$description = trim((string) ($record['operation_description'] ?? ''));
$description = trim((string) ($record['remark'] ?? $record['operation_description'] ?? ''));
if ($skuId === '' || ! str_contains($description, '采购价')) {
continue;
}
@ -127,7 +124,7 @@ class JushuitanOpenApiClient
'product_name' => $this->nullableString($record['name'] ?? null),
'properties_value' => $this->nullableString($record['properties_value'] ?? null),
'sku_type' => $this->nullableString($record['sku_type'] ?? null),
'operation_type' => $this->nullableString($record['operation_type'] ?? null),
'operation_type' => $this->nullableString($record['type'] ?? $record['operation_type'] ?? null),
'operation_description' => $description,
'operator_name' => $this->nullableString($record['creator_name'] ?? null),
'operated_at' => $this->nullableString($record['created'] ?? null),

View File

@ -32,27 +32,24 @@ class JushuitanCostPriceHistorySynchronizerTest extends TestCase
public function test_full_sync_uses_stable_time_windows_and_page_cursor(): void
{
Carbon::setTestNow('2026-09-14 00:00:00');
Carbon::setTestNow('2026-08-15 00:00:00');
config()->set('services.jushuitan.cost_price_history_start', '2026-08-01 00:00:00');
config()->set('services.jushuitan.cost_price_history_window_days', 30);
$responses = [[
'records' => [['operation_id' => '10', 'sku_id' => 'SKU-10']],
'source_rows' => 2,
'current_page' => 1,
'total_pages' => 2,
'total_count' => 3,
'has_next' => true,
], [
'records' => [['operation_id' => '20', 'sku_id' => 'SKU-20']],
'source_rows' => 1,
'current_page' => 2,
'total_pages' => 2,
'total_count' => 3,
'has_next' => false,
], [
'records' => [],
'source_rows' => 1,
'current_page' => 1,
'total_pages' => 1,
'total_count' => 1,
'has_next' => false,
]];
$apiCalls = [];
$upstream = $this->createMock(JushuitanOpenApiClient::class);
@ -85,13 +82,13 @@ class JushuitanCostPriceHistorySynchronizerTest extends TestCase
$result = (new JushuitanCostPriceHistorySynchronizer($upstream, $mirror))->sync(true, 2000, 0);
$this->assertSame([
['2026-08-01 00:00:00', '2026-08-31 00:00:00', 1, 2000],
['2026-08-01 00:00:00', '2026-08-31 00:00:00', 2, 2000],
['2026-08-31 00:00:00', '2026-09-14 00:00:01', 1, 2000],
['2026-08-01 00:00:00', '2026-08-08 00:00:00', 1, 50],
['2026-08-01 00:00:00', '2026-08-08 00:00:00', 2, 50],
['2026-08-08 00:00:00', '2026-08-15 00:00:00', 1, 50],
], $apiCalls);
$this->assertSame('2026-08-01 00:00:00', $storedPages[0][1]->format('Y-m-d H:i:s'));
$this->assertSame(2, $storedPages[0][2]);
$this->assertSame('2026-08-31 00:00:00', $storedPages[1][1]->format('Y-m-d H:i:s'));
$this->assertSame('2026-08-08 00:00:00', $storedPages[1][1]->format('Y-m-d H:i:s'));
$this->assertSame(1, $storedPages[1][2]);
$this->assertSame('succeeded', $result['status']);
$this->assertSame(4, $result['source_rows']);
@ -118,7 +115,7 @@ class JushuitanCostPriceHistorySynchronizerTest extends TestCase
]);
$mirror->expects($this->never())->method('storePage');
$mirror->expects($this->never())->method('complete');
$mirror->expects($this->once())->method('fail')->with(RuntimeException::class);
$mirror->expects($this->once())->method('fail')->with('upstream_unavailable');
$this->expectExceptionMessage('upstream_unavailable');
(new JushuitanCostPriceHistorySynchronizer($upstream, $mirror))->sync();

View File

@ -16,12 +16,12 @@ class JushuitanOpenApiClientTest extends TestCase
parent::setUp();
Carbon::setTestNow('2026-09-14 06:00:00');
config()->set('services.jushuitan', [
'base_url' => 'https://jushuitan.example.test',
'app_key' => 'test-app-key',
'app_secret' => 'test-app-secret',
'access_token' => 'test-access-token',
'base_url' => 'https://jushuitan.example.test/api/open/query.aspx',
'partner_id' => 'test-partner-id',
'partner_key' => 'test-partner-key',
'token' => 'test-token',
'timeout' => 20,
'product_operation_history_path' => '/open/webapi/itemapi/itemsku/queryitemskuopshistory',
'product_operation_history_method' => 'worklog.query',
]);
Http::preventStrayRequests();
}
@ -32,17 +32,18 @@ class JushuitanOpenApiClientTest extends TestCase
parent::tearDown();
}
public function test_it_posts_signed_form_and_keeps_only_cost_price_operations(): void
public function test_it_posts_signed_json_and_keeps_only_cost_price_operations(): void
{
Http::fake([
'https://jushuitan.example.test/*' => Http::response([
'code' => 0,
'data' => [
'page' => ['current_page' => 1, 'page_size' => 2000, 'count' => 2, 'pages' => 1],
'list' => [[
'page_size' => 2,
'page_index' => 1,
'has_next' => false,
'datas' => [[
'autoid' => 101,
'operation_type' => '商品与库存管理导入',
'operation_description' => '采购价:72.2222->65',
'type' => '商品与库存管理导入',
'remark' => '采购价:72.2222->65',
'created' => '2026-06-06 14:32:02.993',
'creator_name' => '简尚',
'sku_id' => '15961G',
@ -52,19 +53,18 @@ class JushuitanOpenApiClientTest extends TestCase
'sku_type' => 'normal',
], [
'autoid' => 102,
'operation_type' => '编辑',
'operation_description' => '商品名称:旧名称->新名称',
'type' => '编辑',
'remark' => '商品名称:旧名称->新名称',
'created' => '2026-06-06 14:33:00',
'creator_name' => '测试人',
'sku_id' => '15961G',
]],
],
]),
]);
$result = (new JushuitanOpenApiClient())->getCostPriceOperationHistory(
Carbon::parse('2026-06-01 00:00:00'),
Carbon::parse('2026-06-30 23:59:59'),
Carbon::parse('2026-06-08 00:00:00'),
1,
2000,
);
@ -78,25 +78,24 @@ class JushuitanOpenApiClientTest extends TestCase
$this->assertSame('简尚', $result['records'][0]['operator_name']);
Http::assertSent(function (Request $request): bool {
$data = $request->data();
$sign = $data['sign'] ?? null;
unset($data['sign']);
ksort($data, SORT_STRING);
$plain = 'test-app-secret';
foreach ($data as $key => $value) {
$plain .= $key . (string) $value;
}
$biz = json_decode((string) ($data['biz'] ?? ''), true);
parse_str((string) parse_url($request->url(), PHP_URL_QUERY), $query);
$timestamp = (string) Carbon::now()->timestamp;
return $request->method() === 'POST'
&& $request->url() === 'https://jushuitan.example.test/open/webapi/itemapi/itemsku/queryitemskuopshistory'
&& str_starts_with((string) $request->header('Content-Type')[0], 'application/x-www-form-urlencoded')
&& $sign === md5($plain)
&& $biz === [
'current_page' => 1,
'page_size' => 2000,
'start_time' => '2026-06-01 00:00:00',
'end_time' => '2026-06-30 23:59:59',
&& str_starts_with($request->url(), 'https://jushuitan.example.test/api/open/query.aspx?')
&& str_starts_with((string) $request->header('Content-Type')[0], 'application/json')
&& $query === [
'method' => 'worklog.query',
'partnerid' => 'test-partner-id',
'token' => 'test-token',
'ts' => $timestamp,
'sign' => md5('worklog.querytest-partner-idtokentest-tokents' . $timestamp . 'test-partner-key'),
]
&& $request->data() === [
'page_index' => 1,
'page_size' => 50,
'modified_begin' => '2026-06-01 00:00:00',
'modified_end' => '2026-06-08 00:00:00',
];
});
}
@ -106,15 +105,14 @@ class JushuitanOpenApiClientTest extends TestCase
Http::fake([
'*' => Http::response([
'code' => 0,
'data' => [
'page' => ['current_page' => 1, 'count' => 1, 'pages' => 1],
'list' => [[
'page_index' => 1,
'has_next' => false,
'datas' => [[
'autoid' => 103,
'operation_description' => '采购价已由批量任务更新',
'remark' => '采购价已由批量任务更新',
'created' => '2026-06-07 08:00:00',
'sku_id' => 'SKU-UNPARSED',
]],
],
]),
]);
@ -133,7 +131,7 @@ class JushuitanOpenApiClientTest extends TestCase
public function test_it_rejects_missing_credentials_and_upstream_error_codes(): void
{
config()->set('services.jushuitan.access_token', null);
config()->set('services.jushuitan.token', null);
try {
(new JushuitanOpenApiClient())->getCostPriceOperationHistory(now(), now(), 1, 100);
$this->fail('Expected missing credential exception.');
@ -141,7 +139,7 @@ class JushuitanOpenApiClientTest extends TestCase
$this->assertSame('jushuitan_api_credentials_missing', $exception->getMessage());
}
config()->set('services.jushuitan.access_token', 'test-access-token');
config()->set('services.jushuitan.token', 'test-token');
Http::fake(['*' => Http::response(['code' => 10001, 'msg' => 'denied'])]);
$this->expectExceptionMessage('jushuitan_api_code_10001');