255 lines
8.7 KiB
PHP
255 lines
8.7 KiB
PHP
<?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()
|
||
{
|
||
$appId = trim((string) config('services.esign.app_id'));
|
||
$appSecret = trim((string) config('services.esign.app_secret'));
|
||
|
||
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);
|
||
}
|
||
|
||
public static function configured(): bool
|
||
{
|
||
return filled(config('services.esign.app_id')) && filled(config('services.esign.app_secret'));
|
||
}
|
||
|
||
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 = trim((string) config('services.esign.notify_url'));
|
||
|
||
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 max(5, (int) config('services.esign.timeout', 20));
|
||
}
|
||
}
|