Просмотр исходного кода

净亏邀请:可重复参加,优惠券奖励金额只计邀请人券

- 领取后进入下一轮:清空本轮申请,净亏从上一次领取起算,邀请只算新一轮申请后的会员;领取轮次由后端计算
- 优惠券模式发奖金额改为邀请人券抵扣总额,含被邀请人券的总额记入 coupon_face_value,预算仍按总额扣减
- 后台申请记录增加 claimed_count、reward_round_no,名单取最近一次领取
- 迁移:已领取会员进入下一轮,修正历史优惠券奖励金额
- 新增前端修改清单,更新接口文档与交付副本

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
doge 12 часов назад
Родитель
Сommit
490cc1d422

+ 6 - 2
app/Services/PromotionAdminService.php

@@ -188,8 +188,10 @@ class PromotionAdminService
     /** Loss-invite record display only: historical recipients and actual issued coupon quantities. */
     private function lossRecordPeople($rows, string $kind): void
     {
-        $rewards = $kind === 'rewards' ? $rows : DB::table('promotion_rewards')->whereIn('account_id', $rows->pluck('id'))
-            ->where('round_no', 1)->get();
+        // Loss invite repeats; an application row shows its latest claim, and applied_at belongs to the current round.
+        $all = $kind === 'rewards' ? $rows : DB::table('promotion_rewards')->whereIn('account_id', $rows->pluck('id'))->orderBy('round_no')->get();
+        $claimed = $all->countBy('account_id');
+        $rewards = $kind === 'rewards' ? $rows : $all->keyBy('account_id')->values();
         $byAccount = $rewards->keyBy('account_id');
         $couponRewards = $rewards->filter(fn ($reward) => ($this->recordSnapshot($reward->rule_snapshot)['reward_mode'] ?? 'cash') === 'coupon');
         $groups = $couponRewards->isEmpty() ? collect() : DB::table('promotion_coupons')->whereIn('reward_id', $couponRewards->pluck('id'))
@@ -223,6 +225,8 @@ class PromotionAdminService
             if ($kind === 'accounts') {
                 $row->reward_id = $reward ? (int)$reward->id : null;
                 $row->reward_status = $reward ? $reward->status : null;
+                $row->reward_round_no = $reward ? (int)$reward->round_no : null;
+                $row->claimed_count = (int)($claimed[$row->id] ?? 0);
             }
             $row->inviter = ['id' => $owner, 'account' => $accounts[$owner] ?? null, 'coupons' => $coupons[$rewardId][$owner]['inviter'] ?? []];
             $row->invitees = array_map(fn ($id) => ['id' => $id, 'account' => $accounts[$id] ?? null,

+ 36 - 9
app/Services/PromotionService.php

@@ -165,6 +165,7 @@ class PromotionService
             if ((int)$activity->type !== Rules::LOSS) $this->invalid('只有净亏邀请活动需要申请');
             $account = $this->account($activity, $member);
             if (!$account->applied_at) {
+                $start = $this->lossStart($account, $start);
                 $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';
@@ -189,6 +190,13 @@ class PromotionService
         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
     {
         $key = $task . ':' . $event;
@@ -264,16 +272,18 @@ class PromotionService
         $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();
         DB::table('promotion_rewards')->insert(['activity_id' => $activity->id, 'user_id' => $member->id, 'account_id' => $account->id,
             'round_no' => $roundNo, 'amount' => $amount,
             '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()]);
         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();
     }
 
@@ -353,30 +363,44 @@ class PromotionService
         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) {
             $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();
             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('请先申请活动');
+                if ($roundNo !== (int)$account->round_no || !$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('有效邀请人数不足');
+                // 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') {
                     // Exactly the invitees counted toward the target receive invitee coupons, earliest registrations first.
                     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) {
                 // 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->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) {
                 $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()]);
@@ -442,6 +466,7 @@ class PromotionService
             $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');
+            if ($account && $start) $start = $this->lossStart($account, $start);
             $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()];
@@ -453,7 +478,9 @@ class PromotionService
             // 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']);
             $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;
     }

+ 42 - 0
database/migrations/2026_11_03_170000_repeat_loss_invite_rounds.php

@@ -0,0 +1,42 @@
+<?php
+
+use App\Support\PromotionRules;
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Support\Facades\DB;
+
+return new class extends Migration {
+    /**
+     * Loss invite now repeats. Members who already claimed move to the next round, keeping their application on the reward;
+     * coupon rewards record only the inviter's own coupons as the amount (budget_used already includes invitee coupons).
+     */
+    public function up(): void
+    {
+        $activities = DB::table('promotion_activities')->where('type', PromotionRules::LOSS)->pluck('id');
+        if ($activities->isEmpty()) return;
+        DB::transaction(function () use ($activities) {
+            $now = date('Y-m-d H:i:s');
+            $rewards = DB::table('promotion_rewards')->whereIn('activity_id', $activities)->lockForUpdate()->get();
+            foreach ($rewards as $reward) {
+                $rules = json_decode((string)$reward->rule_snapshot, true) ?: [];
+                $update = [];
+                $account = DB::table('promotion_accounts')->where('id', $reward->account_id)->lockForUpdate()->first();
+                if ($account && (int)$account->round_no === (int)$reward->round_no) {
+                    $application = json_decode((string)$account->application_snapshot, true) ?: [];
+                    $rules['application'] ??= ['applied_at' => $account->applied_at] + array_diff_key($application, ['rules' => true]);
+                    DB::table('promotion_accounts')->where('id', $account->id)->update(['round_no' => (int)$account->round_no + 1,
+                        'applied_at' => null, 'application_snapshot' => null, 'updated_at' => $now]);
+                }
+                if (($rules['reward_mode'] ?? 'cash') === 'coupon' && !isset($rules['coupon_face_value'])) {
+                    $rules['coupon_face_value'] = bcadd((string)$reward->amount, '0', 2);
+                    $update['amount'] = PromotionRules::faceValue($rules['inviter_coupons'] ?? []);
+                }
+                $update['rule_snapshot'] = json_encode($rules);
+                DB::table('promotion_rewards')->where('id', $reward->id)->update($update + ['updated_at' => $now]);
+            }
+        });
+    }
+
+    public function down(): void
+    {
+    }
+};

+ 6 - 0
docs/前端交付/README.md

@@ -2,6 +2,12 @@
 
 本目录是2026-10-26的接口文档交付副本,按**项目 → 前台/后台/代理端**组织。
 
+## 净亏邀请可重复参加(2026-11-03)
+
+- **先看** [净亏邀请-前端修改清单](bot-28/后台/独立活动/净亏邀请-前端修改清单.md):会员前端、管理后台各自需要改什么,以及验收项。
+- [净亏邀请-会员接口](melbet_sport-api/前台/独立活动/净亏邀请-会员接口.md):领取后可再次参加;`claim` 的轮次由后端计算,不需要传 `round_no`;不再返回 `claimed`,新增 `loss_from`。
+- [净亏邀请-后台接口](bot-28/后台/独立活动/净亏邀请-后台接口.md):轮次改为显示;申请记录新增 `claimed_count`、`reward_round_no`;优惠券模式 `amount` 只计邀请人券。
+
 ## 记录列表展示增补(2026-11-02)
 
 - [净亏邀请-后台接口](bot-28/后台/独立活动/净亏邀请-后台接口.md):配置表单与记录合并为一份完整文档。“记录查询”一节包含申请、发奖、已发优惠券三个列表的中文列名,以及新增的奖励方式、邀请人与被邀请人 ID/账号、获券档位和张数。原单独的《净亏邀请记录-中文字段与名单展示》已并入,不再单独发送。

+ 1 - 0
docs/前端交付/bot-28/后台/README.md

@@ -9,6 +9,7 @@
 | 信息:发送对象、多语言、定时发送、撤回和日志 | [信息管理-后台接口](信息管理-后台接口.md) |
 | 维护:整站与游戏分类、平台、时区、起止时间、开关和日志 | [整站与游戏维护-后台接口](整站与游戏维护-后台接口.md) |
 | 三个独立活动的公共约定和目录 | [独立活动-后台接口](独立活动-后台接口.md) |
+| 净亏邀请:可重复参加与奖励金额修正,会员前端和后台需要改什么(2026-11-03) | [净亏邀请-前端修改清单](独立活动/净亏邀请-前端修改清单.md) |
 | 净亏邀请:平铺配置表单、现金/优惠券模式、申请/发奖/已发券记录(含中文列名、名单与获券) | [净亏邀请-后台接口](独立活动/净亏邀请-后台接口.md) |
 | 首充奖励配置与记录 | [首充奖励-后台接口](独立活动/首充奖励-后台接口.md) |
 | 转盘任务、概率配置与记录 | [转盘活动-后台接口](独立活动/转盘活动-后台接口.md) |

+ 116 - 0
docs/前端交付/bot-28/后台/独立活动/净亏邀请-前端修改清单.md

@@ -0,0 +1,116 @@
+# 净亏邀请-前端修改清单(2026-11-03)
+
+本次后端有两项变更,会员前端和管理后台前端都要配合修改:
+
+1. **可重复参加**:原来每人只能领取一次。现在领取后进入下一轮,可以再次申请、再次领取,次数不限。
+2. **奖励金额修正**:优惠券模式下,发奖记录的 `amount` 原来包含被邀请人的券,例如配置奖励 500,却显示 620。现在只计邀请人本人的券,显示 500。历史记录已由后端迁移修正。
+
+接口地址、认证和返回格式不变。完整字段见:
+
+- 会员前端:[净亏邀请-会员接口](../../../melbet_sport-api/前台/独立活动/净亏邀请-会员接口.md)
+- 管理后台:[净亏邀请-后台接口](净亏邀请-后台接口.md)
+
+## 一、会员前端(melbet_sport-api)
+
+### 1. 领取不需要传轮次
+
+领取轮次由后端计算:`POST /api/promotion/claim` 只传 `activity_id`、`platform`,**不需要传 `round_no`**。旧代码仍传 `round_no=1` 也不影响,后端会忽略。
+
+| 场景 | 结果 |
+|---|---|
+| 本轮已达标 | 领取成功,返回的 `round_no` 为下一轮 |
+| 领取成功后重试,或再次点击 | `replayed=true`,返回刚领取的记录,不会重复发放 |
+| 首次参加且未申请 | `code=0`,`msg` 为"请先申请活动" |
+| 本轮已申请但邀请人数不足 | `code=0`,`msg` 为"有效邀请人数不足" |
+
+### 2. 状态不再有 `claimed`
+
+详情和 progress 的 `status` 只表示当前这一轮:
+
+| status | 页面 |
+|---|---|
+| not_applied | 本轮未申请。`rewards` 为空时显示"申请";`rewards` 不为空时显示"再次参加",净亏达到门槛后可申请 |
+| collecting | 本轮已申请,显示邀请进度 `qualified_invitees / rules.invite_count` |
+| ready | 本轮已达标,显示"领取" |
+
+原来按 `claimed` 显示"已领取"并关闭入口的逻辑要去掉。领取成功后重新请求详情:`status` 回到 `not_applied`,`loss` 和 `qualified_invitees` 从 0 重新累计。
+
+### 3. 净亏和邀请人数按本轮计算
+
+| 字段 | 说明 |
+|---|---|
+| round_no | 第几次参加,从 1 开始 |
+| loss | 本轮累计净亏,从 `loss_from` 起算 |
+| loss_from | 新增。本轮净亏的起算时间:第一轮为活动首次上架时间,之后为上一次领取时间 |
+| qualified_invitees | 本轮申请之后新邀请的有效会员数;上一轮已计入的会员不再计入 |
+| rules | 本轮已申请时为申请快照,未申请时为当前配置 |
+
+页面可提示"本轮净亏自 `loss_from` 起计算"。
+
+### 4. 奖励记录和金额
+
+- 历次奖励使用详情 `rewards`(最近 10 条)或 `GET /api/promotion/rewards` 分页展示,每条带 `round_no`,可显示为"第 N 次"。
+- 优惠券模式下,`reward.amount` 为邀请人本人获得的券抵扣总额,不含被邀请人的券。直接展示即可,不要自行加上被邀请人的券。
+- 详情 `coupons` 为本人在本活动历次获得的券合计,按档位分组。
+
+### 5. 文案
+
+删除"每人仅限一次""已参加过"等文案,改为"可重复参加,每次领取后重新累计净亏和邀请人数"。
+
+### 6. 券包
+
+券包接口本次不变。
+
+### 会员前端验收
+
+- 第一轮领取后,页面显示"再次参加",净亏和邀请人数归零。
+- 第二轮净亏达标后可以申请,邀请新会员达标后领取成功;奖励记录出现第 2 条,`round_no=2`。
+- 领取请求不带 `round_no` 也能领取第二轮;网络重试不会重复发放。
+- 优惠券模式配置奖励 500 时,领取记录显示 500。
+
+## 二、管理后台前端(bot-28)
+
+配置表单**不需要改字段**,只调整提示文案和记录页。
+
+### 1. 配置表单文案
+
+- 活动说明:每名会员可多次参加,每次领取后重新累计净亏和邀请人数。
+- `reward_amount`:现金模式为每次发放金额;优惠券模式为邀请人每次可获得的券抵扣总额上限。
+- `budget`:累计奖励额度,包括每次领取发出的邀请人券和被邀请人券。所以 `budget_used` 可能大于发奖记录 `amount` 之和,这不是错误。
+- `reward_quota`:限制获奖会员人数,同一会员多次领取只算一人。
+
+### 2. 申请记录 `kind=accounts`
+
+每名会员仍然只有一行,显示当前轮次和最近一次领取:
+
+| 字段 | 列名 | 修改 |
+|---|---|---|
+| round_no | 当前轮次 | 原来隐藏,改为显示 |
+| claimed_count | 已领取次数 | 新增 |
+| reward_round_no | 最近领取轮次 | 新增,未领取过为 `null`,显示"—" |
+| reward_id / reward_status | 最近一次发奖记录 ID / 状态 | 原为"关联发奖",改为"最近一次" |
+| applied_at | 本轮申请时间 | 领取后会清空,为空时显示"本轮未申请" |
+| application_snapshot | 本轮申请时规则 | 领取后清空;以前的申请改在发奖记录中查看 |
+| inviter / invitees | 名单与获券 | 为最近一次领取的名单,建议标注"第 `reward_round_no` 轮" |
+
+### 3. 发奖记录 `kind=rewards`
+
+- 同一会员每领取一次有一行。`round_no` 原来隐藏,改为显示"第几次"。
+- `amount` 在优惠券模式下改称"邀请人券抵扣总额",不含被邀请人券。历史记录已修正,例如 620 改为 500。
+- 详情区可增加以下字段,取自 `rule_snapshot`(先解析 JSON):
+
+| 字段 | 名称 |
+|---|---|
+| application.applied_at | 该轮申请时间 |
+| application.loss | 申请时净亏 |
+| application.from | 净亏统计开始时间 |
+| coupon_face_value | 本次发券总额(含被邀请人券),仅优惠券模式 |
+
+`inviter`、`invitees` 和已发优惠券列表 `kind=coupons` 不变。
+
+### 管理后台验收
+
+- 申请记录显示当前轮次、已领取次数、最近领取轮次;领取后本轮申请时间显示"本轮未申请"。
+- 同一会员领取两次后,发奖记录有两行,轮次分别为 1、2。
+- 优惠券模式发奖记录金额等于邀请人券抵扣总额,不再是 620 这类包含被邀请人券的数值。
+- 没有残留"固定为 1,隐藏轮次"或"每人只能领取一次"的文案。

+ 31 - 14
docs/前端交付/bot-28/后台/独立活动/净亏邀请-后台接口.md

@@ -46,7 +46,7 @@
 
 保存必填根字段:`id/title/type/status/start_time/end_time` 及三语 `translations`。时间按UTC+8填写,结束必须晚于开始。可选 `lang/sort/content/publish_platforms`;平台为 `PC/H5/APP`,空数组表示全部。
 
-可选通用资格:`register_time_limited/register_start_time/register_end_time/register_platforms`、`deposit_limited/deposit_min/deposit_max`、`show_ineligible`。可选 `reward_quota` 限制不同获奖会员数,`budget` 限制累计奖励金额;`null` 为不限制,0为无剩余额度。`rewarded_count/budget_used/first_published_at` 为服务端字段,只展示不提交。
+可选通用资格:`register_time_limited/register_start_time/register_end_time/register_platforms`、`deposit_limited/deposit_min/deposit_max`、`show_ineligible`。可选 `reward_quota` 限制不同获奖会员数(同一会员多次领取只算一人),`budget` 限制累计奖励金额(优惠券模式按邀请人券和被邀请人券的抵扣总额计入);`null` 为不限制,0为无剩余额度。`rewarded_count/budget_used/first_published_at` 为服务端字段,只展示不提交。
 
 净亏邀请没有图片字段,不展示图片上传/预览,也不校验或提交 `image`。
 
@@ -149,15 +149,19 @@
 ## 业务规则
 
 1. 净亏和有效邀请人数直接使用接口返回值。
-2. 达到净亏门槛后会员点击申请;申请成功时固定当时的整套规则(包括奖励模式、券档位、券时间与游戏),之后修改配置不影响已申请的会员。无效申请不消耗机会,重复有效申请返回原进度。
+2. 达到净亏门槛后会员点击申请;申请成功时固定当时的整套规则(包括奖励模式、券档位、券时间与游戏),之后修改配置只影响下一次申请。无效申请不消耗机会,重复有效申请返回原进度。
 3. 会员是否达到活动条件由服务端判断,记录页显示对应状态。
-4. 新增有效直属会员达到人数后,会员自行领取。每名会员在本活动只能领取一次,下架再上架不重置机会或首次上架时间。
+4. 新增有效直属会员达到人数后,会员自行领取。**可重复参加**:每领取一次进入下一轮(`round_no` 加 1),会员需要重新达到净亏门槛并重新申请:
+   - 新一轮净亏从上一次领取时开始累计,已用过的净亏不重复计算;
+   - 有效邀请只算本轮申请之后新注册并绑定的直属会员,上一轮已计入的会员不再计入;
+   - 次数不限,下架再上架不重置轮次或首次上架时间。
 5. **现金模式**:记录页显示接口返回的奖励金额和状态。
 6. **优惠券模式**:领取时不发现金,改为发券:
    - 邀请人按 `inviter_coupons` 每份一张券。
    - 计入人数的被邀请人每人按 `invitee_coupons` 一套券。如果有效下级多于 `invite_count`,按注册先后取前 `invite_count` 名。
    - 所有券使用同一可用时间和可用游戏。
-   - 奖励记录 `amount` 为本次发出的券抵扣总额,状态为 `paid`;记录页直接展示,会员钱包不增加现金。
+   - 奖励记录 `amount` 为**邀请人本人**获得的券抵扣总额(`inviter_coupons` 的抵扣×份数,配置为奖励金额时即等于奖励金额),不含被邀请人的券;状态为 `paid`,会员钱包不增加现金。
+   - 被邀请人券计入活动预算 `budget_used`,所以 `budget_used` 可能大于各条 `amount` 之和。
 7. 券只能在 F1 自有游戏(F1体育、F1彩票)下注时使用;退款后的可用状态以券包接口返回为准。
 
 ## 记录查询:申请、发奖、已发优惠券
@@ -186,13 +190,15 @@
 | id | 申请记录 ID | 显示 |
 | activity_id | 活动 ID | 可隐藏(页面已在本活动内) |
 | user_id | 申请会员 ID | 与 `inviter.account` 合并显示为"ID / 账号" |
-| round_no | 参与轮次 | 固定为 1,隐藏 |
+| round_no | 当前轮次 | 显示,第几次参加 |
 | spins | 剩余抽奖次数 | 转盘专用,隐藏 |
-| applied_at | 申请时间 | 为空时显示"未申请" |
-| application_snapshot | 申请时规则 | 按下方摘要展示,不显示原始 JSON |
+| applied_at | 本轮申请时间 | 为空时显示"本轮未申请"(领取后会清空,等待下一轮申请) |
+| application_snapshot | 本轮申请时规则 | 按下方摘要展示,不显示原始 JSON;领取后清空,历史申请在发奖记录 `rule_snapshot.application` 中 |
+| claimed_count | 已领取次数 | 显示 |
+| reward_round_no | 最近一次领取的轮次 | 无领取时为 `null` |
 | reward_mode | 奖励方式 | `cash` 现金 / `coupon` 优惠券;`null` 显示"—" |
-| reward_id | 关联发奖记录 ID | 未发奖时为 `null`,显示"—" |
-| reward_status | 发奖状态 | 枚举同发奖记录 `status`;`null` 显示"暂无发奖记录" |
+| reward_id | 最近一次发奖记录 ID | 未发奖时为 `null`,显示"—" |
+| reward_status | 最近一次发奖状态 | 枚举同发奖记录 `status`;`null` 显示"暂无发奖记录" |
 | inviter / invitees | 邀请人、被邀请人及获券 | 见下方[名单与获券](#名单与获券申请发奖共用) |
 | created_at / updated_at | 创建时间 / 更新时间 | 显示 |
 
@@ -217,8 +223,8 @@
 | activity_id | 活动 ID | 可隐藏 |
 | user_id | 领奖会员 ID(邀请人) | 与 `inviter.account` 合并显示 |
 | account_id | 关联申请记录 ID | 对应申请记录的 `id` |
-| round_no | 发奖轮次 | 固定为 1,隐藏 |
-| amount | 现金奖励金额 / 优惠券抵扣总额 | 列名随本行 `reward_mode` 切换 |
+| round_no | 发奖轮次 | 显示,第几次领取 |
+| amount | 现金奖励金额 / 邀请人券抵扣总额 | 列名随本行 `reward_mode` 切换;不含被邀请人券 |
 | required_flow | 解冻所需有效投注额 | 现金模式为 0 时显示"无需解冻";券模式隐藏 |
 | status | 发奖状态 | 见下表 |
 | credited_log_id | 奖励入账流水 ID | 券模式隐藏;无值显示"—" |
@@ -236,7 +242,15 @@
 | locked | 待解冻(已入冻结余额) |
 | paid | 已发放 |
 
-即使金额为 0 或无需解冻,也按 `status` 展示状态,不要当作已发放。每名会员只能领取一次,重复领取不会重复入账或发券。停止活动用下架,参与历史不会删除。
+即使金额为 0 或无需解冻,也按 `status` 展示状态,不要当作已发放。同一会员每领取一轮有一条发奖记录;同一轮重复领取不会重复入账或发券。停止活动用下架,参与历史不会删除。
+
+`rule_snapshot` 中可用于详情的字段(解析 JSON 后):
+
+| 字段 | 中文名称 |
+|---|---|
+| application.applied_at | 本轮申请时间 |
+| application.loss / application.from | 申请时净亏 / 净亏统计开始时间 |
+| coupon_face_value | 本次发券抵扣总额(含被邀请人券),仅优惠券模式 |
 
 ### 名单与获券(申请、发奖共用)
 
@@ -272,6 +286,7 @@
 | 现金记录 | `invitees` 为发奖时达标的会员,双方 `coupons=[]` | 列出名单,获券显示"本次未发券";不要把被邀请人显示成现金收款人 |
 | 优惠券记录 | `invitees` 为本次计入发券的会员 | 列出每人账号与获券 |
 | 尚未发奖的申请 | `reward_id=null`、`reward_status=null`、`invitees=[]` | 显示"尚未生成发奖名单"(不代表没有下级或邀请数为 0) |
+| 已领取过的会员(申请记录) | 名单为最近一次领取的名单,`applied_at` 为本轮申请 | 名单列标注"第 `reward_round_no` 轮";全部历史看发奖记录 |
 | 账号未设置或会员已删除 | `account=null`,ID 保留 | 账号显示"—" |
 | 被邀请人未配置券 | 该人 `coupons=[]` | 显示"本次未发券" |
 
@@ -294,7 +309,8 @@
         "activity_id": 1,
         "user_id": 164,
         "account_id": 20001,
-        "amount": "500",
+        "round_no": 1,
+        "amount": "200",
         "status": "paid",
         "reward_mode": "coupon",
         "inviter": {
@@ -339,7 +355,8 @@
 
 ### 记录页验收
 
-- 列名全为中文;不展示 `spins`、固定轮次和内部快照字段。
+- 列名全为中文;不展示 `spins` 和内部快照字段;显示轮次和已领取次数。
+- 同一会员领取多轮时,发奖记录逐轮列出,申请记录显示当前轮申请和最近一次领取。
 - 现金记录显示邀请人和达标会员的 ID、账号,获券显示"本次未发券"。
 - 优惠券记录在同一行显示邀请人和每名被邀请人的 ID、账号、满减档位和张数。
 - 未发奖的申请显示"尚未生成发奖名单";账号缺失显示"—",保留会员 ID。

+ 1 - 0
docs/前端交付/melbet_sport-api/前台/README.md

@@ -8,6 +8,7 @@
 | [统计装置与活动采集-前台接口](统计装置与活动采集-前台接口.md) | 正文 |
 | [会员接入](独立活动/会员接入.md) | 目录 |
 | [净亏邀请-会员接口](独立活动/净亏邀请-会员接口.md) | 正文 |
+| [净亏邀请-前端修改清单(可重复参加,2026-11-03)](../../bot-28/后台/独立活动/净亏邀请-前端修改清单.md) | 修正单 |
 | [优惠券券包-会员接口](独立活动/优惠券券包-会员接口.md) | 正文 |
 | [首充奖励-会员接口](独立活动/首充奖励-会员接口.md) | 正文 |
 | [转盘活动-会员接口](独立活动/转盘活动-会员接口.md) | 正文 |

+ 22 - 9
docs/前端交付/melbet_sport-api/前台/独立活动/净亏邀请-会员接口.md

@@ -19,7 +19,7 @@
 | GET | `/api/promotion/detail` | `activity_id`,净亏、申请及邀请进度 |
 | POST | `/api/promotion/apply` | `activity_id`,达到净亏门槛后申请 |
 | POST | `/api/promotion/progress` | `activity_id`,返回最新参与状态 |
-| POST | `/api/promotion/claim` | `activity_id/round_no=1`,邀请达标后领奖 |
+| POST | `/api/promotion/claim` | `activity_id`,邀请达标后领奖;轮次由服务端计算,不需要传 `round_no` |
 | GET | `/api/promotion/rewards` | `activity_id/page/limit`,本人奖金记录 |
 
 分页默认1/20,`limit` 最大100。详情和progress返回:
@@ -42,6 +42,7 @@
     "invitee_coupons": []
   },
   "loss": "500",
+  "loss_from": "2026-10-26 12:00:00",
   "applied_at": "2026-10-26 15:00:00",
   "qualified_invitees": 2,
   "status": "collecting",
@@ -58,6 +59,10 @@
 
 门槛数值仅为示例,有效推广的充值、投注条件使用 `valid_member_policy`,不写死200。
 
+- `round_no`:当前第几次参加,从 1 开始,每领取一次加 1。
+- `loss`:本轮累计净亏,从 `loss_from` 起算。第一轮从活动首次上架起算,之后每轮从上一次领取时起算。
+- `qualified_invitees`:本轮有效邀请人数。
+
 ## 奖励模式
 
 `rules.reward_mode` 决定领奖内容,页面按它切换文案:
@@ -67,7 +72,7 @@
 | cash | `reward_amount` 现金,`turnover_ratio>0` 时先进冻结余额 | 展示奖金金额及流水倍数 |
 | coupon | 优惠券,不发现金 | 展示 `inviter_coupons` 券档位、可用时间、可用游戏;可提示"好友达标后也可获得" `invitee_coupons` |
 
-申请成功后 `rules` 为申请当时的快照(含奖励模式和券配置);尚未申请时为当前配置。旧申请的快照没有券字段,服务端会补默认值(`reward_mode=cash`)。
+本轮申请成功后 `rules` 为申请当时的快照(含奖励模式和券配置);本轮尚未申请时为当前配置。旧申请的快照没有券字段,服务端会补默认值(`reward_mode=cash`)。
 
 优惠券模式 `rules` 示例:
 
@@ -108,16 +113,24 @@
 
 | status | 含义 | 页面操作 |
 |---|---|---|
-| not_applied | 尚未成功申请 | 净亏达到 `rules.loss_amount` 后可申请 |
-| collecting | 已申请,继续邀请 | 展示 `qualified_invitees/rules.invite_count` |
-| ready | 有效邀请人数达标 | 传 `round_no=1` 领取 |
-| claimed | 已领取 | 现金模式展示奖金记录;优惠券模式展示 `coupons` 并引导去券包;关闭重复领取入口 |
+| not_applied | 本轮尚未申请 | 净亏达到 `rules.loss_amount` 后可申请;`rewards` 非空时表示已领过,可提示"再次参加" |
+| collecting | 本轮已申请,继续邀请 | 展示 `qualified_invitees/rules.invite_count` |
+| ready | 本轮有效邀请人数达标 | 领取 |
+
+不再返回 `claimed`。领取成功后进入下一轮:`claim` 返回新的 `round_no`,详情 `status` 回到 `not_applied`、`loss` 从领取时重新累计、`qualified_invitees` 归零。页面直接展示接口返回的净亏、有效邀请人数和达标状态,历次奖励用 `rewards` 或 `/api/promotion/rewards` 展示。
+
+**可重复参加,次数不限。** 每一轮都需要:
+1. 本轮净亏达到门槛后重新申请;
+2. 本轮申请之后新邀请的有效直属会员达到人数。上一轮已计入的会员不再计入。
 
-页面直接展示接口返回的净亏、有效邀请人数和达标状态。
+重复申请返回本轮原进度,无效申请不消耗机会;下架再上架不重置轮次。申请成功时固定本轮奖励规则,后来修改配置只影响下一轮。
 
-每人同一活动只有一次机会。重复申请返回原进度,无效申请不消耗机会;下架再上架不重置。申请成功时固定奖励规则,页面显示服务端返回规则;奖励配置后来修改不影响该申请快照。领取重试始终使用 `round_no=1`,不会重复入账。
+`claim` 不需要传 `round_no`,服务端总是领取当前轮;传了也会被忽略。领取成功后重试(本轮尚未重新申请)返回刚领取的记录和 `replayed=true`,不会重复入账。
 
-优惠券模式领取成功后:邀请人获得 `inviter_coupons` 全部券;计入人数的被邀请人(按注册先后取前 `invite_count` 名)每人获得一套 `invitee_coupons`,被邀请人在自己的券包里看到。`claim` 返回的 `reward.amount` 为本次发出的券抵扣总额,`reward.status=paid`,钱包余额不变。
+优惠券模式领取成功后:
+- 邀请人获得 `inviter_coupons` 全部券。
+- 计入人数的被邀请人(按注册先后取前 `invite_count` 名)每人获得一套 `invitee_coupons`,在自己的券包里看到。
+- `claim` 返回的 `reward.amount` 为邀请人本人获得的券抵扣总额,不含被邀请人的券,`reward.status=paid`,钱包余额不变。
 
 现金模式 `rewards.status=paid` 表示已可用,`locked` 表示已发入冻结余额待完成流水。倍数0直接可用,大于0时独立累计奖金发放后有效投注,达到“奖金×倍数”自动解冻。活动下架后仍可查询本人rewards,历史记录保留。
 

+ 35 - 6
docs/前端交付/交付清单.json

@@ -37,9 +37,9 @@
       "role": "后台",
       "kind": "正文",
       "git_status": "M",
-      "source_sha256": "a15115b1144e65c867737ab2c7c1c4619c12609f803cfda1f7b014a68ee6c93f",
-      "delivery_sha256": "a15115b1144e65c867737ab2c7c1c4619c12609f803cfda1f7b014a68ee6c93f",
-      "revised_on": "2026-11-02"
+      "source_sha256": "e8cd1de6ca33f2510ac0c3ef90fbb92b906dc42b18fc358c0954790cc5c70161",
+      "delivery_sha256": "e8cd1de6ca33f2510ac0c3ef90fbb92b906dc42b18fc358c0954790cc5c70161",
+      "revised_on": "2026-11-03"
     },
     {
       "project": "bot-28",
@@ -213,9 +213,9 @@
       "role": "前台",
       "kind": "正文",
       "git_status": "??",
-      "source_sha256": "7d3b17c11a0aafa1eedbedac4dd07d249497be21d9e670a7bd5ae69cde8e4249",
-      "delivery_sha256": "7d3b17c11a0aafa1eedbedac4dd07d249497be21d9e670a7bd5ae69cde8e4249",
-      "revised_on": "2026-11-02"
+      "source_sha256": "387fd3b4a4ccc29a83e40d1bfb402e9179c26741da11dccb80859cb7e0398b8d",
+      "delivery_sha256": "387fd3b4a4ccc29a83e40d1bfb402e9179c26741da11dccb80859cb7e0398b8d",
+      "revised_on": "2026-11-03"
     },
     {
       "project": "melbet_sport-api",
@@ -268,6 +268,17 @@
       "source_sha256": "ff9a70b0fff9cea5b95a6bed0938dba8137ea5b7b50527c39ab47822bbbdc99c",
       "delivery_sha256": "ff9a70b0fff9cea5b95a6bed0938dba8137ea5b7b50527c39ab47822bbbdc99c",
       "revised_on": "2026-11-02"
+    },
+    {
+      "project": "bot-28",
+      "source_path": "docs/总后台/独立活动/净亏邀请-前端修改清单.md",
+      "delivery_path": "bot-28/后台/独立活动/净亏邀请-前端修改清单.md",
+      "role": "后台",
+      "kind": "修正单",
+      "git_status": "??",
+      "source_sha256": "cc622d1016c3d52c3a26035199ef9c9639316ec7cf3c281960627238fc7aba3b",
+      "delivery_sha256": "06c0a9f5133f7fad9699b6c160b64934a2856f55aec0a16c07d82ff9534d8d71",
+      "revised_on": "2026-11-03"
     }
   ],
   "excluded": [
@@ -430,6 +441,24 @@
       "project": "bot-28 / melbet_sport-api",
       "branch": "master",
       "description": "净亏邀请后台文档重新整理:记录名单与中文列名说明并入《净亏邀请-后台接口》“记录查询”,删除单独的记录说明;券包文档同步已使用券不显示的变更;更新目录、清单及校验值。"
+    },
+    {
+      "date": "2026-11-03",
+      "project": "bot-28 / melbet_sport-api",
+      "branch": "master / dev",
+      "description": "净亏邀请改为可重复参加:领取后进入下一轮,claim 传当前 round_no,不再返回 claimed;优惠券模式奖励金额只计邀请人券;后台申请记录增加 claimed_count、reward_round_no。同步两份副本与校验值。"
+    },
+    {
+      "date": "2026-11-03",
+      "project": "bot-28",
+      "branch": "master",
+      "description": "新增《净亏邀请-前端修改清单》:会员前端与管理后台为可重复参加和优惠券奖励金额修正需要修改的内容及验收项。"
+    },
+    {
+      "date": "2026-11-03",
+      "project": "bot-28 / melbet_sport-api",
+      "branch": "master / dev",
+      "description": "净亏邀请领取轮次改由后端计算,claim 不再需要 round_no(旧前端传 1 兼容);同步会员接口与前端修改清单。"
     }
   ]
 }

