CaiWuXiTong/app/Support/CourierQuoteSignatureService.php

422 lines
18 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\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\Support\Str;
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, ?string $signatureDate = null): CourierQuoteSignature
{
return DB::transaction(function () use ($version, $userId, $signatureDate): CourierQuoteSignature {
$version = CourierQuoteVersion::query()->disableCache()->lockForUpdate()->findOrFail($version->id);
$existing = CourierQuoteSignature::query()->disableCache()
->where('quote_version_id', $version->id)->lockForUpdate()->first();
if ($signatureDate === null && $existing && $this->sourcePdfIsIntact($existing)) {
return $existing;
}
if ($existing && ! $existing->canSend()) {
throw ValidationException::withMessages([
'signature' => '该报价已经进入签署流程,签署原文不可重新生成。',
]);
}
if (! $existing) {
$deleted = CourierQuoteSignature::onlyTrashed()->disableCache()
->where('quote_version_id', $version->id)->latest('id')->first();
if ($deleted) {
$this->ensureDeletedTaskCanBeReissued($deleted);
}
}
$bytes = $this->pdf->render($version, $signatureDate);
// Every attempt owns its files, including PDFs regenerated before sending.
$path = $this->path($version, Str::uuid() . '/source.pdf');
$sha256 = hash('sha256', $bytes);
if (! Storage::disk('uploads')->put($path, $bytes)) {
throw new RuntimeException('报价 PDF 无法写入私有业务存储。');
}
$signature = $existing ?? new CourierQuoteSignature([
'company_id' => $version->company_id,
'quote_version_id' => $version->id,
]);
$signature->fill([
'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,
])->save();
$this->updateVersionStatus($signature, $signature->statusLabel());
return $signature->fresh('version');
});
}
private function ensureDeletedTaskCanBeReissued(CourierQuoteSignature $signature): void
{
if (in_array($signature->status, [CourierQuoteSignature::STATUS_COMPLETED, CourierQuoteSignature::STATUS_ARCHIVED], true)
|| filled($signature->signed_pdf_path)) {
throw ValidationException::withMessages(['signature' => '该报价已有完成签署的记录,不能重复发起签署。']);
}
if (blank($signature->esign_sign_flow_id) && $signature->canSend()) {
return;
}
if (blank($signature->esign_sign_flow_id) || ! EsignClient::configured()
|| $signature->environment !== config('services.esign.environment', 'sandbox')) {
throw ValidationException::withMessages(['signature' => '无法核对原签署流程状态,暂不能重新发起签署。']);
}
try {
$detail = app(EsignClient::class)->signFlowDetail($signature->esign_sign_flow_id);
} catch (Throwable) {
throw ValidationException::withMessages(['signature' => '原签署流程状态查询失败,请稍后重试。']);
}
if (! in_array((string) ($detail['signFlowStatus'] ?? $detail['status'] ?? ''), ['3', '5', '7'], true)) {
throw ValidationException::withMessages(['signature' => '原签署流程尚未确认过期、撤销或拒签,不能重复发起签署。']);
}
}
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);
$signer = [
'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,
];
if (($data['signer_type'] ?? 'organization') === 'person') {
// 个人快递以本人身份签署,不走企业经办人。
$signer['signers'] = [[
'signer_type' => 0,
'operator_name' => $signature->operator_name,
'operator_account' => $signature->operator_account,
'notice_types' => $signature->notice_types,
]];
}
$signFlowId = $client->createSignFlow($fileId, $fileName, $signer, $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;
}
// 同一份确认单同时只查一次:几个页面同时打开时,避免后到的查询把已归档的状态又写回「签署完成,待归档」。
$lock = \Illuminate\Support\Facades\Cache::lock('esign-quote-sync-' . $signature->id, 120);
if (! $lock->get()) {
return $signature->fresh() ?? $signature;
}
try {
$signature = $signature->fresh() ?? $signature;
if ($signature->status === CourierQuoteSignature::STATUS_ARCHIVED) {
return $signature;
}
return $this->synchronizeUnlocked($signature, $userId);
} finally {
$lock->release();
}
}
private function synchronizeUnlocked(CourierQuoteSignature $signature, int $userId): CourierQuoteSignature
{
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]);
}
}
/**
* 打开报价页面时顺手查一下还在签署中的报价确认单(未开启定时轮询、回调又到不了本系统时,
* 签完能尽快显示「已签署并归档」)。每次最多查几条,距上次查询不足 $minAgeSeconds 秒的跳过,失败不影响页面。
*/
public function refreshPending(int $limit = 5, int $minAgeSeconds = 60): int
{
if (! EsignClient::configured() || ! \Illuminate\Support\Facades\Schema::hasTable('business_courier_quote_signatures')) {
return 0;
}
// 已归档但版本上的文字状态没跟上的,补成「已签署并归档」。
$archivedVersionIds = CourierQuoteSignature::query()
->where('status', CourierQuoteSignature::STATUS_ARCHIVED)
->pluck('quote_version_id');
if ($archivedVersionIds->isNotEmpty()) {
CourierQuoteVersion::query()
->whereIn('id', $archivedVersionIds->all())
->where('signature_status', '<>', '已签署并归档')
->update(['signature_status' => '已签署并归档']);
}
$environment = strtolower(trim((string) config('services.esign.environment', 'sandbox')));
$cutoff = now()->subSeconds($minAgeSeconds);
$pending = CourierQuoteSignature::query()
->where('environment', $environment)
->whereNotNull('esign_sign_flow_id')
->where('esign_sign_flow_id', '<>', '')
->whereIn('status', [CourierQuoteSignature::STATUS_SIGNING, CourierQuoteSignature::STATUS_COMPLETED])
->where(fn ($query) => $query->whereNull('last_synced_at')->orWhere('last_synced_at', '<=', $cutoff))
->orderBy('last_synced_at')
->limit($limit)
->get();
$refreshed = 0;
foreach ($pending as $signature) {
try {
$this->synchronize($signature, (int) ($signature->updated_by ?: $signature->created_by));
$refreshed++;
} catch (Throwable) {
// 查询失败已记在 last_error,页面照常显示,可手动「同步签署状态」。
}
}
return $refreshed;
}
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 === '') {
throw new RuntimeException('E 签宝未返回签署文件下载地址。');
}
$signedBytes = $client->download($signedUrl);
$certificateBytes = $certificateUrl === '' ? null : $client->download($certificateUrl);
if (! str_starts_with($signedBytes, '%PDF-')
|| ($certificateBytes !== null && ! str_starts_with($certificateBytes, '%PDF-'))) {
throw new RuntimeException('E 签宝返回的签署文件或完成证书不是有效 PDF。');
}
$signedPath = dirname($signature->source_pdf_path) . '/signed.pdf';
$certificatePath = dirname($signature->source_pdf_path) . '/completion-certificate.pdf';
$disk = Storage::disk('uploads');
if (! $disk->put($signedPath, $signedBytes)
|| ($certificateBytes !== null && ! $disk->put($certificatePath, $certificateBytes))) {
throw new RuntimeException('签署归档文件无法写入私有业务存储。');
}
$archive = [
'status' => CourierQuoteSignature::STATUS_ARCHIVED,
'signed_pdf_path' => $signedPath,
'signed_pdf_sha256' => hash('sha256', $signedBytes),
'archived_at' => now(),
'last_synced_at' => now(),
'last_error' => null,
'updated_by' => $userId,
];
if ($certificateBytes !== null) {
$archive['certificate_path'] = $certificatePath;
$archive['certificate_sha256'] = hash('sha256', $certificateBytes);
}
$signature->update($archive);
$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)
->whereHas('signature', fn ($query) => $query->whereKey($signature->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);
}
}