|
|
@@ -0,0 +1,481 @@
|
|
|
+<?php
|
|
|
+
|
|
|
+namespace App\Services;
|
|
|
+
|
|
|
+use App\Models\PromotionActivity;
|
|
|
+use App\Models\BalanceLog;
|
|
|
+use App\Models\User;
|
|
|
+use App\Models\Wallet;
|
|
|
+use App\Support\PromotionRules as Rules;
|
|
|
+use App\Support\PromotionTranslations;
|
|
|
+use Illuminate\Support\Facades\DB;
|
|
|
+use Illuminate\Support\Facades\Schema;
|
|
|
+use Illuminate\Validation\ValidationException;
|
|
|
+
|
|
|
+/** Backend jobs and deposit awards. Member HTTP APIs are owned by melbet_sport-api. */
|
|
|
+class PromotionService
|
|
|
+{
|
|
|
+ private PromotionFacts $facts;
|
|
|
+ public function __construct(?PromotionFacts $facts = null) { $this->facts = $facts ?? new PromotionFacts(); }
|
|
|
+
|
|
|
+ public static function publish(PromotionActivity $activity): void
|
|
|
+ {
|
|
|
+ if (!Rules::supports((int)$activity->type)) return;
|
|
|
+ $now = PromotionFacts::now();
|
|
|
+ DB::table('promotion_campaigns')->insertOrIgnore(['activity_id' => $activity->id, 'first_published_at' => null, 'created_at' => $now, 'updated_at' => $now]);
|
|
|
+ if ((int)$activity->status === 1) DB::table('promotion_campaigns')->where('activity_id', $activity->id)->whereNull('first_published_at')
|
|
|
+ ->update(['first_published_at' => $now, 'updated_at' => $now]);
|
|
|
+ }
|
|
|
+
|
|
|
+ private function invalid(string $message): void { throw ValidationException::withMessages(['promotion' => $message]); }
|
|
|
+ private function json($value): array { return is_array($value) ? $value : (json_decode((string)$value, true) ?: []); }
|
|
|
+ private function rules(PromotionActivity $activity): array { return Rules::normalize((int)$activity->type, $this->json($activity->params)); }
|
|
|
+ private function money(int $cents): string { return bcdiv((string)$cents, '100', 2); }
|
|
|
+
|
|
|
+ public function heartbeat(int $userId, string $token, string $platform): array
|
|
|
+ {
|
|
|
+ $this->member($userId);
|
|
|
+ $now = PromotionFacts::now(); $bucket = substr($now, 0, 16) . ':00';
|
|
|
+ DB::table('promotion_presence')->insertOrIgnore(['user_id' => $userId, 'session_hash' => hash('sha256', $token . ':' . $platform), 'observed_at' => $bucket]);
|
|
|
+ return ['recorded' => true, 'server_time' => $now, 'interval_seconds' => 60];
|
|
|
+ }
|
|
|
+
|
|
|
+ public function listing(int $userId, string $platform, string $lang = 'zh', ?int $type = null): array
|
|
|
+ {
|
|
|
+ $member = $this->member($userId); $context = ActivityEligibilityService::memberContext($member->member_id);
|
|
|
+ $q = PromotionActivity::where('status', 1)->where('start_time', '<=', now()->timestamp)->where('end_time', '>=', now()->timestamp);
|
|
|
+ if ($type !== null) $q->where('type', $type);
|
|
|
+ $data = [];
|
|
|
+ foreach ($q->orderByDesc('sort')->orderByDesc('id')->get() as $activity) {
|
|
|
+ $text = PromotionTranslations::select($activity->toArray(), $lang);
|
|
|
+ if (!$text) continue;
|
|
|
+ if (!ActivityEligibilityService::isPublishedOn($activity, $platform)) continue;
|
|
|
+ $eligibility = ActivityEligibilityService::evaluate($activity, $context);
|
|
|
+ if (!$eligibility['is_eligible'] && !$activity->show_ineligible) continue;
|
|
|
+ $item = ['activity_id' => $activity->id, 'type' => (int)$activity->type, 'type_name' => $activity->type_name,
|
|
|
+ 'start_time' => (int)$activity->start_time, 'end_time' => (int)$activity->end_time, 'rules' => $this->rules($activity)] + $text + $eligibility;
|
|
|
+ if ((int) $activity->type === Rules::FIRST_DEPOSIT) $item['image'] = $activity->image;
|
|
|
+ $data[] = $item;
|
|
|
+ }
|
|
|
+ return ['total' => count($data), 'data' => $data];
|
|
|
+ }
|
|
|
+
|
|
|
+ private function activity(int $id, bool $lock = false): PromotionActivity
|
|
|
+ {
|
|
|
+ $query = PromotionActivity::where('id', $id);
|
|
|
+ $row = ($lock ? $query->lockForUpdate() : $query)->first();
|
|
|
+ if (!$row || !Rules::supports((int)$row->type)) $this->invalid('活动不存在');
|
|
|
+ return $row;
|
|
|
+ }
|
|
|
+
|
|
|
+ private function member(int $id): User
|
|
|
+ {
|
|
|
+ $member = User::find($id);
|
|
|
+ if (!$member || (int)$member->from === 2 || !empty($member->getAttributes()['delete_time']) || (int)($member->getAttributes()['is_banned'] ?? 0) === 1) $this->invalid('会员不可参与活动');
|
|
|
+ return $member;
|
|
|
+ }
|
|
|
+
|
|
|
+ private function open(PromotionActivity $activity, User $member, ?string $platform = null): string
|
|
|
+ {
|
|
|
+ $now = now('Asia/Shanghai')->timestamp;
|
|
|
+ if ((int)$activity->status !== 1 || (int)$activity->getRawOriginal('start_time') > $now || (int)$activity->getRawOriginal('end_time') < $now) $this->invalid('活动未上架或不在活动时间内');
|
|
|
+ $eligibility = ActivityEligibilityService::evaluate($activity, ActivityEligibilityService::memberContext($member->member_id));
|
|
|
+ if (!$eligibility['is_eligible']) $this->invalid($eligibility['ineligible_reason']);
|
|
|
+ if ($platform !== null && !ActivityEligibilityService::isPublishedOn($activity, $platform)) $this->invalid('此客户端不可参与该活动');
|
|
|
+ $published = DB::table('promotion_campaigns')->where('activity_id', $activity->id)->value('first_published_at');
|
|
|
+ if (!$published) $this->invalid('活动尚未正式开启');
|
|
|
+ return $published;
|
|
|
+ }
|
|
|
+
|
|
|
+ private function mutate(int $activityId, int $userId, callable $callback): array
|
|
|
+ {
|
|
|
+ return DB::transaction(function () use ($activityId, $userId, $callback) {
|
|
|
+ $member = $this->member($userId);
|
|
|
+ $wallet = Wallet::where('member_id', $member->member_id)->lockForUpdate()->first();
|
|
|
+ if (!$wallet) $this->invalid('会员钱包不存在');
|
|
|
+ $activity = $this->activity($activityId, true);
|
|
|
+ return $callback($activity, $member, $wallet);
|
|
|
+ }, 3);
|
|
|
+ }
|
|
|
+
|
|
|
+ private function account(PromotionActivity $activity, User $member): object
|
|
|
+ {
|
|
|
+ $now = PromotionFacts::now();
|
|
|
+ DB::table('promotion_accounts')->insertOrIgnore(['activity_id' => $activity->id, 'user_id' => $member->id,
|
|
|
+ 'round_no' => 1, 'spins' => 0, 'created_at' => $now, 'updated_at' => $now]);
|
|
|
+ return DB::table('promotion_accounts')->where('activity_id', $activity->id)->where('user_id', $member->id)->lockForUpdate()->first();
|
|
|
+ }
|
|
|
+
|
|
|
+ private function newRound(object $account, string $start, array $completed = [], bool $reset = false): object
|
|
|
+ {
|
|
|
+ $now = PromotionFacts::now();
|
|
|
+ DB::table('promotion_rounds')->insert(['account_id' => $account->id, 'round_no' => $account->round_no,
|
|
|
+ 'coins_cents' => 0, 'status' => 'active', 'started_at' => $start,
|
|
|
+ 'deposit_cursor' => $reset ? (int)DB::table('balance_logs')->max('id') : 0,
|
|
|
+ 'bet_cursors' => json_encode($reset ? $this->facts->cursors() : []), 'completed_tasks' => json_encode($completed), 'created_at' => $now, 'updated_at' => $now]);
|
|
|
+ return $this->round($account);
|
|
|
+ }
|
|
|
+
|
|
|
+ private function round(object $account): ?object
|
|
|
+ {
|
|
|
+ return DB::table('promotion_rounds')->where('account_id', $account->id)->where('round_no', $account->round_no)->lockForUpdate()->first();
|
|
|
+ }
|
|
|
+
|
|
|
+ public function detail(int $activityId, int $userId, ?string $platform = null, ?string $lang = null): array
|
|
|
+ {
|
|
|
+ $activity = $this->activity($activityId); $member = $this->member($userId);
|
|
|
+ $this->open($activity, $member, $platform);
|
|
|
+ $account = DB::table('promotion_accounts')->where('activity_id', $activityId)->where('user_id', $userId)->first();
|
|
|
+ $round = $account ? DB::table('promotion_rounds')->where('account_id', $account->id)->where('round_no', $account->round_no)->first() : null;
|
|
|
+ return $this->state($activity, $member, $account, $round, $lang);
|
|
|
+ }
|
|
|
+
|
|
|
+ public function progress(int $activityId, int $userId, ?string $platform = null, ?string $lang = null): array
|
|
|
+ {
|
|
|
+ return $this->mutate($activityId, $userId, function ($activity, $member) use ($platform, $lang) {
|
|
|
+ $start = $this->open($activity, $member, $platform); $account = $this->account($activity, $member); $round = null;
|
|
|
+ if ((int)$activity->type === Rules::WHEEL) {
|
|
|
+ $round = $this->round($account) ?? $this->newRound($account, $start);
|
|
|
+ if ($round->status === 'active') $this->refreshWheel($activity, $member, $account, $round);
|
|
|
+ }
|
|
|
+ return $this->state($activity, $member, $account, $round, $lang);
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ public function apply(int $activityId, int $userId, ?string $platform = null, ?string $lang = null): array
|
|
|
+ {
|
|
|
+ return $this->mutate($activityId, $userId, function ($activity, $member) use ($platform, $lang) {
|
|
|
+ $start = $this->open($activity, $member, $platform);
|
|
|
+ if ((int)$activity->type !== Rules::LOSS) $this->invalid('只有净亏邀请活动需要申请');
|
|
|
+ $account = $this->account($activity, $member);
|
|
|
+ if (!$account->applied_at) {
|
|
|
+ $rules = $this->rules($activity); $policy = $this->facts->policy();
|
|
|
+ $profit = $this->facts->betting([$member->id], $start)['profit'];
|
|
|
+ $loss = bccomp($profit, '0', 10) < 0 ? bcsub('0', $profit, 10) : '0';
|
|
|
+ if (bccomp($loss, $rules['loss_amount'], 10) < 0) $this->invalid('尚未达到净亏申请门槛');
|
|
|
+ $account->applied_at = PromotionFacts::now();
|
|
|
+ $account->application_snapshot = json_encode(['loss' => $loss, 'from' => $start, 'policy' => $policy, 'rules' => $rules,
|
|
|
+ 'invite_user_cursor' => (int)DB::table('users')->max('id')]);
|
|
|
+ DB::table('promotion_accounts')->where('id', $account->id)->update(['applied_at' => $account->applied_at,
|
|
|
+ 'application_snapshot' => $account->application_snapshot, 'updated_at' => PromotionFacts::now()]);
|
|
|
+ }
|
|
|
+ return $this->state($activity, $member, $account, null, $lang);
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ private function lossInvitees(User $member, object $account): array
|
|
|
+ {
|
|
|
+ if (!$account->applied_at) return [];
|
|
|
+ $policy = $this->facts->policy(); $qualified = []; $snapshot = $this->json($account->application_snapshot);
|
|
|
+ foreach ($this->facts->children($member->id) as $child) {
|
|
|
+ if ((int)$child['id'] > ($snapshot['invite_user_cursor'] ?? 0) && $child['created_at'] >= $account->applied_at && $child['bound_at'] >= $account->applied_at && $this->facts->qualified($child, $policy)) $qualified[] = (int)$child['id'];
|
|
|
+ }
|
|
|
+ return $qualified;
|
|
|
+ }
|
|
|
+
|
|
|
+ private function grantTask(object $account, object $round, string $task, string $event, array $evidence): void
|
|
|
+ {
|
|
|
+ $inserted = DB::table('promotion_task_grants')->insertOrIgnore(['account_id' => $account->id, 'round_no' => $round->round_no,
|
|
|
+ 'task' => $task, 'event_key' => $event, 'evidence' => json_encode($evidence), 'created_at' => PromotionFacts::now(), 'updated_at' => PromotionFacts::now()]);
|
|
|
+ if ($inserted) { ++$account->spins; DB::table('promotion_accounts')->where('id', $account->id)->update(['spins' => $account->spins, 'updated_at' => PromotionFacts::now()]); }
|
|
|
+ }
|
|
|
+
|
|
|
+ private function refreshWheel(PromotionActivity $activity, User $member, object $account, object $round): void
|
|
|
+ {
|
|
|
+ $rules = $this->rules($activity); $policy = $this->facts->policy();
|
|
|
+ $completed = $this->json($round->completed_tasks);
|
|
|
+ $campaignStart = DB::table('promotion_campaigns')->where('activity_id', $activity->id)->value('first_published_at');
|
|
|
+ // Daily tasks are campaign-wide. They survive round reset, but never grant twice on one day.
|
|
|
+ foreach ($this->facts->logins($member->member_id, $campaignStart) as $day => $_) {
|
|
|
+ $this->grantTask($account, $round, 'login', $day, ['day' => $day]); $completed['login'] = true;
|
|
|
+ }
|
|
|
+ foreach ($this->facts->online($member->id, $campaignStart) as $day => $seconds) if ($seconds >= $rules['online_seconds']) {
|
|
|
+ $this->grantTask($account, $round, 'online', $day, ['day' => $day, 'seconds' => $seconds]); $completed['online'] = true;
|
|
|
+ }
|
|
|
+ $children = $this->facts->children($member->id);
|
|
|
+ foreach ($children as $child) {
|
|
|
+ if ($this->facts->qualified($child, $policy) && !$this->facts->qualified($child, $policy, $round->started_at)) {
|
|
|
+ $this->grantTask($account, $round, 'invite', (string)$child['id'], ['user_id' => $child['id'], 'policy' => $policy]);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ // Only a grant belonging to this round can satisfy its resettable invitation task.
|
|
|
+ $completed['invite'] = false; $childrenById = array_column($children, null, 'id');
|
|
|
+ foreach (DB::table('promotion_task_grants')->where('account_id', $account->id)->where('round_no', $round->round_no)->where('task', 'invite')->get() as $grant) {
|
|
|
+ $evidence = $this->json($grant->evidence); $child = $childrenById[$evidence['user_id']] ?? null;
|
|
|
+ if ($child && $this->facts->qualified($child, $evidence['policy'])) $completed['invite'] = true;
|
|
|
+ }
|
|
|
+ $cursors = $this->json($round->bet_cursors);
|
|
|
+ $teamDeposits = $teamBets = [];
|
|
|
+ foreach ($children as $child) {
|
|
|
+ $from = max($round->started_at, $child['bound_at']);
|
|
|
+ foreach ($this->facts->deposits([$child['member_id']], $from, null, $round->deposit_cursor) as $day => $amount) $teamDeposits[$day] = bcadd($teamDeposits[$day] ?? '0', $amount, 10);
|
|
|
+ foreach ($this->facts->betting([$child['id']], $from, null, $cursors)['effective'] as $day => $amount) $teamBets[$day] = bcadd($teamBets[$day] ?? '0', $amount, 10);
|
|
|
+ }
|
|
|
+ $metrics = [
|
|
|
+ 'deposit' => $this->facts->deposits([$member->member_id], $round->started_at, null, $round->deposit_cursor),
|
|
|
+ 'team_deposit' => $teamDeposits,
|
|
|
+ 'bet' => $this->facts->betting([$member->id], $round->started_at, null, $cursors)['effective'],
|
|
|
+ 'team_bet' => $teamBets,
|
|
|
+ ];
|
|
|
+ foreach ($metrics as $task => $days) {
|
|
|
+ // Recompute qualification after corrections; daily amounts never carry across midnight.
|
|
|
+ $completed[$task] = false;
|
|
|
+ foreach ($days as $day => $amount) if (bccomp($amount, $rules[$task . '_amount'], 10) >= 0) {
|
|
|
+ $this->grantTask($account, $round, $task, $round->round_no . ':' . $day, ['day' => $day, 'amount' => $amount, 'target' => $rules[$task . '_amount']]); $completed[$task] = true;
|
|
|
+ }
|
|
|
+ foreach (DB::table('promotion_task_grants')->where('account_id', $account->id)->where('round_no', $round->round_no)->where('task', $task)->get() as $grant) {
|
|
|
+ $proof = $this->json($grant->evidence);
|
|
|
+ if (bccomp($days[$proof['day']] ?? '0', $proof['target'] ?? $rules[$task . '_amount'], 10) >= 0) $completed[$task] = true;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ $round->completed_tasks = json_encode($completed);
|
|
|
+ DB::table('promotion_rounds')->where('id', $round->id)->update(['completed_tasks' => $round->completed_tasks, 'updated_at' => PromotionFacts::now()]);
|
|
|
+ }
|
|
|
+
|
|
|
+ private function reserve(PromotionActivity $activity, User $member, object $account, int $roundNo, string $amount, array $rules): object
|
|
|
+ {
|
|
|
+ $this->capacity($activity, $member, $amount);
|
|
|
+ $received = DB::table('promotion_rewards')->where('activity_id', $activity->id)->where('user_id', $member->id)->exists();
|
|
|
+ DB::table('promotion_rewards')->insert(['activity_id' => $activity->id, 'user_id' => $member->id, 'account_id' => $account->id,
|
|
|
+ 'round_no' => $roundNo, 'amount' => $amount, 'required_flow' => bcmul($amount, $rules['turnover_ratio'], 2),
|
|
|
+ 'status' => 'reserved', 'rule_snapshot' => json_encode($rules), 'created_at' => PromotionFacts::now(), 'updated_at' => PromotionFacts::now()]);
|
|
|
+ if (!$received) ++$activity->rewarded_count;
|
|
|
+ $activity->budget_used = bcadd((string)$activity->budget_used, $amount, 2); $activity->save();
|
|
|
+ return DB::table('promotion_rewards')->where('account_id', $account->id)->where('round_no', $roundNo)->first();
|
|
|
+ }
|
|
|
+
|
|
|
+ private function capacity(PromotionActivity $activity, User $member, string $amount): void
|
|
|
+ {
|
|
|
+ $received = DB::table('promotion_rewards')->where('activity_id', $activity->id)->where('user_id', $member->id)->exists();
|
|
|
+ if (!$received && $activity->reward_quota !== null && (int)$activity->rewarded_count >= (int)$activity->reward_quota) $this->invalid('活动获奖份数已用完');
|
|
|
+ if ($activity->budget !== null && bccomp(bcadd((string)$activity->budget_used, $amount, 2), (string)$activity->budget, 2) > 0) $this->invalid('活动奖励额度不足');
|
|
|
+ }
|
|
|
+
|
|
|
+ public function spin(int $activityId, int $userId, string $requestKey, ?string $platform = null): array
|
|
|
+ {
|
|
|
+ return $this->mutate($activityId, $userId, function ($activity, $member) use ($requestKey, $platform) {
|
|
|
+ $account = $this->account($activity, $member);
|
|
|
+ $previous = DB::table('promotion_spins')->where('account_id', $account->id)->where('request_key', $requestKey)->first();
|
|
|
+ if ($previous) return ['spin' => $this->spinRow($previous), 'spins' => $account->spins, 'replayed' => true];
|
|
|
+ $start = $this->open($activity, $member, $platform);
|
|
|
+ if ((int)$activity->type !== Rules::WHEEL) $this->invalid('该活动不是转盘活动');
|
|
|
+ $round = $this->round($account) ?? $this->newRound($account, $start);
|
|
|
+ if ($round->status !== 'active') $this->invalid('本轮已达到666,请先领取');
|
|
|
+ $this->refreshWheel($activity, $member, $account, $round);
|
|
|
+ if ($account->spins < 1) $this->invalid('抽奖次数不足');
|
|
|
+ $rules = $this->rules($activity); $flags = $this->json($round->completed_tasks);
|
|
|
+ $complete = count(array_filter(array_intersect_key($flags, array_flip(Rules::TASKS)))) === count(Rules::TASKS);
|
|
|
+ // Capacity is checked before random sampling; a winning outcome is reserved immediately.
|
|
|
+ if ($complete && bccomp($rules['completion_probability'], '0', 2) > 0) $this->capacity($activity, $member, '666.00');
|
|
|
+ $draw = Rules::draw((int)$round->coins_cents, $complete, $rules['completion_probability']);
|
|
|
+ if ($draw['reached']) $this->reserve($activity, $member, $account, $round->round_no, '666.00', $rules);
|
|
|
+ $coins = $round->coins_cents + $draw['amount_cents']; $now = PromotionFacts::now();
|
|
|
+ $id = DB::table('promotion_spins')->insertGetId(['account_id' => $account->id, 'round_no' => $round->round_no, 'request_key' => $requestKey,
|
|
|
+ 'prize_index' => $draw['prize_index'], 'amount_cents' => $draw['amount_cents'], 'coins_cents' => $coins,
|
|
|
+ 'conditions_complete' => $complete, 'probability' => $complete ? $rules['completion_probability'] : '0.00', 'created_at' => $now, 'updated_at' => $now]);
|
|
|
+ DB::table('promotion_rounds')->where('id', $round->id)->update(['coins_cents' => $coins, 'status' => $draw['reached'] ? 'reached' : 'active', 'updated_at' => $now]);
|
|
|
+ --$account->spins;
|
|
|
+ DB::table('promotion_accounts')->where('id', $account->id)->update(['spins' => $account->spins, 'updated_at' => $now]);
|
|
|
+ return ['spin' => $this->spinRow(DB::table('promotion_spins')->where('id', $id)->first()), 'spins' => $account->spins, 'replayed' => false];
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ private function pay(object $reward, User $member, Wallet $wallet): object
|
|
|
+ {
|
|
|
+ if ($reward->status !== 'reserved') return $reward;
|
|
|
+ $frozen = bccomp((string)$reward->required_flow, '0', 2) > 0;
|
|
|
+ $field = $frozen ? 'frozen_balance' : 'available_balance';
|
|
|
+ $before = (string)$wallet->$field; $wallet->$field = bcadd($before, (string)$reward->amount, 10); $wallet->save();
|
|
|
+ $log = BalanceLog::create(['member_id' => $member->member_id, 'type' => $frozen ? 2 : 1, 'change_type' => '优惠活动',
|
|
|
+ 'amount' => $reward->amount, 'before_balance' => $before, 'after_balance' => $wallet->$field, 'related_id' => $reward->id,
|
|
|
+ // Frozen promotion rewards are intentionally unlocked by this service, never by the legacy 1x list.
|
|
|
+ 'frozen_status' => $frozen ? 1 : null, 'remark' => '新活动奖励; activity=' . $reward->activity_id . '; round=' . $reward->round_no]);
|
|
|
+ $now = PromotionFacts::now();
|
|
|
+ DB::table('promotion_rewards')->where('id', $reward->id)->update(['status' => $frozen ? 'locked' : 'paid', 'credited_log_id' => $log->id,
|
|
|
+ 'flow_snapshot' => json_encode(['from' => $now, 'cursors' => $this->facts->cursors()]), 'updated_at' => $now]);
|
|
|
+ return DB::table('promotion_rewards')->where('id', $reward->id)->first();
|
|
|
+ }
|
|
|
+
|
|
|
+ public function claim(int $activityId, int $userId, int $roundNo, ?string $platform = null): array
|
|
|
+ {
|
|
|
+ return $this->mutate($activityId, $userId, function ($activity, $member, $wallet) use ($roundNo, $platform) {
|
|
|
+ $account = $this->account($activity, $member);
|
|
|
+ $reward = DB::table('promotion_rewards')->where('account_id', $account->id)->where('round_no', $roundNo)->lockForUpdate()->first();
|
|
|
+ if ($reward && $reward->status !== 'reserved') return ['reward' => (array)$reward, 'round_no' => $account->round_no, 'spins' => $account->spins, 'replayed' => true];
|
|
|
+ if ((int)$activity->type === Rules::LOSS) {
|
|
|
+ $this->open($activity, $member, $platform);
|
|
|
+ if ($roundNo !== 1 || !$account->applied_at) $this->invalid('请先申请活动');
|
|
|
+ $snapshot = $this->json($account->application_snapshot); $rules = $snapshot['rules'];
|
|
|
+ $qualified = $this->lossInvitees($member, $account);
|
|
|
+ if (count($qualified) < $rules['invite_count']) $this->invalid('有效邀请人数不足');
|
|
|
+ $reward ??= $this->reserve($activity, $member, $account, 1, $rules['reward_amount'], $rules + ['qualified_invitees' => $qualified]);
|
|
|
+ } elseif ((int)$activity->type === Rules::WHEEL) {
|
|
|
+ // A reserved, earned reward remains payable after the campaign is unpublished.
|
|
|
+ $round = $this->round($account);
|
|
|
+ if (!$reward || !$round || $round->round_no !== $roundNo || $round->status !== 'reached') $this->invalid('本轮尚未达到666');
|
|
|
+ } else $this->invalid('首充奖励由充值成功后自动发放');
|
|
|
+ $reward = $this->pay($reward, $member, $wallet);
|
|
|
+ if ((int)$activity->type === Rules::WHEEL) {
|
|
|
+ $flags = array_intersect_key($this->json($round->completed_tasks), array_flip(Rules::DAILY));
|
|
|
+ DB::table('promotion_rounds')->where('id', $round->id)->update(['status' => 'claimed', 'claimed_at' => PromotionFacts::now(), 'updated_at' => PromotionFacts::now()]);
|
|
|
+ ++$account->round_no;
|
|
|
+ DB::table('promotion_accounts')->where('id', $account->id)->update(['round_no' => $account->round_no, 'updated_at' => PromotionFacts::now()]);
|
|
|
+ $this->newRound($account, PromotionFacts::now(), $flags, true);
|
|
|
+ }
|
|
|
+ return ['reward' => (array)$reward, 'round_no' => $account->round_no, 'spins' => $account->spins, 'replayed' => false];
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ public function onDeposit(BalanceLog $deposit): void
|
|
|
+ {
|
|
|
+ if (!DB::connection()->getSchemaBuilder()->hasTable('promotion_first_deposits') || (int)$deposit->type !== 1 || !in_array($deposit->change_type, ['充值', '三方充值'], true) || bccomp((string)$deposit->amount, '0', 10) <= 0) return;
|
|
|
+ $member = User::where('member_id', $deposit->member_id)->first();
|
|
|
+ if (!$member || (int)$member->from === 2) return;
|
|
|
+ DB::transaction(function () use ($member, $deposit) {
|
|
|
+ $wallet = Wallet::where('member_id', $member->member_id)->lockForUpdate()->first();
|
|
|
+ if (!$wallet) $this->invalid('会员钱包不存在');
|
|
|
+ $first = DB::table('balance_logs')->where('member_id', $member->member_id)->where('type', 1)->where('amount', '>', 0)
|
|
|
+ ->whereIn('change_type', ['充值', '三方充值'])->orderBy('created_at')->orderBy('id')->value('id');
|
|
|
+ if ((int)$first !== (int)$deposit->id) return;
|
|
|
+ $inserted = DB::table('promotion_first_deposits')->insertOrIgnore(['user_id' => $member->id, 'deposit_log_id' => $deposit->id,
|
|
|
+ 'created_at' => PromotionFacts::now(), 'updated_at' => PromotionFacts::now()]);
|
|
|
+ if (!$inserted) return;
|
|
|
+ $activities = PromotionActivity::where('type', Rules::FIRST_DEPOSIT)->where('status', 1)->where('start_time', '<=', now()->timestamp)
|
|
|
+ ->where('end_time', '>=', now()->timestamp)->orderByDesc('sort')->orderByDesc('id')->get();
|
|
|
+ foreach ($activities as $candidate) {
|
|
|
+ $activity = $this->activity($candidate->id, true);
|
|
|
+ if (!PromotionTranslations::select($activity->toArray(), $member->language ?: 'zh')) continue;
|
|
|
+ if ((int)$activity->status !== 1 || (int)$activity->type !== Rules::FIRST_DEPOSIT) continue;
|
|
|
+ $published = DB::table('promotion_campaigns')->where('activity_id', $activity->id)->value('first_published_at');
|
|
|
+ $creditedAt = (string)$deposit->getRawOriginal('created_at');
|
|
|
+ if (!$published || $creditedAt < $published || strtotime($creditedAt) < (int)$activity->start_time || strtotime($creditedAt) > (int)$activity->end_time) continue;
|
|
|
+ if (!ActivityEligibilityService::evaluate($activity, ActivityEligibilityService::memberContext($member->member_id))['is_eligible']) continue;
|
|
|
+ $rules = $this->rules($activity);
|
|
|
+ if (bccomp((string)$deposit->amount, $rules['min_deposit'], 10) < 0) continue;
|
|
|
+ try { $this->capacity($activity, $member, $rules['reward_amount']); } catch (ValidationException $e) { continue; }
|
|
|
+ $account = $this->account($activity, $member);
|
|
|
+ $reward = $this->reserve($activity, $member, $account, 1, $rules['reward_amount'], $rules + ['deposit_log_id' => $deposit->id]);
|
|
|
+ $this->pay($reward, $member, $wallet); break;
|
|
|
+ }
|
|
|
+ }, 3);
|
|
|
+ }
|
|
|
+
|
|
|
+ private function state(PromotionActivity $activity, User $member, ?object $account, ?object $round, ?string $lang = null): array
|
|
|
+ {
|
|
|
+ $rules = $this->rules($activity); $type = (int)$activity->type;
|
|
|
+ $text = PromotionTranslations::select($activity->toArray(), $lang ?? ($member->language ?: 'zh'));
|
|
|
+ if (!$text) $this->invalid('活动未配置所选语言');
|
|
|
+ $result = ['activity_id' => $activity->id, 'type' => $type, 'rules' => $rules, 'account_id' => $account->id ?? null,
|
|
|
+ 'round_no' => $account->round_no ?? 1, 'spins' => $account->spins ?? 0, 'server_time' => PromotionFacts::now()] + $text;
|
|
|
+ if ($type === Rules::WHEEL) {
|
|
|
+ $flags = $round ? $this->json($round->completed_tasks) : [];
|
|
|
+ $result += ['coins' => $this->money($round->coins_cents ?? 0), 'remaining' => $this->money(66600 - ($round->coins_cents ?? 0)),
|
|
|
+ 'status' => $round->status ?? 'not_started', 'completed_tasks' => $flags,
|
|
|
+ 'conditions_complete' => count(array_filter(array_intersect_key($flags, array_flip(Rules::TASKS)))) === 7,
|
|
|
+ 'prizes' => Rules::PRIZES, 'valid_member_policy' => $this->facts->policy(),
|
|
|
+ 'ordinary_pool_rule' => '固定奖项仅在累计后小于666时参与普通奖池;普通奖池可用奖项等概率,浮动奖项按分等概率取值。'];
|
|
|
+ $today = substr(PromotionFacts::now(), 0, 10);
|
|
|
+ $result['today_granted_tasks'] = $account ? DB::table('promotion_task_grants')->where('account_id', $account->id)
|
|
|
+ ->where(function ($q) use ($today, $account) { $q->where('event_key', $today)->orWhere('event_key', $account->round_no . ':' . $today); })->pluck('task')->all() : [];
|
|
|
+ $result['tasks'] = $this->wheelToday($activity, $member, $account, $round, $flags, $result['today_granted_tasks']);
|
|
|
+ } elseif ($type === Rules::LOSS) {
|
|
|
+ $start = DB::table('promotion_campaigns')->where('activity_id', $activity->id)->value('first_published_at');
|
|
|
+ $profit = $this->facts->betting([$member->id], $start)['profit'];
|
|
|
+ $result += ['loss' => bccomp($profit, '0', 10) < 0 ? bcsub('0', $profit, 2) : '0.00', 'applied_at' => $account->applied_at ?? null,
|
|
|
+ 'qualified_invitees' => $account ? count($this->lossInvitees($member, $account)) : 0, 'valid_member_policy' => $this->facts->policy()];
|
|
|
+ } else {
|
|
|
+ $result['has_deposited'] = DB::table('balance_logs')->where('member_id', $member->member_id)->where('type', 1)->where('amount', '>', 0)->whereIn('change_type', ['充值', '三方充值'])->exists();
|
|
|
+ }
|
|
|
+ $result['rewards'] = $account ? DB::table('promotion_rewards')->where('account_id', $account->id)->orderByDesc('round_no')->limit(10)->get()->map(fn ($r) => (array)$r)->all() : [];
|
|
|
+ if ($type === Rules::LOSS) {
|
|
|
+ if ($account?->applied_at) $result['rules'] = $this->json($account->application_snapshot)['rules'];
|
|
|
+ $result['status'] = $result['rewards'] ? 'claimed' : (!$account?->applied_at ? 'not_applied' : ($result['qualified_invitees'] >= $result['rules']['invite_count'] ? 'ready' : 'collecting'));
|
|
|
+ }
|
|
|
+ return $result;
|
|
|
+ }
|
|
|
+
|
|
|
+ private function wheelToday(PromotionActivity $activity, User $member, ?object $account, ?object $round, array $flags, array $granted): array
|
|
|
+ {
|
|
|
+ $rules = $this->rules($activity); $today = substr(PromotionFacts::now(), 0, 10);
|
|
|
+ $published = DB::table('promotion_campaigns')->where('activity_id', $activity->id)->value('first_published_at');
|
|
|
+ $dailyFrom = max($published, $today . ' 00:00:00'); $from = max($round->started_at ?? $published, $dailyFrom);
|
|
|
+ $cursors = $round ? $this->json($round->bet_cursors) : []; $cursor = (int)($round->deposit_cursor ?? 0);
|
|
|
+ $online = $this->facts->online($member->id, $dailyFrom); $logins = $this->facts->logins($member->member_id, $dailyFrom);
|
|
|
+ $values = ['online' => $online[$today] ?? 0, 'login' => isset($logins[$today]) ? 1 : 0,
|
|
|
+ 'invite' => $account ? DB::table('promotion_task_grants')->where('account_id', $account->id)->where('round_no', $account->round_no)->where('task', 'invite')->count() : 0,
|
|
|
+ 'deposit' => $this->facts->total($this->facts->deposits([$member->member_id], $from, null, $cursor)),
|
|
|
+ 'team_deposit' => '0', 'bet' => $this->facts->total($this->facts->betting([$member->id], $from, null, $cursors)['effective']), 'team_bet' => '0'];
|
|
|
+ foreach ($this->facts->children($member->id) as $child) {
|
|
|
+ $childFrom = max($from, $child['bound_at']);
|
|
|
+ $values['team_deposit'] = bcadd($values['team_deposit'], $this->facts->total($this->facts->deposits([$child['member_id']], $childFrom, null, $cursor)), 10);
|
|
|
+ $values['team_bet'] = bcadd($values['team_bet'], $this->facts->total($this->facts->betting([$child['id']], $childFrom, null, $cursors)['effective']), 10);
|
|
|
+ }
|
|
|
+ $tasks = [];
|
|
|
+ foreach (Rules::TASKS as $key) {
|
|
|
+ $target = match ($key) { 'online' => $rules['online_seconds'], 'login', 'invite' => 1, default => $rules[$key . '_amount'] };
|
|
|
+ $tasks[] = ['key' => $key, 'round_completed' => (bool)($flags[$key] ?? false), 'value' => $values[$key], 'target' => $target,
|
|
|
+ 'today_rewarded' => in_array($key, $granted, true), 'reward_spins' => 1,
|
|
|
+ 'remaining_seconds' => $key === 'online' ? max(0, $target - $values[$key]) : null];
|
|
|
+ }
|
|
|
+ return $tasks;
|
|
|
+ }
|
|
|
+
|
|
|
+ private function spinRow(object $row): array
|
|
|
+ {
|
|
|
+ return ['id' => $row->id, 'round_no' => $row->round_no, 'prize_index' => $row->prize_index, 'prize' => Rules::PRIZES[$row->prize_index],
|
|
|
+ 'amount' => $this->money($row->amount_cents), 'coins' => $this->money($row->coins_cents), 'reached' => (int)$row->coins_cents === 66600,
|
|
|
+ 'conditions_complete' => (bool)$row->conditions_complete, 'probability' => (string)$row->probability, 'created_at' => $row->created_at];
|
|
|
+ }
|
|
|
+
|
|
|
+ public function records(int $activityId, int $userId, int $page = 1, int $limit = 20): array
|
|
|
+ {
|
|
|
+ $this->activity($activityId); $this->member($userId);
|
|
|
+ $account = DB::table('promotion_accounts')->where('activity_id', $activityId)->where('user_id', $userId)->first();
|
|
|
+ if (!$account) return ['total' => 0, 'data' => []];
|
|
|
+ $q = DB::table('promotion_spins')->where('account_id', $account->id);
|
|
|
+ return ['total' => $q->count(), 'data' => $q->orderByDesc('id')->forPage($page, $limit)->get()->map(fn ($r) => $this->spinRow($r))->all()];
|
|
|
+ }
|
|
|
+
|
|
|
+ public function broadcasts(int $activityId): array
|
|
|
+ {
|
|
|
+ $activity = $this->activity($activityId);
|
|
|
+ if ((int)$activity->type !== Rules::WHEEL) $this->invalid('该活动不是转盘活动');
|
|
|
+ $q = DB::table('promotion_spins as s')->join('promotion_accounts as a', 'a.id', '=', 's.account_id')->join('users as u', 'u.id', '=', 'a.user_id')
|
|
|
+ ->where('a.activity_id', $activityId);
|
|
|
+ $map = fn ($r) => ['account' => substr((string)$r->member_id, 0, strlen((string)$r->member_id) >= 10 ? 4 : 1) . '****' . substr((string)$r->member_id, -1),
|
|
|
+ 'amount' => $this->money($r->amount_cents), 'created_at' => $r->created_at];
|
|
|
+ return ['ordinary' => (clone $q)->orderByDesc('s.id')->limit(50)->get(['u.member_id', 's.amount_cents', 's.created_at'])->map($map)->all(),
|
|
|
+ 'reached_666' => $q->where('s.coins_cents', 66600)->orderByDesc('s.id')->limit(50)->get(['u.member_id', 's.amount_cents', 's.created_at'])->map(fn ($r) => array_replace($map($r), ['amount' => '666.00']))->all()];
|
|
|
+ }
|
|
|
+
|
|
|
+ public function rewards(int $activityId, int $userId, int $page = 1, int $limit = 20): array
|
|
|
+ {
|
|
|
+ $this->activity($activityId); $this->member($userId);
|
|
|
+ $q = DB::table('promotion_rewards')->where('activity_id', $activityId)->where('user_id', $userId);
|
|
|
+ return ['total' => $q->count(), 'data' => $q->orderByDesc('id')->forPage($page, $limit)->get()];
|
|
|
+ }
|
|
|
+
|
|
|
+ public function unlock(int $userId): int
|
|
|
+ {
|
|
|
+ return DB::transaction(function () use ($userId) {
|
|
|
+ $member = $this->member($userId); $wallet = Wallet::where('member_id', $member->member_id)->lockForUpdate()->first();
|
|
|
+ if (!$wallet) return 0;
|
|
|
+ $count = 0;
|
|
|
+ foreach (DB::table('promotion_rewards')->where('user_id', $userId)->where('status', 'locked')->orderBy('id')->lockForUpdate()->get() as $reward) {
|
|
|
+ $snapshot = $this->json($reward->flow_snapshot);
|
|
|
+ $effective = $this->facts->total($this->facts->betting([$userId], $snapshot['from'], null, $snapshot['cursors'])['effective']);
|
|
|
+ if (bccomp($effective, (string)$reward->required_flow, 10) < 0) continue;
|
|
|
+ if (bccomp((string)$wallet->frozen_balance, (string)$reward->amount, 10) < 0) $this->invalid('冻结奖励余额不足');
|
|
|
+ $beforeFrozen = (string)$wallet->frozen_balance; $before = (string)$wallet->available_balance;
|
|
|
+ $wallet->frozen_balance = bcsub($beforeFrozen, (string)$reward->amount, 10); $wallet->available_balance = bcadd($before, (string)$reward->amount, 10); $wallet->save();
|
|
|
+ BalanceLog::create(['member_id' => $member->member_id, 'type' => 2, 'change_type' => '流水解冻', 'amount' => bcsub('0', (string)$reward->amount, 2),
|
|
|
+ 'before_balance' => $beforeFrozen, 'after_balance' => $wallet->frozen_balance, 'related_id' => $reward->id, 'remark' => '新活动奖励解冻']);
|
|
|
+ $log = BalanceLog::create(['member_id' => $member->member_id, 'type' => 1, 'change_type' => '流水解冻', 'amount' => $reward->amount,
|
|
|
+ 'before_balance' => $before, 'after_balance' => $wallet->available_balance, 'related_id' => $reward->id, 'remark' => '新活动奖励解冻']);
|
|
|
+ DB::table('balance_logs')->where('id', $reward->credited_log_id)->update(['frozen_status' => 2]);
|
|
|
+ DB::table('promotion_rewards')->where('id', $reward->id)->update(['status' => 'paid', 'unlocked_log_id' => $log->id, 'unlocked_at' => PromotionFacts::now(), 'updated_at' => PromotionFacts::now()]); ++$count;
|
|
|
+ }
|
|
|
+ return $count;
|
|
|
+ }, 3);
|
|
|
+ }
|
|
|
+}
|