diff --git a/app/Http/Controllers/Integrations/EsignCallbacks.php b/app/Http/Controllers/Integrations/EsignCallbacks.php index 41ad7511..4f31e6f1 100644 --- a/app/Http/Controllers/Integrations/EsignCallbacks.php +++ b/app/Http/Controllers/Integrations/EsignCallbacks.php @@ -8,6 +8,7 @@ use App\Models\Business\CourierAgreementSignature; use App\Models\Business\CourierQuoteSignature; use App\Support\CourierAgreementSignatureService; use App\Support\CourierQuoteSignatureService; +use App\Services\Esign\EsignConfiguration; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Routing\Controller; @@ -16,13 +17,15 @@ class EsignCallbacks extends Controller { public function __invoke(Request $request, CourierQuoteSignatureService $service, CourierAgreementSignatureService $agreementService): JsonResponse { - $appId = trim((string) config('services.esign.app_id')); - $appSecret = trim((string) config('services.esign.app_secret')); + $configuration = EsignConfiguration::fromConfig(); - if ($appId === '' || $appSecret === '') { + if (! $configuration->isConfigured()) { return response()->json(['code' => '503', 'msg' => 'not configured'], 503); } + $appId = $configuration->appId(); + $appSecret = $configuration->appSecret(); + $timestamp = trim((string) $request->header('X-Tsign-Open-TIMESTAMP')); $algorithm = trim((string) $request->header('X-Tsign-Open-SIGNATURE-ALGORITHM')); $callbackAppId = trim((string) $request->header('X-Tsign-Open-App-Id')); @@ -94,7 +97,7 @@ class EsignCallbacks extends Controller private function timestampIsFresh(string $timestamp): bool { $seconds = strlen($timestamp) >= 13 ? intdiv((int) $timestamp, 1000) : (int) $timestamp; - $tolerance = max(60, (int) config('services.esign.callback_tolerance_seconds', 900)); + $tolerance = EsignConfiguration::fromConfig()->callbackToleranceSeconds(); return abs(now()->timestamp - $seconds) <= $tolerance; } diff --git a/app/Services/Esign/EsignClient.php b/app/Services/Esign/EsignClient.php index 20b8086b..0f64f3be 100644 --- a/app/Services/Esign/EsignClient.php +++ b/app/Services/Esign/EsignClient.php @@ -12,22 +12,30 @@ final class EsignClient private readonly EsignRequestSigner $signer; - public function __construct() + public function __construct(?EsignConfiguration $configuration = null) { - $appId = trim((string) config('services.esign.app_id')); - $appSecret = trim((string) config('services.esign.app_secret')); + $configuration ??= EsignConfiguration::fromConfig(); + $configuration->assertReadyForApi(); - if ($appId === '' || $appSecret === '') { - throw new RuntimeException('E 签宝沙箱尚未配置 App ID 或 AppSecret。'); - } - - $this->baseUrl = rtrim((string) config('services.esign.base_url'), '/'); - $this->signer = new EsignRequestSigner($appId, $appSecret); + $this->baseUrl = $configuration->baseUrl(); + $this->signer = new EsignRequestSigner($configuration->appId(), $configuration->appSecret()); } public static function configured(): bool { - return filled(config('services.esign.app_id')) && filled(config('services.esign.app_secret')); + $configuration = EsignConfiguration::fromConfig(); + + if (! $configuration->isConfigured()) { + return false; + } + + try { + $configuration->assertReadyForApi(); + } catch (RuntimeException) { + return false; + } + + return true; } public function uploadPdf(string $bytes, string $fileName): string @@ -82,7 +90,7 @@ final class EsignClient 'autoFinish' => true, 'identityVerify' => true, ]; - $notifyUrl = trim((string) config('services.esign.notify_url')); + $notifyUrl = EsignConfiguration::fromConfig()->notifyUrl(); if ($notifyUrl !== '') { $flowConfig['notifyUrl'] = $notifyUrl; @@ -249,6 +257,6 @@ final class EsignClient private function timeout(): int { - return max(5, (int) config('services.esign.timeout', 20)); + return EsignConfiguration::fromConfig()->timeout(); } } diff --git a/app/Services/Esign/EsignConfiguration.php b/app/Services/Esign/EsignConfiguration.php new file mode 100644 index 00000000..e3e57c8d --- /dev/null +++ b/app/Services/Esign/EsignConfiguration.php @@ -0,0 +1,132 @@ +environment = strtolower(trim($environment)); + $this->baseUrl = rtrim(trim($baseUrl), '/'); + $this->appId = trim($appId); + $this->appSecret = trim($appSecret); + $this->notifyUrl = trim($notifyUrl); + $this->callbackToleranceSeconds = max(60, $callbackToleranceSeconds); + $this->timeout = max(5, $timeout); + } + + public static function fromConfig(): self + { + return new self( + environment: strtolower(trim((string) config('services.esign.environment', 'sandbox'))), + baseUrl: rtrim(trim((string) config('services.esign.base_url')), '/'), + appId: trim((string) config('services.esign.app_id')), + appSecret: trim((string) config('services.esign.app_secret')), + 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)), + ); + } + + public function isConfigured(): bool + { + return $this->appId !== '' && $this->appSecret !== ''; + } + + public function assertReadyForApi(): void + { + if (! $this->isConfigured()) { + $label = $this->environment === 'production' ? '正式' : '沙箱'; + + throw new RuntimeException("E 签宝{$label}尚未配置 App ID 或 AppSecret。"); + } + + if (! in_array($this->environment, ['sandbox', 'production'], true)) { + throw new RuntimeException('E 签宝环境只能是 sandbox 或 production。'); + } + + if ($this->baseUrl === '' || filter_var($this->baseUrl, FILTER_VALIDATE_URL) === false) { + throw new RuntimeException('E 签宝 API 地址无效,请检查 ESIGN_BASE_URL。'); + } + + $scheme = strtolower((string) parse_url($this->baseUrl, PHP_URL_SCHEME)); + $host = strtolower((string) parse_url($this->baseUrl, PHP_URL_HOST)); + + if ($scheme !== 'https') { + throw new RuntimeException('E 签宝 API 地址必须使用 HTTPS。'); + } + + if ($this->environment === 'production' && $host === 'smlopenapi.esign.cn') { + throw new RuntimeException('E 签宝正式环境不能使用沙箱 API 地址。'); + } + + if ($this->environment === 'sandbox' && $host === 'openapi.esign.cn') { + throw new RuntimeException('E 签宝沙箱环境不能使用正式 API 地址。'); + } + } + + public function environment(): string + { + return $this->environment; + } + + public function baseUrl(): string + { + return $this->baseUrl; + } + + public function appId(): string + { + return $this->appId; + } + + public function appSecret(): string + { + return $this->appSecret; + } + + public function notifyUrl(): string + { + return $this->notifyUrl; + } + + public function callbackToleranceSeconds(): int + { + return $this->callbackToleranceSeconds; + } + + public function timeout(): int + { + return $this->timeout; + } +} diff --git a/config/services.php b/config/services.php index e7f3c686..83aeed93 100644 --- a/config/services.php +++ b/config/services.php @@ -35,8 +35,16 @@ return [ ], 'esign' => [ + // Credentials are deliberately read from the private runtime/.env only. + // Keep the endpoint aligned with the selected environment when the + // explicit ESIGN_BASE_URL override is omitted. 'environment' => env('ESIGN_ENVIRONMENT', 'sandbox'), - 'base_url' => env('ESIGN_BASE_URL', 'https://smlopenapi.esign.cn'), + 'base_url' => env( + 'ESIGN_BASE_URL', + env('ESIGN_ENVIRONMENT', 'sandbox') === 'production' + ? 'https://openapi.esign.cn' + : 'https://smlopenapi.esign.cn', + ), 'app_id' => env('ESIGN_APP_ID'), 'app_secret' => env('ESIGN_APP_SECRET'), 'notify_url' => env('ESIGN_NOTIFY_URL'), diff --git a/docs/esign-production.env.example b/docs/esign-production.env.example new file mode 100644 index 00000000..b47d8d01 --- /dev/null +++ b/docs/esign-production.env.example @@ -0,0 +1,10 @@ +# 脱敏模板:复制需要的键到服务器私有 runtime/.env,再填写易签宝正式应用值。 +# 本文件只用于说明,禁止直接作为生产配置提交或把真实值回填到这里。 + +ESIGN_ENVIRONMENT=production +ESIGN_BASE_URL=https://openapi.esign.cn +ESIGN_APP_ID= +ESIGN_APP_SECRET= +ESIGN_NOTIFY_URL=https://<你的公网域名>/integrations/esign/callback +ESIGN_CALLBACK_TOLERANCE_SECONDS=900 +ESIGN_TIMEOUT=20 diff --git a/tests/Unit/EsignConfigurationTest.php b/tests/Unit/EsignConfigurationTest.php new file mode 100644 index 00000000..85bea7fa --- /dev/null +++ b/tests/Unit/EsignConfigurationTest.php @@ -0,0 +1,75 @@ +assertReadyForApi(); + + $this->assertTrue($configuration->isConfigured()); + $this->assertSame('https://openapi.esign.cn', $configuration->baseUrl()); + $this->assertSame('production-app-id', $configuration->appId()); + } + + public function test_it_rejects_production_credentials_with_the_sandbox_endpoint(): void + { + $configuration = $this->configuration(baseUrl: 'https://smlopenapi.esign.cn'); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('正式环境不能使用沙箱 API 地址'); + + $configuration->assertReadyForApi(); + } + + public function test_it_rejects_sandbox_credentials_with_the_production_endpoint(): void + { + $configuration = $this->configuration(environment: 'sandbox', baseUrl: 'https://openapi.esign.cn'); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('沙箱环境不能使用正式 API 地址'); + + $configuration->assertReadyForApi(); + } + + public function test_it_requires_both_credentials_before_an_api_request(): void + { + $configuration = $this->configuration(appSecret: ''); + + $this->assertFalse($configuration->isConfigured()); + $this->expectExceptionMessage('尚未配置 App ID 或 AppSecret'); + $configuration->assertReadyForApi(); + } + + private function configuration( + string $environment = 'production', + string $baseUrl = 'https://openapi.esign.cn', + string $appId = 'production-app-id', + string $appSecret = 'production-app-secret', + ): EsignConfiguration { + return new EsignConfiguration( + environment: $environment, + baseUrl: $baseUrl, + appId: $appId, + appSecret: $appSecret, + notifyUrl: '', + callbackToleranceSeconds: 900, + timeout: 20, + ); + } +}