RechargeBonusService.php 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127
  1. <?php
  2. namespace App\Services;
  3. use App\Models\BalanceLog;
  4. use App\Models\User;
  5. use App\Models\Wallet;
  6. use App\Services\AdminRealtime\AdminRealtime;
  7. use Carbon\Carbon;
  8. use Illuminate\Support\Facades\DB;
  9. use Illuminate\Support\Facades\Schema;
  10. use RuntimeException;
  11. final class RechargeBonusService
  12. {
  13. public static function available(): bool { return Schema::hasTable('recharge_bonuses'); }
  14. public static function now(): string { return Carbon::now('Asia/Shanghai')->format('Y-m-d H:i:s'); }
  15. public static function percent($value): string { $n = bcadd((string)$value, '0', 2); return str_ends_with($n, '.00') ? substr($n, 0, -3) : $n; }
  16. public static function capture(string $channel, string $member): ?array
  17. {
  18. if (!self::available()) return null;
  19. $rule = DB::table('recharge_bonuses')->where('channel_type', $channel)->first();
  20. $user = User::where('member_id', $member)->first();
  21. return ['channel_type' => $channel, 'enabled' => (bool)($rule->enabled ?? false) && $user && (int)$user->from === 1,
  22. 'bonus_percent' => bcadd((string)($rule->bonus_percent ?? '0'), '0', 2), 'captured_at' => self::now(), 'version' => $rule->updated_at ?? null];
  23. }
  24. public static function decorate(array $channels, string $language, bool $eligible = true): array
  25. {
  26. $rules = self::available() ? DB::table('recharge_bonuses')->whereIn('channel_type', array_column($channels, 'value'))->get()->keyBy('channel_type') : collect();
  27. foreach ($channels as &$channel) {
  28. $rule = $rules[$channel['value']] ?? null; $enabled = $eligible && (bool)($rule->enabled ?? false);
  29. $texts = $rule ? json_decode($rule->translations, true) : [];
  30. $channel['bonus_enabled'] = $enabled;
  31. $channel['bonus_percent'] = $enabled ? self::percent($rule->bonus_percent) : '0';
  32. $channel['badge_text'] = $enabled ? (string)($texts[$language]['text'] ?? '') : '';
  33. }
  34. unset($channel); return $channels;
  35. }
  36. public static function chainKey(string $net, string $txid): string
  37. {
  38. return hash('sha256', 'chain:' . strtoupper(trim($net)) . ':' . strtolower(trim($txid)));
  39. }
  40. public static function registerChain(array $row): int
  41. {
  42. if (!self::available()) {
  43. $inserted = DB::table('recharges')->insertOrIgnore($row);
  44. if ($inserted && !empty($row['txid']) && !empty($row['net'])) {
  45. AdminRealtime::record('recharges', self::chainRows($row['net'], $row['txid'])->orderByDesc('id')->value('id'), 'created');
  46. }
  47. return $inserted;
  48. }
  49. if (empty($row['txid']) || empty($row['net'])) return 0;
  50. return DB::transaction(function () use ($row) {
  51. $key = self::chainKey($row['net'], $row['txid']); $now = self::now();
  52. DB::table('recharge_bonus_chain_receipts')->insertOrIgnore(['dedup_key' => $key, 'created_at' => $now, 'updated_at' => $now]);
  53. $receipt = DB::table('recharge_bonus_chain_receipts')->where('dedup_key', $key)->lockForUpdate()->first();
  54. if ($receipt->recharge_id !== null) return 0;
  55. $old = self::chainRows($row['net'], $row['txid'])->orderBy('id')->first();
  56. if ($old) {
  57. DB::table('recharge_bonus_chain_receipts')->where('dedup_key', $key)->update(['recharge_id' => $old->id]);
  58. return 0;
  59. }
  60. $snapshot = self::capture('usdt', (string)$row['member_id']);
  61. $row['recharge_bonus_snapshot'] = $snapshot === null ? null : json_encode($snapshot, JSON_THROW_ON_ERROR);
  62. $row['created_at'] = $row['updated_at'] = $now;
  63. $id = DB::table('recharges')->insertGetId($row);
  64. AdminRealtime::record('recharges', $id, 'created');
  65. DB::table('recharge_bonus_chain_receipts')->where('dedup_key', $key)->update(['recharge_id' => $id]);
  66. return 1;
  67. }, 3);
  68. }
  69. private static function chainRows(string $net, string $txid)
  70. {
  71. return DB::table('recharges')->whereRaw('UPPER(TRIM(net)) = ?', [strtoupper(trim($net))])->whereRaw('LOWER(TRIM(txid)) = ?', [strtolower(trim($txid))]);
  72. }
  73. public static function chainCreditAllowed(object $order): bool
  74. {
  75. if (!self::available() || (int)$order->type !== 1 || empty($order->txid)) return true;
  76. $key = self::chainKey((string)$order->net, (string)$order->txid); $now = self::now();
  77. DB::table('recharge_bonus_chain_receipts')->insertOrIgnore(['dedup_key' => $key, 'recharge_id' => $order->id, 'created_at' => $now, 'updated_at' => $now]);
  78. $receipt = DB::table('recharge_bonus_chain_receipts')->where('dedup_key', $key)->lockForUpdate()->first();
  79. return $receipt->credited_log_id === null && !self::chainRows((string)$order->net, (string)$order->txid)->where('status', 1)->where('id', '<>', $order->id)->exists();
  80. }
  81. public static function markChainCredited(object $order, BalanceLog $log): void
  82. {
  83. if (!self::available() || (int)$order->type !== 1 || empty($order->txid)) return;
  84. DB::table('recharge_bonus_chain_receipts')->where('dedup_key', self::chainKey((string)$order->net, (string)$order->txid))->update(['credited_log_id' => $log->id]);
  85. }
  86. /** Caller holds the source order and wallet locks in the principal-credit transaction. */
  87. public static function grant(string $source, object $order, BalanceLog $principal): void
  88. {
  89. $snapshot = $order->recharge_bonus_snapshot ?? null;
  90. if (is_string($snapshot)) $snapshot = json_decode($snapshot, true);
  91. if (!$snapshot || empty($snapshot['enabled']) || bccomp((string)$principal->amount, '0', 10) <= 0) return;
  92. if (!in_array($source, ['payment_order', 'recharge'], true) || (int)$principal->related_id !== (int)$order->id
  93. || !in_array($principal->change_type, ['充值', '三方充值'], true) || (string)$order->member_id !== (string)$principal->member_id) {
  94. throw new RuntimeException('充值赠送来源不匹配');
  95. }
  96. if (DB::connection()->transactionLevel() < 1) throw new RuntimeException('充值赠送必须与本金同事务处理');
  97. $user = User::where('member_id', $principal->member_id)->first();
  98. if (!$user || (int)$user->from !== 1 || (string)$order->member_id !== (string)$principal->member_id) return;
  99. $amount = bcdiv(bcmul((string)$principal->amount, (string)$snapshot['bonus_percent'], 12), '100', 2);
  100. $key = $source === 'recharge' && (int)($order->type ?? 0) === 1 && !empty($order->txid)
  101. ? self::chainKey((string)$order->net, (string)$order->txid) : hash('sha256', $source . ':' . $order->id);
  102. $now = self::now();
  103. $wallet = Wallet::where('member_id', $principal->member_id)->lockForUpdate()->first();
  104. if (!$wallet) throw new RuntimeException('充值赠送钱包不存在');
  105. $created = DB::table('recharge_bonus_rewards')->insertOrIgnore(['source_type' => $source, 'source_id' => $order->id,
  106. 'dedup_key' => $key, 'member_id' => $principal->member_id, 'channel_type' => $snapshot['channel_type'],
  107. 'principal_log_id' => $principal->id, 'principal_amount' => $principal->amount, 'bonus_percent' => $snapshot['bonus_percent'],
  108. 'amount' => $amount, 'rule_snapshot' => json_encode($snapshot, JSON_THROW_ON_ERROR), 'created_at' => $now, 'updated_at' => $now]);
  109. if (!$created || bccomp($amount, '0', 2) <= 0) return;
  110. $before = (string)$wallet->available_balance; $after = bcadd($before, $amount, 10);
  111. $wallet->available_balance = $after; $wallet->save();
  112. $reward = DB::table('recharge_bonus_rewards')->where('source_type', $source)->where('source_id', $order->id)->first();
  113. $log = BalanceLogService::addLog($principal->member_id, $amount, $before, $after, '优惠活动', $reward->id, '充值渠道赠送');
  114. DB::table('recharge_bonus_rewards')->where('id', $reward->id)->update(['credited_log_id' => $log->id]);
  115. }
  116. }