+ 1 - 0
docs/前端交付/交付清单.md

@@ -8,6 +8,7 @@
 
 | 项目 | 来源路径 | 状态 | 交付文件 | 类型 |
 |---|---|---|---|---|
+| bot-28 | `docs/总后台/独立活动/净亏邀请-前端修改清单.md` | 新增(2026-11-03) | [净亏邀请-前端修改清单](bot-28/后台/独立活动/净亏邀请-前端修改清单.md) | 修正单 |
 | bot-28 | `docs/总后台/后台实时推送-前端接入.md` | ??(2026-10-31 增补) | [后台实时推送-前端接入.md](bot-28/后台/后台实时推送-前端接入.md) | 正文 |
 | bot-28 | `docs/总后台/独立活动-后台接口.md` | M | [独立活动-后台接口.md](bot-28/后台/独立活动-后台接口.md) | 目录 |
 | bot-28 | `docs/总后台/独立活动/净亏邀请-后台接口.md` | M | [净亏邀请-后台接口.md](bot-28/后台/独立活动/净亏邀请-后台接口.md) | 正文 |

+ 13 - 0
docs/后端/净亏邀请优惠券-部署说明.md

@@ -22,6 +22,19 @@ php artisan migrate
 
 如果 melbet_sport-api 先于迁移上线,带券的下注、券包和净亏邀请详情会因缺表报错;不带券的下注不写新字段,不受影响。
 
