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')); } }