CaiWuXiTong/app/Support/CourierQuoteSignatureService.php

299 lines
12 KiB
PHP

<?php
namespace App\Support;
use App\Models\Business\CourierQuoteSignature;
use App\Models\Business\CourierQuoteVersion;
use App\Services\Esign\EsignClient;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use Illuminate\Validation\ValidationException;
use RuntimeException;
use Throwable;
final class CourierQuoteSignatureService
{
public function __construct(private readonly CourierQuotePdf $pdf)
{
}
public function prepareVersion(CourierQuoteVersion $version, int $userId): CourierQuoteSignature
{
$existing = $version->signature;
if ($existing && $this->sourcePdfIsIntact($existing)) {
return $existing;
}
if ($existing && ! $existing->canSend()) {
throw ValidationException::withMessages([
'signature' => '该报价已经进入签署流程,签署原文不可重新生成。',
]);
}
$bytes = $this->pdf->render($version);
$path = $this->path($version, 'source.pdf');
$sha256 = hash('sha256', $bytes);
if (! Storage::disk('uploads')->put($path, $bytes)) {
throw new RuntimeException('报价 PDF 无法写入私有业务存储。');
}
$signature = CourierQuoteSignature::updateOrCreate(
['company_id' => $version->company_id, 'quote_version_id' => $version->id],
[
'provider' => 'esign',
'environment' => (string) config('services.esign.environment', 'sandbox'),
'status' => CourierQuoteSignature::STATUS_PREPARED,
'organization_name' => $version->carrier_name,
'source_pdf_path' => $path,
'source_pdf_sha256' => $sha256,
'source_pdf_generated_at' => now(),
'last_error' => null,
'created_by' => $existing?->created_by ?? $userId,
'updated_by' => $userId,
],
);
$this->updateVersionStatus($signature, $signature->statusLabel());
return $signature->fresh('version');
}
public function send(CourierQuoteSignature $signature, array $data, int $userId): CourierQuoteSignature
{
$signature = DB::transaction(function () use ($signature, $data, $userId): CourierQuoteSignature {
$locked = CourierQuoteSignature::query()->lockForUpdate()->findOrFail($signature->id);
if (! $locked->canSend()) {
throw ValidationException::withMessages(['signature' => '当前报价版本已经进入签署流程,不能重复推送。']);
}
if (! $this->sourcePdfIsIntact($locked)) {
throw ValidationException::withMessages(['signature' => '待签报价 PDF 缺失或校验不一致,请重新生成后再推送。']);
}
$locked->update([
'status' => CourierQuoteSignature::STATUS_SENDING,
'organization_name' => $data['organization_name'],
'operator_name' => $data['operator_name'],
'operator_account' => $data['operator_account'],
'notice_types' => (string) $data['notice_type'],
'expires_at' => Carbon::parse($data['expires_at']),
'last_error' => null,
'updated_by' => $userId,
]);
return $locked->fresh('version');
});
$this->updateVersionStatus($signature, '推送中');
try {
$client = app(EsignClient::class);
$bytes = Storage::disk('uploads')->get($signature->source_pdf_path);
$fileName = 'quote-' . $signature->version->version_code . '.pdf';
$fileId = $client->uploadPdf($bytes, $fileName);
$signFlowId = $client->createSignFlow($fileId, $fileName, [
'flow_title' => $signature->version->carrier_name . ' ' . $signature->version->version_code . ' 报价确认',
'organization_name' => $signature->organization_name,
'operator_name' => $signature->operator_name,
'operator_account' => $signature->operator_account,
'notice_types' => $signature->notice_types,
], $signature->expires_at->getTimestampMs());
$signature->update([
'status' => CourierQuoteSignature::STATUS_SIGNING,
'esign_file_id' => $fileId,
'esign_sign_flow_id' => $signFlowId,
'remote_status' => '1',
'sent_at' => now(),
'last_synced_at' => now(),
'last_error' => null,
'updated_by' => $userId,
]);
$this->updateVersionStatus($signature, '待签署');
return $signature->fresh('version');
} catch (Throwable $exception) {
$signature->update([
'status' => CourierQuoteSignature::STATUS_FAILED,
'last_error' => $this->safeError($exception),
'updated_by' => $userId,
]);
$this->updateVersionStatus($signature, '推送失败');
throw ValidationException::withMessages(['signature' => $signature->last_error]);
}
}
public function synchronize(CourierQuoteSignature $signature, int $userId): CourierQuoteSignature
{
if (blank($signature->esign_sign_flow_id)) {
throw ValidationException::withMessages(['signature' => '该报价版本尚未创建 E 签宝签署流程。']);
}
if ($signature->status === CourierQuoteSignature::STATUS_ARCHIVED) {
return $signature;
}
try {
$client = app(EsignClient::class);
$detail = $client->signFlowDetail($signature->esign_sign_flow_id);
$remoteStatus = (string) ($detail['signFlowStatus'] ?? $detail['status'] ?? '');
if ($remoteStatus === '') {
throw new RuntimeException('E 签宝未返回签署流程状态。');
}
$signature = $this->applyRemoteStatus($signature, $remoteStatus, $userId);
return $remoteStatus === '2' ? $this->archive($signature, $client, $userId) : $signature;
} catch (Throwable $exception) {
$signature->update([
'last_error' => $this->safeError($exception),
'last_synced_at' => now(),
'updated_by' => $userId,
]);
throw ValidationException::withMessages(['signature' => $signature->last_error]);
}
}
public function synchronizeById(int $signatureId): void
{
$signature = CourierQuoteSignature::allCompanies()->find($signatureId);
if (! $signature) {
return;
}
$this->synchronize($signature, $signature->updated_by);
}
public function acceptCallback(string $signFlowId, string $remoteStatus, string $payloadHash): ?CourierQuoteSignature
{
return DB::transaction(function () use ($signFlowId, $remoteStatus, $payloadHash): ?CourierQuoteSignature {
$signature = CourierQuoteSignature::allCompanies()
->where('esign_sign_flow_id', $signFlowId)
->lockForUpdate()
->first();
if (! $signature) {
return null;
}
if ($signature->callback_payload_hash === $payloadHash) {
return null;
}
$signature->callback_payload_hash = $payloadHash;
return $this->applyRemoteStatus($signature, $remoteStatus, $signature->updated_by);
});
}
public function sourcePdfIsIntact(CourierQuoteSignature $signature): bool
{
if (blank($signature->source_pdf_path) || blank($signature->source_pdf_sha256)) {
return false;
}
$disk = Storage::disk('uploads');
return $disk->exists($signature->source_pdf_path)
&& hash_equals($signature->source_pdf_sha256, hash('sha256', $disk->get($signature->source_pdf_path)));
}
private function applyRemoteStatus(CourierQuoteSignature $signature, string $remoteStatus, int $userId): CourierQuoteSignature
{
[$status, $label] = match ($remoteStatus) {
'1' => [CourierQuoteSignature::STATUS_SIGNING, '待签署'],
'2' => [CourierQuoteSignature::STATUS_COMPLETED, '签署完成,待归档'],
'3' => [CourierQuoteSignature::STATUS_REVOKED, '已撤销'],
'5' => [CourierQuoteSignature::STATUS_EXPIRED, '已过期'],
'7' => [CourierQuoteSignature::STATUS_REJECTED, '已拒签'],
default => [$signature->status, $signature->statusLabel()],
};
$signature->forceFill([
'status' => $status,
'remote_status' => $remoteStatus,
'completed_at' => $remoteStatus === '2' ? ($signature->completed_at ?? now()) : $signature->completed_at,
'last_synced_at' => now(),
'last_error' => null,
'updated_by' => $userId,
])->save();
$this->updateVersionStatus($signature, $label);
$signature->refresh();
return $signature;
}
private function archive(CourierQuoteSignature $signature, EsignClient $client, int $userId): CourierQuoteSignature
{
if ($signature->status === CourierQuoteSignature::STATUS_ARCHIVED) {
return $signature;
}
$urls = $client->downloadUrls($signature->esign_sign_flow_id);
$signedUrl = (string) data_get($urls, 'files.0.downloadUrl', '');
$certificateUrl = (string) ($urls['certificateDownloadUrl'] ?? '');
if ($signedUrl === '' || $certificateUrl === '') {
throw new RuntimeException('E 签宝未返回签署文件或完成证书下载地址。');
}
$signedBytes = $client->download($signedUrl);
$certificateBytes = $client->download($certificateUrl);
if (! str_starts_with($signedBytes, '%PDF-') || ! str_starts_with($certificateBytes, '%PDF-')) {
throw new RuntimeException('E 签宝返回的签署文件或完成证书不是有效 PDF。');
}
$version = CourierQuoteVersion::allCompanies()->findOrFail($signature->quote_version_id);
$signedPath = $this->path($version, 'signed.pdf');
$certificatePath = $this->path($version, 'completion-certificate.pdf');
$disk = Storage::disk('uploads');
if (! $disk->put($signedPath, $signedBytes) || ! $disk->put($certificatePath, $certificateBytes)) {
throw new RuntimeException('签署归档文件无法写入私有业务存储。');
}
$signature->update([
'status' => CourierQuoteSignature::STATUS_ARCHIVED,
'signed_pdf_path' => $signedPath,
'signed_pdf_sha256' => hash('sha256', $signedBytes),
'certificate_path' => $certificatePath,
'certificate_sha256' => hash('sha256', $certificateBytes),
'archived_at' => now(),
'last_synced_at' => now(),
'last_error' => null,
'updated_by' => $userId,
]);
$this->updateVersionStatus($signature, '已签署并归档');
return $signature->fresh('version');
}
private function path(CourierQuoteVersion $version, string $fileName): string
{
$versionCode = preg_replace('/[^A-Za-z0-9_-]/', '-', $version->version_code) ?: (string) $version->id;
return "courier-quotes/{$version->company_id}/{$versionCode}/{$fileName}";
}
private function updateVersionStatus(CourierQuoteSignature $signature, string $label): void
{
CourierQuoteVersion::allCompanies()
->whereKey($signature->quote_version_id)
->update(['signature_status' => $label]);
}
private function safeError(Throwable $exception): string
{
$message = preg_replace('/[[:cntrl:]]+/u', ' ', $exception->getMessage()) ?: 'E 签宝处理失败。';
return mb_substr($message, 0, 1000);
}
}