fix: 按所选签署主体指定易签宝发起企业 (#994)

This commit is contained in:
limeng 2026-11-07 19:17:57 +08:00
parent dffc01dc51
commit 2efc0b766c
11 changed files with 208 additions and 9 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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. 在页面“协议签署 → 发起签章”选择文件类型,填写该类型专属字段和对方实名绑定的手机号/邮箱。

View File

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

View File

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

View File

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

View File

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