feat: add safe esign production config template

This commit is contained in:
limeng 2026-08-31 14:18:26 +08:00
parent 2075e7a1a1
commit a4724f12e2
6 changed files with 253 additions and 17 deletions

View File

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

View File

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

View File

@ -0,0 +1,132 @@
<?php
namespace App\Services\Esign;
use RuntimeException;
/**
* Reads the E签宝 settings that Laravel loaded from the private runtime/.env.
*
* Keeping this normalization and endpoint check in one place prevents a
* production credential from silently being sent to the sandbox endpoint (or
* the reverse) when a deployment omits ESIGN_BASE_URL.
*/
final class EsignConfiguration
{
private readonly string $environment;
private readonly string $baseUrl;
private readonly string $appId;
private readonly string $appSecret;
private readonly string $notifyUrl;
private readonly int $callbackToleranceSeconds;
private readonly int $timeout;
public function __construct(
string $environment,
string $baseUrl,
string $appId,
string $appSecret,
string $notifyUrl,
int $callbackToleranceSeconds,
int $timeout,
) {
$this->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;
}
}

View File

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

View File

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

View File

@ -0,0 +1,75 @@
<?php
namespace Tests\Unit;
use App\Services\Esign\EsignConfiguration;
use PHPUnit\Framework\TestCase;
use RuntimeException;
class EsignConfigurationTest extends TestCase
{
public function test_it_normalizes_private_environment_values_without_exposing_the_secret(): void
{
$configuration = new EsignConfiguration(
environment: 'production',
baseUrl: 'https://openapi.esign.cn/',
appId: 'production-app-id',
appSecret: 'production-app-secret',
notifyUrl: 'https://example.test/integrations/esign/callback',
callbackToleranceSeconds: 900,
timeout: 20,
);
$configuration->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,
);
}
}