+### 可重复参加(2026-11-03)
+
+净亏邀请改为可重复参加:领取后 `promotion_accounts.round_no` 加 1,并清空 `applied_at`、`application_snapshot`;下一轮净亏从上一次领取(该轮发奖记录 `created_at`)起算,有效邀请只算新一轮申请后注册的会员。被清空的申请信息保存在发奖记录 `rule_snapshot.application` 中。
+
+优惠券模式的发奖记录 `amount` 改为邀请人本人券的抵扣总额;含被邀请人券的总额记在 `rule_snapshot.coupon_face_value`,`budget_used` 仍按总额扣减。
+
+迁移 `2026_11_03_170000_repeat_loss_invite_rounds.php`(可重复执行)处理历史数据:
+
+- 已领取但仍停在原轮次的会员进入下一轮,原申请写入该发奖记录的 `rule_snapshot.application`。否则这些会员会因为 `applied_at` 未清空而无法再次申请。
+- 已发的优惠券奖励把原 `amount` 写入 `coupon_face_value`,`amount` 改为邀请人券抵扣总额(例如 500 + 被邀请人券 3×40 = 620 的记录改为 500)。`budget_used` 不变。
+
+两个项目的 `PromotionService` 需与迁移一起发布。净亏邀请领取的轮次由服务端决定,忽略请求里的 `round_no`(会员接口中该参数改为可选,转盘仍必填):本轮已申请时领取本轮;领取后尚未重新申请时,重试返回上一轮记录(`replayed=true`)。旧前端固定传 1 不受影响。
+
 ## 口径
 
 - **派奖**:仍按下注原额计算,赔率、和局/走水退本金都不变。

