From e1d62f0d79ab7d1a592a2f11ff17ecfb251f724c Mon Sep 17 00:00:00 2001 From: WuYongJie <549727955@qq.com> Date: Tue, 8 Sep 2026 18:25:37 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=BD=93=E5=89=8D=E9=A1=B5?= =?UTF-8?q?=E5=AF=86=E7=A0=81=E7=BD=91=E5=85=B3=E5=88=86=E7=89=87=E8=AF=BB?= =?UTF-8?q?=E5=8F=96=20(#130)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/Services/StoreAccountPasswordClient.php | 37 ++++++++++- docs/店铺密码按需读取.md | 4 +- .../partials/store-account-password.blade.php | 2 +- .../Business/StoreAccountPasswordsTest.php | 66 +++++++++++++++++++ 4 files changed, 105 insertions(+), 4 deletions(-) diff --git a/app/Services/StoreAccountPasswordClient.php b/app/Services/StoreAccountPasswordClient.php index 6813e9c4..a139fcc1 100644 --- a/app/Services/StoreAccountPasswordClient.php +++ b/app/Services/StoreAccountPasswordClient.php @@ -74,10 +74,45 @@ class StoreAccountPasswordClient || collect($accountIds)->contains(fn ($id) => ! is_string($id) || ! preg_match('/^[A-Za-z0-9_-]{1,128}$/D', $id))) { return ['status' => 503]; } + + $requesterId = 'finance-user-'.$actorId; + $chunks = []; + $chunk = []; + foreach ($accountIds as $accountId) { + $candidate = [...$chunk, $accountId]; + $payload = ['requesterId' => $requesterId, 'purpose' => 'view', 'accountIds' => $candidate]; + if ($chunk !== [] && (count($candidate) > 25 || strlen((string) json_encode($payload)) > 1000)) { + $chunks[] = $chunk; + $chunk = [$accountId]; + } else { + $chunk = $candidate; + } + } + $chunks[] = $chunk; + + $items = []; + foreach ($chunks as $accountIdChunk) { + $result = $this->revealVisiblePageChunk($url, $token, $requesterId, $accountIdChunk); + if (($result['status'] ?? 503) !== 200) { + foreach ($items as &$item) { + unset($item['password']); + } + unset($item, $items); + + return ['status' => $result['status'] ?? 503]; + } + $items += $result['items']; + } + + return ['status' => 200, 'items' => $items]; + } + + private function revealVisiblePageChunk(string $url, string $token, string $requesterId, array $accountIds): array + { try { $response = $this->http->request('POST', $url, [ 'headers' => ['Authorization' => 'Bearer '.$token, 'Accept' => 'application/json'], - 'json' => ['requesterId' => 'finance-user-'.$actorId, 'purpose' => 'view', 'accountIds' => $accountIds], + 'json' => ['requesterId' => $requesterId, 'purpose' => 'view', 'accountIds' => $accountIds], 'verify' => true, 'allow_redirects' => false, 'http_errors' => false, 'connect_timeout' => 3, 'timeout' => 10, 'sink' => fopen('php://memory', 'w+'), diff --git a/docs/店铺密码按需读取.md b/docs/店铺密码按需读取.md index af12eb57..bfe4ae4e 100644 --- a/docs/店铺密码按需读取.md +++ b/docs/店铺密码按需读取.md @@ -13,9 +13,9 @@ - 当前页批量路由:`POST /{company_id}/store-account-passwords/visible-page/reveal`,请求体仅允许 `accounts` 和框架 CSRF 字段;账号 ID 必须是 1 至 50 个互不重复的合法字符串。 - 单账号复制路由:`POST /{company_id}/store-account-passwords/{account}/reveal`,请求体仅允许 `purpose=view|copy` 和框架 CSRF 字段。前端复制使用 `purpose=copy`。 - 财务后端从可信会话生成 `requesterId=finance-user-{id}`,不接受浏览器提供操作者身份。每次请求重新计算页面权限并验证镜像账号。 -- 上游批量路由:`POST https://cp.jiansun.vip/internal/store-accounts/visible-page/password/reveal`;单账号路由保持原契约。两者使用同一专用 Bearer 凭据。 +- 上游批量路由:`POST https://cp.jiansun.vip/internal/store-accounts/visible-page/password/reveal`;单账号路由保持原契约。两者使用同一专用 Bearer 凭据。财务后端把当前页请求拆成最多 25 个账号、JSON 请求体不超过 1000 字节的分片,常规 50 条页面会顺序发出两个上游请求,以适配公网入口的 1 KiB 请求体限制。 - 批量响应按账号返回独立的 `200/404/429/503` 状态和追踪 ID。任何协议错误、缺少 `no-store`、无效追踪 ID、重复或遗漏账号都会使本批次在财务端按失败处理,密码不会返回浏览器。 -- 财务端每用户每分钟最多自动批量读取 5 次,单账号复制仍为每分钟 10 次。上游另有持久化的服务、人员、批次和账号限流。 +- 财务端每用户每分钟最多自动读取当前页 5 次,单账号复制仍为每分钟 10 次。上游分片批次限制为每服务每分钟 20 次、每天 1000 次,每人员每分钟 10 次;每账号每分钟 5 次的限制保持不变。 - `store_account_password_audits` 为当前页每个账号写入一条 `view` 审计,复制写入 `copy` 审计。开始审计失败时不调用上游;完成审计无法落库时不向浏览器返回密码。审计只保存操作者、公司、账号 ID、用途、状态、追踪 ID和时间。 ## 密码处理 diff --git a/resources/views/business/pages/partials/store-account-password.blade.php b/resources/views/business/pages/partials/store-account-password.blade.php index 74e7b57b..79f6c0c5 100644 --- a/resources/views/business/pages/partials/store-account-password.blade.php +++ b/resources/views/business/pages/partials/store-account-password.blade.php @@ -69,7 +69,7 @@ retry.hidden = true; retry.disabled = true; page.setAttribute('aria-busy', 'true'); - const timeout = setTimeout(() => controller.abort(), 12000); + const timeout = setTimeout(() => controller.abort(), 25000); try { const response = await fetch(page.dataset.passwordPageUrl, { diff --git a/tests/Feature/Business/StoreAccountPasswordsTest.php b/tests/Feature/Business/StoreAccountPasswordsTest.php index 52f85b0d..ca97b2fd 100644 --- a/tests/Feature/Business/StoreAccountPasswordsTest.php +++ b/tests/Feature/Business/StoreAccountPasswordsTest.php @@ -148,6 +148,72 @@ class StoreAccountPasswordsTest extends FeatureTestCase ], $sent); } + public function test_fifty_visible_accounts_are_sent_as_two_gateway_safe_chunks(): void + { + $accountIds = array_map(fn ($index) => sprintf('fixture-account-%02d', $index), range(1, 50)); + $responses = []; + foreach (array_chunk($accountIds, 25) as $batchIndex => $batch) { + $items = []; + foreach ($batch as $index => $accountId) { + $items[] = [ + 'accountId' => $accountId, + 'status' => 200, + 'requestId' => sprintf('12345678-1234-4123-8123-%012d', ($batchIndex * 25) + $index + 1), + 'password' => $this->fixture, + ]; + } + $responses[] = new Response(200, ['Cache-Control' => 'private, no-store'], json_encode([ + 'requestId' => sprintf('22345678-1234-4123-8123-%012d', $batchIndex + 1), + 'items' => $items, + ])); + } + $handler = HandlerStack::create(new MockHandler($responses)); + $handler->push(Middleware::history($this->history)); + $this->app->instance(StoreAccountPasswordClient::class, new StoreAccountPasswordClient(new Client(['handler' => $handler]))); + + $response = $this->loginAs()->postJson($this->visiblePageEndpoint(), ['accounts' => $accountIds]) + ->assertOk()->assertJsonCount(50, 'items'); + + $this->assertCount(2, $this->history); + foreach ($this->history as $index => $transaction) { + $body = (string) $transaction['request']->getBody(); + $sent = json_decode($body, true); + $this->assertSame(array_slice($accountIds, $index * 25, 25), $sent['accountIds']); + $this->assertLessThanOrEqual(1024, strlen($body)); + } + $this->assertSame(50, DB::table('store_account_password_audits')->count()); + $this->assertSame(50, DB::table('store_account_password_audits')->where('status', 200)->count()); + $this->assertStringNotContainsString($this->fixture, json_encode(DB::table('store_account_password_audits')->get())); + $this->assertSame($this->fixture, $response->json('items.49.password')); + } + + public function test_later_chunk_failure_suppresses_passwords_from_earlier_chunks(): void + { + $accountIds = array_map(fn ($index) => sprintf('fixture-account-%02d', $index), range(1, 50)); + $items = array_map(fn ($accountId, $index) => [ + 'accountId' => $accountId, + 'status' => 200, + 'requestId' => sprintf('12345678-1234-4123-8123-%012d', $index + 1), + 'password' => $this->fixture, + ], array_slice($accountIds, 0, 25), range(0, 24)); + $handler = HandlerStack::create(new MockHandler([ + new Response(200, ['Cache-Control' => 'private, no-store'], json_encode([ + 'requestId' => '22345678-1234-4123-8123-123456789001', + 'items' => $items, + ])), + new Response(403, ['Cache-Control' => 'no-store'], json_encode(['message' => $this->fixture])), + ])); + $handler->push(Middleware::history($this->history)); + $this->app->instance(StoreAccountPasswordClient::class, new StoreAccountPasswordClient(new Client(['handler' => $handler]))); + + $response = $this->loginAs()->postJson($this->visiblePageEndpoint(), ['accounts' => $accountIds]) + ->assertStatus(503)->assertJsonMissingPath('items')->assertJsonMissingPath('password'); + + $this->assertCount(2, $this->history); + $this->assertStringNotContainsString($this->fixture, $response->getContent()); + $this->assertSame(50, DB::table('store_account_password_audits')->where('status', 503)->count()); + } + public function test_visible_page_rejects_duplicates_and_more_than_fifty_accounts(): void { $this->mock(StoreAccountPasswordClient::class)->shouldNotReceive('revealVisiblePage');