feat: add callback-free eSign status polling

This commit is contained in:
WuYongJie 2026-09-02 16:07:16 +08:00
parent aaab198edb
commit 59a984d32a
10 changed files with 453 additions and 19 deletions

View File

@ -0,0 +1,140 @@
<?php
namespace App\Console\Commands;
use App\Models\Business\CourierAgreementSignature;
use App\Models\Business\CourierQuoteSignature;
use App\Services\Esign\EsignClient;
use App\Support\CourierAgreementSignatureService;
use App\Support\CourierQuoteSignatureService;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
use Throwable;
class PollEsignSignatures extends Command
{
protected $signature = 'esign:poll-signatures
{--limit= : 本轮最多查询的签署流程数}
{--min-age= : 同一流程两次查询之间的最少秒数}';
protected $description = '在不使用回调地址时,定时查询 E 签宝流程状态并归档已签文件';
public function handle(
CourierQuoteSignatureService $quoteSignatures,
CourierAgreementSignatureService $agreementSignatures,
): int {
if (! (bool) config('services.esign.polling_enabled', false)) {
$this->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<int,array{type:string,model:CourierQuoteSignature|CourierAgreementSignature}> */
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();
}
}

View File

@ -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();
}
}
/**

View File

@ -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();
}

View File

@ -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');

View File

@ -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),
],
];

View File

@ -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,
},

View File

@ -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) {

View File

@ -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();

View File

@ -0,0 +1,187 @@
<?php
namespace Tests\Feature\Console;
use App\Models\Business\CourierAgreementSignature;
use App\Models\Business\CourierQuoteSignature;
use Illuminate\Support\Facades\Http;
use Tests\Feature\FeatureTestCase;
class PollEsignSignaturesTest extends FeatureTestCase
{
protected function setUp(): void
{
parent::setUp();
config([
'services.esign.environment' => '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));
}
}

View File

@ -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',