+ 116 - 0
docs/总后台/独立活动/净亏邀请-前端修改清单.md

@@ -0,0 +1,116 @@
+# 净亏邀请-前端修改清单(2026-11-03)
+
+本次后端有两项变更,会员前端和管理后台前端都要配合修改:
+
+1. **可重复参加**:原来每人只能领取一次。现在领取后进入下一轮,可以再次申请、再次领取,次数不限。
+2. **奖励金额修正**:优惠券模式下,发奖记录的 `amount` 原来包含被邀请人的券,例如配置奖励 500,却显示 620。现在只计邀请人本人的券,显示 500。历史记录已由后端迁移修正。
+
+接口地址、认证和返回格式不变。完整字段见:
+
+- 会员前端:[净亏邀请-会员接口](../../../../melbet_sport-api/docs/独立活动/净亏邀请-会员接口.md)
+- 管理后台:[净亏邀请-后台接口](净亏邀请-后台接口.md)
+
+## 一、会员前端(melbet_sport-api)
+
+### 1. 领取不需要传轮次
+
+领取轮次由后端计算:`POST /api/promotion/claim` 只传 `activity_id`、`platform`,**不需要传 `round_no`**。旧代码仍传 `round_no=1` 也不影响,后端会忽略。
+
+| 场景 | 结果 |
+|---|---|
+| 本轮已达标 | 领取成功,返回的 `round_no` 为下一轮 |
+| 领取成功后重试,或再次点击 | `replayed=true`,返回刚领取的记录,不会重复发放 |
+| 首次参加且未申请 | `code=0`,`msg` 为"请先申请活动" |
+| 本轮已申请但邀请人数不足 | `code=0`,`msg` 为"有效邀请人数不足" |
+
+### 2. 状态不再有 `claimed`
+
+详情和 progress 的 `status` 只表示当前这一轮:
+
+| status | 页面 |
+|---|---|
+| not_applied | 本轮未申请。`rewards` 为空时显示"申请";`rewards` 不为空时显示"再次参加",净亏达到门槛后可申请 |
+| collecting | 本轮已申请,显示邀请进度 `qualified_invitees / rules.invite_count` |
+| ready | 本轮已达标,显示"领取" |
+
+原来按 `claimed` 显示"已领取"并关闭入口的逻辑要去掉。领取成功后重新请求详情:`status` 回到 `not_applied`,`loss` 和 `qualified_invitees` 从 0 重新累计。
+
+### 3. 净亏和邀请人数按本轮计算
+
+| 字段 | 说明 |
+|---|---|
+| round_no | 第几次参加,从 1 开始 |
+| loss | 本轮累计净亏,从 `loss_from` 起算 |
+| loss_from | 新增。本轮净亏的起算时间:第一轮为活动首次上架时间,之后为上一次领取时间 |
+| qualified_invitees | 本轮申请之后新邀请的有效会员数;上一轮已计入的会员不再计入 |
+| rules | 本轮已申请时为申请快照,未申请时为当前配置 |
+
+页面可提示"本轮净亏自 `loss_from` 起计算"。
+
+### 4. 奖励记录和金额
+
+- 历次奖励使用详情 `rewards`(最近 10 条)或 `GET /api/promotion/rewards` 分页展示,每条带 `round_no`,可显示为"第 N 次"。
+- 优惠券模式下,`reward.amount` 为邀请人本人获得的券抵扣总额,不含被邀请人的券。直接展示即可,不要自行加上被邀请人的券。
+- 详情 `coupons` 为本人在本活动历次获得的券合计,按档位分组。
+
+### 5. 文案
+
+删除"每人仅限一次""已参加过"等文案,改为"可重复参加,每次领取后重新累计净亏和邀请人数"。
+
+### 6. 券包
+
+券包接口本次不变。
+
+### 会员前端验收
+
+- 第一轮领取后,页面显示"再次参加",净亏和邀请人数归零。
+- 第二轮净亏达标后可以申请,邀请新会员达标后领取成功;奖励记录出现第 2 条,`round_no=2`。
+- 领取请求不带 `round_no` 也能领取第二轮;网络重试不会重复发放。
+- 优惠券模式配置奖励 500 时,领取记录显示 500。
+
+## 二、管理后台前端(bot-28)
+
+配置表单**不需要改字段**,只调整提示文案和记录页。
+
+### 1. 配置表单文案
+
+- 活动说明:每名会员可多次参加,每次领取后重新累计净亏和邀请人数。
+- `reward_amount`:现金模式为每次发放金额;优惠券模式为邀请人每次可获得的券抵扣总额上限。
+- `budget`:累计奖励额度,包括每次领取发出的邀请人券和被邀请人券。所以 `budget_used` 可能大于发奖记录 `amount` 之和,这不是错误。
+- `reward_quota`:限制获奖会员人数,同一会员多次领取只算一人。
+
+### 2. 申请记录 `kind=accounts`
+
+每名会员仍然只有一行,显示当前轮次和最近一次领取:
+
+| 字段 | 列名 | 修改 |
+|---|---|---|
+| round_no | 当前轮次 | 原来隐藏,改为显示 |
+| claimed_count | 已领取次数 | 新增 |
+| reward_round_no | 最近领取轮次 | 新增,未领取过为 `null`,显示"—" |
+| reward_id / reward_status | 最近一次发奖记录 ID / 状态 | 原为"关联发奖",改为"最近一次" |
+| applied_at | 本轮申请时间 | 领取后会清空,为空时显示"本轮未申请" |
+| application_snapshot | 本轮申请时规则 | 领取后清空;以前的申请改在发奖记录中查看 |
+| inviter / invitees | 名单与获券 | 为最近一次领取的名单,建议标注"第 `reward_round_no` 轮" |
+
+### 3. 发奖记录 `kind=rewards`
+
+- 同一会员每领取一次有一行。`round_no` 原来隐藏,改为显示"第几次"。
+- `amount` 在优惠券模式下改称"邀请人券抵扣总额",不含被邀请人券。历史记录已修正,例如 620 改为 500。
+- 详情区可增加以下字段,取自 `rule_snapshot`(先解析 JSON):
+
+| 字段 | 名称 |
+|---|---|
+| application.applied_at | 该轮申请时间 |
+| application.loss | 申请时净亏 |
+| application.from | 净亏统计开始时间 |
+| coupon_face_value | 本次发券总额(含被邀请人券),仅优惠券模式 |
+
+`inviter`、`invitees` 和已发优惠券列表 `kind=coupons` 不变。
+
+### 管理后台验收
+
+- 申请记录显示当前轮次、已领取次数、最近领取轮次;领取后本轮申请时间显示"本轮未申请"。
+- 同一会员领取两次后,发奖记录有两行,轮次分别为 1、2。
+- 优惠券模式发奖记录金额等于邀请人券抵扣总额,不再是 620 这类包含被邀请人券的数值。
+- 没有残留"固定为 1,隐藏轮次"或"每人只能领取一次"的文案。

