feat: update appraisal ordering and payment flows

This commit is contained in:
wushumin
2026-06-03 18:14:40 +08:00
parent 0838db5aba
commit 6383ec5a2a
50 changed files with 6143 additions and 988 deletions

View File

@@ -0,0 +1,613 @@
<?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'] !== '') {
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 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'));
}
}