FirstDepositChoice.php 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129
  1. <?php
  2. namespace App\Support;
  3. use App\Models\PromotionActivity;
  4. use App\Models\User;
  5. use App\Models\Wallet;
  6. use App\Services\ActivityEligibilityService;
  7. use App\Services\PromotionFacts;
  8. use Illuminate\Support\Facades\DB;
  9. use Illuminate\Validation\ValidationException;
  10. /**
  11. * 与 melbet_sport-api 的 FirstDepositChoice 共用选择表及活动凭据。
  12. */
  13. final class FirstDepositChoice
  14. {
  15. public const TABLE = 'promotion_first_deposit_choices';
  16. public const DEPOSIT_TYPES = ['充值', '人工充值', '三方充值'];
  17. private const CUTOVERS = 'promotion_first_deposit_cutovers';
  18. private static ?\WeakMap $readyConnections = null;
  19. /** 解析 join_first_deposit:未传返回 null(不改动已有选择),0/1 之外报错。 */
  20. public static function parse($value): ?bool
  21. {
  22. if ($value === null || $value === '') return null;
  23. if (in_array($value, [1, '1', true, 'true'], true)) return true;
  24. if (in_array($value, [0, '0', false, 'false'], true)) return false;
  25. throw ValidationException::withMessages(['join_first_deposit' => 'join_first_deposit只能为0或1']);
  26. }
  27. public static function offer(int $activityId, string $platform, array $rules, ?int $start, ?int $end): string
  28. {
  29. $terms = [$activityId, $platform, $start, $end, $rules['min_deposit'], $rules['reward_amount'],
  30. $rules['turnover_ratio'], $rules['forbidden_categories']];
  31. return $activityId . '.' . $platform . '.' . substr(hash('sha256', json_encode($terms, JSON_THROW_ON_ERROR)), 0, 32);
  32. }
  33. /** 与到账共用钱包行锁,锁内读取历史并一次写入完整选择。 */
  34. public static function record(User $member, ?bool $joined, string $source, $offer = null): bool
  35. {
  36. if ($joined === null) return false;
  37. if (!self::ready()) self::invalid('首充活动暂不可用,请稍后重试');
  38. return DB::transaction(function () use ($member, $joined, $source, $offer) {
  39. $userId = (int) $member->id;
  40. $memberId = (string) $member->member_id;
  41. if (!Wallet::where('member_id', $memberId)->lockForUpdate()->first(['id'])) self::invalid('会员钱包不存在');
  42. if (self::deposited($userId, $memberId, true)) {
  43. if ($joined) self::invalid('已有充值记录,不能再选择首充活动');
  44. return false;
  45. }
  46. $activityId = null;
  47. $snapshot = null;
  48. if ($joined) {
  49. [$activityId, $rules] = self::confirmedRules($member, $source, $offer);
  50. $snapshot = json_encode($rules, JSON_THROW_ON_ERROR);
  51. }
  52. $now = PromotionFacts::now();
  53. DB::table(self::TABLE)->upsert([
  54. 'user_id' => $userId, 'member_id' => $memberId, 'joined' => (int) $joined, 'source' => $source,
  55. 'activity_id' => $activityId, 'rule_snapshot' => $snapshot, 'created_at' => $now, 'updated_at' => $now,
  56. ], ['user_id'], ['joined', 'source', 'activity_id', 'rule_snapshot', 'updated_at']);
  57. return true;
  58. }, 3);
  59. }
  60. private static function confirmedRules(User $member, string $source, $offer): array
  61. {
  62. if (!is_string($offer) || !preg_match('/^([1-9]\d{0,18})\.(PC|H5|APP|TG)\.[a-f0-9]{32}$/D', $offer, $parts)) {
  63. self::invalid('请刷新首充活动并确认参加');
  64. }
  65. $id = (int) $parts[1];
  66. $platform = $parts[2];
  67. $activity = PromotionActivity::where('type', PromotionRules::FIRST_DEPOSIT)->whereKey($id)->lockForUpdate()->first();
  68. if ((int) $member->from === 2 || ($source === 'telegram') !== ($platform === 'TG') || !$activity || (int) $activity->status !== 1
  69. || !ActivityPeriod::contains($activity->start_time, $activity->end_time, now()->timestamp)
  70. || !ActivityEligibilityService::isPublishedOn($activity, $platform)
  71. || !ActivityEligibilityService::evaluate($activity, ActivityEligibilityService::memberContext($member->member_id))['is_eligible']) {
  72. self::invalid('首充活动已变更,请刷新后重新确认');
  73. }
  74. $rules = PromotionRules::normalize(PromotionRules::FIRST_DEPOSIT, json_decode($activity->params, true) ?: []);
  75. if (!PromotionRules::hasPlayableCategory($rules, $platform === 'TG' ? 'self:lottery' : null)
  76. || !hash_equals(self::offer($id, $platform, $rules, $activity->start_time, $activity->end_time), $offer)) {
  77. self::invalid('首充活动已变更,请刷新后重新确认');
  78. }
  79. return [$id, $rules];
  80. }
  81. public static function ready(): bool
  82. {
  83. self::$readyConnections ??= new \WeakMap();
  84. $connection = DB::connection();
  85. $pdo = $connection->getPdo();
  86. if (isset(self::$readyConnections[$pdo])) return true;
  87. // Both cutovers must exist before the opt-in rules take effect.
  88. if (!$connection->getSchemaBuilder()->hasTable(self::CUTOVERS)
  89. || $connection->table(self::CUTOVERS)->useWritePdo()->count() !== 2) return false;
  90. return self::$readyConnections[$pdo] = true;
  91. }
  92. public static function selection(int $userId, bool $lock = false): ?object
  93. {
  94. if (!self::ready()) return null;
  95. $query = DB::table(self::TABLE)->where('user_id', $userId);
  96. return ($lock ? $query->lockForUpdate() : $query)->first();
  97. }
  98. public static function legacyOrder(?string $source, int $orderId): bool
  99. {
  100. if (!in_array($source, ['payment_orders', 'recharges'], true) || $orderId <= 0) return false;
  101. return $orderId <= (int) DB::table(self::CUTOVERS)->where('source', $source)->value('last_order_id');
  102. }
  103. public static function deposited(int $userId, string $memberId, bool $lock = false): bool
  104. {
  105. $first = DB::table('promotion_first_deposits')->where('user_id', $userId);
  106. if (($lock ? $first->lockForUpdate() : $first)->first(['user_id'])) return true;
  107. $logs = DB::table('balance_logs')->where('member_id', $memberId)->where('type', 1)
  108. ->where('amount', '>', 0)->whereIn('change_type', self::DEPOSIT_TYPES);
  109. return (bool) ($lock ? $logs->lockForUpdate() : $logs)->first(['id']);
  110. }
  111. private static function invalid(string $message): never
  112. {
  113. throw ValidationException::withMessages(['join_first_deposit' => $message]);
  114. }
  115. }