WalletFreezeService.php 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173
  1. <?php
  2. namespace App\Services;
  3. use App\Constants\HttpStatus;
  4. use App\Models\User;
  5. use App\Models\Wallet;
  6. use App\Models\WalletFreezeRecord as Record;
  7. use Carbon\Carbon;
  8. use Exception;
  9. use Illuminate\Database\QueryException;
  10. use Illuminate\Support\Facades\DB;
  11. /**
  12. * 后台人工冻结 / 解冻。
  13. * 冻结:可用余额 → 冻结余额,并减少打码量;解冻:冻结余额 → 可用余额,并增加打码量。
  14. * 解冻金额不超过该会员未解冻的人工冻结金额,提现冻结中的资金不能被人工解冻。
  15. */
  16. class WalletFreezeService
  17. {
  18. public static function freeze(string $requestId, int $adminId, string $memberId, string $amount, string $wagerAmount, string $reason): array
  19. {
  20. return self::apply(true, $requestId, $adminId, $memberId, $amount, $wagerAmount, $reason);
  21. }
  22. public static function unfreeze(string $requestId, int $adminId, string $memberId, string $amount, string $wagerAmount, string $reason): array
  23. {
  24. return self::apply(false, $requestId, $adminId, $memberId, $amount, $wagerAmount, $reason);
  25. }
  26. /** 会员尚未解冻的人工冻结金额。 */
  27. public static function manualFrozen(string $memberId): string
  28. {
  29. $frozen = (string)Record::where('member_id', $memberId)->where('type', Record::TYPE_MANUAL_FREEZE)->sum('amount');
  30. $unfrozen = (string)Record::where('member_id', $memberId)->where('type', Record::TYPE_MANUAL_UNFREEZE)->sum('amount');
  31. $left = bcsub(bcadd($frozen, '0', 10), bcadd($unfrozen, '0', 10), 10);
  32. return bccomp($left, '0', 10) > 0 ? $left : '0';
  33. }
  34. private static function apply(bool $isFreeze, string $requestId, int $adminId, string $memberId, string $amount, string $wagerAmount, string $reason): array
  35. {
  36. $type = $isFreeze ? Record::TYPE_MANUAL_FREEZE : Record::TYPE_MANUAL_UNFREEZE;
  37. $amount = bcadd($amount, '0', 10);
  38. $wagerAmount = bcadd($wagerAmount === '' ? '0' : $wagerAmount, '0', 10);
  39. if (bccomp($amount, '0', 10) <= 0) throw new Exception('金额必须大于0', HttpStatus::CUSTOM_ERROR);
  40. if (bccomp($wagerAmount, '0', 10) < 0) throw new Exception('打码量不能小于0', HttpStatus::CUSTOM_ERROR);
  41. $replay = fn () => self::replay($requestId, $type, $memberId, $amount, $wagerAmount);
  42. if ($existing = $replay()) return $existing;
  43. try {
  44. $record = DB::transaction(function () use ($isFreeze, $type, $requestId, $adminId, $memberId, $amount, $wagerAmount, $reason) {
  45. $wallet = Wallet::where('member_id', $memberId)->lockForUpdate()->first();
  46. if (!$wallet) throw new Exception('用户钱包不存在', HttpStatus::CUSTOM_ERROR);
  47. $user = User::where('member_id', $memberId)->first(['id']);
  48. if (!$user) throw new Exception('用户不存在', HttpStatus::CUSTOM_ERROR);
  49. $beforeAvailable = bcadd((string)$wallet->available_balance, '0', 10);
  50. $beforeFrozen = bcadd((string)$wallet->frozen_balance, '0', 10);
  51. if ($isFreeze) {
  52. if (bccomp($beforeAvailable, $amount, 10) < 0) throw new Exception('可用余额不足', HttpStatus::CUSTOM_ERROR);
  53. $afterAvailable = bcsub($beforeAvailable, $amount, 10);
  54. $afterFrozen = bcadd($beforeFrozen, $amount, 10);
  55. } else {
  56. $limit = self::manualFrozen($memberId);
  57. if (bccomp($beforeFrozen, $limit, 10) < 0) $limit = $beforeFrozen;
  58. if (bccomp($limit, $amount, 10) < 0) throw new Exception('可解冻金额不足,最多可解冻:' . bcadd($limit, '0', 2), HttpStatus::CUSTOM_ERROR);
  59. $afterAvailable = bcadd($beforeAvailable, $amount, 10);
  60. $afterFrozen = bcsub($beforeFrozen, $amount, 10);
  61. }
  62. $wallet->available_balance = $afterAvailable;
  63. $wallet->frozen_balance = $afterFrozen;
  64. if (!$wallet->save()) throw new Exception('钱包更新失败', HttpStatus::CUSTOM_ERROR);
  65. $record = Record::create([
  66. 'user_id' => $user->id, 'member_id' => $memberId, 'type' => $type, 'amount' => $amount,
  67. 'wager_amount' => $wagerAmount, 'reason' => mb_substr($reason, 0, 255),
  68. 'status' => $isFreeze ? Record::STATUS_FROZEN : Record::STATUS_DONE, 'request_id' => $requestId,
  69. 'admin_id' => $adminId, 'before_available' => $beforeAvailable, 'after_available' => $afterAvailable,
  70. 'before_frozen' => $beforeFrozen, 'after_frozen' => $afterFrozen,
  71. ]);
  72. BalanceLogService::addLog($memberId, $isFreeze ? bcsub('0', $amount, 10) : $amount, $beforeAvailable, $afterAvailable,
  73. $isFreeze ? '人工冻结' : '人工解冻', $record->id, $reason);
  74. WageringService::addRequirement($wallet, $isFreeze ? bcsub('0', $wagerAmount, 10) : $wagerAmount,
  75. $isFreeze ? 'freeze' : 'unfreeze', $record->id, $reason, $adminId);
  76. return $record;
  77. }, 3);
  78. } catch (QueryException $e) {
  79. // 同一 request_id 并发提交:唯一索引冲突后返回已成功的那一笔
  80. if ($existing = $replay()) return $existing;
  81. throw $e;
  82. }
  83. return self::format($record);
  84. }
  85. private static function replay(string $requestId, string $type, string $memberId, string $amount, string $wagerAmount): ?array
  86. {
  87. $existing = Record::where('request_id', $requestId)->first();
  88. if (!$existing) return null;
  89. if ($existing->type !== $type || (string)$existing->member_id !== $memberId
  90. || bccomp((string)$existing->amount, $amount, 10) !== 0 || bccomp((string)$existing->wager_amount, $wagerAmount, 10) !== 0) {
  91. throw new Exception('request_id 已被其他冻结操作使用', HttpStatus::CUSTOM_ERROR);
  92. }
  93. return self::format($existing);
  94. }
  95. /** 冻结记录列表(后台)。 */
  96. public static function list(array $params): array
  97. {
  98. $page = max(1, (int)($params['page'] ?? 1));
  99. $limit = min(200, max(1, (int)($params['limit'] ?? 20)));
  100. $query = Record::query()->with('member');
  101. if (!empty($params['member_id'])) $query->where('member_id', (string)$params['member_id']);
  102. if (!empty($params['account'])) {
  103. $query->whereIn('member_id', User::select('member_id')->where('account', 'like', '%' . trim((string)$params['account']) . '%'));
  104. }
  105. if (!empty($params['type'])) $query->where('type', (string)$params['type']);
  106. if (!empty($params['status'])) $query->where('status', (string)$params['status']);
  107. if (!empty($params['start_date']) && !empty($params['end_date'])) {
  108. $query->whereBetween('created_at', [
  109. Carbon::parse($params['start_date'], 'Asia/Shanghai')->startOfDay(),
  110. Carbon::parse($params['end_date'], 'Asia/Shanghai')->endOfDay(),
  111. ]);
  112. }
  113. $total = (clone $query)->count('id');
  114. $rows = $query->orderByDesc('id')->forPage($page, $limit)->get();
  115. $admins = DB::table('admin')->whereIn('id', $rows->pluck('admin_id')->filter()->unique()->all() ?: [0])->pluck('username', 'id');
  116. return [
  117. 'total' => $total,
  118. 'data' => $rows->map(fn (Record $row) => self::format($row, $admins->all()))->all(),
  119. 'options' => [
  120. 'types' => self::options(Record::TYPE_LABELS),
  121. 'statuses' => self::options(Record::STATUS_LABELS),
  122. ],
  123. ];
  124. }
  125. public static function format(Record $row, array $admins = []): array
  126. {
  127. $member = $row->relationLoaded('member') ? $row->member : null;
  128. return [
  129. 'id' => (int)$row->id,
  130. 'member_id' => (string)$row->member_id,
  131. 'account' => (string)($member->account ?? ''),
  132. 'username' => (string)($member->username ?? ''),
  133. 'first_name' => (string)($member->first_name ?? ''),
  134. 'type' => (string)$row->type,
  135. 'type_text' => Record::TYPE_LABELS[$row->type] ?? $row->type,
  136. 'amount' => bcadd((string)$row->amount, '0', 2),
  137. 'wager_amount' => bcadd((string)$row->wager_amount, '0', 2),
  138. 'reason' => (string)$row->reason,
  139. 'status' => (string)$row->status,
  140. 'status_text' => Record::STATUS_LABELS[$row->status] ?? $row->status,
  141. 'related_type' => $row->related_type,
  142. 'related_id' => $row->related_id === null ? null : (int)$row->related_id,
  143. 'admin_id' => $row->admin_id === null ? null : (int)$row->admin_id,
  144. 'admin_name' => $row->admin_id === null ? '' : (string)($admins[$row->admin_id] ?? ''),
  145. 'before_available' => bcadd((string)$row->before_available, '0', 2),
  146. 'after_available' => bcadd((string)$row->after_available, '0', 2),
  147. 'before_frozen' => bcadd((string)$row->before_frozen, '0', 2),
  148. 'after_frozen' => bcadd((string)$row->after_frozen, '0', 2),
  149. 'finished_at' => $row->finished_at ? (string)$row->finished_at : null,
  150. 'created_at' => (string)$row->created_at,
  151. ];
  152. }
  153. private static function options(array $labels): array
  154. {
  155. $result = [];
  156. foreach ($labels as $value => $label) $result[] = ['label' => $label, 'value' => $value];
  157. return $result;
  158. }
  159. }