Files
anxinyan/server-api/app/support/ShouqianbaPaymentService.php
2026-06-04 16:12:59 +08:00

657 lines
25 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 support\think\Db;
class ShouqianbaPaymentService
{
private ?ShouqianbaConfigService $configService;
private ?ShouqianbaClient $client;
private ?MiniProgramAuthService $miniProgramAuthService;
public function __construct(
?ShouqianbaConfigService $configService = null,
?ShouqianbaClient $client = null,
?MiniProgramAuthService $miniProgramAuthService = null
) {
$this->configService = $configService ?: new ShouqianbaConfigService();
$this->client = $client ?: new ShouqianbaClient($this->configService);
$this->miniProgramAuthService = $miniProgramAuthService ?: new MiniProgramAuthService();
}
public function assertReadyForSource(string $sourceChannel, int $userId): void
{
$this->configService->assertReady(true);
if ($sourceChannel === 'h5' && $this->configService->h5OrderDetailUrl(1) === '') {
throw new \RuntimeException('H5 页面根地址未配置,无法生成支付回跳地址');
}
if ($sourceChannel === 'mini_program' && $this->miniProgramAuthService->openidForUser($userId) === '') {
throw new \RuntimeException('小程序 openid 未绑定,请先完成小程序登录授权');
}
}
public function createOrReusePayment(int $orderId): array
{
$order = $this->findOrder($orderId);
if ((string)$order['payment_status'] === 'paid') {
$payment = $this->latestPayment($orderId);
return [
'status' => 'paid',
'channel' => (string)$order['source_channel'],
'check_sn' => (string)($payment['check_sn'] ?? ''),
'order_token' => (string)($payment['order_token'] ?? ''),
'cashier_url' => (string)($payment['cashier_url'] ?? ''),
'order_sn' => (string)($payment['order_sn'] ?? ''),
'order_status' => (string)$order['order_status'],
];
}
if ((string)$order['order_status'] !== 'pending_payment') {
throw new \RuntimeException('当前订单状态不可发起支付');
}
$this->assertReadyForSource((string)$order['source_channel'], (int)$order['user_id']);
$latest = $this->latestPayment($orderId);
if ($latest && in_array((string)$latest['status'], ['pending', 'created'], true) && (string)$latest['order_token'] !== '') {
if ($this->shouldRefreshH5ReturnUrl($latest, $order)) {
$replacement = $this->createPayment($order);
$this->markPaymentReplaced($latest);
return $replacement;
}
return $this->buildPaymentLaunchPayload($latest, $order);
}
return $this->createPayment($order);
}
public function syncOrderPaymentStatus(int $orderId, ?int $userId = null): array
{
$order = $this->findOrder($orderId, $userId);
$payment = $this->latestPayment($orderId);
if (!$payment) {
return $this->orderPaymentPayload($order, null);
}
if ((string)$order['payment_status'] === 'paid') {
return $this->orderPaymentPayload($order, $payment);
}
if (!in_array((string)$payment['status'], ['pending', 'created', 'failed'], true)) {
return $this->orderPaymentPayload($order, $payment);
}
$data = $this->queryRemotePayment($payment);
$this->applyRemotePaymentData($payment, $data, 'query');
return $this->orderPaymentPayload($this->findOrder($orderId, $userId), $this->latestPayment($orderId));
}
public function cancelPendingOrder(int $orderId, int $userId): array
{
$order = $this->findOrder($orderId, $userId);
if ((string)$order['payment_status'] === 'paid') {
throw new \RuntimeException('订单已支付,不能取消');
}
if ((string)$order['order_status'] !== 'pending_payment') {
throw new \RuntimeException('当前订单状态不可取消');
}
$payment = $this->latestPayment($orderId);
if ($payment && in_array((string)$payment['status'], ['pending', 'created'], true)) {
$data = $this->queryRemotePayment($payment);
if ($this->isRemotePaid($data)) {
$this->applyRemotePaymentData($payment, $data, 'query');
throw new \RuntimeException('订单已支付,不能取消');
}
if (!in_array((string)($data['order_status'] ?? ''), ['0', '6', '7'], true)) {
$this->voidRemotePayment($payment);
}
}
$now = date('Y-m-d H:i:s');
Db::startTrans();
try {
Db::name('orders')->where('id', $orderId)->update([
'order_status' => 'cancelled',
'display_status' => '已取消',
'cancelled_at' => $now,
'updated_at' => $now,
]);
if ($payment) {
Db::name('shouqianba_payments')->where('id', (int)$payment['id'])->update([
'status' => 'cancelled',
'cancelled_at' => $now,
'updated_at' => $now,
]);
}
if (!$this->timelineExists($orderId, 'cancelled')) {
Db::name('order_timelines')->insert([
'order_id' => $orderId,
'node_code' => 'cancelled',
'node_text' => '订单已取消',
'node_desc' => '用户已取消待支付订单。',
'operator_type' => 'user',
'operator_id' => $userId,
'occurred_at' => $now,
'created_at' => $now,
]);
}
Db::commit();
} catch (\Throwable $e) {
Db::rollback();
throw $e;
}
return $this->orderPaymentPayload($this->findOrder($orderId, $userId), $this->latestPayment($orderId));
}
public function handleNotification(string $rawBody): array
{
$data = $this->client->decodeNotification($rawBody);
$payment = $this->findPaymentByRemoteData($data);
if (!$payment) {
throw new \RuntimeException('收钱吧通知对应的支付流水不存在');
}
Db::name('shouqianba_payments')->where('id', (int)$payment['id'])->update([
'notify_json' => $this->encodeJson($data),
'updated_at' => date('Y-m-d H:i:s'),
]);
$payment = $this->latestPayment((int)$payment['order_id']) ?: $payment;
$this->applyRemotePaymentData($payment, $data, 'notify');
return [
'order_id' => (int)$payment['order_id'],
'check_sn' => (string)$payment['check_sn'],
'status' => (string)($this->latestPayment((int)$payment['order_id'])['status'] ?? ''),
];
}
public function notificationResponse(bool $ok = true): array
{
return $this->client->signedResponse($ok ? '200' : '500', $ok ? '200' : '500');
}
private function createPayment(array $order): array
{
$sourceChannel = (string)$order['source_channel'];
$config = $this->configService->assertReady(true);
$amount = $this->amountCents((float)$order['pay_amount']);
if ($amount <= 0) {
throw new \RuntimeException('订单支付金额必须大于 0');
}
$checkSn = $this->generateCheckSn((string)$order['order_no']);
$product = Db::name('order_products')->where('order_id', (int)$order['id'])->find() ?: [];
$subject = $this->truncateText('安心验鉴定', 64);
$description = $this->truncateText((string)($product['product_name'] ?? $order['appraisal_no']), 255);
$scene = $sourceChannel === 'h5' ? '2' : '5';
$body = [
'request_id' => $this->generateRequestId('P'),
'brand_code' => $config['brand_code'],
'store_sn' => $config['store_sn'],
'workstation_sn' => $config['workstation_sn'],
'check_sn' => $checkSn,
'sales_sn' => (string)$order['order_no'],
'scene' => $scene,
'sales_time' => date('c'),
'expire_time' => (string)$config['order_expire_minutes'],
'amount' => (string)$amount,
'currency' => '156',
'subject' => $subject,
'description' => $description,
'operator' => 'system',
'industry_code' => $config['industry_code'],
'pos_info' => 'anxinyan',
'notify_url' => $config['notify_url'],
'reflect' => (string)$order['order_no'],
];
if ($config['store_name'] !== '') {
$body['store_name'] = $config['store_name'];
}
if ($sourceChannel === 'h5') {
$returnUrl = $this->configService->h5OrderDetailUrl((int)$order['id']);
if ($returnUrl !== '') {
$body['return_url'] = $returnUrl;
$body['back_url'] = $returnUrl;
}
}
$remote = $this->client->purchase($body);
$data = $remote['data'];
$now = date('Y-m-d H:i:s');
$paymentId = (int)Db::name('shouqianba_payments')->insertGetId([
'order_id' => (int)$order['id'],
'order_no' => (string)$order['order_no'],
'check_sn' => $checkSn,
'order_sn' => (string)($data['order_sn'] ?? ''),
'order_token' => (string)($data['order_token'] ?? ''),
'cashier_url' => (string)($data['cashier_url'] ?? ''),
'order_image_url' => (string)($data['order_image_url'] ?? ''),
'order_landing_url' => (string)($data['order_landing_url'] ?? ''),
'scene' => $scene,
'source_channel' => $sourceChannel,
'status' => 'pending',
'amount' => $amount,
'currency' => '156',
'request_json' => $this->encodeJson($remote['request']),
'response_json' => $this->encodeJson($remote['response']),
'notify_json' => null,
'paid_at' => null,
'cancelled_at' => null,
'created_at' => $now,
'updated_at' => $now,
]);
$payment = Db::name('shouqianba_payments')->where('id', $paymentId)->find();
if (!$payment) {
throw new \RuntimeException('收钱吧支付流水创建失败');
}
return $this->buildPaymentLaunchPayload($payment, $order);
}
private function buildPaymentLaunchPayload(array $payment, array $order): array
{
$sourceChannel = (string)$order['source_channel'];
$payload = [
'status' => (string)$payment['status'],
'channel' => $sourceChannel,
'check_sn' => (string)$payment['check_sn'],
'order_token' => (string)$payment['order_token'],
'cashier_url' => (string)$payment['cashier_url'],
'order_sn' => (string)$payment['order_sn'],
];
if ($sourceChannel === 'mini_program') {
$appId = $this->systemConfig('mini_program', 'app_id');
$openid = $this->miniProgramAuthService->openidForUser((int)$order['user_id']);
if ($appId === '' || $openid === '') {
throw new \RuntimeException('小程序支付参数未准备完成');
}
$query = http_build_query([
'token' => (string)$payment['order_token'],
'appid' => $appId,
'openid' => $openid,
'callback_url' => $this->configService->miniProgramCallbackPath((int)$order['id']),
], '', '&', PHP_QUERY_RFC3986);
$payload['plugin_url'] = 'plugin://lite-pos-plugin/cashierV2?' . $query;
$payload['plugin_provider'] = ShouqianbaConfigService::MINI_PROGRAM_PLUGIN_PROVIDER;
}
return $payload;
}
private function shouldRefreshH5ReturnUrl(array $payment, array $order): bool
{
if ((string)$order['source_channel'] !== 'h5') {
return false;
}
$expectedUrl = $this->configService->h5OrderDetailUrl((int)$order['id']);
if ($expectedUrl === '') {
return false;
}
$requestJson = json_decode((string)($payment['request_json'] ?? ''), true);
if (!is_array($requestJson)) {
return true;
}
$body = $requestJson['body'] ?? ($requestJson['request']['body'] ?? null);
if (!is_array($body)) {
return true;
}
return (string)($body['return_url'] ?? '') !== $expectedUrl
|| (string)($body['back_url'] ?? '') !== $expectedUrl;
}
private function markPaymentReplaced(array $payment): void
{
$now = date('Y-m-d H:i:s');
Db::name('shouqianba_payments')
->where('id', (int)$payment['id'])
->whereIn('status', ['pending', 'created'])
->update([
'status' => 'replaced',
'cancelled_at' => $now,
'updated_at' => $now,
]);
}
private function queryRemotePayment(array $payment): array
{
$config = $this->configService->assertReady(true);
$body = [
'brand_code' => $config['brand_code'],
'store_sn' => $config['store_sn'],
'workstation_sn' => $config['workstation_sn'],
'check_sn' => (string)$payment['check_sn'],
];
if ((string)$payment['order_sn'] !== '') {
$body['order_sn'] = (string)$payment['order_sn'];
}
$remote = $this->client->query($body);
Db::name('shouqianba_payments')->where('id', (int)$payment['id'])->update([
'response_json' => $this->encodeJson($remote['response']),
'updated_at' => date('Y-m-d H:i:s'),
]);
return $remote['data'];
}
private function voidRemotePayment(array $payment): void
{
$config = $this->configService->assertReady(true);
$body = [
'request_id' => $this->generateRequestId('V'),
'brand_code' => $config['brand_code'],
'original_store_sn' => $config['store_sn'],
'original_workstation_sn' => $config['workstation_sn'],
'original_check_sn' => (string)$payment['check_sn'],
'reflect' => (string)$payment['order_no'],
];
if ((string)$payment['order_sn'] !== '') {
$body['original_order_sn'] = (string)$payment['order_sn'];
}
$remote = $this->client->void($body);
Db::name('shouqianba_payments')->where('id', (int)$payment['id'])->update([
'response_json' => $this->encodeJson($remote['response']),
'updated_at' => date('Y-m-d H:i:s'),
]);
}
private function applyRemotePaymentData(array $payment, array $data, string $source): void
{
if (isset($data['amount']) && (int)$data['amount'] !== (int)$payment['amount']) {
throw new \RuntimeException('收钱吧支付金额与本地订单金额不一致');
}
if ($this->isRemotePaid($data)) {
$this->markOrderPaid($payment, $data, $source);
return;
}
$remoteStatus = (string)($data['order_status'] ?? '');
$statusMap = [
'0' => 'cancelled',
'6' => 'failed',
'7' => 'terminated',
];
$status = $statusMap[$remoteStatus] ?? 'pending';
Db::name('shouqianba_payments')->where('id', (int)$payment['id'])->update([
'status' => $status,
'order_sn' => (string)($data['order_sn'] ?? $payment['order_sn']),
'updated_at' => date('Y-m-d H:i:s'),
]);
if ($status === 'cancelled') {
$this->markPendingOrderCancelled($payment, '收钱吧已取消该支付订单。');
}
}
private function markOrderPaid(array $payment, array $data, string $source): void
{
$orderId = (int)$payment['order_id'];
$now = date('Y-m-d H:i:s');
Db::startTrans();
try {
$order = Db::name('orders')->where('id', $orderId)->lock(true)->find();
if (!$order) {
throw new \RuntimeException('支付对应订单不存在');
}
Db::name('shouqianba_payments')->where('id', (int)$payment['id'])->update([
'status' => 'paid',
'order_sn' => (string)($data['order_sn'] ?? $payment['order_sn']),
'paid_at' => $payment['paid_at'] ?: $now,
'updated_at' => $now,
]);
if ((string)$order['payment_status'] !== 'paid') {
Db::name('orders')->where('id', $orderId)->update([
'payment_status' => 'paid',
'order_status' => 'pending_shipping',
'display_status' => '待寄送商品',
'paid_at' => $now,
'updated_at' => $now,
]);
$product = Db::name('order_products')->where('order_id', $orderId)->find() ?: [];
$defaultAddress = Db::name('user_addresses')
->where('user_id', (int)$order['user_id'])
->where('is_default', 1)
->find();
$shippingTarget = (new WarehouseService())->bindOrderTarget(
$orderId,
(string)$order['service_provider'],
!empty($product['category_id']) ? (int)$product['category_id'] : null,
$defaultAddress ?: null
);
if (!Db::name('appraisal_tasks')->where('order_id', $orderId)->find()) {
Db::name('appraisal_tasks')->insert([
'order_id' => $orderId,
'task_stage' => 'first_review',
'service_provider' => (string)$order['service_provider'],
'status' => 'pending',
'assignee_id' => null,
'assignee_name' => '未分配',
'started_at' => null,
'submitted_at' => null,
'sla_deadline' => $order['estimated_finish_time'],
'is_overtime' => 0,
'created_at' => $now,
'updated_at' => $now,
]);
}
if (!$this->timelineExists($orderId, 'payment_paid')) {
Db::name('order_timelines')->insert([
'order_id' => $orderId,
'node_code' => 'payment_paid',
'node_text' => '支付成功',
'node_desc' => $source === 'notify' ? '已收到收钱吧支付成功通知。' : '已同步确认收钱吧支付成功。',
'operator_type' => 'system',
'operator_id' => null,
'occurred_at' => $now,
'created_at' => $now,
]);
}
if (!$this->timelineExists($orderId, 'pending_shipping')) {
Db::name('order_timelines')->insert([
'order_id' => $orderId,
'node_code' => 'pending_shipping',
'node_text' => '待寄送商品',
'node_desc' => sprintf('请尽快将商品寄送至%s以免影响处理时效', $shippingTarget['warehouse_name'] ?: '鉴定中心'),
'operator_type' => 'system',
'operator_id' => null,
'occurred_at' => $now,
'created_at' => $now,
]);
}
(new MessageDispatcher())->sendInboxEvent('order_created', [
'user_id' => (int)$order['user_id'],
'biz_type' => 'order',
'biz_id' => $orderId,
'order_no' => (string)$order['order_no'],
'appraisal_no' => (string)$order['appraisal_no'],
'product_name' => (string)($product['product_name'] ?? ''),
'pay_amount' => (string)$order['pay_amount'],
'fallback_title' => '订单支付成功',
'fallback_content' => '您的鉴定订单已支付成功,可前往订单中心查看进度。',
]);
}
Db::commit();
} catch (\Throwable $e) {
Db::rollback();
throw $e;
}
}
private function orderPaymentPayload(array $order, ?array $payment): array
{
return [
'order_id' => (int)$order['id'],
'order_no' => (string)$order['order_no'],
'payment_status' => (string)$order['payment_status'],
'order_status' => (string)$order['order_status'],
'display_status' => (string)$order['display_status'],
'payment' => $payment ? [
'status' => (string)$payment['status'],
'channel' => (string)$payment['source_channel'],
'check_sn' => (string)$payment['check_sn'],
'order_sn' => (string)$payment['order_sn'],
'order_token' => (string)$payment['order_token'],
'cashier_url' => (string)$payment['cashier_url'],
] : null,
];
}
private function markPendingOrderCancelled(array $payment, string $nodeDesc): void
{
$orderId = (int)$payment['order_id'];
$now = date('Y-m-d H:i:s');
Db::startTrans();
try {
$order = Db::name('orders')->where('id', $orderId)->lock(true)->find();
if (!$order || (string)$order['payment_status'] === 'paid' || (string)$order['order_status'] !== 'pending_payment') {
Db::commit();
return;
}
Db::name('orders')->where('id', $orderId)->update([
'order_status' => 'cancelled',
'display_status' => '已取消',
'cancelled_at' => $now,
'updated_at' => $now,
]);
if (!$this->timelineExists($orderId, 'cancelled')) {
Db::name('order_timelines')->insert([
'order_id' => $orderId,
'node_code' => 'cancelled',
'node_text' => '订单已取消',
'node_desc' => $nodeDesc,
'operator_type' => 'system',
'operator_id' => null,
'occurred_at' => $now,
'created_at' => $now,
]);
}
Db::commit();
} catch (\Throwable $e) {
Db::rollback();
throw $e;
}
}
private function findOrder(int $orderId, ?int $userId = null): array
{
$query = Db::name('orders')->where('id', $orderId);
if ($userId !== null) {
$query->where('user_id', $userId);
}
$order = $query->find();
if (!$order) {
throw new \RuntimeException('订单不存在');
}
return $order;
}
private function latestPayment(int $orderId): ?array
{
$payment = Db::name('shouqianba_payments')->where('order_id', $orderId)->order('id', 'desc')->find();
return $payment ?: null;
}
private function findPaymentByRemoteData(array $data): ?array
{
$checkSn = trim((string)($data['check_sn'] ?? ''));
if ($checkSn !== '') {
$payment = Db::name('shouqianba_payments')->where('check_sn', $checkSn)->find();
if ($payment) {
return $payment;
}
}
$orderSn = trim((string)($data['order_sn'] ?? ''));
if ($orderSn !== '') {
$payment = Db::name('shouqianba_payments')->where('order_sn', $orderSn)->order('id', 'desc')->find();
if ($payment) {
return $payment;
}
}
return null;
}
private function isRemotePaid(array $data): bool
{
return in_array((string)($data['order_status'] ?? ''), ['4', '5'], true);
}
private function timelineExists(int $orderId, string $nodeCode): bool
{
return (bool)Db::name('order_timelines')
->where('order_id', $orderId)
->where('node_code', $nodeCode)
->find();
}
private function amountCents(float $amount): int
{
return (int)round($amount * 100);
}
private function generateCheckSn(string $orderNo): string
{
return substr($orderNo . 'P' . date('His') . random_int(10, 99), 0, 32);
}
private function generateRequestId(string $prefix): string
{
return $prefix . date('YmdHis') . bin2hex(random_bytes(6));
}
private function truncateText(string $value, int $maxLength): string
{
if (mb_strlen($value, 'UTF-8') <= $maxLength) {
return $value;
}
return mb_substr($value, 0, $maxLength, 'UTF-8');
}
private function encodeJson(array $payload): string
{
$json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if (!is_string($json)) {
throw new \RuntimeException('收钱吧支付数据编码失败');
}
return $json;
}
private function systemConfig(string $group, string $key): string
{
return trim((string)Db::name('system_configs')
->where('config_group', $group)
->where('config_key', $key)
->value('config_value'));
}
}