assertReadyForApi(); $this->baseUrl = $configuration->baseUrl(); $this->signer = new EsignRequestSigner($configuration->appId(), $configuration->appSecret()); } public static function configured(): bool { $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 { if (! str_starts_with($bytes, '%PDF-')) { throw new RuntimeException('只能向 E 签宝上传有效 PDF 文件。'); } if (strlen($bytes) > 50 * 1024 * 1024) { throw new RuntimeException('E 签宝单文件不能超过 50 MB。'); } if (preg_match('/[\\/:*"<>|??\x00-\x1F]/u', $fileName) === 1 || ! str_ends_with(strtolower($fileName), '.pdf')) { throw new RuntimeException('E 签宝文件名必须是安全的 PDF 文件名。'); } $contentMd5 = base64_encode(md5($bytes, true)); $data = $this->request('POST', '/v3/files/file-upload-url', [ 'contentMd5' => $contentMd5, 'contentType' => 'application/pdf', 'fileName' => $fileName, 'fileSize' => strlen($bytes), 'convertToPDF' => false, ]); $fileId = (string) ($data['fileId'] ?? ''); $uploadUrl = (string) ($data['fileUploadUrl'] ?? ''); if ($fileId === '' || $uploadUrl === '') { throw new RuntimeException('E 签宝未返回文件 ID 或上传地址。'); } $uploadResponse = Http::timeout($this->timeout()) ->withHeaders(['Content-MD5' => $contentMd5]) ->withBody($bytes, 'application/pdf') ->send('PUT', $uploadUrl); if (! $uploadResponse->successful()) { throw new RuntimeException('报价 PDF 上传到 E 签宝文件服务失败。'); } $this->waitUntilFileReady($fileId); return $fileId; } public function createSignFlow(string $fileId, string $fileName, array $signer, int $expiresAtMs): string { $flowConfig = [ 'signFlowTitle' => $signer['flow_title'], 'signFlowExpireTime' => $expiresAtMs, 'autoStart' => true, 'autoFinish' => true, 'identityVerify' => true, ]; $notifyUrl = EsignConfiguration::fromConfig()->notifyUrl(); if ($notifyUrl !== '') { $flowConfig['notifyUrl'] = $notifyUrl; } $signers = $signer['signers'] ?? null; $signerPayload = is_array($signers) && $signers !== [] ? array_map(fn (array $item, int $index): array => $this->signerPayload($fileId, $item, $index), array_values($signers), array_keys(array_values($signers))) : [[ 'signerType' => 1, 'signConfig' => ['signOrder' => 1], 'noticeConfig' => ['noticeTypes' => $signer['notice_types']], 'orgSignerInfo' => [ 'orgName' => $signer['organization_name'], 'transactorInfo' => [ 'psnAccount' => $signer['operator_account'], 'psnInfo' => ['psnName' => $signer['operator_name']], ], ], 'signFields' => [[ 'fileId' => $fileId, 'normalSignFieldConfig' => [ 'freeMode' => true, 'movableSignField' => true, 'signFieldSize' => '159', ], ]], ]]; $docs = [[ 'fileId' => $fileId, 'fileName' => $fileName, ]]; foreach ($signer['additional_docs'] ?? [] as $document) { $index = (int) $document['signer_index']; if (! isset($signerPayload[$index]) || blank($document['fileId'])) { throw new RuntimeException('证件推送单的签署方或文件 ID 无效。'); } if (($document['stamp_only'] ?? false) && $signerPayload[$index]['signerType'] !== 1) { throw new RuntimeException('营业执照需要企业盖章,请选择企业签署方。'); } $docs[] = ['fileId' => $document['fileId'], 'fileName' => $document['fileName']]; // Each credential PDF is one page, with exactly one field assigned // to the chosen recipient. Other signers only sign the main file. $signerPayload[$index]['signFields'][] = [ 'fileId' => $document['fileId'], 'normalSignFieldConfig' => [ 'freeMode' => false, 'movableSignField' => false, 'signFieldStyle' => 1, 'signFieldSize' => '120', 'signFieldPosition' => ['positionPage' => '1', 'positionX' => 420.0, 'positionY' => 725.0], ], ]; } $data = $this->request('POST', '/v3/sign-flow/create-by-file', [ 'docs' => $docs, 'signFlowConfig' => $flowConfig, 'signers' => $signerPayload, ]); $signFlowId = (string) ($data['signFlowId'] ?? ''); if ($signFlowId === '') { throw new RuntimeException('E 签宝未返回签署流程 ID。'); } return $signFlowId; } private function signerPayload(string $fileId, array $signer, int $index): array { $type = (int) ($signer['signer_type'] ?? 1); $payload = [ 'signerType' => $type, 'signConfig' => ['signOrder' => $index + 1], 'noticeConfig' => ['noticeTypes' => (string) ($signer['notice_types'] ?? '1')], 'signFields' => [[ 'fileId' => $fileId, 'normalSignFieldConfig' => [ 'freeMode' => true, 'movableSignField' => true, 'signFieldSize' => '159', ], ]], ]; if ($type === 0) { $payload['psnSignerInfo'] = [ 'psnAccount' => $signer['operator_account'], 'psnInfo' => ['psnName' => $signer['operator_name']], ]; } else { $payload['orgSignerInfo'] = [ 'orgName' => $signer['organization_name'], 'transactorInfo' => [ 'psnAccount' => $signer['operator_account'], 'psnInfo' => ['psnName' => $signer['operator_name']], ], ]; } return $payload; } /** * 获取签署人的签署页面链接(免登录,扫码或点开即可签署)。企业签署时带上企业名称,直接进入以该企业身份签署的页面。 */ public function signUrl(string $signFlowId, string $psnAccount, ?string $orgName = null): string { $payload = [ 'needLogin' => false, 'urlType' => 2, 'operator' => ['psnAccount' => $psnAccount], 'clientType' => 'ALL', ]; if ($orgName !== null && $orgName !== '') { $payload['organization'] = ['orgName' => $orgName]; } $data = $this->request('POST', "/v3/sign-flow/{$signFlowId}/sign-url", $payload); $url = (string) ($data['shortUrl'] ?? '') ?: (string) ($data['url'] ?? ''); if ($url === '') { throw new RuntimeException('E 签宝未返回签署链接。'); } return $url; } /** 撤回还没签完的签署流程(签署人那边的待签任务随之失效)。 */ public function revokeSignFlow(string $signFlowId, string $reason): void { $this->request('POST', "/v3/sign-flow/{$signFlowId}/revoke", [ 'revokeReason' => mb_substr($reason, 0, 50), ]); } public function signFlowDetail(string $signFlowId): array { return $this->request('GET', "/v3/sign-flow/{$signFlowId}/detail"); } public function downloadUrls(string $signFlowId): array { return $this->request('POST', "/v3/sign-flow/{$signFlowId}/file-download-url", [ 'urlAvailableDate' => '3600', ]); } public function downloadUploadedPdf(string $fileId): string { $data = $this->request('GET', "/v3/files/{$fileId}"); $downloadUrl = (string) ($data['fileDownloadUrl'] ?? $data['downloadUrl'] ?? data_get($data, 'files.0.downloadUrl', '')); if ($downloadUrl === '') { throw new RuntimeException('E 签宝未返回历史原文件下载地址。'); } return $this->download($downloadUrl); } public function download(string $url): string { $response = Http::timeout($this->timeout())->get($url); if (! $response->successful()) { throw new RuntimeException('无法从 E 签宝下载签署归档文件。'); } return $response->body(); } private function waitUntilFileReady(string $fileId): void { for ($attempt = 0; $attempt < 8; $attempt++) { $data = $this->request('GET', "/v3/files/{$fileId}"); $status = (string) ($data['fileStatus'] ?? $data['status'] ?? ''); if (in_array($status, ['2', '5'], true)) { return; } if ($attempt < 7) { usleep(400000); } } throw new RuntimeException('E 签宝文件处理超时,请稍后重试。'); } private function request(string $method, string $path, array $payload = []): array { $method = strtoupper($method); $body = $method === 'GET' ? '' : $this->encodeJson($payload); $request = Http::timeout($this->timeout()) ->withHeaders($this->signer->headers($method, $path, $body)); if ($body !== '') { $request = $request->withBody($body, EsignRequestSigner::CONTENT_TYPE); } $response = $request->send($method, $this->baseUrl . $path); if (! $response->successful()) { throw new RuntimeException("E 签宝接口请求失败(HTTP {$response->status()})。"); } $decoded = $response->json(); if (! is_array($decoded)) { throw new RuntimeException('E 签宝接口返回了无法识别的数据。'); } if ((int) ($decoded['code'] ?? -1) !== 0) { $code = (string) ($decoded['code'] ?? 'unknown'); $message = mb_substr((string) ($decoded['message'] ?? '未知错误'), 0, 300); throw new RuntimeException("E 签宝接口返回业务错误({$code}):{$message}"); } return is_array($decoded['data'] ?? null) ? $decoded['data'] : []; } private function encodeJson(array $payload): string { try { return json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR); } catch (JsonException $exception) { throw new RuntimeException('E 签宝请求数据无法编码。', previous: $exception); } } private function timeout(): int { return EsignConfiguration::fromConfig()->timeout(); } }