CaiWuXiTong/app/Support/CourierAgreementSignatureService.php

393 lines
19 KiB
PHP

<?php
namespace App\Support;
use App\Models\Business\CourierAgreementSignature;
use App\Models\Business\CourierAgreementSignatureEvent;
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 CourierAgreementSignatureService
{
public function __construct(private readonly CourierAgreementPdf $pdf)
{
}
public function send(array $data, int $userId): CourierAgreementSignature
{
abort_if(! EsignClient::configured(), 422, 'E 签宝沙箱尚未配置 App ID 或 AppSecret。');
$companyId = (int) (company_id() ?: 1);
$document = CourierAgreementSignature::DOCUMENTS[$data['document_type']] ?? null;
if (! $document) {
throw ValidationException::withMessages(['document_type' => '不支持的协议文件类型。']);
}
$payload = is_array($data['payload'] ?? null) ? $data['payload'] : $data;
$payload['authorization_items'] = array_values(array_map('strval', $data['authorization_items'] ?? []));
$idempotencyKey = 'agreement-' . hash('sha256', $companyId . '|' . (string) ($data['idempotency_key'] ?? ''));
$businessNo = 'AGR-' . strtoupper(substr(hash('sha256', $idempotencyKey), 0, 20));
$signature = DB::transaction(function () use ($companyId, $document, $data, $payload, $idempotencyKey, $businessNo, $userId): CourierAgreementSignature {
$existing = CourierAgreementSignature::allCompanies()
->where('company_id', $companyId)
->where('idempotency_key', $idempotencyKey)
->lockForUpdate()
->first();
if ($existing) {
if ($existing->esign_sign_flow_id || in_array($existing->status, [CourierAgreementSignature::STATUS_SENDING, CourierAgreementSignature::STATUS_SIGNING, CourierAgreementSignature::STATUS_COMPLETED, CourierAgreementSignature::STATUS_ARCHIVED], true)) {
return $existing;
}
if (! $existing->canSend()) {
throw ValidationException::withMessages(['signature' => '该协议已进入不可重复发起状态。']);
}
return $existing;
}
$signature = new CourierAgreementSignature([
'company_id' => $companyId,
'business_no' => $businessNo,
'idempotency_key' => $idempotencyKey,
'document_type' => $data['document_type'],
'template_code' => $document['code'],
'template_version' => $document['version'],
'source_file_name' => $document['file'],
'status' => CourierAgreementSignature::STATUS_SENDING,
'courier_name' => $data['courier_name'],
'company_full_name' => $data['company_full_name'],
'organization_name' => $data['organization_name'],
'operator_name' => $data['operator_name'] ?? null,
'operator_account' => $data['operator_account'] ?? null,
'payload' => $payload,
'environment' => (string) config('services.esign.environment', 'sandbox'),
'expires_at' => Carbon::parse($data['signing_deadline'])->endOfDay(),
'source_pdf_path' => "courier-agreements/{$companyId}/{$businessNo}/source.pdf",
'source_pdf_sha256' => '',
'created_by' => $userId,
'updated_by' => $userId,
]);
$signature->save();
$bytes = $this->pdf->render($signature);
$signature->source_pdf_sha256 = hash('sha256', $bytes);
if (! Storage::disk('uploads')->put($signature->source_pdf_path, $bytes)) {
throw new RuntimeException('协议 PDF 无法写入私有业务存储。');
}
$signature->save();
return $signature;
});
if ($signature->esign_sign_flow_id || $signature->status === CourierAgreementSignature::STATUS_SIGNING) {
return $signature->fresh();
}
$flowCreationStarted = false;
try {
$signers = $this->signersFor($signature);
$client = app(EsignClient::class);
$bytes = Storage::disk('uploads')->get($signature->source_pdf_path);
$fileName = 'agreement-' . $signature->business_no . '.pdf';
$fileId = $client->uploadPdf($bytes, $fileName);
$signature->update([
'esign_file_id' => $fileId,
'updated_by' => $userId,
]);
$signer = [
'flow_title' => $this->safeTitle($signature->company_full_name . ' ' . $signature->documentLabel()),
'organization_name' => $signature->organization_name,
'operator_name' => $signature->document_type === 'shared-contract'
? $signature->operator_name
: ($signature->operator_name ?: $signature->courier_name),
'operator_account' => $signature->operator_account,
'notice_types' => '1',
];
$signer['signers'] = $signers;
$flowCreationStarted = true;
$signFlowId = $client->createSignFlow($fileId, $fileName, $signer, $signature->expires_at->getTimestampMs());
$signature->update([
'status' => CourierAgreementSignature::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,
]);
return $signature->fresh();
} catch (Throwable $exception) {
$error = EsignErrorMessage::sanitize($exception->getMessage(), 1000) ?? 'E 签宝处理失败。';
$signature->update([
'status' => $flowCreationStarted && ! EsignErrorMessage::isDefinitiveFailure($exception->getMessage())
? CourierAgreementSignature::STATUS_UNKNOWN
: CourierAgreementSignature::STATUS_FAILED,
'last_error' => $error,
'updated_by' => $userId,
]);
throw ValidationException::withMessages(['signature' => $signature->last_error]);
}
}
public function synchronize(CourierAgreementSignature $signature, ?int $userId = null): CourierAgreementSignature
{
if (blank($signature->esign_sign_flow_id)) {
throw ValidationException::withMessages(['signature' => '该协议尚未创建 E 签宝签署流程。']);
}
if ($signature->status === CourierAgreementSignature::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 ?: (int) $signature->updated_by);
return $remoteStatus === '2' && $this->autoArchive($signature)
? $this->archive($signature, $client, $userId ?: (int) $signature->updated_by)
: $signature;
} catch (Throwable $exception) {
$signature->update(['last_error' => mb_substr($exception->getMessage(), 0, 1000), 'last_synced_at' => now()]);
throw ValidationException::withMessages(['signature' => $signature->last_error]);
}
}
public function acceptCallback(string $signFlowId, string $remoteStatus, string $payloadHash, array $event = []): ?CourierAgreementSignature
{
return DB::transaction(function () use ($signFlowId, $remoteStatus, $payloadHash, $event): ?CourierAgreementSignature {
$signature = CourierAgreementSignature::allCompanies()->disableCache()->where('esign_sign_flow_id', $signFlowId)->lockForUpdate()->first();
if (! $signature) {
return null;
}
$eventKey = (string) ($event['event_key'] ?? hash('sha256', $signFlowId . '|' . $payloadHash));
$audit = CourierAgreementSignatureEvent::query()->disableCache()->where('event_key', $eventKey)->lockForUpdate()->first();
if ($audit || $signature->callback_payload_hash === $payloadHash) {
return null;
}
if (in_array($signature->status, [
CourierAgreementSignature::STATUS_COMPLETED,
CourierAgreementSignature::STATUS_ARCHIVED,
CourierAgreementSignature::STATUS_REVOKED,
CourierAgreementSignature::STATUS_EXPIRED,
CourierAgreementSignature::STATUS_REJECTED,
], true) && $remoteStatus === $signature->remote_status) {
return null;
}
if ($event !== []) {
$audit = CourierAgreementSignatureEvent::create([
'company_id' => $signature->company_id,
'agreement_signature_id' => $signature->id,
'event_key' => $eventKey,
'action' => (string) ($event['action'] ?? 'SIGN_FLOW_COMPLETE'),
'esign_sign_flow_id' => $signFlowId,
'event_timestamp' => (int) ($event['timestamp'] ?? now()->getTimestampMs()),
'verified' => true,
'raw_body' => (string) ($event['raw_body'] ?? ''),
'received_at' => now(),
]);
}
$signature->callback_payload_hash = $payloadHash;
$result = $this->applyRemoteStatus($signature, $remoteStatus, (int) $signature->updated_by);
if ($audit) {
$audit->update(['processed' => true, 'processed_at' => now()]);
}
return $result;
});
}
public function archive(CourierAgreementSignature $signature, ?EsignClient $client = null, ?int $userId = null): CourierAgreementSignature
{
if ($signature->status === CourierAgreementSignature::STATUS_ARCHIVED) {
return $signature;
}
$client ??= app(EsignClient::class);
$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。');
}
$base = "courier-agreements/{$signature->company_id}/{$signature->business_no}";
$signedPath = $base . '/signed.pdf';
$certificatePath = $base . '/completion-certificate.pdf';
$disk = Storage::disk('uploads');
if (! $disk->put($signedPath, $signedBytes)
|| ($certificateBytes !== null && ! $disk->put($certificatePath, $certificateBytes))) {
throw new RuntimeException('签署归档文件无法写入私有业务存储。');
}
$archive = [
'status' => CourierAgreementSignature::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 ?: (int) $signature->updated_by,
];
if ($certificateBytes !== null) {
$archive['certificate_path'] = $certificatePath;
$archive['certificate_sha256'] = hash('sha256', $certificateBytes);
}
$signature->update($archive);
return $signature->fresh();
}
public function autoArchive(CourierAgreementSignature $signature): bool
{
return ($signature->payload['archive_mode'] ?? 'auto') === 'auto';
}
private function applyRemoteStatus(CourierAgreementSignature $signature, string $remoteStatus, int $userId): CourierAgreementSignature
{
$status = match ($remoteStatus) {
'1' => CourierAgreementSignature::STATUS_SIGNING,
'2' => CourierAgreementSignature::STATUS_COMPLETED,
'3' => CourierAgreementSignature::STATUS_REVOKED,
'5' => CourierAgreementSignature::STATUS_EXPIRED,
'7' => CourierAgreementSignature::STATUS_REJECTED,
default => $signature->status,
};
$terminal = [CourierAgreementSignature::STATUS_COMPLETED, CourierAgreementSignature::STATUS_REVOKED, CourierAgreementSignature::STATUS_EXPIRED, CourierAgreementSignature::STATUS_REJECTED, CourierAgreementSignature::STATUS_ARCHIVED];
if (in_array($signature->status, $terminal, true) && $status === CourierAgreementSignature::STATUS_SIGNING) {
return $signature;
}
$signature->forceFill([
'status' => $status,
'remote_status' => $remoteStatus,
'completed_at' => $remoteStatus === '2' ? ($signature->completed_at ?? now()) : $signature->completed_at,
'last_synced_at' => now(),
'updated_by' => $userId,
])->save();
return $signature->refresh();
}
private function signersFor(CourierAgreementSignature $signature): array
{
$payload = $signature->payload ?? [];
if ($signature->document_type === 'shared-contract') {
$isPerson = ($payload['counterparty_signer_type'] ?? 'organization') === 'person';
if (blank($signature->operator_name)) {
throw new RuntimeException('请填写物流商归属主体签署人的 E 签宝实名认证姓名。');
}
return [[
'signer_type' => $isPerson ? 0 : 1,
'organization_name' => $isPerson ? null : $signature->organization_name,
'operator_name' => $signature->operator_name,
'operator_account' => $signature->operator_account,
]];
}
if (in_array($signature->document_type, ['account-proof', 'account-change'], true)) {
$isPerson = ($payload['counterparty_signer_type'] ?? 'organization') === 'person';
return [[
'signer_type' => $isPerson ? 0 : 1,
'organization_name' => $isPerson ? null : $signature->organization_name,
'operator_name' => $isPerson ? ($signature->operator_name ?: $signature->organization_name) : $signature->operator_name,
'operator_account' => $signature->operator_account,
]];
}
$primary = [
'signer_type' => 1,
'organization_name' => $signature->organization_name,
'operator_name' => $signature->operator_name ?: $signature->courier_name,
'operator_account' => $signature->operator_account,
];
if ($signature->document_type === 'cooperation-contract') {
$counterpartyIsPerson = ($payload['counterparty_signer_type'] ?? 'organization') === 'person';
return [
[
'signer_type' => 1,
'organization_name' => $payload['cooperation_party_a_name'] ?? $signature->company_full_name,
'operator_name' => $payload['secondary_operator_name'],
'operator_account' => $payload['secondary_esign_account'] ?? $signature->operator_account,
],
[
'signer_type' => $counterpartyIsPerson ? 0 : 1,
'organization_name' => $counterpartyIsPerson ? null : ($payload['cooperation_party_b_name'] ?? $signature->courier_name),
'operator_name' => $signature->operator_name,
'operator_account' => $signature->operator_account,
],
];
}
if ($signature->document_type === 'entity-change') {
$formerCompanyDeregistered = filter_var(
$payload['entity_change_old_company_deregistered'] ?? false,
FILTER_VALIDATE_BOOLEAN,
);
if ($formerCompanyDeregistered || ($payload['counterparty_signer_type'] ?? 'organization') === 'person') {
return [[
'signer_type' => 0,
'organization_name' => null,
'operator_name' => $signature->operator_name,
'operator_account' => $signature->operator_account,
]];
}
return [
array_merge($primary, ['organization_name' => $payload['former_company_name'] ?? $signature->organization_name]),
[
'signer_type' => 1,
'organization_name' => $payload['changed_company_name'] ?? $signature->organization_name,
'operator_name' => $payload['secondary_operator_name'] ?? $signature->operator_name,
'operator_account' => $payload['secondary_esign_account'] ?? $signature->operator_account,
],
];
}
if ($signature->document_type === 'authorization') {
$principalIsPerson = ($payload['counterparty_signer_type'] ?? 'organization') === 'person';
$trusteeIsPerson = ($payload['secondary_signer_type'] ?? 'organization') === 'person';
return [
[
'signer_type' => $principalIsPerson ? 0 : 1,
'organization_name' => $principalIsPerson ? null : ($payload['principal_name'] ?? $signature->organization_name),
'operator_name' => $signature->operator_name,
'operator_account' => $signature->operator_account,
],
[
'signer_type' => $trusteeIsPerson ? 0 : 1,
'organization_name' => $trusteeIsPerson ? null : ($payload['trustee_name'] ?? $signature->organization_name),
'operator_name' => $payload['secondary_operator_name'],
'operator_account' => $payload['secondary_esign_account'] ?? $signature->operator_account,
],
];
}
return [$primary];
}
private function safeTitle(string $title): string
{
$title = preg_replace('/[^\p{Han}\p{L}\p{N}_ -]/u', '-', $title) ?: '协议签署';
return mb_substr(trim($title), 0, 180);
}
}