|
@@ -165,6 +165,7 @@ class PromotionService
|
|
|
if ((int)$activity->type !== Rules::LOSS) $this->invalid('只有净亏邀请活动需要申请');
|
|
if ((int)$activity->type !== Rules::LOSS) $this->invalid('只有净亏邀请活动需要申请');
|
|
|
$account = $this->account($activity, $member);
|
|
$account = $this->account($activity, $member);
|
|
|
if (!$account->applied_at) {
|
|
if (!$account->applied_at) {
|
|
|
|
|
+ $start = $this->lossStart($account, $start);
|
|
|
$rules = $this->rules($activity); $policy = $this->facts->policy();
|
|
$rules = $this->rules($activity); $policy = $this->facts->policy();
|
|
|
$profit = $this->facts->betting([$member->id], $start)['profit'];
|
|
$profit = $this->facts->betting([$member->id], $start)['profit'];
|
|
|
$loss = bccomp($profit, '0', 10) < 0 ? bcsub('0', $profit, 10) : '0';
|
|
$loss = bccomp($profit, '0', 10) < 0 ? bcsub('0', $profit, 10) : '0';
|
|
@@ -189,6 +190,13 @@ class PromotionService
|
|
|
return $qualified;
|
|
return $qualified;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ /** Each loss-invite round counts net loss from the previous claim; the first round from the campaign's first publication. */
|
|
|
|
|
+ private function lossStart(object $account, string $published): string
|
|
|
|
|
+ {
|
|
|
|
|
+ if ((int)$account->round_no <= 1) return $published;
|
|
|
|
|
+ return (string)(DB::table('promotion_rewards')->where('account_id', $account->id)->where('round_no', (int)$account->round_no - 1)->value('created_at') ?: $published);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
private function grantTask(object $account, object $round, string $task, string $event, array $evidence, array &$known): bool
|
|
private function grantTask(object $account, object $round, string $task, string $event, array $evidence, array &$known): bool
|
|
|
{
|
|
{
|
|
|
$key = $task . ':' . $event;
|
|
$key = $task . ':' . $event;
|
|
@@ -264,16 +272,18 @@ class PromotionService
|
|
|
$round->completed_tasks = $encoded;
|
|
$round->completed_tasks = $encoded;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- private function reserve(PromotionActivity $activity, User $member, object $account, int $roundNo, string $amount, array $rules): object
|
|
|
|
|
|
|
+ /** $budget defaults to $amount; coupon rewards also charge the invitee coupons they issue. */
|
|
|
|
|
+ private function reserve(PromotionActivity $activity, User $member, object $account, int $roundNo, string $amount, array $rules, ?string $budget = null): object
|
|
|
{
|
|
{
|
|
|
- $this->capacity($activity, $member, $amount);
|
|
|
|
|
|
|
+ $budget ??= $amount;
|
|
|
|
|
+ $this->capacity($activity, $member, $budget);
|
|
|
$received = DB::table('promotion_rewards')->where('activity_id', $activity->id)->where('user_id', $member->id)->exists();
|
|
$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,
|
|
DB::table('promotion_rewards')->insert(['activity_id' => $activity->id, 'user_id' => $member->id, 'account_id' => $account->id,
|
|
|
'round_no' => $roundNo, 'amount' => $amount,
|
|
'round_no' => $roundNo, 'amount' => $amount,
|
|
|
'required_flow' => ($rules['reward_mode'] ?? 'cash') === 'coupon' ? '0.00' : bcmul($amount, $rules['turnover_ratio'], 2),
|
|
'required_flow' => ($rules['reward_mode'] ?? 'cash') === 'coupon' ? '0.00' : bcmul($amount, $rules['turnover_ratio'], 2),
|
|
|
'status' => 'reserved', 'rule_snapshot' => json_encode($rules), 'created_at' => PromotionFacts::now(), 'updated_at' => PromotionFacts::now()]);
|
|
'status' => 'reserved', 'rule_snapshot' => json_encode($rules), 'created_at' => PromotionFacts::now(), 'updated_at' => PromotionFacts::now()]);
|
|
|
if (!$received) ++$activity->rewarded_count;
|
|
if (!$received) ++$activity->rewarded_count;
|
|
|
- $activity->budget_used = bcadd((string)$activity->budget_used, $amount, 2); $activity->save();
|
|
|
|
|
|
|
+ $activity->budget_used = bcadd((string)$activity->budget_used, $budget, 2); $activity->save();
|
|
|
return DB::table('promotion_rewards')->where('account_id', $account->id)->where('round_no', $roundNo)->first();
|
|
return DB::table('promotion_rewards')->where('account_id', $account->id)->where('round_no', $roundNo)->first();
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -353,30 +363,44 @@ class PromotionService
|
|
|
return DB::table('promotion_rewards')->where('id', $reward->id)->first();
|
|
return DB::table('promotion_rewards')->where('id', $reward->id)->first();
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- public function claim(int $activityId, int $userId, int $roundNo, ?string $platform = null): array
|
|
|
|
|
|
|
+ public function claim(int $activityId, int $userId, ?int $roundNo = null, ?string $platform = null): array
|
|
|
{
|
|
{
|
|
|
return $this->mutate($activityId, $userId, function ($activity, $member, $wallet) use ($roundNo, $platform) {
|
|
return $this->mutate($activityId, $userId, function ($activity, $member, $wallet) use ($roundNo, $platform) {
|
|
|
$account = $this->account($activity, $member);
|
|
$account = $this->account($activity, $member);
|
|
|
|
|
+ // Loss invite ignores the client's round: it claims the current round, or replays the last claim when a retry
|
|
|
|
|
+ // arrives after the account already moved on to an unapplied next round.
|
|
|
|
|
+ if ((int)$activity->type === Rules::LOSS) $roundNo = (int)$account->round_no - (!$account->applied_at && (int)$account->round_no > 1 ? 1 : 0);
|
|
|
|
|
+ elseif ($roundNo === null) $this->invalid('round_no必须为正整数');
|
|
|
$reward = DB::table('promotion_rewards')->where('account_id', $account->id)->where('round_no', $roundNo)->lockForUpdate()->first();
|
|
$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 ($reward && $reward->status !== 'reserved') return ['reward' => (array)$reward, 'round_no' => $account->round_no, 'spins' => $account->spins, 'replayed' => true];
|
|
|
if ((int)$activity->type === Rules::LOSS) {
|
|
if ((int)$activity->type === Rules::LOSS) {
|
|
|
$this->open($activity, $member, $platform);
|
|
$this->open($activity, $member, $platform);
|
|
|
- if ($roundNo !== 1 || !$account->applied_at) $this->invalid('请先申请活动');
|
|
|
|
|
|
|
+ if ($roundNo !== (int)$account->round_no || !$account->applied_at) $this->invalid('请先申请活动');
|
|
|
$snapshot = $this->json($account->application_snapshot); $rules = $snapshot['rules'];
|
|
$snapshot = $this->json($account->application_snapshot); $rules = $snapshot['rules'];
|
|
|
$qualified = $this->lossInvitees($member, $account);
|
|
$qualified = $this->lossInvitees($member, $account);
|
|
|
if (count($qualified) < $rules['invite_count']) $this->invalid('有效邀请人数不足');
|
|
if (count($qualified) < $rules['invite_count']) $this->invalid('有效邀请人数不足');
|
|
|
|
|
+ // The application is cleared for the next round, so the reward keeps it for history.
|
|
|
|
|
+ $history = ['qualified_invitees' => $qualified, 'application' => ['applied_at' => $account->applied_at] + array_diff_key($snapshot, ['rules' => true])];
|
|
|
if (($rules['reward_mode'] ?? 'cash') === 'coupon') {
|
|
if (($rules['reward_mode'] ?? 'cash') === 'coupon') {
|
|
|
// Exactly the invitees counted toward the target receive invitee coupons, earliest registrations first.
|
|
// Exactly the invitees counted toward the target receive invitee coupons, earliest registrations first.
|
|
|
sort($qualified); $counted = array_slice($qualified, 0, $rules['invite_count']);
|
|
sort($qualified); $counted = array_slice($qualified, 0, $rules['invite_count']);
|
|
|
- $amount = bcadd(Rules::faceValue($rules['inviter_coupons']), bcmul(Rules::faceValue($rules['invitee_coupons']), (string)count($counted), 2), 2);
|
|
|
|
|
- $reward ??= $this->reserve($activity, $member, $account, 1, $amount, $rules + ['qualified_invitees' => $qualified, 'coupon_invitees' => $counted]);
|
|
|
|
|
- } else $reward ??= $this->reserve($activity, $member, $account, 1, $rules['reward_amount'], $rules + ['qualified_invitees' => $qualified]);
|
|
|
|
|
|
|
+ // The reward amount is the inviter's own coupons; invitee coupons only count toward the budget.
|
|
|
|
|
+ $own = Rules::faceValue($rules['inviter_coupons']);
|
|
|
|
|
+ $total = bcadd($own, bcmul(Rules::faceValue($rules['invitee_coupons']), (string)count($counted), 2), 2);
|
|
|
|
|
+ $reward ??= $this->reserve($activity, $member, $account, $roundNo, $own, $rules + $history + ['coupon_invitees' => $counted, 'coupon_face_value' => $total], $total);
|
|
|
|
|
+ } else $reward ??= $this->reserve($activity, $member, $account, $roundNo, $rules['reward_amount'], $rules + $history);
|
|
|
} elseif ((int)$activity->type === Rules::WHEEL) {
|
|
} elseif ((int)$activity->type === Rules::WHEEL) {
|
|
|
// A reserved, earned reward remains payable after the campaign is unpublished.
|
|
// A reserved, earned reward remains payable after the campaign is unpublished.
|
|
|
$round = $this->round($account);
|
|
$round = $this->round($account);
|
|
|
if (!$reward || !$round || $round->round_no !== $roundNo || $round->status !== 'reached') $this->invalid('本轮尚未达到666');
|
|
if (!$reward || !$round || $round->round_no !== $roundNo || $round->status !== 'reached') $this->invalid('本轮尚未达到666');
|
|
|
} else $this->invalid('首充奖励由充值成功后自动发放');
|
|
} else $this->invalid('首充奖励由充值成功后自动发放');
|
|
|
$reward = ($this->json($reward->rule_snapshot)['reward_mode'] ?? 'cash') === 'coupon' ? $this->issueCoupons($reward, $member) : $this->pay($reward, $member, $wallet);
|
|
$reward = ($this->json($reward->rule_snapshot)['reward_mode'] ?? 'cash') === 'coupon' ? $this->issueCoupons($reward, $member) : $this->pay($reward, $member, $wallet);
|
|
|
|
|
+ if ((int)$activity->type === Rules::LOSS) {
|
|
|
|
|
+ // Loss invite repeats: the next round needs a new application, net loss from now and new invitees.
|
|
|
|
|
+ ++$account->round_no;
|
|
|
|
|
+ DB::table('promotion_accounts')->where('id', $account->id)->update(['round_no' => $account->round_no, 'applied_at' => null,
|
|
|
|
|
+ 'application_snapshot' => null, 'updated_at' => PromotionFacts::now()]);
|
|
|
|
|
+ }
|
|
|
if ((int)$activity->type === Rules::WHEEL) {
|
|
if ((int)$activity->type === Rules::WHEEL) {
|
|
|
$flags = array_intersect_key($this->json($round->completed_tasks), array_flip(Rules::DAILY));
|
|
$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()]);
|
|
DB::table('promotion_rounds')->where('id', $round->id)->update(['status' => 'claimed', 'claimed_at' => PromotionFacts::now(), 'updated_at' => PromotionFacts::now()]);
|
|
@@ -442,6 +466,7 @@ class PromotionService
|
|
|
$result['tasks'] = $this->wheelToday($activity, $member, $account, $round, $flags, $result['today_granted_tasks']);
|
|
$result['tasks'] = $this->wheelToday($activity, $member, $account, $round, $flags, $result['today_granted_tasks']);
|
|
|
} elseif ($type === Rules::LOSS) {
|
|
} elseif ($type === Rules::LOSS) {
|
|
|
$start = DB::table('promotion_campaigns')->where('activity_id', $activity->id)->value('first_published_at');
|
|
$start = DB::table('promotion_campaigns')->where('activity_id', $activity->id)->value('first_published_at');
|
|
|
|
|
+ if ($account && $start) $start = $this->lossStart($account, $start);
|
|
|
$profit = $this->facts->betting([$member->id], $start)['profit'];
|
|
$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,
|
|
$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()];
|
|
'qualified_invitees' => $account ? count($this->lossInvitees($member, $account)) : 0, 'valid_member_policy' => $this->facts->policy()];
|
|
@@ -453,7 +478,9 @@ class PromotionService
|
|
|
// Applications made before coupon mode existed snapshot cash rules without the coupon keys.
|
|
// Applications made before coupon mode existed snapshot cash rules without the coupon keys.
|
|
|
if ($account?->applied_at) $result['rules'] = array_replace(Rules::defaults(Rules::LOSS), $this->json($account->application_snapshot)['rules']);
|
|
if ($account?->applied_at) $result['rules'] = array_replace(Rules::defaults(Rules::LOSS), $this->json($account->application_snapshot)['rules']);
|
|
|
$result['coupons'] = $this->couponSummary((int)$activity->id, (int)$member->id);
|
|
$result['coupons'] = $this->couponSummary((int)$activity->id, (int)$member->id);
|
|
|
- $result['status'] = $result['rewards'] ? 'claimed' : (!$account?->applied_at ? 'not_applied' : ($result['qualified_invitees'] >= $result['rules']['invite_count'] ? 'ready' : 'collecting'));
|
|
|
|
|
|
|
+ // Status is for the current round; earlier rounds are in rewards.
|
|
|
|
|
+ $result['status'] = !$account?->applied_at ? 'not_applied' : ($result['qualified_invitees'] >= $result['rules']['invite_count'] ? 'ready' : 'collecting');
|
|
|
|
|
+ $result['loss_from'] = $start;
|
|
|
}
|
|
}
|
|
|
return $result;
|
|
return $result;
|
|
|
}
|
|
}
|