diff --git a/app/Console/Commands/PollEsignSignatures.php b/app/Console/Commands/PollEsignSignatures.php new file mode 100644 index 00000000..74dc2ca0 --- /dev/null +++ b/app/Console/Commands/PollEsignSignatures.php @@ -0,0 +1,140 @@ +components->info('E 签宝定时轮询未启用。'); + + return self::SUCCESS; + } + + if (! EsignClient::configured()) { + $this->components->warn('E 签宝尚未正确配置,已跳过定时轮询。'); + + return self::SUCCESS; + } + + $limit = min(max((int) ($this->option('limit') + ?? config('services.esign.poll_batch_size', 25)), 1), 100); + $minAge = max((int) ($this->option('min-age') + ?? config('services.esign.poll_min_age_seconds', 120)), 0); + $cutoff = now()->subSeconds($minAge); + $candidates = $this->candidates($limit, $cutoff); + $succeeded = 0; + $failed = 0; + + foreach ($candidates as $candidate) { + $model = $candidate['model']; + + try { + if ($candidate['type'] === 'quote') { + $quoteSignatures->synchronize($model, (int) ($model->updated_by ?: $model->created_by)); + } else { + $agreementSignatures->synchronize($model, (int) ($model->updated_by ?: $model->created_by)); + } + + $succeeded++; + } catch (Throwable $exception) { + $failed++; + Log::warning('E 签宝签署流程定时查询失败。', [ + 'signature_type' => $candidate['type'], + 'signature_id' => $model->getKey(), + 'exception' => $exception::class, + ]); + } + } + + $this->components->info(sprintf( + 'E 签宝轮询完成:候选 %d,成功 %d,失败 %d。', + $candidates->count(), + $succeeded, + $failed, + )); + + return $failed > 0 ? self::FAILURE : self::SUCCESS; + } + + /** @return Collection */ + private function candidates(int $limit, \DateTimeInterface $cutoff): Collection + { + $environment = strtolower(trim((string) config('services.esign.environment', 'sandbox'))); + + $quotes = CourierQuoteSignature::allCompanies() + ->where('environment', $environment) + ->whereNotNull('esign_sign_flow_id') + ->where('esign_sign_flow_id', '<>', '') + ->whereIn('status', [ + CourierQuoteSignature::STATUS_SIGNING, + CourierQuoteSignature::STATUS_COMPLETED, + ]) + ->where(fn (Builder $query) => $query + ->whereNull('last_synced_at') + ->orWhere('last_synced_at', '<=', $cutoff)) + ->orderByRaw('CASE WHEN last_synced_at IS NULL THEN 0 ELSE 1 END') + ->orderBy('last_synced_at') + ->orderBy('id') + ->limit($limit) + ->get() + ->map(fn (CourierQuoteSignature $signature): array => [ + 'type' => 'quote', + 'model' => $signature, + ]); + + $agreements = CourierAgreementSignature::allCompanies() + ->disableCache() + ->where('environment', $environment) + ->whereNotNull('esign_sign_flow_id') + ->where('esign_sign_flow_id', '<>', '') + ->whereIn('status', [ + CourierAgreementSignature::STATUS_SIGNING, + CourierAgreementSignature::STATUS_UNKNOWN, + CourierAgreementSignature::STATUS_COMPLETED, + ]) + ->where(fn (Builder $query) => $query + ->whereNull('last_synced_at') + ->orWhere('last_synced_at', '<=', $cutoff)) + ->orderByRaw('CASE WHEN last_synced_at IS NULL THEN 0 ELSE 1 END') + ->orderBy('last_synced_at') + ->orderBy('id') + ->limit($limit) + ->get() + ->map(fn (CourierAgreementSignature $signature): array => [ + 'type' => 'agreement', + 'model' => $signature, + ]); + + return $quotes + ->concat($agreements) + ->sortBy(fn (array $candidate): string => sprintf( + '%020d:%s:%020d', + $candidate['model']->last_synced_at?->getTimestamp() ?? 0, + $candidate['type'], + $candidate['model']->getKey(), + )) + ->take($limit) + ->values(); + } +} diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 9b09ec25..2a5881d3 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -34,6 +34,13 @@ class Kernel extends ConsoleKernel $schedule->command('recurring:check')->dailyAt($schedule_time)->runInBackground(); $schedule->command('storage-temp:clear')->dailyAt('17:00'); $schedule->command('model:prune')->dailyAt('17:00'); + + if ((bool) config('services.esign.polling_enabled', false)) { + $schedule->command('esign:poll-signatures --quiet') + ->cron('*/2 * * * *') + ->withoutOverlapping(5) + ->runInBackground(); + } } /** diff --git a/app/Support/CourierAgreementSignatureService.php b/app/Support/CourierAgreementSignatureService.php index 967a28a4..db154e4c 100644 --- a/app/Support/CourierAgreementSignatureService.php +++ b/app/Support/CourierAgreementSignatureService.php @@ -210,32 +210,37 @@ final class CourierAgreementSignatureService $urls = $client->downloadUrls($signature->esign_sign_flow_id); $signedUrl = (string) data_get($urls, 'files.0.downloadUrl', ''); $certificateUrl = (string) ($urls['certificateDownloadUrl'] ?? ''); - if ($signedUrl === '' || $certificateUrl === '') { - throw new RuntimeException('E 签宝未返回签署文件或完成证书下载地址。'); + if ($signedUrl === '') { + throw new RuntimeException('E 签宝未返回签署文件下载地址。'); } $signedBytes = $client->download($signedUrl); - $certificateBytes = $client->download($certificateUrl); - if (! str_starts_with($signedBytes, '%PDF-') || ! str_starts_with($certificateBytes, '%PDF-')) { + $certificateBytes = $certificateUrl === '' ? null : $client->download($certificateUrl); + if (! str_starts_with($signedBytes, '%PDF-') + || ($certificateBytes !== null && ! str_starts_with($certificateBytes, '%PDF-'))) { throw new RuntimeException('E 签宝返回的签署文件或完成证书不是有效 PDF。'); } $base = "courier-agreements/{$signature->company_id}/{$signature->business_no}"; $signedPath = $base . '/signed.pdf'; $certificatePath = $base . '/completion-certificate.pdf'; $disk = Storage::disk('uploads'); - if (! $disk->put($signedPath, $signedBytes) || ! $disk->put($certificatePath, $certificateBytes)) { + if (! $disk->put($signedPath, $signedBytes) + || ($certificateBytes !== null && ! $disk->put($certificatePath, $certificateBytes))) { throw new RuntimeException('签署归档文件无法写入私有业务存储。'); } - $signature->update([ + $archive = [ 'status' => CourierAgreementSignature::STATUS_ARCHIVED, 'signed_pdf_path' => $signedPath, 'signed_pdf_sha256' => hash('sha256', $signedBytes), - 'certificate_path' => $certificatePath, - 'certificate_sha256' => hash('sha256', $certificateBytes), 'archived_at' => now(), 'last_synced_at' => now(), 'last_error' => null, 'updated_by' => $userId ?: (int) $signature->updated_by, - ]); + ]; + if ($certificateBytes !== null) { + $archive['certificate_path'] = $certificatePath; + $archive['certificate_sha256'] = hash('sha256', $certificateBytes); + } + $signature->update($archive); return $signature->fresh(); } diff --git a/app/Support/CourierQuoteSignatureService.php b/app/Support/CourierQuoteSignatureService.php index dc573f54..72f60bae 100644 --- a/app/Support/CourierQuoteSignatureService.php +++ b/app/Support/CourierQuoteSignatureService.php @@ -239,14 +239,15 @@ final class CourierQuoteSignatureService $signedUrl = (string) data_get($urls, 'files.0.downloadUrl', ''); $certificateUrl = (string) ($urls['certificateDownloadUrl'] ?? ''); - if ($signedUrl === '' || $certificateUrl === '') { - throw new RuntimeException('E 签宝未返回签署文件或完成证书下载地址。'); + if ($signedUrl === '') { + throw new RuntimeException('E 签宝未返回签署文件下载地址。'); } $signedBytes = $client->download($signedUrl); - $certificateBytes = $client->download($certificateUrl); + $certificateBytes = $certificateUrl === '' ? null : $client->download($certificateUrl); - if (! str_starts_with($signedBytes, '%PDF-') || ! str_starts_with($certificateBytes, '%PDF-')) { + if (! str_starts_with($signedBytes, '%PDF-') + || ($certificateBytes !== null && ! str_starts_with($certificateBytes, '%PDF-'))) { throw new RuntimeException('E 签宝返回的签署文件或完成证书不是有效 PDF。'); } @@ -255,21 +256,25 @@ final class CourierQuoteSignatureService $certificatePath = $this->path($version, 'completion-certificate.pdf'); $disk = Storage::disk('uploads'); - if (! $disk->put($signedPath, $signedBytes) || ! $disk->put($certificatePath, $certificateBytes)) { + if (! $disk->put($signedPath, $signedBytes) + || ($certificateBytes !== null && ! $disk->put($certificatePath, $certificateBytes))) { throw new RuntimeException('签署归档文件无法写入私有业务存储。'); } - $signature->update([ + $archive = [ 'status' => CourierQuoteSignature::STATUS_ARCHIVED, 'signed_pdf_path' => $signedPath, 'signed_pdf_sha256' => hash('sha256', $signedBytes), - 'certificate_path' => $certificatePath, - 'certificate_sha256' => hash('sha256', $certificateBytes), 'archived_at' => now(), 'last_synced_at' => now(), 'last_error' => null, 'updated_by' => $userId, - ]); + ]; + if ($certificateBytes !== null) { + $archive['certificate_path'] = $certificatePath; + $archive['certificate_sha256'] = hash('sha256', $certificateBytes); + } + $signature->update($archive); $this->updateVersionStatus($signature, '已签署并归档'); return $signature->fresh('version'); diff --git a/config/services.php b/config/services.php index 83aeed93..b14926cc 100644 --- a/config/services.php +++ b/config/services.php @@ -50,6 +50,11 @@ return [ 'notify_url' => env('ESIGN_NOTIFY_URL'), 'callback_tolerance_seconds' => (int) env('ESIGN_CALLBACK_TOLERANCE_SECONDS', 900), 'timeout' => (int) env('ESIGN_TIMEOUT', 20), + // Callback-free deployments opt in to bounded status polling. Keep it + // disabled until the private production credentials are verified. + 'polling_enabled' => filter_var(env('ESIGN_POLLING_ENABLED', false), FILTER_VALIDATE_BOOL), + 'poll_batch_size' => (int) env('ESIGN_POLL_BATCH_SIZE', 25), + 'poll_min_age_seconds' => (int) env('ESIGN_POLL_MIN_AGE_SECONDS', 120), ], ]; diff --git a/scripts/kaifa-progress-sync.mjs b/scripts/kaifa-progress-sync.mjs index 46dc31c5..a095ffa4 100644 --- a/scripts/kaifa-progress-sync.mjs +++ b/scripts/kaifa-progress-sync.mjs @@ -69,7 +69,7 @@ export const FEATURE_RULES = [ paths: /(?:ProductPrice|SupplierPayable|supplier-payables)/i, }, { - key: 'courier-esign', priority: 90, moduleName: '快递与审批', featureName: '快递报价与易签宝协议签署', + key: 'courier-esign', priority: 90, moduleName: '快递与审批', featureName: 'E 签宝无回调状态轮询与归档', subject: /快递|报价|签署|协议|E\s*签|esign|cooperation|courier|signing/i, paths: /(?:Courier|Esign|agreement-signing|quote-management)/i, }, diff --git a/tests/Feature/Business/CourierAgreementSignatureWorkflowTest.php b/tests/Feature/Business/CourierAgreementSignatureWorkflowTest.php index bb9490f5..1fdcbfea 100644 --- a/tests/Feature/Business/CourierAgreementSignatureWorkflowTest.php +++ b/tests/Feature/Business/CourierAgreementSignatureWorkflowTest.php @@ -174,6 +174,56 @@ class CourierAgreementSignatureWorkflowTest extends FeatureTestCase Queue::assertPushed(ArchiveCourierAgreementSignature::class, 1); } + public function test_polling_archives_mainland_agreement_without_completion_certificate(): void + { + $signature = CourierAgreementSignature::create([ + 'company_id' => $this->company->id, + 'business_no' => 'AGR-MAINLAND-001', + 'idempotency_key' => 'mainland-001', + 'document_type' => 'account-proof', + 'template_code' => 'TPL-ACCOUNT-CERT-V2', + 'template_version' => '2', + 'source_file_name' => '收款账户证明文件.doc', + 'status' => CourierAgreementSignature::STATUS_SIGNING, + 'courier_name' => '新北中通', + 'company_full_name' => $this->company->name, + 'organization_name' => '新北中通快递有限公司', + 'operator_name' => '张经理', + 'operator_account' => '13800138000', + 'payload' => ['archive_mode' => 'auto'], + 'environment' => 'sandbox', + 'source_pdf_path' => 'courier-agreements/mainland/source.pdf', + 'source_pdf_sha256' => hash('sha256', '%PDF-source'), + 'esign_file_id' => 'file-agreement-mainland', + 'esign_sign_flow_id' => 'flow-agreement-mainland', + 'remote_status' => '1', + 'created_by' => $this->user->id, + 'updated_by' => $this->user->id, + ]); + Http::fake([ + 'https://smlopenapi.esign.test/v3/sign-flow/flow-agreement-mainland/detail' => Http::response([ + 'code' => 0, + 'data' => ['signFlowStatus' => 2], + ]), + 'https://smlopenapi.esign.test/v3/sign-flow/flow-agreement-mainland/file-download-url' => Http::response([ + 'code' => 0, + 'data' => [ + 'files' => [['downloadUrl' => 'https://download.esign.test/agreement-mainland']], + 'certificateDownloadUrl' => null, + ], + ]), + 'https://download.esign.test/agreement-mainland' => Http::response('%PDF-agreement-mainland'), + ]); + + $result = app(\App\Support\CourierAgreementSignatureService::class) + ->synchronize($signature, $this->user->id); + + $this->assertSame(CourierAgreementSignature::STATUS_ARCHIVED, $result->status); + Storage::disk('uploads')->assertExists($result->signed_pdf_path); + $this->assertNull($result->certificate_path); + $this->assertNull($result->certificate_sha256); + } + private function fakeEsign(): void { Http::fake(function (ClientRequest $request) { diff --git a/tests/Feature/Business/CourierQuoteSignatureWorkflowTest.php b/tests/Feature/Business/CourierQuoteSignatureWorkflowTest.php index 35e022ae..e367acf8 100644 --- a/tests/Feature/Business/CourierQuoteSignatureWorkflowTest.php +++ b/tests/Feature/Business/CourierQuoteSignatureWorkflowTest.php @@ -246,6 +246,38 @@ class CourierQuoteSignatureWorkflowTest extends FeatureTestCase $this->assertNull($signature->archived_at); } + public function test_manual_sync_archives_when_mainland_flow_has_no_completion_certificate(): void + { + $signature = $this->createPreparedSignature([ + 'status' => CourierQuoteSignature::STATUS_SIGNING, + 'esign_file_id' => 'file-mainland', + 'esign_sign_flow_id' => 'flow-mainland', + 'remote_status' => '1', + 'sent_at' => now(), + ]); + Http::fake([ + 'https://smlopenapi.esign.test/v3/sign-flow/flow-mainland/detail' => Http::response([ + 'code' => 0, + 'data' => ['signFlowStatus' => 2], + ]), + 'https://smlopenapi.esign.test/v3/sign-flow/flow-mainland/file-download-url' => Http::response([ + 'code' => 0, + 'data' => [ + 'files' => [['downloadUrl' => 'https://download.esign.test/mainland-signed']], + 'certificateDownloadUrl' => null, + ], + ]), + 'https://download.esign.test/mainland-signed' => Http::response('%PDF-mainland-signed'), + ]); + + $result = app(CourierQuoteSignatureService::class)->synchronize($signature, $this->user->id); + + $this->assertSame(CourierQuoteSignature::STATUS_ARCHIVED, $result->status); + Storage::disk('uploads')->assertExists($result->signed_pdf_path); + $this->assertNull($result->certificate_path); + $this->assertNull($result->certificate_sha256); + } + public function test_verified_completion_callback_is_idempotent_and_queues_archiving(): void { Queue::fake(); diff --git a/tests/Feature/Console/PollEsignSignaturesTest.php b/tests/Feature/Console/PollEsignSignaturesTest.php new file mode 100644 index 00000000..9b699d60 --- /dev/null +++ b/tests/Feature/Console/PollEsignSignaturesTest.php @@ -0,0 +1,187 @@ + 'production', + 'services.esign.base_url' => 'https://openapi.esign.test', + 'services.esign.app_id' => 'production-app-id', + 'services.esign.app_secret' => 'production-app-secret', + 'services.esign.notify_url' => null, + 'services.esign.polling_enabled' => true, + ]); + } + + public function test_it_polls_only_due_non_terminal_flows_without_a_callback(): void + { + $quote = $this->quoteSignature([ + 'status' => CourierQuoteSignature::STATUS_SIGNING, + 'esign_sign_flow_id' => 'quote-flow-due', + 'last_synced_at' => now()->subMinutes(5), + ]); + $this->quoteSignature([ + 'status' => CourierQuoteSignature::STATUS_ARCHIVED, + 'esign_sign_flow_id' => 'quote-flow-archived', + 'last_synced_at' => now()->subMinutes(5), + ]); + $agreement = $this->agreementSignature([ + 'status' => CourierAgreementSignature::STATUS_UNKNOWN, + 'esign_sign_flow_id' => 'agreement-flow-due', + 'last_synced_at' => null, + ]); + $this->agreementSignature([ + 'status' => CourierAgreementSignature::STATUS_SIGNING, + 'esign_sign_flow_id' => 'agreement-flow-recent', + 'last_synced_at' => now(), + ]); + + Http::fake([ + 'https://openapi.esign.test/v3/sign-flow/*/detail' => Http::response([ + 'code' => 0, + 'data' => ['signFlowStatus' => 1], + ]), + ]); + + $this->artisan('esign:poll-signatures', ['--limit' => 10, '--min-age' => 120]) + ->expectsOutputToContain('候选 2,成功 2,失败 0') + ->assertSuccessful(); + + Http::assertSentCount(2); + $this->assertNotNull($quote->fresh()->last_synced_at); + $this->assertNotNull($agreement->fresh()->last_synced_at); + } + + public function test_it_skips_polling_until_the_private_switch_is_enabled(): void + { + config(['services.esign.polling_enabled' => false]); + Http::preventStrayRequests(); + + $this->artisan('esign:poll-signatures') + ->expectsOutputToContain('定时轮询未启用') + ->assertSuccessful(); + + Http::assertNothingSent(); + } + + public function test_it_does_not_poll_flows_created_in_another_environment(): void + { + $this->quoteSignature([ + 'environment' => 'sandbox', + 'esign_sign_flow_id' => 'sandbox-quote-flow', + 'last_synced_at' => now()->subMinutes(5), + ]); + $this->agreementSignature([ + 'environment' => 'sandbox', + 'esign_sign_flow_id' => 'sandbox-agreement-flow', + 'last_synced_at' => now()->subMinutes(5), + ]); + Http::preventStrayRequests(); + + $this->artisan('esign:poll-signatures', ['--limit' => 10, '--min-age' => 0]) + ->expectsOutputToContain('候选 0,成功 0,失败 0') + ->assertSuccessful(); + + Http::assertNothingSent(); + } + + public function test_it_limits_the_total_number_of_remote_queries_per_run(): void + { + $this->agreementSignature([ + 'esign_sign_flow_id' => 'agreement-flow-first', + 'last_synced_at' => now()->subMinutes(10), + ]); + $this->agreementSignature([ + 'esign_sign_flow_id' => 'agreement-flow-second', + 'last_synced_at' => now()->subMinutes(5), + ]); + Http::fake([ + 'https://openapi.esign.test/v3/sign-flow/*/detail' => Http::response([ + 'code' => 0, + 'data' => ['signFlowStatus' => 1], + ]), + ]); + + $this->artisan('esign:poll-signatures', ['--limit' => 1, '--min-age' => 0]) + ->expectsOutputToContain('候选 1,成功 1,失败 0') + ->assertSuccessful(); + + Http::assertSentCount(1); + } + + public function test_it_continues_after_one_remote_query_fails(): void + { + $failed = $this->agreementSignature([ + 'esign_sign_flow_id' => 'agreement-flow-failed', + 'last_synced_at' => now()->subMinutes(10), + ]); + $succeeded = $this->agreementSignature([ + 'esign_sign_flow_id' => 'agreement-flow-succeeded', + 'last_synced_at' => now()->subMinutes(5), + ]); + Http::fake(fn ($request) => str_contains($request->url(), 'agreement-flow-failed') + ? Http::response(['code' => 500, 'message' => 'temporary failure'], 503) + : Http::response(['code' => 0, 'data' => ['signFlowStatus' => 1]])); + + $this->artisan('esign:poll-signatures', ['--limit' => 10, '--min-age' => 0]) + ->expectsOutputToContain('候选 2,成功 1,失败 1') + ->assertFailed(); + + Http::assertSentCount(2); + $this->assertNotNull($failed->fresh()->last_error); + $this->assertNotNull($succeeded->fresh()->last_synced_at); + } + + private function quoteSignature(array $overrides = []): CourierQuoteSignature + { + return CourierQuoteSignature::create(array_merge([ + 'company_id' => $this->company->id, + 'quote_version_id' => random_int(1000, 999999), + 'provider' => 'esign', + 'environment' => 'production', + 'status' => CourierQuoteSignature::STATUS_SIGNING, + 'organization_name' => '测试快递公司', + 'source_pdf_path' => 'quotes/source.pdf', + 'source_pdf_sha256' => str_repeat('a', 64), + 'source_pdf_generated_at' => now(), + 'created_by' => $this->user->id, + 'updated_by' => $this->user->id, + ], $overrides)); + } + + private function agreementSignature(array $overrides = []): CourierAgreementSignature + { + $sequence = CourierAgreementSignature::count() + 1; + + return CourierAgreementSignature::create(array_merge([ + 'company_id' => $this->company->id, + 'business_no' => 'ESIGN-POLL-' . $sequence, + 'idempotency_key' => 'esign-poll-' . $sequence, + 'document_type' => 'account-proof', + 'template_code' => 'TPL-ACCOUNT-CERT-V2', + 'template_version' => '2', + 'source_file_name' => 'account-proof.doc', + 'status' => CourierAgreementSignature::STATUS_SIGNING, + 'courier_name' => '测试快递', + 'company_full_name' => '测试公司', + 'organization_name' => '测试快递公司', + 'operator_account' => '13800138000', + 'payload' => ['archive_mode' => 'auto'], + 'environment' => 'production', + 'source_pdf_path' => 'agreements/source.pdf', + 'source_pdf_sha256' => str_repeat('b', 64), + 'created_by' => $this->user->id, + 'updated_by' => $this->user->id, + ], $overrides)); + } +} diff --git a/tests/scripts/kaifa-progress-sync.test.mjs b/tests/scripts/kaifa-progress-sync.test.mjs index 43c3d66a..20d4dca3 100644 --- a/tests/scripts/kaifa-progress-sync.test.mjs +++ b/tests/scripts/kaifa-progress-sync.test.mjs @@ -30,6 +30,9 @@ test('matches exactly one project and rejects ambiguous matches', () => { test('assigns one primary feature and stops on ambiguous feature matches', () => { assert.equal(classifyCommit('财务经营报表增加安装明细', ['app/Models/Business/InstallationDetail.php']).rule.key, 'installation-details') assert.equal(classifyCommit('修复云数据库管理不支持 PostgreSQL 连接', ['app/Http/Controllers/SystemSettings/Databases.php']).rule.key, 'upstream-connections') + const esign = classifyCommit('feat: add callback-free eSign status polling', ['app/Console/Commands/PollEsignSignatures.php']).rule + assert.equal(esign.key, 'courier-esign') + assert.equal(esign.featureName, 'E 签宝无回调状态轮询与归档') assert.equal( classifyCommit('feat: align taobao reconciliation headers', ['resources/views/store_receivables/zhongcai_reconciliations/partials/embedded-table.blade.php']).rule.key, 'zhongcai-reconciliation',