FirstDepositChoice.php 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136
  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. // 已经参加、这次没带凭据(比如充值页沿用之前的选择):保持原选择,不要求重新确认
  47. if ($joined && ($offer === null || $offer === '')) {
  48. $current = DB::table(self::TABLE)->where('user_id', $userId)->lockForUpdate()->first(['joined', 'activity_id']);
  49. if ($current && (int) $current->joined === 1 && $current->activity_id) return true;
  50. }
  51. $activityId = null;
  52. $snapshot = null;
  53. if ($joined) {
  54. [$activityId, $rules, $platform] = self::confirmedRules($member, $source, $offer);
  55. // platform:会员在哪个端确认的,到账按档位发奖时只在这个端发布的首充活动里选
  56. $snapshot = json_encode($rules + ['platform' => $platform], JSON_THROW_ON_ERROR);
  57. }
  58. $now = PromotionFacts::now();
  59. DB::table(self::TABLE)->upsert([
  60. 'user_id' => $userId, 'member_id' => $memberId, 'joined' => (int) $joined, 'source' => $source,
  61. 'activity_id' => $activityId, 'rule_snapshot' => $snapshot, 'created_at' => $now, 'updated_at' => $now,
  62. ], ['user_id'], ['joined', 'source', 'activity_id', 'rule_snapshot', 'updated_at']);
  63. return true;
  64. }, 3);
  65. }
  66. private static function confirmedRules(User $member, string $source, $offer): array
  67. {
  68. if (!is_string($offer) || !preg_match('/^([1-9]\d{0,18})\.(PC|H5|APP|TG)\.[a-f0-9]{32}$/D', $offer, $parts)) {
  69. self::invalid('请刷新首充活动并确认参加');
  70. }
  71. $id = (int) $parts[1];
  72. $platform = $parts[2];
  73. $activity = PromotionActivity::where('type', PromotionRules::FIRST_DEPOSIT)->whereKey($id)->lockForUpdate()->first();
  74. if ((int) $member->from === 2 || ($source === 'telegram') !== ($platform === 'TG') || !$activity || (int) $activity->status !== 1
  75. || !ActivityPeriod::contains($activity->start_time, $activity->end_time, now()->timestamp)
  76. || !ActivityEligibilityService::isPublishedOn($activity, $platform)
  77. || !ActivityEligibilityService::evaluate($activity, ActivityEligibilityService::memberContext($member->member_id))['is_eligible']) {
  78. self::invalid('首充活动已变更,请刷新后重新确认');
  79. }
  80. $rules = PromotionRules::withForbidden(PromotionRules::normalize(PromotionRules::FIRST_DEPOSIT, json_decode($activity->params, true) ?: []));
  81. if (!PromotionRules::hasPlayableCategory($rules, $platform === 'TG' ? 'self:lottery' : null)
  82. || !hash_equals(self::offer($id, $platform, $rules, $activity->start_time, $activity->end_time), $offer)) {
  83. self::invalid('首充活动已变更,请刷新后重新确认');
  84. }
  85. return [$id, $rules, $platform];
  86. }
  87. public static function ready(): bool
  88. {
  89. self::$readyConnections ??= new \WeakMap();
  90. $connection = DB::connection();
  91. $pdo = $connection->getPdo();
  92. if (isset(self::$readyConnections[$pdo])) return true;
  93. // Both cutovers must exist before the opt-in rules take effect.
  94. if (!$connection->getSchemaBuilder()->hasTable(self::CUTOVERS)
  95. || $connection->table(self::CUTOVERS)->useWritePdo()->count() !== 2) return false;
  96. return self::$readyConnections[$pdo] = true;
  97. }
  98. public static function selection(int $userId, bool $lock = false): ?object
  99. {
  100. if (!self::ready()) return null;
  101. $query = DB::table(self::TABLE)->where('user_id', $userId);
  102. return ($lock ? $query->lockForUpdate() : $query)->first();
  103. }
  104. public static function legacyOrder(?string $source, int $orderId): bool
  105. {
  106. if (!in_array($source, ['payment_orders', 'recharges'], true) || $orderId <= 0) return false;
  107. return $orderId <= (int) DB::table(self::CUTOVERS)->where('source', $source)->value('last_order_id');
  108. }
  109. public static function deposited(int $userId, string $memberId, bool $lock = false): bool
  110. {
  111. $first = DB::table('promotion_first_deposits')->where('user_id', $userId);
  112. if (($lock ? $first->lockForUpdate() : $first)->first(['user_id'])) return true;
  113. $logs = DB::table('balance_logs')->where('member_id', $memberId)->where('type', 1)
  114. ->where('amount', '>', 0)->whereIn('change_type', self::DEPOSIT_TYPES);
  115. return (bool) ($lock ? $logs->lockForUpdate() : $logs)->first(['id']);
  116. }
  117. private static function invalid(string $message): never
  118. {
  119. throw ValidationException::withMessages(['join_first_deposit' => $message]);
  120. }
  121. }