RechargeBonusService.php 7.5 KB

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