Files
webman-buildadmin/app/common/library/finance/DepositSettlement.php
2026-04-18 15:19:36 +08:00

219 lines
8.0 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
declare(strict_types=1);
namespace app\common\library\finance;
use RuntimeException;
use support\think\Db;
use Throwable;
/**
* 充值订单结算公共库
*
* 所有"把 deposit_order 变为成功并给玩家钱包加币"的逻辑必须收敛到这里,
* 以便 mock 支付瞬时成功、未来第三方网关回调、历史数据的人工补单共用同一事务边界。
*
* 关键约束:
* - 只结算 status=0 的订单,幂等;重复调用同一订单返回现有结算结果;
* - 钱包流水 user_wallet_record 以 "deposit_settle_{order_no}" 为 idempotency_key保证不重复入账
* - 同时更新 user.coin 与 update_timeuser_wallet_record 记录 balance_before/after 快照。
*/
final class DepositSettlement
{
public const SOURCE_MOCK_GATEWAY = 'mock_gateway';
public const SOURCE_ADMIN_APPROVE = 'admin_approve';
public const SOURCE_THIRD_PARTY = 'third_party';
/**
* 结算指定订单。
*
* @param int $orderId deposit_order.id
* @param string $source 来源SOURCE_* 常量),写入 remark
* @param string $sourceLabel 人类可读描述,写入 remark如 "mock gateway auto settled"
* @param int|null $operatorAdminId 操作管理员 ID仅管理员审核时有值
* @param string|null $extraRemark 追加到订单 remark可选
*
* @return array{
* order_id: int,
* order_no: string,
* amount: string,
* balance_before: string,
* balance_after: string,
* pay_time: int,
* already_settled: bool,
* }
*
* @throws RuntimeException 订单不存在、金额非法、并发冲突等
*/
public static function settle(
int $orderId,
string $source,
string $sourceLabel,
?int $operatorAdminId = null,
?string $extraRemark = null
): array {
if ($orderId <= 0) {
throw new RuntimeException('订单 ID 非法');
}
$order = Db::name('deposit_order')->where('id', $orderId)->find();
if (!$order) {
throw new RuntimeException('订单不存在');
}
$orderNo = is_string($order['order_no']) ? $order['order_no'] : strval($order['order_no']);
if ($orderNo === '') {
throw new RuntimeException('订单号为空');
}
$statusRaw = $order['status'] ?? 0;
$status = is_numeric($statusRaw) ? intval($statusRaw) : 0;
// 如果已结算,直接返回已有结果(幂等)
if ($status === 1) {
$userId = is_numeric($order['user_id'] ?? null) ? intval($order['user_id']) : 0;
$coinAfter = '0.0000';
if ($userId > 0) {
$coin = Db::name('user')->where('id', $userId)->value('coin');
$coinAfter = is_string($coin) ? $coin : strval($coin);
}
$amt = self::amountString($order['amount'] ?? '0');
$bns = self::amountString($order['bonus_amount'] ?? '0');
return [
'order_id' => $orderId,
'order_no' => $orderNo,
'amount' => $amt,
'bonus_amount' => $bns,
'credit' => bcadd($amt, $bns, 4),
'balance_before' => $coinAfter,
'balance_after' => $coinAfter,
'pay_time' => is_numeric($order['pay_time'] ?? null) ? intval($order['pay_time']) : 0,
'already_settled' => true,
];
}
if ($status !== 0) {
throw new RuntimeException('订单状态不允许结算');
}
$amount = self::amountString($order['amount'] ?? '0');
if (bccomp($amount, '0', 4) <= 0) {
throw new RuntimeException('订单金额异常');
}
$bonus = self::amountString($order['bonus_amount'] ?? '0');
if (bccomp($bonus, '0', 4) < 0) {
$bonus = '0.0000';
}
$credit = bcadd($amount, $bonus, 4);
$userId = is_numeric($order['user_id'] ?? null) ? intval($order['user_id']) : 0;
if ($userId <= 0) {
throw new RuntimeException('订单所属玩家无效');
}
$user = Db::name('user')->where('id', $userId)->find();
if (!$user) {
throw new RuntimeException('玩家不存在');
}
$channelId = is_numeric($order['channel_id'] ?? null) ? intval($order['channel_id']) : null;
$balanceBefore = self::amountString($user['coin'] ?? '0');
$balanceAfter = bcadd($balanceBefore, $credit, 4);
$now = time();
$baseRemark = is_string($order['remark'] ?? null) ? $order['remark'] : '';
// 备注包含充值与赠送的明细,方便后续稽核
$detail = sprintf('amount=%s,bonus=%s,credit=%s', $amount, $bonus, $credit);
$note = sprintf('[%s] %s (%s)', $source, $sourceLabel, $detail);
$combined = $baseRemark === '' ? $note : ($baseRemark . ' | ' . $note);
if ($extraRemark !== null && $extraRemark !== '') {
$combined .= ' | ' . $extraRemark;
}
$finalRemark = mb_substr($combined, 0, 255);
$walletIdem = 'deposit_settle_' . $orderNo;
Db::startTrans();
try {
$affected = Db::name('deposit_order')
->where('id', $orderId)
->where('status', 0)
->update([
'status' => 1,
'pay_time' => $now,
'review_admin_id' => $operatorAdminId,
'review_time' => $operatorAdminId !== null ? $now : null,
'remark' => $finalRemark,
'update_time' => $now,
]);
if ($affected <= 0) {
throw new RuntimeException('订单状态已变更,请刷新后重试');
}
Db::name('user')->where('id', $userId)->update([
'coin' => $balanceAfter,
'update_time' => $now,
]);
$walletExists = Db::name('user_wallet_record')
->where('idempotency_key', $walletIdem)
->value('id');
if (!$walletExists) {
Db::name('user_wallet_record')->insert([
'user_id' => $userId,
'channel_id' => $channelId,
'biz_type' => 'deposit',
'direction' => 1,
'amount' => $credit,
'balance_before' => $balanceBefore,
'balance_after' => $balanceAfter,
'ref_type' => 'deposit_order',
'ref_id' => $orderId,
'idempotency_key' => $walletIdem,
'operator_admin_id' => $operatorAdminId,
'remark' => mb_substr($note, 0, 500),
'create_time' => $now,
]);
}
Db::commit();
} catch (Throwable $e) {
Db::rollback();
throw new RuntimeException($e->getMessage());
}
return [
'order_id' => $orderId,
'order_no' => $orderNo,
'amount' => $amount,
'bonus_amount' => $bonus,
'credit' => $credit,
'balance_before' => $balanceBefore,
'balance_after' => $balanceAfter,
'pay_time' => $now,
'already_settled' => false,
];
}
/**
* 将任意数值输入格式化为 4 位小数字符串(不做强制类型转换)
*/
private static function amountString($raw): string
{
if (is_string($raw)) {
$s = trim($raw);
} elseif (is_int($raw) || is_float($raw)) {
$s = strval($raw);
} else {
return '0.0000';
}
if (!is_numeric($s)) {
return '0.0000';
}
return bcadd($s, '0', 4);
}
}