+ 31 - 14
docs/总后台/独立活动/净亏邀请-后台接口.md

@@ -46,7 +46,7 @@
 
 保存必填根字段:`id/title/type/status/start_time/end_time` 及三语 `translations`。时间按UTC+8填写,结束必须晚于开始。可选 `lang/sort/content/publish_platforms`;平台为 `PC/H5/APP`,空数组表示全部。
 
-可选通用资格:`register_time_limited/register_start_time/register_end_time/register_platforms`、`deposit_limited/deposit_min/deposit_max`、`show_ineligible`。可选 `reward_quota` 限制不同获奖会员数,`budget` 限制累计奖励金额;`null` 为不限制,0为无剩余额度。`rewarded_count/budget_used/first_published_at` 为服务端字段,只展示不提交。
+可选通用资格:`register_time_limited/register_start_time/register_end_time/register_platforms`、`deposit_limited/deposit_min/deposit_max`、`show_ineligible`。可选 `reward_quota` 限制不同获奖会员数(同一会员多次领取只算一人),`budget` 限制累计奖励金额(优惠券模式按邀请人券和被邀请人券的抵扣总额计入);`null` 为不限制,0为无剩余额度。`rewarded_count/budget_used/first_published_at` 为服务端字段,只展示不提交。
 
 净亏邀请没有图片字段,不展示图片上传/预览,也不校验或提交 `image`。
 
