CaiWuXiTong/app/Services/Esign/EsignClient.php

333 lines
12 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',
],
]],
]];
$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();
}
}