'join_first_deposit只能为0或1']); } public static function offer(int $activityId, string $platform, array $rules, ?int $start, ?int $end): string { $terms = [$activityId, $platform, $start, $end, $rules['min_deposit'], $rules['reward_amount'], $rules['turnover_ratio'], $rules['forbidden_categories']]; return $activityId . '.' . $platform . '.' . substr(hash('sha256', json_encode($terms, JSON_THROW_ON_ERROR)), 0, 32); } /** 与到账共用钱包行锁,锁内读取历史并一次写入完整选择。 */ public static function record(User $member, ?bool $joined, string $source, $offer = null): bool { if ($joined === null) return false; if (!self::ready()) self::invalid('首充活动暂不可用,请稍后重试'); return DB::transaction(function () use ($member, $joined, $source, $offer) { $userId = (int) $member->id; $memberId = (string) $member->member_id; if (!Wallet::where('member_id', $memberId)->lockForUpdate()->first(['id'])) self::invalid('会员钱包不存在'); if (self::deposited($userId, $memberId, true)) { if ($joined) self::invalid('已有充值记录,不能再选择首充活动'); return false; } // 已经参加、这次没带凭据(比如充值页沿用之前的选择):保持原选择,不要求重新确认 if ($joined && ($offer === null || $offer === '')) { $current = DB::table(self::TABLE)->where('user_id', $userId)->lockForUpdate()->first(['joined', 'activity_id']); if ($current && (int) $current->joined === 1 && $current->activity_id) return true; } $activityId = null; $snapshot = null; if ($joined) { [$activityId, $rules, $platform] = self::confirmedRules($member, $source, $offer); // platform:会员在哪个端确认的,到账按档位发奖时只在这个端发布的首充活动里选 $snapshot = json_encode($rules + ['platform' => $platform], JSON_THROW_ON_ERROR); } $now = PromotionFacts::now(); DB::table(self::TABLE)->upsert([ 'user_id' => $userId, 'member_id' => $memberId, 'joined' => (int) $joined, 'source' => $source, 'activity_id' => $activityId, 'rule_snapshot' => $snapshot, 'created_at' => $now, 'updated_at' => $now, ], ['user_id'], ['joined', 'source', 'activity_id', 'rule_snapshot', 'updated_at']); return true; }, 3); } private static function confirmedRules(User $member, string $source, $offer): array { if (!is_string($offer) || !preg_match('/^([1-9]\d{0,18})\.(PC|H5|APP|TG)\.[a-f0-9]{32}$/D', $offer, $parts)) { self::invalid('请刷新首充活动并确认参加'); } $id = (int) $parts[1]; $platform = $parts[2]; $activity = PromotionActivity::where('type', PromotionRules::FIRST_DEPOSIT)->whereKey($id)->lockForUpdate()->first(); if ((int) $member->from === 2 || ($source === 'telegram') !== ($platform === 'TG') || !$activity || (int) $activity->status !== 1 || !ActivityPeriod::contains($activity->start_time, $activity->end_time, now()->timestamp) || !ActivityEligibilityService::isPublishedOn($activity, $platform) || !ActivityEligibilityService::evaluate($activity, ActivityEligibilityService::memberContext($member->member_id))['is_eligible']) { self::invalid('首充活动已变更,请刷新后重新确认'); } $rules = PromotionRules::withForbidden(PromotionRules::normalize(PromotionRules::FIRST_DEPOSIT, json_decode($activity->params, true) ?: [])); if (!PromotionRules::hasPlayableCategory($rules, $platform === 'TG' ? 'self:lottery' : null) || !hash_equals(self::offer($id, $platform, $rules, $activity->start_time, $activity->end_time), $offer)) { self::invalid('首充活动已变更,请刷新后重新确认'); } return [$id, $rules, $platform]; } public static function ready(): bool { self::$readyConnections ??= new \WeakMap(); $connection = DB::connection(); $pdo = $connection->getPdo(); if (isset(self::$readyConnections[$pdo])) return true; // Both cutovers must exist before the opt-in rules take effect. if (!$connection->getSchemaBuilder()->hasTable(self::CUTOVERS) || $connection->table(self::CUTOVERS)->useWritePdo()->count() !== 2) return false; return self::$readyConnections[$pdo] = true; } public static function selection(int $userId, bool $lock = false): ?object { if (!self::ready()) return null; $query = DB::table(self::TABLE)->where('user_id', $userId); return ($lock ? $query->lockForUpdate() : $query)->first(); } public static function legacyOrder(?string $source, int $orderId): bool { if (!in_array($source, ['payment_orders', 'recharges'], true) || $orderId <= 0) return false; return $orderId <= (int) DB::table(self::CUTOVERS)->where('source', $source)->value('last_order_id'); } public static function deposited(int $userId, string $memberId, bool $lock = false): bool { $first = DB::table('promotion_first_deposits')->where('user_id', $userId); if (($lock ? $first->lockForUpdate() : $first)->first(['user_id'])) return true; $logs = DB::table('balance_logs')->where('member_id', $memberId)->where('type', 1) ->where('amount', '>', 0)->whereIn('change_type', self::DEPOSIT_TYPES); return (bool) ($lock ? $logs->lockForUpdate() : $logs)->first(['id']); } private static function invalid(string $message): never { throw ValidationException::withMessages(['join_first_deposit' => $message]); } }