@@ -149,15 +149,19 @@
 ## 业务规则
 
 1. 净亏和有效邀请人数直接使用接口返回值。
-2. 达到净亏门槛后会员点击申请;申请成功时固定当时的整套规则(包括奖励模式、券档位、券时间与游戏),之后修改配置不影响已申请的会员。无效申请不消耗机会,重复有效申请返回原进度。
+2. 达到净亏门槛后会员点击申请;申请成功时固定当时的整套规则(包括奖励模式、券档位、券时间与游戏),之后修改配置只影响下一次申请。无效申请不消耗机会,重复有效申请返回原进度。
 3. 会员是否达到活动条件由服务端判断,记录页显示对应状态。
-4. 新增有效直属会员达到人数后,会员自行领取。每名会员在本活动只能领取一次,下架再上架不重置机会或首次上架时间。
+4. 新增有效直属会员达到人数后,会员自行领取。**可重复参加**:每领取一次进入下一轮(`round_no` 加 1),会员需要重新达到净亏门槛并重新申请:
+   - 新一轮净亏从上一次领取时开始累计,已用过的净亏不重复计算;
+   - 有效邀请只算本轮申请之后新注册并绑定的直属会员,上一轮已计入的会员不再计入;
+   - 次数不限,下架再上架不重置轮次或首次上架时间。
 5. **现金模式**:记录页显示接口返回的奖励金额和状态。
 6. **优惠券模式**:领取时不发现金,改为发券:
    - 邀请人按 `inviter_coupons` 每份一张券。
    - 计入人数的被邀请人每人按 `invitee_coupons` 一套券。如果有效下级多于 `invite_count`,按注册先后取前 `invite_count` 名。
    - 所有券使用同一可用时间和可用游戏。
