CaiWuXiTong/app/Services/Esign/EsignClient.php

390 lines
14 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;
/**
* 骑缝章中心的纵向坐标(PDF 坐标,原点在左下角,A4 高 841.89)。
* 按企业签署方顺序依次取用:第一家偏上、第二家居中偏下、第三家靠下。
* 相邻两枚间隔 200,大于骑缝章高度 120,叠页后各自完整、互不重叠。
*/
private const ACROSS_PAGE_SEAL_Y = [620.0, 420.0, 220.0];
private const ACROSS_PAGE_SEAL_SIZE = '120';
/** @var array<string, int> 本实例上传过的文件 ID => 页数(0 表示未能识别)。 */
private array $pageCounts = [];
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);
$this->pageCounts[$fileId] = self::pdfPageCount($bytes);
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',
],
]],
]];
$signerPayload = $this->withAcrossPageSeals($fileId, $signerPayload);
$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;
}
/**
* 给主文件上的每个企业签署方各加一个固定位置的骑缝签署区,并按顺序错开纵向位置。
*
* 原来骑缝章由签署人在自由签模式下自己添加,E 签宝把各方的骑缝章放在同一默认高度,
* 双方的章会叠在一起,看起来像盖了两遍。现在由系统预先放好,每方一个高度。
* 个人签署方不加骑缝;主文件少于 2 页或页数识别不出时也不加。
*/
private function withAcrossPageSeals(string $fileId, array $signerPayload): array
{
if (($this->pageCounts[$fileId] ?? 0) < 2) {
return $signerPayload;
}
$orgOrder = 0;
foreach ($signerPayload as $index => $payload) {
if (($payload['signerType'] ?? null) !== 1) {
continue;
}
$positionY = self::ACROSS_PAGE_SEAL_Y[$orgOrder++] ?? null;
if ($positionY === null) {
break;
}
$signerPayload[$index]['signFields'][] = [
'fileId' => $fileId,
'normalSignFieldConfig' => [
'freeMode' => false,
'movableSignField' => false,
'signFieldStyle' => 2,
'signFieldSize' => self::ACROSS_PAGE_SEAL_SIZE,
'signFieldPosition' => ['acrossPageMode' => 'ALL', 'positionY' => $positionY],
],
];
}
return $signerPayload;
}
/** 统计 PDF 页数:数 /Type /Page 对象(不含 /Pages)。系统生成的 dompdf 文件可直接识别;识别不出返回 0。 */
private static function pdfPageCount(string $bytes): int
{
return (int) preg_match_all('#/Type\s*/Page(?![a-zA-Z])#', $bytes);
}
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();
}
}