diff --git a/app/Http/Controllers/Business/CourierAgreementSignatures.php b/app/Http/Controllers/Business/CourierAgreementSignatures.php index f916de00..00e21d30 100644 --- a/app/Http/Controllers/Business/CourierAgreementSignatures.php +++ b/app/Http/Controllers/Business/CourierAgreementSignatures.php @@ -574,6 +574,7 @@ class CourierAgreementSignatures extends Controller $signature = $this->quoteSignatureService->prepareVersion($version, user_id(), $data['signature_date']); $account = trim((string) $data['esign_account']); $signature = $this->quoteSignatureService->send($signature, [ + 'signing_subject' => $data['signing_subject'], 'organization_name' => $version->carrier_name, 'operator_name' => trim((string) ($data['operator_name'] ?? '')) ?: $version->carrier_name, 'operator_account' => $account, diff --git a/app/Services/Esign/EsignClient.php b/app/Services/Esign/EsignClient.php index 700a8acb..b2c28f09 100644 --- a/app/Services/Esign/EsignClient.php +++ b/app/Services/Esign/EsignClient.php @@ -12,6 +12,8 @@ final class EsignClient private readonly EsignRequestSigner $signer; + private readonly EsignConfiguration $configuration; + /** * 骑缝章中心的纵向坐标(PDF 坐标,原点在左下角,A4 高 841.89)。 * 按企业签署方顺序依次取用:第一家偏上、第二家居中偏下、第三家靠下。 @@ -29,6 +31,7 @@ final class EsignClient $configuration ??= EsignConfiguration::fromConfig(); $configuration->assertReadyForApi(); + $this->configuration = $configuration; $this->baseUrl = $configuration->baseUrl(); $this->signer = new EsignRequestSigner($configuration->appId(), $configuration->appSecret()); } @@ -94,8 +97,14 @@ final class EsignClient return $fileId; } + public function assertSignFlowInitiator(?string $organizationName = null): void + { + $this->configuration->signFlowInitiator($organizationName); + } + public function createSignFlow(string $fileId, string $fileName, array $signer, int $expiresAtMs): string { + $initiator = $this->configuration->signFlowInitiator($signer['initiator_name'] ?? null); $flowConfig = [ 'signFlowTitle' => $signer['flow_title'], 'signFlowExpireTime' => $expiresAtMs, @@ -160,11 +169,15 @@ final class EsignClient ], ]; } - $data = $this->request('POST', '/v3/sign-flow/create-by-file', [ + $payload = [ 'docs' => $docs, 'signFlowConfig' => $flowConfig, 'signers' => $signerPayload, - ]); + ]; + if ($initiator !== null) { + $payload['signFlowInitiator'] = $initiator; + } + $data = $this->request('POST', '/v3/sign-flow/create-by-file', $payload); $signFlowId = (string) ($data['signFlowId'] ?? ''); if ($signFlowId === '') { diff --git a/app/Services/Esign/EsignConfiguration.php b/app/Services/Esign/EsignConfiguration.php index e3e57c8d..93d6f376 100644 --- a/app/Services/Esign/EsignConfiguration.php +++ b/app/Services/Esign/EsignConfiguration.php @@ -2,6 +2,7 @@ namespace App\Services\Esign; +use JsonException; use RuntimeException; /** @@ -27,6 +28,10 @@ final class EsignConfiguration private readonly int $timeout; + private readonly string $initiatorAccounts; + + private readonly string $defaultInitiator; + public function __construct( string $environment, string $baseUrl, @@ -35,6 +40,8 @@ final class EsignConfiguration string $notifyUrl, int $callbackToleranceSeconds, int $timeout, + string $initiatorAccounts = '{}', + string $defaultInitiator = '', ) { $this->environment = strtolower(trim($environment)); $this->baseUrl = rtrim(trim($baseUrl), '/'); @@ -43,6 +50,8 @@ final class EsignConfiguration $this->notifyUrl = trim($notifyUrl); $this->callbackToleranceSeconds = max(60, $callbackToleranceSeconds); $this->timeout = max(5, $timeout); + $this->initiatorAccounts = trim($initiatorAccounts); + $this->defaultInitiator = trim($defaultInitiator); } public static function fromConfig(): self @@ -55,6 +64,8 @@ final class EsignConfiguration notifyUrl: trim((string) config('services.esign.notify_url')), callbackToleranceSeconds: max(60, (int) config('services.esign.callback_tolerance_seconds', 900)), timeout: max(5, (int) config('services.esign.timeout', 20)), + initiatorAccounts: (string) config('services.esign.initiator_accounts', '{}'), + defaultInitiator: (string) config('services.esign.default_initiator', ''), ); } @@ -100,6 +111,35 @@ final class EsignConfiguration return $this->environment; } + /** Resolve the selected legal entity without changing credentials used by historical flows. */ + public function signFlowInitiator(?string $organizationName = null): ?array + { + try { + $accounts = json_decode($this->initiatorAccounts ?: '{}', true, 32, JSON_THROW_ON_ERROR); + } catch (JsonException) { + throw new RuntimeException('E 签宝发起身份配置格式无效,请联系管理员。'); + } + + if (! is_array($accounts)) { + throw new RuntimeException('E 签宝发起身份配置格式无效,请联系管理员。'); + } + if ($accounts === [] && $this->defaultInitiator === '') { + return null; + } + + $name = trim((string) $organizationName) ?: $this->defaultInitiator; + $account = $accounts[$name] ?? null; + if (! is_array($account) || ! is_string($account['org_id'] ?? null) || ! is_string($account['psn_id'] ?? null) + || trim($account['org_id']) === '' || trim($account['psn_id']) === '') { + throw new RuntimeException('所选签署主体未配置完整的 E 签宝发起企业和经办人身份,请联系管理员。'); + } + + return ['orgInitiator' => [ + 'orgId' => trim($account['org_id']), + 'transactor' => ['psnId' => trim($account['psn_id'])], + ]]; + } + public function baseUrl(): string { return $this->baseUrl; diff --git a/app/Support/CourierAgreementSignatureService.php b/app/Support/CourierAgreementSignatureService.php index 5cdba331..6e96002b 100644 --- a/app/Support/CourierAgreementSignatureService.php +++ b/app/Support/CourierAgreementSignatureService.php @@ -116,6 +116,8 @@ final class CourierAgreementSignatureService try { $signers = $this->signersFor($signature); $client = app(EsignClient::class); + $initiatorName = data_get($signature->payload, 'signing_subject'); + $client->assertSignFlowInitiator($initiatorName); $bytes = Storage::disk('uploads')->get($signature->source_pdf_path); $fileName = 'agreement-' . $signature->business_no . '.pdf'; $fileId = $client->uploadPdf($bytes, $fileName); @@ -124,6 +126,7 @@ final class CourierAgreementSignatureService 'updated_by' => $userId, ]); $signer = [ + 'initiator_name' => $initiatorName, 'flow_title' => $this->safeTitle($signature->company_full_name . ' ' . $signature->documentLabel()), 'organization_name' => $signature->organization_name, 'operator_name' => $signature->document_type === 'shared-contract' diff --git a/app/Support/CourierQuoteSignatureService.php b/app/Support/CourierQuoteSignatureService.php index b163c729..da8238c1 100644 --- a/app/Support/CourierQuoteSignatureService.php +++ b/app/Support/CourierQuoteSignatureService.php @@ -132,10 +132,12 @@ final class CourierQuoteSignatureService try { $client = app(EsignClient::class); + $client->assertSignFlowInitiator($data['signing_subject'] ?? null); $bytes = Storage::disk('uploads')->get($signature->source_pdf_path); $fileName = 'quote-' . $signature->version->version_code . '.pdf'; $fileId = $client->uploadPdf($bytes, $fileName); $signer = [ + 'initiator_name' => $data['signing_subject'] ?? null, 'flow_title' => $signature->version->carrier_name . ' ' . $signature->version->version_code . ' 报价确认', 'organization_name' => $signature->organization_name, 'operator_name' => $signature->operator_name, diff --git a/config/services.php b/config/services.php index 58b7392b..d11f2bf6 100644 --- a/config/services.php +++ b/config/services.php @@ -47,6 +47,8 @@ return [ ), 'app_id' => env('ESIGN_APP_ID'), 'app_secret' => env('ESIGN_APP_SECRET'), + 'initiator_accounts' => env('ESIGN_INITIATOR_ACCOUNTS', '{}'), + 'default_initiator' => env('ESIGN_DEFAULT_INITIATOR', ''), 'notify_url' => env('ESIGN_NOTIFY_URL'), 'callback_tolerance_seconds' => (int) env('ESIGN_CALLBACK_TOLERANCE_SECONDS', 900), 'timeout' => (int) env('ESIGN_TIMEOUT', 20), diff --git a/docs/esign-agreement-integration.md b/docs/esign-agreement-integration.md index 06900fbd..5d6484a7 100644 --- a/docs/esign-agreement-integration.md +++ b/docs/esign-agreement-integration.md @@ -33,6 +33,21 @@ ESIGN_TIMEOUT=20 在 E签宝开放平台完成:企业认证、SaaS API V3 应用、出口 IP 白名单、Webhook、`SIGN_FLOW_COMPLETE` 订阅,以及短信/邮件和企业签署服务开通。 +### 指定合同发起企业 + +当签署主体与应用所属企业不同时,在私有 `runtime/.env` 配置精确企业名称与实名账号 ID 的对应关系: + +```dotenv +ESIGN_DEFAULT_INITIATOR="示例发起企业" +ESIGN_INITIATOR_ACCOUNTS='{"示例发起企业":{"org_id":"企业实名账号ID","psn_id":"我方经办人实名账号ID"}}' +``` + +协议和报价确认单按页面选择的 `signing_subject` 查找对应身份;旧报价推送入口未选择主体时使用配置中的默认企业。每个企业必须单独核对认证、有效的 `org_initiate_sign` 授权及经办人的 `psn_initiate_sign` 授权,不能使用对方快递经办人的账号。真实企业和经办人 ID 只保存在私有配置中。 + +启用对应关系后,缺少所选企业或经办人配置时会在上传前拒绝推送,不再静默使用应用所属企业。未启用时保持应用自身发起的接入方式。配置只用于新建流程,不切换 App ID/AppSecret,历史流程的查询、回调和归档继续使用原应用。 + +e签宝要求高级版通过“关联企业”开通,生态伙伴版通过企业和经办人授权;套餐能力以e签宝实际开通结果为准。参考[指定合同发起方](https://open.esign.cn/doc/opendoc/pdf-sign3/su5g42)。 + ## 联调步骤 1. 在页面“协议签署 → 发起签章”选择文件类型,填写该类型专属字段和对方实名绑定的手机号/邮箱。 diff --git a/tests/Feature/Business/CourierAgreementCredentialsTest.php b/tests/Feature/Business/CourierAgreementCredentialsTest.php index 61a9768e..aa6af871 100644 --- a/tests/Feature/Business/CourierAgreementCredentialsTest.php +++ b/tests/Feature/Business/CourierAgreementCredentialsTest.php @@ -22,7 +22,11 @@ class CourierAgreementCredentialsTest extends FeatureTestCase Storage::fake('uploads'); config(['app.key' => 'base64:' . base64_encode(str_repeat('k', 32)), 'services.esign.environment' => 'sandbox', 'services.esign.base_url' => 'https://smlopenapi.esign.test', - 'services.esign.app_id' => 'sandbox-test-id', 'services.esign.app_secret' => 'sandbox-test-secret']); + 'services.esign.app_id' => 'sandbox-test-id', 'services.esign.app_secret' => 'sandbox-test-secret', + 'services.esign.initiator_accounts' => json_encode([ + $this->company->name => ['org_id' => 'org-selected', 'psn_id' => 'person-selected'], + ], JSON_THROW_ON_ERROR), + 'services.esign.default_initiator' => '']); $this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail()); } @@ -69,10 +73,18 @@ class CourierAgreementCredentialsTest extends FeatureTestCase Http::assertSent(function (ClientRequest $request): bool { if (! str_ends_with($request->url(), '/create-by-file')) return false; $data = $request->data(); + $this->assertSame('org-selected', data_get($data, 'signFlowInitiator.orgInitiator.orgId')); + $this->assertSame('person-selected', data_get($data, 'signFlowInitiator.orgInitiator.transactor.psnId')); $this->assertCount(3, $data['docs']); - $this->assertCount(3, $data['signers'][0]['signFields']); - $this->assertCount(1, $data['signers'][1]['signFields']); - foreach (array_slice($data['signers'][0]['signFields'], 1) as $field) { + // Multi-page main documents also carry an across-page seal; credential fields remain separate. + $normalFields = array_map(static fn (array $signer): array => array_values(array_filter( + $signer['signFields'], + static fn (array $field): bool => data_get($field, 'normalSignFieldConfig.signFieldStyle') !== 2, + )), $data['signers']); + $this->assertCount(3, $normalFields[0]); + $this->assertCount(1, $normalFields[1]); + foreach (array_slice($normalFields[0], 1) as $index => $field) { + $this->assertSame($data['docs'][$index + 1]['fileId'], $field['fileId']); $this->assertSame('1', $field['normalSignFieldConfig']['signFieldPosition']['positionPage']); $this->assertFalse($field['normalSignFieldConfig']['freeMode']); } diff --git a/tests/Feature/Business/CourierAgreementSignatureWorkflowTest.php b/tests/Feature/Business/CourierAgreementSignatureWorkflowTest.php index 5504ea18..1140566c 100644 --- a/tests/Feature/Business/CourierAgreementSignatureWorkflowTest.php +++ b/tests/Feature/Business/CourierAgreementSignatureWorkflowTest.php @@ -35,6 +35,11 @@ class CourierAgreementSignatureWorkflowTest extends FeatureTestCase 'services.esign.app_id' => 'sandbox-app-id', 'services.esign.app_secret' => 'sandbox-app-secret', 'services.esign.notify_url' => null, + 'services.esign.initiator_accounts' => json_encode([ + $this->company->name => ['org_id' => 'org-selected', 'psn_id' => 'person-selected'], + '默认企业' => ['org_id' => 'org-default', 'psn_id' => 'person-default'], + ], JSON_THROW_ON_ERROR), + 'services.esign.default_initiator' => '默认企业', ]); $this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail()); } @@ -68,7 +73,9 @@ class CourierAgreementSignatureWorkflowTest extends FeatureTestCase return false; } - return data_get($request->data(), 'signers.0.signerType') === 1 + return data_get($request->data(), 'signFlowInitiator.orgInitiator.orgId') === 'org-selected' + && data_get($request->data(), 'signFlowInitiator.orgInitiator.transactor.psnId') === 'person-selected' + && data_get($request->data(), 'signers.0.signerType') === 1 && data_get($request->data(), 'signers.0.orgSignerInfo.orgName') === '新北中通快递有限公司' && data_get($request->data(), 'signers.0.orgSignerInfo.transactorInfo.psnInfo.psnName') === '张经理'; }); @@ -78,6 +85,23 @@ class CourierAgreementSignatureWorkflowTest extends FeatureTestCase $this->assertSame(1, CourierAgreementSignature::query()->count()); } + public function test_missing_selected_initiator_fails_before_upload_without_unknown_status(): void + { + config(['services.esign.initiator_accounts' => '{}']); + Http::fake(); + + $this->withExceptionHandling()->loginAs() + ->post(route('business.courier-agreement-signatures.store'), $this->accountProofPayload('missing-initiator')) + ->assertSessionHasErrors('signature'); + + Http::assertNothingSent(); + $signature = CourierAgreementSignature::query()->firstOrFail(); + $this->assertSame(CourierAgreementSignature::STATUS_FAILED, $signature->status); + $this->assertNull($signature->esign_file_id); + $this->assertNull($signature->esign_sign_flow_id); + $this->assertStringContainsString('所选签署主体未配置完整', $signature->last_error); + } + public function test_authorized_business_user_can_push_without_becoming_an_administrator(): void { $this->user->syncRoles([Role::where('name', 'user')->firstOrFail()->id]); @@ -1054,7 +1078,9 @@ class CourierAgreementSignatureWorkflowTest extends FeatureTestCase $signers = $request->data()['signers'] ?? []; - return count($signers) === 1 + return data_get($request->data(), 'signFlowInitiator.orgInitiator.orgId') === 'org-selected' + && data_get($request->data(), 'signFlowInitiator.orgInitiator.transactor.psnId') === 'person-selected' + && count($signers) === 1 && ($signers[0]['signerType'] ?? null) === 1 && ($signers[0]['orgSignerInfo']['orgName'] ?? null) === '新北中通' && ($signers[0]['orgSignerInfo']['transactorInfo']['psnAccount'] ?? null) === '13800138000'; diff --git a/tests/Feature/Business/CourierQuoteSignatureWorkflowTest.php b/tests/Feature/Business/CourierQuoteSignatureWorkflowTest.php index 158f938e..77a7975a 100644 --- a/tests/Feature/Business/CourierQuoteSignatureWorkflowTest.php +++ b/tests/Feature/Business/CourierQuoteSignatureWorkflowTest.php @@ -27,6 +27,8 @@ class CourierQuoteSignatureWorkflowTest extends FeatureTestCase 'services.esign.app_id' => 'sandbox-app-id', 'services.esign.app_secret' => 'sandbox-app-secret', 'services.esign.notify_url' => null, + 'services.esign.initiator_accounts' => '{"默认企业":{"org_id":"org-default","psn_id":"person-default"}}', + 'services.esign.default_initiator' => '默认企业', ]); $this->user->attachPermission(Permission::where('name', 'read-admin-panel')->firstOrFail()); } @@ -124,12 +126,38 @@ class CourierQuoteSignatureWorkflowTest extends FeatureTestCase $payload = json_decode($request->body(), true); - return data_get($payload, 'signers.0.orgSignerInfo.orgName') === '新北中通快递有限公司' + return data_get($payload, 'signFlowInitiator.orgInitiator.orgId') === 'org-default' + && data_get($payload, 'signFlowInitiator.orgInitiator.transactor.psnId') === 'person-default' + && data_get($payload, 'signers.0.orgSignerInfo.orgName') === '新北中通快递有限公司' && data_get($payload, 'signers.0.normalSignFieldConfig') === null && data_get($payload, 'signers.0.signFields.0.normalSignFieldConfig.freeMode') === true; }); } + public function test_unmapped_quote_initiator_stops_before_upload(): void + { + $signature = $this->createPreparedSignature(); + Http::fake(); + + try { + app(CourierQuoteSignatureService::class)->send($signature, [ + 'signing_subject' => '未配置企业', + 'organization_name' => '测试快递有限公司', + 'operator_name' => '张经理', + 'operator_account' => '13800138000', + 'notice_type' => '1', + 'expires_at' => now()->addDays(7), + ], $this->user->id); + $this->fail('An unmapped company must not fall back to the default initiator.'); + } catch (\Illuminate\Validation\ValidationException $exception) { + $this->assertStringContainsString('所选签署主体未配置完整', $exception->errors()['signature'][0]); + } + + Http::assertNothingSent(); + $this->assertSame(CourierQuoteSignature::STATUS_FAILED, $signature->fresh()->status); + $this->assertNull($signature->fresh()->esign_sign_flow_id); + } + public function test_tampered_source_pdf_is_not_sent(): void { $signature = $this->createPreparedSignature(); @@ -232,6 +260,7 @@ class CourierQuoteSignatureWorkflowTest extends FeatureTestCase public function test_manual_sync_downloads_signed_pdf_and_completion_certificate(): void { + config(['services.esign.initiator_accounts' => '{invalid']); $signature = $this->createPreparedSignature([ 'status' => CourierQuoteSignature::STATUS_SIGNING, 'esign_file_id' => 'file-001', diff --git a/tests/Unit/EsignConfigurationTest.php b/tests/Unit/EsignConfigurationTest.php index 85bea7fa..124b6ce3 100644 --- a/tests/Unit/EsignConfigurationTest.php +++ b/tests/Unit/EsignConfigurationTest.php @@ -56,11 +56,65 @@ class EsignConfigurationTest extends TestCase $configuration->assertReadyForApi(); } + public function test_selected_company_overrides_the_default_initiator(): void + { + $configuration = $this->configuration( + initiatorAccounts: json_encode([ + '默认企业' => ['org_id' => 'org-default', 'psn_id' => 'person-default'], + '所选企业' => ['org_id' => 'org-selected', 'psn_id' => 'person-selected'], + ], JSON_THROW_ON_ERROR), + defaultInitiator: '默认企业', + ); + + $this->assertSame(['orgInitiator' => [ + 'orgId' => 'org-selected', 'transactor' => ['psnId' => 'person-selected'], + ]], $configuration->signFlowInitiator('所选企业')); + $this->assertSame('org-default', $configuration->signFlowInitiator()['orgInitiator']['orgId']); + } + + public function test_unconfigured_installations_keep_the_application_initiator(): void + { + $this->assertNull($this->configuration()->signFlowInitiator('旧企业')); + } + + public function test_unmapped_company_cannot_silently_use_the_default_initiator(): void + { + $configuration = $this->configuration( + initiatorAccounts: '{"默认企业":{"org_id":"org-default","psn_id":"person-default"}}', + defaultInitiator: '默认企业', + ); + $this->expectExceptionMessage('所选签署主体未配置完整'); + $configuration->signFlowInitiator('另一企业'); + } + + /** @dataProvider invalidInitiatorAccounts */ + public function test_invalid_initiator_configuration_prevents_new_flows(string $accounts): void + { + $configuration = $this->configuration(initiatorAccounts: $accounts, defaultInitiator: '企业'); + // Historical reads still use the existing application credentials. + $configuration->assertReadyForApi(); + $this->expectException(RuntimeException::class); + $configuration->signFlowInitiator('企业'); + } + + public static function invalidInitiatorAccounts(): array + { + return [ + 'invalid json' => ['{'], + 'invalid type' => ['true'], + 'missing transactor' => ['{"企业":{"org_id":"org-a"}}'], + 'blank organization' => ['{"企业":{"org_id":" ","psn_id":"person-a"}}'], + 'invalid account type' => ['{"企业":{"org_id":"org-a","psn_id":123}}'], + ]; + } + private function configuration( string $environment = 'production', string $baseUrl = 'https://openapi.esign.cn', string $appId = 'production-app-id', string $appSecret = 'production-app-secret', + string $initiatorAccounts = '{}', + string $defaultInitiator = '', ): EsignConfiguration { return new EsignConfiguration( environment: $environment, @@ -70,6 +124,8 @@ class EsignConfigurationTest extends TestCase notifyUrl: '', callbackToleranceSeconds: 900, timeout: 20, + initiatorAccounts: $initiatorAccounts, + defaultInitiator: $defaultInitiator, ); } }