-   - 奖励记录 `amount` 为本次发出的券抵扣总额,状态为 `paid`;记录页直接展示,会员钱包不增加现金。
+   - 奖励记录 `amount` 为**邀请人本人**获得的券抵扣总额(`inviter_coupons` 的抵扣×份数,配置为奖励金额时即等于奖励金额),不含被邀请人的券;状态为 `paid`,会员钱包不增加现金。
+   - 被邀请人券计入活动预算 `budget_used`,所以 `budget_used` 可能大于各条 `amount` 之和。
 7. 券只能在 F1 自有游戏(F1体育、F1彩票)下注时使用;退款后的可用状态以券包接口返回为准。
 
 ## 记录查询:申请、发奖、已发优惠券
@@ -186,13 +190,15 @@
 | id | 申请记录 ID | 显示 |
 | activity_id | 活动 ID | 可隐藏(页面已在本活动内) |
 | user_id | 申请会员 ID | 与 `inviter.account` 合并显示为"ID / 账号" |
-| round_no | 参与轮次 | 固定为 1,隐藏 |
+| round_no | 当前轮次 | 显示,第几次参加 |
 | spins | 剩余抽奖次数 | 转盘专用,隐藏 |
-| applied_at | 申请时间 | 为空时显示"未申请" |
-| application_snapshot | 申请时规则 | 按下方摘要展示,不显示原始 JSON |
+| applied_at | 本轮申请时间 | 为空时显示"本轮未申请"(领取后会清空,等待下一轮申请) |
+| application_snapshot | 本轮申请时规则 | 按下方摘要展示,不显示原始 JSON;领取后清空,历史申请在发奖记录 `rule_snapshot.application` 中 |
+| claimed_count | 已领取次数 | 显示 |
+| reward_round_no | 最近一次领取的轮次 | 无领取时为 `null` |
 | reward_mode | 奖励方式 | `cash` 现金 / `coupon` 优惠券;`null` 显示"—" |
-| reward_id | 关联发奖记录 ID | 未发奖时为 `null`,显示"—" |
-| reward_status | 发奖状态 | 枚举同发奖记录 `status`;`null` 显示"暂无发奖记录" |
+| reward_id | 最近一次发奖记录 ID | 未发奖时为 `null`,显示"—" |
+| reward_status | 最近一次发奖状态 | 枚举同发奖记录 `status`;`null` 显示"暂无发奖记录" |
 | inviter / invitees | 邀请人、被邀请人及获券 | 见下方[名单与获券](#名单与获券申请发奖共用) |
 | created_at / updated_at | 创建时间 / 更新时间 | 显示 |
 
@@ -217,8 +223,8 @@
 | activity_id | 活动 ID | 可隐藏 |
 | user_id | 领奖会员 ID(邀请人) | 与 `inviter.account` 合并显示 |
 | account_id | 关联申请记录 ID | 对应申请记录的 `id` |
-| round_no | 发奖轮次 | 固定为 1,隐藏 |
-| amount | 现金奖励金额 / 优惠券抵扣总额 | 列名随本行 `reward_mode` 切换 |
+| round_no | 发奖轮次 | 显示,第几次领取 |
+| amount | 现金奖励金额 / 邀请人券抵扣总额 | 列名随本行 `reward_mode` 切换;不含被邀请人券 |
 | required_flow | 解冻所需有效投注额 | 现金模式为 0 时显示"无需解冻";券模式隐藏 |
 | status | 发奖状态 | 见下表 |
 | credited_log_id | 奖励入账流水 ID | 券模式隐藏;无值显示"—" |
@@ -236,7 +242,15 @@
 | locked | 待解冻(已入冻结余额) |
 | paid | 已发放 |
 
-即使金额为 0 或无需解冻,也按 `status` 展示状态,不要当作已发放。每名会员只能领取一次,重复领取不会重复入账或发券。停止活动用下架,参与历史不会删除。
+即使金额为 0 或无需解冻,也按 `status` 展示状态,不要当作已发放。同一会员每领取一轮有一条发奖记录;同一轮重复领取不会重复入账或发券。停止活动用下架,参与历史不会删除。
+
+`rule_snapshot` 中可用于详情的字段(解析 JSON 后):
+
+| 字段 | 中文名称 |
+|---|---|
+| application.applied_at | 本轮申请时间 |
+| application.loss / application.from | 申请时净亏 / 净亏统计开始时间 |
+| coupon_face_value | 本次发券抵扣总额(含被邀请人券),仅优惠券模式 |
 
 ### 名单与获券(申请、发奖共用)
 
@@ -272,6 +286,7 @@
 | 现金记录 | `invitees` 为发奖时达标的会员,双方 `coupons=[]` | 列出名单,获券显示"本次未发券";不要把被邀请人显示成现金收款人 |
 | 优惠券记录 | `invitees` 为本次计入发券的会员 | 列出每人账号与获券 |
 | 尚未发奖的申请 | `reward_id=null`、`reward_status=null`、`invitees=[]` | 显示"尚未生成发奖名单"(不代表没有下级或邀请数为 0) |
+| 已领取过的会员(申请记录) | 名单为最近一次领取的名单,`applied_at` 为本轮申请 | 名单列标注"第 `reward_round_no` 轮";全部历史看发奖记录 |
 | 账号未设置或会员已删除 | `account=null`,ID 保留 | 账号显示"—" |
 | 被邀请人未配置券 | 该人 `coupons=[]` | 显示"本次未发券" |
 
@@ -294,7 +309,8 @@
         "activity_id": 1,
         "user_id": 164,
         "account_id": 20001,
-        "amount": "500",
+        "round_no": 1,
+        "amount": "200",
         "status": "paid",
         "reward_mode": "coupon",
         "inviter": {
@@ -339,7 +355,8 @@
 
 ### 记录页验收
 
-- 列名全为中文;不展示 `spins`、固定轮次和内部快照字段。
+- 列名全为中文;不展示 `spins` 和内部快照字段;显示轮次和已领取次数。
+- 同一会员领取多轮时,发奖记录逐轮列出,申请记录显示当前轮申请和最近一次领取。
 - 现金记录显示邀请人和达标会员的 ID、账号,获券显示"本次未发券"。
 - 优惠券记录在同一行显示邀请人和每名被邀请人的 ID、账号、满减档位和张数。
 - 未发奖的申请显示"尚未生成发奖名单";账号缺失显示"—",保留会员 ID。

+ 56 - 3
tests/Integration/PromotionActivitiesTest.php

@@ -53,7 +53,7 @@ class PromotionActivitiesTest extends TestCase
             'reporting_activity' => 'id INTEGER PRIMARY KEY AUTOINCREMENT,user_id INTEGER,session_hash TEXT,observed_at TEXT',
             'menus' => 'id INTEGER PRIMARY KEY AUTOINCREMENT,parent_id INTEGER,title TEXT,icon TEXT,uri TEXT,permission_name TEXT,sort INTEGER,status INTEGER,type INTEGER,created_at TEXT,updated_at TEXT',
         ] as $table => $columns) DB::statement('CREATE TABLE bot_' . $table . ' (' . $columns . ')');
