CaiWuXiTong/app/Services/Esign/EsignClient.php

263 lines
8.7 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace App\Services\Esign;
use Illuminate\Support\Facades\Http;
use JsonException;
use RuntimeException;
final class EsignClient
{
private readonly string $baseUrl;
private readonly EsignRequestSigner $signer;
public function __construct(?EsignConfiguration $configuration = null)
{
$configuration ??= EsignConfiguration::fromConfig();
$configuration->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',
],
]],
]];
$data = $this->request('POST', '/v3/sign-flow/create-by-file', [
'docs' => [[
'fileId' => $fileId,
'fileName' => $fileName,
]],
'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 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 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();
}
}