-        foreach (['2026_10_13_120000_add_activity_eligibility_fields.php', '2026_10_14_120000_add_activity_reward_quotas.php', '2026_10_25_120000_add_game_category_to_activity_rewards.php', '2026_10_26_160000_create_promotion_activities.php', '2026_10_27_130000_initialize_fixed_promotion_activities.php', '2026_10_27_140000_add_promotion_activity_translations.php', '2026_11_03_120000_add_promotion_coupons.php'] as $migration) (require dirname(__DIR__, 2) . '/database/migrations/' . $migration)->up();
+        foreach (['2026_10_13_120000_add_activity_eligibility_fields.php', '2026_10_14_120000_add_activity_reward_quotas.php', '2026_10_25_120000_add_game_category_to_activity_rewards.php', '2026_10_26_160000_create_promotion_activities.php', '2026_10_27_130000_initialize_fixed_promotion_activities.php', '2026_10_27_140000_add_promotion_activity_translations.php', '2026_11_03_120000_add_promotion_coupons.php', '2026_11_03_170000_repeat_loss_invite_rounds.php'] as $migration) (require dirname(__DIR__, 2) . '/database/migrations/' . $migration)->up();
         $this->member(1); $this->member(2, 1);
         DB::table('share_policies')->insert(['id' => 1, 'effective_date' => '2026-10-01', 'valid_member_min_deposit' => '200', 'valid_member_min_effective_bet' => '300']);
         $this->service = new PromotionService();
@@ -407,14 +407,16 @@ class PromotionActivitiesTest extends TestCase
         }
         $this->service->claim($id, 1, 1); $this->service->claim($id, 1, 1);
         $reward = DB::table('promotion_rewards')->first();
-        $this->assertSame('paid', $reward->status); $this->assertEquals(220, $reward->amount); $this->assertEquals(0, $reward->required_flow);
+        // The amount is the inviter's own coupons; the invitee coupons only count toward the budget.
+        $this->assertSame('paid', $reward->status); $this->assertEquals(200, $reward->amount); $this->assertEquals(0, $reward->required_flow);
+        $this->assertSame('220.00', json_decode($reward->rule_snapshot, true)['coupon_face_value']);
         $this->assertSame('0', (string)DB::table('wallets')->where('member_id', 'm1')->value('available_balance'));
         $this->assertSame(0, DB::table('balance_logs')->where('change_type', '优惠活动')->count());
         $this->assertSame(3, DB::table('promotion_coupons')->where('user_id', 1)->where('role', 'inviter')->count());
         // Only the first invite_count qualified invitees are counted and receive coupons.
         $this->assertSame([3, 4], DB::table('promotion_coupons')->where('role', 'invitee')->orderBy('user_id')->pluck('user_id')->map(fn ($v) => (int)$v)->all());
         $state = $this->service->detail($id, 1, 'H5');
-        $this->assertSame('claimed', $state['status']); $this->assertSame('coupon', $state['rules']['reward_mode']);
+        $this->assertSame('not_applied', $state['status']); $this->assertSame(2, (int)$state['round_no']); $this->assertSame('coupon', $state['rules']['reward_mode']);
         $this->assertSame([2, 1], array_column($state['coupons'], 'quantity'));
         $this->assertEquals(220, DB::table('promotion_activities')->where('id', $id)->value('budget_used'));
         // Switching back to cash keeps the coupon settings for the next switch.
@@ -424,6 +426,57 @@ class PromotionActivitiesTest extends TestCase
         $this->assertSame(5, $records['total']);
     }
 
+    public function test_loss_invite_repeats_with_net_loss_from_previous_claim_and_new_invitees(): void
+    {
+        $id = $this->activity(7, ['invite_count' => 1]);
+        $this->bet(1, '600', '-600', '2026-10-26 12:00:00'); $this->service->apply($id, 1);
+        $this->travel('2026-10-26 13:00:00');
+        $this->member(3, 1, '2026-10-26 13:00:00'); $this->rawDeposit(3, '200', '2026-10-26 13:00:00'); $this->bet(3, '300', '-300', '2026-10-26 13:00:00');
+        $this->assertSame(2, $this->service->claim($id, 1, 1)['round_no']);
+        $state = $this->service->detail($id, 1, 'H5');
+        $this->assertSame('not_applied', $state['status']); $this->assertSame(2, (int)$state['round_no']);
+        $this->assertSame('0.00', $state['loss']); $this->assertSame('2026-10-26 13:00:00', $state['loss_from']);
+        // Loss before the claim is not counted again; the server picks the round, so a retry before reapplying replays.
+        $this->invalid(fn () => $this->service->apply($id, 1), '净亏');
+        $this->assertTrue($this->service->claim($id, 1, 1)['replayed']);
+        $replay = $this->service->claim($id, 1);
+        $this->assertTrue($replay['replayed']); $this->assertSame(1, (int)$replay['reward']['round_no']);
+        $this->travel('2026-10-27 12:00:00');
+        $this->bet(1, '500', '-500', '2026-10-27 12:00:00'); $this->service->apply($id, 1);
+        // The invitee counted in round 1 registered before this application.
+        $this->invalid(fn () => $this->service->claim($id, 1), '人数不足');
+        $this->travel('2026-10-27 13:00:00');
+        $this->member(4, 1, '2026-10-27 13:00:00'); $this->rawDeposit(4, '200', '2026-10-27 13:00:00'); $this->bet(4, '300', '-300', '2026-10-27 13:00:00');
+        // A client still sending round_no=1 claims the current round.
+        $claim = $this->service->claim($id, 1, 1);
+        $this->assertFalse($claim['replayed']); $this->assertSame(3, $claim['round_no']); $this->assertSame(2, (int)$claim['reward']['round_no']);
+        $rewards = DB::table('promotion_rewards')->orderBy('round_no')->get();
+        $this->assertSame([1, 2], $rewards->pluck('round_no')->map(fn ($v) => (int)$v)->all());
+        $this->assertSame('1000.0000000000', (string)DB::table('wallets')->where('member_id', 'm1')->value('available_balance'));
+        $this->assertEquals(600, json_decode($rewards[0]->rule_snapshot, true)['application']['loss']);
+        $this->assertSame([4], json_decode($rewards[1]->rule_snapshot, true)['qualified_invitees']);
+        $this->assertEquals(1, DB::table('promotion_activities')->where('id', $id)->value('rewarded_count'));
+        $row = (new PromotionAdminService())->records(['activity_id' => $id, 'kind' => 'accounts'])['data'][0];
+        $this->assertSame(2, $row->claimed_count); $this->assertSame(2, $row->reward_round_no); $this->assertNull($row->applied_at);
+        $this->assertSame([4], array_column($row->invitees, 'id'));
+    }
+
+    public function test_repeat_migration_moves_claimed_members_to_next_round_and_keeps_only_inviter_coupon_amount(): void
+    {
+        $id = $this->activity(7);
+        $snapshot = ['reward_mode' => 'coupon', 'inviter_coupons' => [['min_amount' => '100', 'discount' => '50', 'quantity' => 10]], 'invitee_coupons' => [['min_amount' => '100', 'discount' => '40', 'quantity' => 1]]];
+        $account = DB::table('promotion_accounts')->insertGetId(['activity_id' => $id, 'user_id' => 1, 'round_no' => 1, 'spins' => 0,
+            'applied_at' => '2026-10-26 12:00:00', 'application_snapshot' => json_encode(['loss' => '550', 'rules' => $snapshot])]);
+        DB::table('promotion_rewards')->insert(['activity_id' => $id, 'user_id' => 1, 'account_id' => $account, 'round_no' => 1, 'amount' => '620',
+            'status' => 'paid', 'rule_snapshot' => json_encode($snapshot), 'created_at' => '2026-10-26 13:00:00']);
+        $migration = require dirname(__DIR__, 2) . '/database/migrations/2026_11_03_170000_repeat_loss_invite_rounds.php';
+        $migration->up(); $migration->up();
+        $reward = DB::table('promotion_rewards')->first(); $rules = json_decode($reward->rule_snapshot, true);
+        $this->assertEquals(500, $reward->amount); $this->assertSame('620.00', $rules['coupon_face_value']); $this->assertSame('550', $rules['application']['loss']);
+        $row = DB::table('promotion_accounts')->where('id', $account)->first();
+        $this->assertSame(2, (int)$row->round_no); $this->assertNull($row->applied_at); $this->assertNull($row->application_snapshot);
+    }
+
     public function test_successful_online_time_deduplicates_devices_and_does_not_count_closed_session_as_online(): void
     {
         $id = $this->activity();