Browse Source

笔笔返与回水:分类规则配置改版

- 新增笔笔返、回水分类规则配置(RewardRules 后台接口、reward_rules 迁移)
- 新增 rewards:sync-bases 定时同步奖励基数,发放服务按新规则计算
- 补充后台配置说明与部署说明,更新相关测试

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
doge 9 hours ago
parent
commit
fd2d1856ce

+ 19 - 0
app/Console/Commands/SyncRewardBases.php

@@ -0,0 +1,19 @@
+<?php
+
+namespace App\Console\Commands;
+
+use App\Services\RewardBaseSyncService;
+use Illuminate\Console\Command;
+
+class SyncRewardBases extends Command
+{
+    protected $signature = 'rewards:sync-bases {--from= : New York start date Y-m-d for backfill} {--to= : New York end date Y-m-d for backfill}';
+    protected $description = 'Refresh unpaid rebate/backflow rows from per-category bet facts; never touches paid rows or wallets';
+
+    public function handle(RewardBaseSyncService $service): int
+    {
+        $result = $service->run($this->option('from') ?: null, $this->option('to') ?: null);
+        $this->line(json_encode($result, JSON_UNESCAPED_UNICODE));
+        return $result['failed'] > 0 ? self::FAILURE : self::SUCCESS;
+    }
+}

+ 1 - 0
app/Console/Kernel.php

@@ -37,6 +37,7 @@ class Kernel extends ConsoleKernel
             ->dailyAt('04:10')
             ->dailyAt('04:10')
             ->withoutOverlapping(180);
             ->withoutOverlapping(180);
         $schedule->command('share:sync-facts --write')->everyMinute()->withoutOverlapping(30);
         $schedule->command('share:sync-facts --write')->everyMinute()->withoutOverlapping(30);
+        $schedule->command('rewards:sync-bases')->everyMinute()->withoutOverlapping(30);
         $schedule->command('share:settle --reconcile')->everyFiveMinutes()->withoutOverlapping(120);
         $schedule->command('share:settle --reconcile')->everyFiveMinutes()->withoutOverlapping(120);
         $schedule->command('share:settle')->timezone('Asia/Shanghai')->dailyAt('00:10')->withoutOverlapping(180);
         $schedule->command('share:settle')->timezone('Asia/Shanghai')->dailyAt('00:10')->withoutOverlapping(180);
         $schedule->command('admin-realtime:prune')->hourly()->withoutOverlapping(30);
         $schedule->command('admin-realtime:prune')->hourly()->withoutOverlapping(30);

+ 29 - 0
app/Http/Controllers/admin/RewardRules.php

@@ -0,0 +1,29 @@
+<?php
+
+namespace App\Http\Controllers\admin;
+
+use App\Constants\HttpStatus;
+use App\Http\Controllers\Controller;
+use App\Services\RewardRuleService;
+use Illuminate\Support\Facades\Log;
+use Illuminate\Validation\ValidationException;
+use Throwable;
+
+class RewardRules extends Controller
+{
+    public function rebate() { return $this->run(fn () => RewardRuleService::get('rebate')); }
+    public function backflow() { return $this->run(fn () => RewardRuleService::get('backflow')); }
+    public function saveRebate() { return $this->run(fn () => RewardRuleService::save('rebate', request()->all())); }
+    public function saveBackflow() { return $this->run(fn () => RewardRuleService::save('backflow', request()->all())); }
+
+    private function run(callable $callback)
+    {
+        try { return $this->success($callback()); }
+        catch (ValidationException $e) {
+            return $this->error(HttpStatus::CUSTOM_ERROR, $e->validator->errors()->first());
+        } catch (Throwable $e) {
+            Log::error('reward_rules_failed', ['exception' => $e]);
+            return $this->error(HttpStatus::CUSTOM_ERROR, '奖励规则暂不可用,请稍后重试');
+        }
+    }
+}

+ 0 - 2
app/Http/Controllers/api/ActivityReward.php

@@ -33,8 +33,6 @@ class ActivityReward extends BaseController
         try {
         try {
             $data['rule'] = ConfigService::getVal("activity_rule");
             $data['rule'] = ConfigService::getVal("activity_rule");
             $data['service_customer'] = ConfigService::getVal("benefits_service_customer");
             $data['service_customer'] = ConfigService::getVal("benefits_service_customer");
-            $data['rebate']['title'] = ConfigService::getRemark("benefits_bibi_return");
-            $data['rebate']['sub_title'] = ConfigService::getVal("benefits_bibi_return");
         } catch (Exception $e) {
         } catch (Exception $e) {
             return $this->error($e->getMessage(), [], $e->getCode());
             return $this->error($e->getMessage(), [], $e->getCode());
         }
         }

+ 3 - 1
app/Models/Backflow.php

@@ -16,7 +16,9 @@ namespace App\Models;
 class Backflow extends BaseModel
 class Backflow extends BaseModel
 {
 {
     protected $table = 'backflow';
     protected $table = 'backflow';
-    protected $fillable = ['date', 'member_id', 'grant_time', 'recharge_amount', 'withdrawal_amount', 'backflow_ratio', 'amount', 'status'];
+    protected $fillable = ['date', 'member_id', 'grant_time', 'recharge_amount', 'withdrawal_amount', 'backflow_ratio', 'amount', 'status',
+        'loss_amount', 'details', 'need_flow'];
+    protected $casts = ['details' => 'array'];
 
 
     function user()
     function user()
     {
     {

+ 2 - 73
app/Models/Rebate.php

@@ -2,9 +2,6 @@
 
 
 namespace App\Models;
 namespace App\Models;
 
 
-use Carbon\Carbon;
-use Illuminate\Support\Facades\Log;
-
 /**
 /**
  * @property $id
  * @property $id
  * @property $effective_betting_amount
  * @property $effective_betting_amount
@@ -22,8 +19,9 @@ class Rebate extends BaseModel
     protected $fillable = ['date', 'member_id', 'betting_amount', 'rebate_ratio',
     protected $fillable = ['date', 'member_id', 'betting_amount', 'rebate_ratio',
         'amount', 'status', 'first_name', 'username', 'audited_by',
         'amount', 'status', 'first_name', 'username', 'audited_by',
         'profit', 'huishui_percentage', 'huishui_restriction', 'huishui_amount',
         'profit', 'huishui_percentage', 'huishui_restriction', 'huishui_amount',
-        'effective_betting_amount', 'grant_time'
+        'effective_betting_amount', 'grant_time', 'details', 'need_flow'
     ];
     ];
+    protected $casts = ['details' => 'array'];
     const STATUS_WEI_FAN_YONG = 0;//未返佣
     const STATUS_WEI_FAN_YONG = 0;//未返佣
     const STATUS_FAN_YONG = 1; //已返佣
     const STATUS_FAN_YONG = 1; //已返佣
 
 
@@ -33,78 +31,9 @@ class Rebate extends BaseModel
             ->select(['id', 'member_id', 'username', 'first_name', 'admin_note', 'status', 'phone', 'visitor_id', 'register_ip']);
             ->select(['id', 'member_id', 'username', 'first_name', 'admin_note', 'status', 'phone', 'visitor_id', 'register_ip']);
     }
     }
 
 
-
-    static function updateProfit($data)
-    {
-        // Settlement belongs to the original betting day, including overnight draws.
-        $data['date'] = $data['date'] ?? Carbon::now('America/New_York')->format('Y-m-d');
-        $rebate = static::where('date', $data['date'])
-            ->where('member_id', $data['member_id'])->first();
-        if ($rebate) {
-            $profit = bcadd($rebate->profit, $data['profit'], 2);
-            $rebate->profit = $profit;
-            $rebate->save();
-        }
-        return $rebate;
-    }
-
-    static function addOrUpdate($data)
-    {
-        $data['date'] = $data['date'] ?? Carbon::now('America/New_York')->format('Y-m-d');
-        $data['rebate_ratio'] = Config::where('field', 'rebate')->first()->val;
-        $data['huishui_restriction'] = Config::where('field', 'huishui_restriction')->first()->val;
-        $data['huishui_percentage'] = Config::where('field', 'huishui_percentage')->first()->val;
-
-        $rebate = static::where('date', $data['date'])
-            ->where('member_id', $data['member_id'])->first();
-        if ($rebate) {
-            $rebate->rebate_ratio = $data['rebate_ratio'];
-            $rebate->first_name = $data['first_name'];
-            $rebate->username = $data['username'];
-            $rebate->save();
-            $rebate->increment("betting_amount", $data['betting_amount']);
-        } else {
-            $rebate = static::create($data);
-        }
-        return $rebate;
-    }
-
     protected function getGrantTimeAttribute($value): string
     protected function getGrantTimeAttribute($value): string
     {
     {
         if ($value > 0) return date('Y-m-d H:i', $value);
         if ($value > 0) return date('Y-m-d H:i', $value);
         return "";
         return "";
     }
     }
-
-    protected function getHuishuiRestrictionAttribute($value)
-    {
-        if ($this->status == static::STATUS_WEI_FAN_YONG) {
-            $huishui_restriction = Config::where('field', 'huishui_restriction')->first()->val;
-            return $huishui_restriction;
-        }
-        return $value;
-
-    }
-
-    protected function getHuishuiPercentageAttribute($value)
-    {
-        if ($this->status == static::STATUS_WEI_FAN_YONG) {
-            $huishui_percentage = Config::where('field', 'huishui_percentage')->first()->val;
-            return $huishui_percentage;
-        }
-        return $value;
-    }
-
-    protected function getHuishuiAmountAttribute($value)
-    {
-        if ($this->status == static::STATUS_WEI_FAN_YONG) {
-            $value = 0;
-            $huishui_restriction = Config::where('field', 'huishui_restriction')->first()->val;
-            $huishui_percentage = Config::where('field', 'huishui_percentage')->first()->val;
-            $lose = $this->profit * -1;
-            if ($lose >= $huishui_restriction) {
-                $value = bcmul($lose, $huishui_percentage, 2); // 返利金额
-            }
-        }
-        return $value;
-    }
 }
 }

+ 1 - 1
app/Models/WalletBonus.php

@@ -10,7 +10,7 @@ class WalletBonus extends BaseModel
     {
     {
         $data = [
         $data = [
             'member_id' => $memberId,
             'member_id' => $memberId,
-            'type' => $type,    //类型1充值活动返彩;2余额宝收益;3老用户回归日充值返彩 
+            'type' => $type,    //类型1充值活动返彩;2余额宝收益;3老用户回归日充值返彩;4笔笔返;5回水
             'activity_id' => $activityId,
             'activity_id' => $activityId,
             'related_id' => $related_id,
             'related_id' => $related_id,
             'amount' => $bonusAmount,
             'amount' => $bonusAmount,

+ 6 - 28
app/Services/BackflowService.php

@@ -3,7 +3,6 @@
 namespace App\Services;
 namespace App\Services;
 
 
 use App\Models\Backflow;
 use App\Models\Backflow;
-use App\Models\Config;
 use Carbon\Carbon;
 use Carbon\Carbon;
 use Exception;
 use Exception;
 
 
@@ -31,39 +30,18 @@ class BackflowService extends BaseService
 
 
 
 
     /**
     /**
+     * 只记录当月充值/提现,回水金额由 rewards:sync-bases 按分类亏损和规则计算。
      * @param $memberId string 会员ID
      * @param $memberId string 会员ID
      * @param  $changeAmount float 充值或提现金额
      * @param  $changeAmount float 充值或提现金额
      * @return Backflow
      * @return Backflow
      */
      */
     public static function updateOrCreate(string $memberId, float $changeAmount): Backflow
     public static function updateOrCreate(string $memberId, float $changeAmount): Backflow
     {
     {
-        $data['date'] = Carbon::now('Asia/Shanghai')->format('Y-m');
-        $data['backflow_ratio'] = Config::where('field', 'huishui_percentage')->first()->val;
-        $data['member_id'] = $memberId;
-        if ($changeAmount > 0) $data['recharge_amount'] = $changeAmount;
-        else $data['withdrawal_amount'] = $changeAmount;
-
-        $backflow = static::$MODEL::where('date', $data['date'])
-            ->where('member_id', $memberId)->first();
-        if ($backflow) {
-            if ($changeAmount > 0) $field = "recharge_amount";
-            else $field = 'withdrawal_amount';
-            $backflow->backflow_ratio = $data['backflow_ratio'];
-            $backflow->increment($field, $changeAmount);
-            $backflow->save();
-        } else {
-            $backflow = static::$MODEL::create($data);
-        }
-        $restriction = Config::where('field', 'huishui_restriction')->first()->val;
-        $difference = bcadd($backflow->recharge_amount, $backflow->withdrawal_amount, 2);
-        $difference = abs($difference);
-        if ($difference >= $restriction) {
-            $backflow->amount = bcmul($difference, $backflow->backflow_ratio, 2);
-        } else {
-            $backflow->amount = 0;
-        }
-        $backflow->save();
-        return $backflow;
+        $date = Carbon::now('Asia/Shanghai')->format('Y-m');
+        $field = $changeAmount > 0 ? 'recharge_amount' : 'withdrawal_amount';
+        $backflow = static::$MODEL::firstOrCreate(['date' => $date, 'member_id' => $memberId]);
+        if ((int) $backflow->status === 0) $backflow->increment($field, $changeAmount);
+        return $backflow->refresh();
     }
     }
 
 
 
 

+ 0 - 28
app/Services/BetService.php

@@ -7,7 +7,6 @@ use App\Constants\StepStatus;
 use App\Constants\Util;
 use App\Constants\Util;
 use App\Models\Message;
 use App\Models\Message;
 use App\Models\PcIssue;
 use App\Models\PcIssue;
-use App\Models\Rebate;
 use App\Models\User;
 use App\Models\User;
 use App\Models\LhcOrder;
 use App\Models\LhcOrder;
 use App\Models\Config;
 use App\Models\Config;
@@ -22,7 +21,6 @@ use Illuminate\Support\Facades\Log;
 use Telegram\Bot\Api;
 use Telegram\Bot\Api;
 use Telegram\Bot\Exceptions\TelegramSDKException;
 use Telegram\Bot\Exceptions\TelegramSDKException;
 use App\Services\ActivityRewardService;
 use App\Services\ActivityRewardService;
-use App\Services\Sharing\EffectiveBet;
 use App\Services\AdminRealtime\AdminRealtime;
 use App\Services\AdminRealtime\AdminRealtime;
 
 
 /**
 /**
@@ -445,17 +443,6 @@ class BetService extends BaseService
         WalletService::updateBalance($memberId, -$amount);
         WalletService::updateBalance($memberId, -$amount);
         BalanceLogService::addLog($memberId, -$amount, $balance, ($balance - $amount), '投注', $bet_id, '');
         BalanceLogService::addLog($memberId, -$amount, $balance, ($balance - $amount), '投注', $bet_id, '');
 
 
-
-        //记录投注金额
-        $rebate = Rebate::addOrUpdate([
-            'member_id' => $memberId,
-            'date' => EffectiveBet::newYorkDate(($betInfo ?: $newBet)->getRawOriginal('created_at')),
-            'betting_amount' => $amount,
-            'first_name' => $userInfo->first_name,
-            'username' => $userInfo->username,
-        ]);
-
-
         $odds = floatval($gameplayRuleInfo['odds']);
         $odds = floatval($gameplayRuleInfo['odds']);
         $issueNo = $issueInfo->issue_no;
         $issueNo = $issueInfo->issue_no;
         $text .= "{$keywords} {$amount}   ({$odds}" . lang('倍率') . ")";
         $text .= "{$keywords} {$amount}   ({$odds}" . lang('倍率') . ")";
@@ -835,7 +822,6 @@ class BetService extends BaseService
             $v = $settlementOrder->toArray();
             $v = $settlementOrder->toArray();
             $order_item = LhcOrderItem::where('ordernum', $v['ordernum'])->get()->toArray();
             $order_item = LhcOrderItem::where('ordernum', $v['ordernum'])->get()->toArray();
             $total_amount = 0;
             $total_amount = 0;
-            $rebateDate = EffectiveBet::newYorkDate($settlementOrder->getRawOriginal('created_at'));
             foreach($order_item as $value) {
             foreach($order_item as $value) {
                 $number = $value['number'];
                 $number = $value['number'];
                 // Payout uses the full stake; net result and effective bet use what the member paid after the coupon.
                 // Payout uses the full stake; net result and effective bet use what the member paid after the coupon.
@@ -880,13 +866,6 @@ class BetService extends BaseService
 
 
                     $yl = bcsub($profit, $paid, 2); // 盈利
                     $yl = bcsub($profit, $paid, 2); // 盈利
                     $item['profit_and_loss'] = $yl;
                     $item['profit_and_loss'] = $yl;
-                    $rebate = Rebate::updateProfit([
-                        'member_id' => $v['member_id'],
-                        'profit' => $yl,
-                        'date' => $rebateDate,
-                    ]);
-
-                    RebateService::updateEffectiveBettingAmount($rebate, EffectiveBet::amount($paid, $yl));
                     if (!in_array('13操', $awards) && !in_array('14操', $awards)) {
                     if (!in_array('13操', $awards) && !in_array('14操', $awards)) {
                         ActivityUserService::updateBettingAmount($v['member_id'], $paid);
                         ActivityUserService::updateBettingAmount($v['member_id'], $paid);
                     }
                     }
@@ -925,13 +904,6 @@ class BetService extends BaseService
                         $openList[$v['member_id']]['is_send'] = true;
                         $openList[$v['member_id']]['is_send'] = true;
                     }
                     }
                 } else {
                 } else {
-                    $rebate = Rebate::updateProfit([
-                        'member_id' => $v['member_id'],
-                        'profit' => bcsub('0', $paid, 2),
-                        'date' => $rebateDate,
-                    ]);
-
-                    RebateService::updateEffectiveBettingAmount($rebate, EffectiveBet::amount($paid, bcsub('0', $paid, 2)));
                     if (!in_array('13操', $awards) && !in_array('14操', $awards)) {
                     if (!in_array('13操', $awards) && !in_array('14操', $awards)) {
                         ActivityUserService::updateBettingAmount($v['member_id'], $paid);
                         ActivityUserService::updateBettingAmount($v['member_id'], $paid);
                     }
                     }

+ 0 - 10
app/Services/RebateService.php

@@ -88,16 +88,6 @@ class RebateService extends BaseService
     }
     }
 
 
 
 
-    //更新有效投注额
-    public static function updateEffectiveBettingAmount($rebate, $amount): void
-    {
-        if ($rebate) {
-            // Never change the basis of an already paid daily rebate.
-            Rebate::whereKey($rebate->id)->where('status', Rebate::STATUS_WEI_FAN_YONG)
-                ->increment('effective_betting_amount', $amount);
-        }
-    }
-
     //笔笔返 根据某天的有效投注额,和反水比例,进行笔笔返
     //笔笔返 根据某天的有效投注额,和反水比例,进行笔笔返
     public static function BibiReturn(Rebate $rebate, $amount): bool
     public static function BibiReturn(Rebate $rebate, $amount): bool
     {
     {

+ 106 - 0
app/Services/RewardBaseSyncService.php

@@ -0,0 +1,106 @@
+<?php
+
+namespace App\Services;
+
+use App\Services\Sharing\EffectiveBet;
+use Carbon\Carbon;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Log;
+use InvalidArgumentException;
+use Throwable;
+
+/**
+ * Keeps unpaid rebates (New York day) and backflow (Shanghai month) rows in step with share_bet_facts,
+ * so admin lists show every game category. Paid rows are never touched; payout recomputes on its own.
+ */
+class RewardBaseSyncService
+{
+    private const CURSOR = 'reward_bases_cursor';
+
+    public function run(?string $from = null, ?string $to = null): array
+    {
+        foreach ([$from, $to] as $date) {
+            if ($date !== null && (!preg_match('/^\d{4}-\d{2}-\d{2}$/D', $date) || Carbon::parse($date)->toDateString() !== $date)) {
+                throw new InvalidArgumentException('Dates must use Y-m-d');
+            }
+        }
+        if (($from === null) !== ($to === null) || ($from !== null && $from > $to)) throw new InvalidArgumentException('Invalid date range');
+        $result = ['rebates' => 0, 'backflow' => 0, 'failed' => 0];
+        $startedAt = now()->setTimezone('Asia/Shanghai')->format('Y-m-d H:i:s');
+        $query = DB::table('share_bet_facts')->select(['id', 'user_id', 'bet_at', 'bet_date']);
+        if ($from !== null) {
+            // A New York day spans two Shanghai dates.
+            $query->whereBetween('bet_date', [$from, Carbon::parse($to)->addDay()->toDateString()]);
+        } else {
+            $cursor = DB::table('share_system_state')->where('key', self::CURSOR)->value('value');
+            $since = $cursor ? EffectiveBet::time(json_decode($cursor, true, 512, JSON_THROW_ON_ERROR)['updated_at'])->subMinutes(5)
+                : now()->subDays(2);
+            $query->where('updated_at', '>=', $since->setTimezone('Asia/Shanghai')->format('Y-m-d H:i:s'));
+        }
+        $days = $months = [];
+        $query->orderBy('id')->chunkById(500, function ($facts) use (&$days, &$months, $from, $to) {
+            foreach ($facts as $fact) {
+                $day = Carbon::parse($fact->bet_at, 'Asia/Shanghai')->setTimezone('America/New_York')->toDateString();
+                if ($from === null || ($day >= $from && $day <= $to)) $days[$fact->user_id . '|' . $day] = [$fact->user_id, $day];
+                $months[$fact->user_id . '|' . substr((string) $fact->bet_date, 0, 7)] = [$fact->user_id, substr((string) $fact->bet_date, 0, 7)];
+            }
+        });
+        $users = [];
+        foreach (['rebate' => $days, 'backflow' => $months] as $type => $keys) {
+            foreach ($keys as [$userId, $period]) {
+                try {
+                    $user = $users[$userId] ??= DB::table('users')->where('id', $userId)->first(['id', 'member_id', 'level', 'username', 'first_name']);
+                    if (!$user) continue;
+                    if ($type === 'rebate' ? $this->rebate($user, $period) : $this->backflow($user, $period)) {
+                        ++$result[$type === 'rebate' ? 'rebates' : 'backflow'];
+                    }
+                } catch (Throwable $e) {
+                    ++$result['failed'];
+                    Log::error('reward_base_sync_failed', ['type' => $type, 'user_id' => $userId, 'period' => $period, 'exception' => $e]);
+                }
+            }
+        }
+        if ($from === null && $result['failed'] === 0) {
+            DB::table('share_system_state')->updateOrInsert(['key' => self::CURSOR],
+                ['value' => json_encode(['updated_at' => $startedAt]), 'updated_at' => $startedAt, 'created_at' => $startedAt]);
+        }
+        return $result;
+    }
+
+    private function rebate(object $user, string $date): bool
+    {
+        return DB::transaction(function () use ($user, $date) {
+            $row = DB::table('rebates')->where('member_id', $user->member_id)->where('date', $date)->lockForUpdate()->first();
+            if ($row && (int) $row->status !== 0) return false;
+            $reward = RewardBasisService::rebateReward(RewardBasisService::rebateBasis($user, $date), (int) ($user->level ?? 0));
+            if (!$reward['has_facts'] && !$row) return false;
+            $values = ['betting_amount' => $reward['betting_amount'], 'effective_betting_amount' => $reward['effective_amount'],
+                'profit' => $reward['profit'], 'amount' => $reward['amount'], 'need_flow' => $reward['need_flow'],
+                'details' => json_encode($reward['details'], JSON_UNESCAPED_UNICODE),
+                'username' => (string) ($user->username ?? ''), 'first_name' => (string) ($user->first_name ?? ''),
+                'updated_at' => now()];
+            if ($row) {
+                // Rows without facts are legacy PC28 days; their stored basis stays the payout basis.
+                if (!$reward['has_facts']) return false;
+                return DB::table('rebates')->where('id', $row->id)->where('status', 0)->update($values) > 0;
+            }
+            DB::table('rebates')->insert($values + ['member_id' => $user->member_id, 'date' => $date, 'status' => 0, 'created_at' => now()]);
+            return true;
+        }, 3);
+    }
+
+    private function backflow(object $user, string $month): bool
+    {
+        return DB::transaction(function () use ($user, $month) {
+            $row = DB::table('backflow')->where('member_id', $user->member_id)->where('date', $month)->lockForUpdate()->first();
+            if ($row && (int) $row->status !== 0) return false;
+            $reward = RewardBasisService::backflowReward(RewardBasisService::backflowBasis($user, $month));
+            if (!$reward['has_facts'] && !$row) return false;
+            $values = ['loss_amount' => $reward['loss_amount'], 'amount' => $reward['amount'], 'need_flow' => $reward['need_flow'],
+                'details' => json_encode($reward['details'], JSON_UNESCAPED_UNICODE), 'updated_at' => now()];
+            if ($row) return DB::table('backflow')->where('id', $row->id)->where('status', 0)->update($values) > 0;
+            DB::table('backflow')->insert($values + ['member_id' => $user->member_id, 'date' => $month, 'status' => 0, 'created_at' => now()]);
+            return true;
+        }, 3);
+    }
+}

+ 149 - 0
app/Services/RewardBasisService.php

@@ -0,0 +1,149 @@
+<?php
+
+namespace App\Services;
+
+use App\Services\Sharing\EffectiveBet;
+use Carbon\Carbon;
+use Illuminate\Support\Facades\DB;
+
+/**
+ * 笔笔返/回水的计算口径:按分类汇总 share_bet_facts,再套用 RewardRuleService 的档位。
+ * 笔笔返按纽约自然日,回水按北京时间自然月,与原有 rebates.date / backflow.date 一致。
+ */
+class RewardBasisService
+{
+    public static function user(string $memberId): ?object
+    {
+        return DB::table('users')->where('member_id', $memberId)->first(['id', 'member_id', 'level', 'username', 'first_name']);
+    }
+
+    /** Category totals for one New York day: bet (non-void), effective and profit (settled only). */
+    public static function rebateBasis(object $user, string $nyDate): array
+    {
+        $start = Carbon::parse($nyDate . ' 00:00:00', 'America/New_York')->setTimezone('Asia/Shanghai');
+        $end = $start->copy()->addDay();
+        return self::sum(DB::table('share_bet_facts')->where('user_id', $user->id)
+            ->whereBetween('bet_date', [$start->toDateString(), $end->toDateString()])
+            ->where('bet_at', '>=', $start->format('Y-m-d H:i:s'))->where('bet_at', '<', $end->format('Y-m-d H:i:s')));
+    }
+
+    /** Category totals for one Asia/Shanghai month (Y-m). */
+    public static function backflowBasis(object $user, string $month): array
+    {
+        $start = Carbon::parse($month . '-01', 'Asia/Shanghai');
+        return self::sum(DB::table('share_bet_facts')->where('user_id', $user->id)
+            ->whereBetween('bet_date', [$start->toDateString(), $start->copy()->endOfMonth()->toDateString()]));
+    }
+
+    /** Highest tier of the member's level whose threshold the category's effective bet reaches. */
+    public static function rebateReward(array $basis, int $level, ?array $tiers = null): array
+    {
+        $tiers ??= RewardRuleService::tiers('rebate');
+        $result = self::empty($basis);
+        foreach ($basis as $category => $row) {
+            $detail = self::detail($category, $row) + ['level' => $level];
+            if (bccomp($row['effective_amount'], '0', 2) > 0) {
+                foreach ($tiers[$category] ?? [] as $tier) {
+                    if ($tier['level'] !== $level || bccomp($row['effective_amount'], $tier['min_effective_amount'], 2) < 0) continue;
+                    $amount = bcmul($row['effective_amount'], bcdiv($tier['percentage'], '100', 8), 2);
+                    $detail = self::matched($detail, $tier, $amount) + ['min_effective_amount' => $tier['min_effective_amount'],
+                        'percentage' => $tier['percentage']];
+                    break;
+                }
+            }
+            $result = self::add($result, $detail);
+        }
+        return $result;
+    }
+
+    /** Highest tier whose loss threshold the category's monthly net loss reaches; the reward is a fixed amount. */
+    public static function backflowReward(array $basis, ?array $tiers = null): array
+    {
+        $tiers ??= RewardRuleService::tiers('backflow');
+        $result = self::empty($basis) + ['loss_amount' => '0.00'];
+        foreach ($basis as $category => $row) {
+            $loss = bccomp($row['profit'], '0', 2) < 0 ? bcsub('0', $row['profit'], 2) : '0.00';
+            $detail = self::detail($category, $row) + ['loss_amount' => $loss];
+            if (bccomp($loss, '0', 2) > 0) {
+                $result['loss_amount'] = bcadd($result['loss_amount'], $loss, 2);
+                foreach ($tiers[$category] ?? [] as $tier) {
+                    if (bccomp($loss, $tier['min_loss_amount'], 2) < 0) continue;
+                    $detail = self::matched($detail, $tier, $tier['reward_amount']) + ['min_loss_amount' => $tier['min_loss_amount']];
+                    break;
+                }
+            }
+            $result = self::add($result, $detail);
+        }
+        return $result;
+    }
+
+    /** Fresh rebate result for a stored row; legacy PC28 days without any facts keep their stored effective bet. */
+    public static function rebate(object $reward): array
+    {
+        $user = self::user((string) $reward->member_id);
+        $basis = $user ? self::rebateBasis($user, (string) $reward->date) : [];
+        if ($basis === [] && bccomp((string) $reward->effective_betting_amount, '0', 2) > 0) {
+            $basis = ['self:lottery' => ['bet_amount' => self::money($reward->betting_amount),
+                'effective_amount' => self::money($reward->effective_betting_amount), 'profit' => self::money($reward->profit ?? 0)]];
+        }
+        return self::rebateReward($basis, (int) ($user->level ?? 0));
+    }
+
+    public static function backflow(object $reward): array
+    {
+        $user = self::user((string) $reward->member_id);
+        return self::backflowReward($user ? self::backflowBasis($user, (string) $reward->date) : []);
+    }
+
+    private static function sum($query): array
+    {
+        $currency = strtoupper(trim((string) config('third_game.currency', 'CNY')));
+        $rows = $query->where('voided', 0)->where('currency', $currency)->groupBy('category')
+            ->selectRaw('category, SUM(bet_amount) AS bet_amount, SUM(effective_amount) AS effective_amount, SUM(net_profit) AS profit')
+            ->orderBy('category')->get();
+        $basis = [];
+        foreach ($rows as $row) {
+            $basis[$row->category] = ['bet_amount' => self::money($row->bet_amount),
+                'effective_amount' => self::money($row->effective_amount), 'profit' => self::money($row->profit)];
+        }
+        return $basis;
+    }
+
+    private static function empty(array $basis): array
+    {
+        return ['betting_amount' => '0.00', 'effective_amount' => '0.00', 'profit' => '0.00',
+            'amount' => '0.00', 'need_flow' => '0.00', 'details' => [], 'has_facts' => $basis !== []];
+    }
+
+    private static function detail(string $category, array $row): array
+    {
+        return ['category' => $category, 'label' => RewardRuleService::categories()[$category] ?? $category,
+            'bet_amount' => $row['bet_amount'], 'effective_amount' => $row['effective_amount'], 'profit' => $row['profit'],
+            'rule_id' => null, 'turnover_multiple' => '0.00', 'amount' => '0.00', 'need_flow' => '0.00'];
+    }
+
+    private static function matched(array $detail, array $tier, string $amount): array
+    {
+        return array_merge($detail, ['rule_id' => $tier['id'], 'turnover_multiple' => $tier['turnover_multiple'],
+            'amount' => $amount, 'need_flow' => bcmul($amount, $tier['turnover_multiple'], 2)]);
+    }
+
+    private static function add(array $result, array $detail): array
+    {
+        $result['betting_amount'] = bcadd($result['betting_amount'], $detail['bet_amount'], 2);
+        $result['effective_amount'] = bcadd($result['effective_amount'], $detail['effective_amount'], 2);
+        $result['profit'] = bcadd($result['profit'], $detail['profit'], 2);
+        $result['amount'] = bcadd($result['amount'], $detail['amount'], 2);
+        $result['need_flow'] = bcadd($result['need_flow'], $detail['need_flow'], 2);
+        $result['details'][] = $detail;
+        return $result;
+    }
+
+    /** SUM() is an exact string on MySQL but may be a float on SQLite. */
+    private static function money($value): string
+    {
+        if ($value === null || $value === '') return '0.00';
+        if (is_float($value) || is_int($value)) $value = number_format((float) $value, 10, '.', '');
+        return bcadd(EffectiveBet::decimal($value), '0', 2);
+    }
+}

+ 25 - 27
app/Services/RewardGrantService.php

@@ -4,9 +4,9 @@ namespace App\Services;
 
 
 use App\Constants\HttpStatus;
 use App\Constants\HttpStatus;
 use App\Models\Backflow;
 use App\Models\Backflow;
-use App\Models\Config;
 use App\Models\Rebate;
 use App\Models\Rebate;
 use App\Models\Wallet;
 use App\Models\Wallet;
+use App\Models\WalletBonus;
 use Carbon\Carbon;
 use Carbon\Carbon;
 use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\DB;
 use RuntimeException;
 use RuntimeException;
@@ -37,28 +37,32 @@ class RewardGrantService
             if ($scheduledCutoff !== null && (string) $reward->date >= $scheduledCutoff) return false;
             if ($scheduledCutoff !== null && (string) $reward->date >= $scheduledCutoff) return false;
 
 
             if ($type === 'rebate') {
             if ($type === 'rebate') {
-                $ratio = self::configDecimal('rebate');
-                $basis = (string) $reward->effective_betting_amount;
-                if (bccomp($basis, '0', 2) < 0) throw new RuntimeException('有效投注金额异常', HttpStatus::CUSTOM_ERROR);
-                $amount = bcmul($basis, $ratio, 2);
-                $reward->rebate_ratio = $ratio;
+                $result = RewardBasisService::rebate($reward);
+                $basis = $result['effective_amount'];
+                $reward->betting_amount = $result['betting_amount'];
+                $reward->effective_betting_amount = $basis;
+                $reward->rebate_ratio = bccomp($basis, '0', 2) > 0 ? bcdiv($result['amount'], $basis, 6) : '0';
                 $reward->audited_by = $operator;
                 $reward->audited_by = $operator;
                 $changeType = '笔笔返';
                 $changeType = '笔笔返';
-                $remark = "日期:{$reward->date};有效投注:{$basis};比例:" . bcmul($ratio, '100', 2) . '%';
-            } else {
-                // Preserve the existing monthly formula: abs(recharge + signed withdrawal).
-                $basis = bcadd((string) $reward->recharge_amount, (string) $reward->withdrawal_amount, 2);
-                if (bccomp($basis, '0', 2) < 0) $basis = bcsub('0', $basis, 2);
-                $amount = '0.00';
-                $ratio = (string) $reward->backflow_ratio;
-                if (bccomp($basis, self::configDecimal('huishui_restriction'), 2) >= 0) {
-                    $ratio = self::configDecimal('huishui_percentage');
-                    $reward->backflow_ratio = $ratio;
-                    $amount = bcmul($basis, $ratio, 2);
+                $remark = "日期:{$reward->date};有效投注:{$basis}";
+                foreach ($result['details'] as $detail) {
+                    if ($detail['rule_id'] === null) continue;
+                    $remark .= ";{$detail['label']}:{$detail['effective_amount']}×{$detail['percentage']}%";
                 }
                 }
+            } else {
+                $result = RewardBasisService::backflow($reward);
+                $reward->loss_amount = $result['loss_amount'];
+                $reward->backflow_ratio = '0';
                 $changeType = '回水';
                 $changeType = '回水';
-                $remark = "日期:{$reward->date};盈亏:-{$basis};回水比例:" . bcmul($ratio ?: '0', '100', 2) . '%';
+                $remark = "日期:{$reward->date};亏损:{$result['loss_amount']}";
+                foreach ($result['details'] as $detail) {
+                    if ($detail['rule_id'] === null) continue;
+                    $remark .= ";{$detail['label']}亏损{$detail['loss_amount']}奖励{$detail['amount']}";
+                }
             }
             }
+            $amount = $result['amount'];
+            $reward->details = $result['details'];
+            $reward->need_flow = $result['need_flow'];
 
 
             if (bccomp($amount, '0', 2) > 0) {
             if (bccomp($amount, '0', 2) > 0) {
                 $wallet = Wallet::where('member_id', $reward->member_id)->lockForUpdate()->first();
                 $wallet = Wallet::where('member_id', $reward->member_id)->lockForUpdate()->first();
@@ -68,6 +72,9 @@ class RewardGrantService
                 if (!$wallet->save()) throw new RuntimeException('余额更新失败', HttpStatus::CUSTOM_ERROR);
                 if (!$wallet->save()) throw new RuntimeException('余额更新失败', HttpStatus::CUSTOM_ERROR);
                 BalanceLogService::addLog($reward->member_id, $amount, $before, $wallet->available_balance,
                 BalanceLogService::addLog($reward->member_id, $amount, $before, $wallet->available_balance,
                     $changeType, $reward->id, $remark);
                     $changeType, $reward->id, $remark);
+                if (bccomp($result['need_flow'], '0', 2) > 0) {
+                    WalletBonus::addData($reward->member_id, $type === 'rebate' ? 4 : 5, $amount, 0, $reward->id, $result['need_flow']);
+                }
             }
             }
             $reward->amount = $amount;
             $reward->amount = $amount;
             $reward->status = 1;
             $reward->status = 1;
@@ -76,13 +83,4 @@ class RewardGrantService
             return true;
             return true;
         }, 3);
         }, 3);
     }
     }
-
-    private static function configDecimal(string $field): string
-    {
-        $value = trim((string) Config::where('field', $field)->value('val'));
-        if (!preg_match('/^\d+(?:\.\d+)?$/D', $value)) {
-            throw new RuntimeException('奖励比例或门槛配置异常', HttpStatus::CUSTOM_ERROR);
-        }
-        return $value;
-    }
 }
 }

+ 127 - 0
app/Services/RewardRuleService.php

@@ -0,0 +1,127 @@
+<?php
+
+namespace App\Services;
+
+use App\Services\Sharing\ShareDefaults;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Validation\Rule;
+use Illuminate\Validation\ValidationException;
+use InvalidArgumentException;
+
+/** Per-category tiers for 笔笔返 (rebate) and 回水 (backflow); each save replaces one category's list. */
+class RewardRuleService
+{
+    private const TABLES = ['rebate' => 'rebate_rules', 'backflow' => 'backflow_rules'];
+
+    public static function categories(): array
+    {
+        return ShareDefaults::categories();
+    }
+
+    public static function get(string $type): array
+    {
+        $table = self::table($type);
+        $categories = $rules = [];
+        foreach (self::categories() as $value => $label) {
+            $categories[] = ['value' => $value, 'label' => $label];
+            $rules[$value] = [];
+        }
+        $query = DB::table($table)->orderBy('category');
+        if ($type === 'rebate') $query->orderBy('level')->orderBy('min_effective_amount');
+        else $query->orderBy('min_loss_amount');
+        foreach ($query->get() as $row) {
+            if (!array_key_exists($row->category, $rules)) continue;
+            $rules[$row->category][] = self::present($type, $row);
+        }
+        $result = ['type' => $type, 'categories' => $categories, 'rules' => $rules];
+        if ($type === 'rebate') {
+            $result['levels'] = DB::table('level')->orderBy('level')->get(['level', 'level_name'])
+                ->map(fn ($row) => ['level' => (int) $row->level, 'level_name' => (string) $row->level_name])->all();
+        }
+        return $result;
+    }
+
+    public static function save(string $type, array $params): array
+    {
+        $table = self::table($type);
+        $plain = 'regex:/^\d{1,12}(?:\.\d{1,4})?$/D';
+        $money = 'regex:/^\d{1,12}(?:\.\d{1,2})?$/D';
+        $amount = ['required', 'numeric', $money, 'min:0'];
+        $rules = ['category' => ['required', 'string', Rule::in(array_keys(self::categories()))],
+            'rules' => ['present', 'array', 'max:200'],
+            'rules.*' => ['array'],
+            'rules.*.turnover_multiple' => ['required', 'numeric', $money, 'min:0', 'max:999999']];
+        if ($type === 'rebate') {
+            $rules['rules.*.level'] = ['required', 'integer', Rule::in(DB::table('level')->pluck('level')->map(fn ($v) => (int) $v)->all())];
+            $rules['rules.*.min_effective_amount'] = $amount;
+            $rules['rules.*.percentage'] = ['required', 'numeric', $plain, 'min:0', 'max:100'];
+        } else {
+            $rules['rules.*.min_loss_amount'] = $amount;
+            $rules['rules.*.reward_amount'] = $amount;
+        }
+        $params = validator($params, $rules, [], ['rules.*.level' => '会员等级', 'rules.*.percentage' => '返回百分比',
+            'rules.*.min_effective_amount' => '有效投注', 'rules.*.min_loss_amount' => '亏损金额',
+            'rules.*.reward_amount' => '奖励金额', 'rules.*.turnover_multiple' => '需要打码量'])->validate();
+
+        $rows = $seen = [];
+        $now = now();
+        foreach ($params['rules'] as $rule) {
+            $row = ['category' => $params['category'], 'turnover_multiple' => self::decimal($rule['turnover_multiple']),
+                'created_at' => $now, 'updated_at' => $now];
+            if ($type === 'rebate') {
+                $row += ['level' => (int) $rule['level'], 'min_effective_amount' => self::decimal($rule['min_effective_amount']),
+                    'percentage' => bcadd((string) $rule['percentage'], '0', 4)];
+                $key = $row['level'] . '|' . $row['min_effective_amount'];
+            } else {
+                $row += ['min_loss_amount' => self::decimal($rule['min_loss_amount']),
+                    'reward_amount' => self::decimal($rule['reward_amount'])];
+                $key = $row['min_loss_amount'];
+            }
+            if (isset($seen[$key])) {
+                throw ValidationException::withMessages(['rules' => $type === 'rebate'
+                    ? '同一会员等级的有效投注门槛不能重复' : '亏损金额门槛不能重复']);
+            }
+            $seen[$key] = true;
+            $rows[] = $row;
+        }
+        DB::transaction(function () use ($table, $params, $rows) {
+            DB::table($table)->where('category', $params['category'])->delete();
+            if ($rows) DB::table($table)->insert($rows);
+        });
+        return self::get($type);
+    }
+
+    /** Rules of one type grouped by category, highest threshold first, for settlement. */
+    public static function tiers(string $type): array
+    {
+        $tiers = [];
+        $threshold = $type === 'rebate' ? 'min_effective_amount' : 'min_loss_amount';
+        foreach (DB::table(self::table($type))->orderByDesc($threshold)->get() as $row) {
+            $tiers[$row->category][] = self::present($type, $row);
+        }
+        return $tiers;
+    }
+
+    private static function present(string $type, object $row): array
+    {
+        $data = ['id' => (int) $row->id, 'category' => (string) $row->category];
+        if ($type === 'rebate') {
+            $data += ['level' => (int) $row->level, 'min_effective_amount' => self::decimal($row->min_effective_amount),
+                'percentage' => bcadd((string) $row->percentage, '0', 4)];
+        } else {
+            $data += ['min_loss_amount' => self::decimal($row->min_loss_amount), 'reward_amount' => self::decimal($row->reward_amount)];
+        }
+        return $data + ['turnover_multiple' => self::decimal($row->turnover_multiple)];
+    }
+
+    private static function decimal($value): string
+    {
+        return bcadd((string) $value, '0', 2);
+    }
+
+    private static function table(string $type): string
+    {
+        if (!isset(self::TABLES[$type])) throw new InvalidArgumentException('奖励类型错误');
+        return self::TABLES[$type];
+    }
+}

+ 86 - 0
database/migrations/2026_11_03_160000_create_reward_rules.php

@@ -0,0 +1,86 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+
+/** Per-category rebate/backflow tiers replace the three global config values. */
+return new class extends Migration {
+    private const FIELDS = ['rebate', 'huishui_percentage', 'benefits_bibi_return'];
+
+    public function up(): void
+    {
+        Schema::create('rebate_rules', function (Blueprint $t) {
+            $t->engine = 'InnoDB';
+            $t->id();
+            $t->string('category', 32)->comment('游戏分类,与分享返佣分类标识一致');
+            $t->integer('level')->comment('会员等级 level.level');
+            $t->decimal('min_effective_amount', 20, 2)->default(0)->comment('有效投注门槛');
+            $t->decimal('percentage', 8, 4)->default(0)->comment('返还百分比,0.8 表示 0.8%');
+            $t->decimal('turnover_multiple', 8, 2)->default(0)->comment('打码量倍数,0 表示无');
+            $t->timestamps();
+            $t->unique(['category', 'level', 'min_effective_amount'], 'rebate_rules_tier_unique');
+        });
+        Schema::create('backflow_rules', function (Blueprint $t) {
+            $t->engine = 'InnoDB';
+            $t->id();
+            $t->string('category', 32)->comment('游戏分类,与分享返佣分类标识一致');
+            $t->decimal('min_loss_amount', 20, 2)->default(0)->comment('亏损金额门槛');
+            $t->decimal('reward_amount', 20, 2)->default(0)->comment('奖励金额');
+            $t->decimal('turnover_multiple', 8, 2)->default(0)->comment('打码量倍数,0 表示无');
+            $t->timestamps();
+            $t->unique(['category', 'min_loss_amount'], 'backflow_rules_tier_unique');
+        });
+        Schema::table('rebates', function (Blueprint $t) {
+            $t->json('details')->nullable()->comment('分类明细');
+            $t->decimal('need_flow', 20, 2)->default(0)->comment('需要打码量');
+        });
+        Schema::table('rebates', function (Blueprint $t) {
+            $t->decimal('rebate_ratio', 12, 6)->nullable()->comment('综合返还比例')->change();
+        });
+        Schema::table('backflow', function (Blueprint $t) {
+            $t->decimal('loss_amount', 20, 2)->default(0)->comment('分类净亏损合计');
+            $t->json('details')->nullable()->comment('分类明细');
+            $t->decimal('need_flow', 20, 2)->default(0)->comment('需要打码量');
+        });
+        if (Schema::hasTable('share_bet_facts')) {
+            Schema::table('share_bet_facts', function (Blueprint $t) {
+                $t->index('updated_at', 'share_bet_facts_updated_at_index');
+            });
+        }
+        // Keep the old values in the down() path, so rollback restores what operators had.
+        $old = DB::table('config')->whereIn('field', self::FIELDS)->get()->map(fn ($row) => (array) $row)->all();
+        Schema::create('reward_rule_config_backup', function (Blueprint $t) {
+            $t->string('field', 64)->primary(); $t->json('row');
+        });
+        foreach ($old as $row) {
+            DB::table('reward_rule_config_backup')->insert(['field' => $row['field'], 'row' => json_encode($row, JSON_UNESCAPED_UNICODE)]);
+        }
+        DB::table('config')->whereIn('field', self::FIELDS)->delete();
+    }
+
+    public function down(): void
+    {
+        if (Schema::hasTable('reward_rule_config_backup')) {
+            foreach (DB::table('reward_rule_config_backup')->get() as $backup) {
+                $row = json_decode($backup->row, true);
+                if (!DB::table('config')->where('field', $row['field'])->exists()) DB::table('config')->insert($row);
+            }
+            Schema::drop('reward_rule_config_backup');
+        }
+        if (Schema::hasTable('share_bet_facts')) {
+            Schema::table('share_bet_facts', function (Blueprint $t) {
+                $t->dropIndex('share_bet_facts_updated_at_index');
+            });
+        }
+        Schema::table('backflow', function (Blueprint $t) {
+            $t->dropColumn(['loss_amount', 'details', 'need_flow']);
+        });
+        Schema::table('rebates', function (Blueprint $t) {
+            $t->dropColumn(['details', 'need_flow']);
+        });
+        Schema::dropIfExists('backflow_rules');
+        Schema::dropIfExists('rebate_rules');
+    }
+};

+ 27 - 0
docs/后端/笔笔返与回水分类规则-部署说明.md

@@ -0,0 +1,27 @@
+# 笔笔返与回水分类规则:部署说明
+
+前端对接见 `docs/总后台/笔笔返规则配置.md` 和 `docs/总后台/回水规则配置.md`。
+
+## 步骤
+
+1. `php artisan migrate`
+   - 新建 `rebate_rules`、`backflow_rules`;
+   - `rebates` 增加 `details`、`need_flow`,`rebate_ratio` 放宽为 6 位小数;
+   - `backflow` 增加 `loss_amount`、`details`、`need_flow`;
+   - `share_bet_facts.updated_at` 加索引;
+   - 删除配置 `rebate`、`huishui_percentage`、`benefits_bibi_return`(原值备份在 `reward_rule_config_backup`,回滚时恢复)。
+2. 在后台配置好各分类规则。没有规则的分类不发放。
+3. 回填未发放的历史记录(纽约日期范围),例如:
+
+   ```bash
+   php artisan rewards:sync-bases --from=2026-10-24 --to=2026-11-02
+   ```
+
+4. 之后调度每分钟执行 `rewards:sync-bases`(排在 `share:sync-facts --write` 之后),只更新未发放记录,不动钱包和已发放记录。
+
+## 说明
+
+- 注单数据来自分享返佣的注单事实表 `share_bet_facts`(由 `share:sync-facts` 同步)。
+- 早于 `SHARE_LOTTERY_LEGACY_MAX_ID` 的旧 PC28 订单不在事实表中。这类未发放的旧记录仍按原来存的有效投注,用「F1彩票」(`self:lottery`)分类的规则发放。
+- `huishui_restriction` 已不再参与计算,仍保留在配置表中,确认后可手动删除。
+- `BetService` 不再在下注/结算时累加 `rebates`;充值/提现仍记录到 `backflow.recharge_amount / withdrawal_amount`,仅用于展示。

+ 122 - 0
docs/总后台/回水规则配置.md

@@ -0,0 +1,122 @@
+# 回水:按游戏分类配置规则
+
+本文供后台管理前端对接。接口由 **`bot-28` 后台**提供,使用后台 API 域名和管理员登录态,成功码 **`code=0`**。笔笔返见 `笔笔返规则配置.md`。
+
+## 1. 变更概览
+
+- 系统配置中的「回水比例」「输钱达到限制值」已不再使用,前端不再展示。
+- 回水页面新增规则配置:按游戏分类设置「亏损金额 → 奖励金额」档位。
+
+## 2. 接口与权限
+
+| 功能 | 请求方式 | 地址 | 权限 |
+| --- | --- | --- | --- |
+| 读取规则 | GET | `/admin/backflow/rules` | `admin/backflow/grant` |
+| 保存规则(单个分类) | POST | `/admin/backflow/rules` | `admin/backflow/grant` |
+
+## 3. 读取规则
+
+```http
+GET /admin/backflow/rules
+```
+
+```json
+{
+  "code": 0,
+  "data": {
+    "type": "backflow",
+    "categories": [{"value": "third:1", "label": "真人"}, "..."],
+    "rules": {
+      "third:1": [
+        {"id": 1, "category": "third:1", "min_loss_amount": "1000.00", "reward_amount": "20.00", "turnover_multiple": "0.00"},
+        {"id": 2, "category": "third:1", "min_loss_amount": "2000.00", "reward_amount": "60.00", "turnover_multiple": "2.00"}
+      ],
+      "third:2": []
+    }
+  }
+}
+```
+
+下拉选项直接用这个接口的返回,不需要另外调用接口:
+
+| 返回字段 | 用途 |
+| --- | --- |
+| `categories` | 游戏分类选项(分类 Tab / 下拉),`value` 用于保存,`label` 用于显示 |
+| `rules` | 已配置的档位,以分类 `value` 为键;每个分类都会返回,没有规则时为空数组 |
+
+目前的分类:
+
+| `value` | `label` |
+| --- | --- |
+| `third:1` | 真人 |
+| `third:2` | 电子 |
+| `third:3` | 彩票 |
+| `third:4` | 体育 |
+| `third:5` | 电竞 |
+| `third:6` | 捕鱼 |
+| `third:7` | 棋牌 |
+| `self:sport` | F1体育 |
+| `self:lottery` | F1彩票 |
+
+建议页面以分类为 Tab,每个 Tab 内展示该分类的档位,保存时提交当前 Tab 的全部档位。
+
+## 4. 保存规则
+
+一次保存**一个分类的完整列表**,会整体替换该分类原有规则;传空数组即清空该分类。
+
+```http
+POST /admin/backflow/rules
+Content-Type: application/json
+
+{
+  "category": "third:1",
+  "rules": [
+    {"min_loss_amount": 1000, "reward_amount": 20, "turnover_multiple": 0},
+    {"min_loss_amount": 2000, "reward_amount": 60, "turnover_multiple": 2}
+  ]
+}
+```
+
+| 字段 | 含义 | 校验 |
+| --- | --- | --- |
+| `category` | 游戏分类,取 `categories[].value` | 必填 |
+| `min_loss_amount` | 亏损金额门槛 | ≥0,最多 2 位小数 |
+| `reward_amount` | 奖励金额(固定金额) | ≥0,最多 2 位小数 |
+| `turnover_multiple` | 需要打码量倍数,`0` 表示没有 | ≥0,最多 2 位小数 |
+
+同一分类的亏损门槛不能重复。返回值与读取接口相同。
+
+## 5. 计算方式
+
+- 每月按游戏分类分别计算,再相加。
+- 每个分类取亏损达到门槛的最高一档,奖励为该档的固定金额。
+- 盈利或未达到最低档的分类不给。
+- 需要打码量 = 奖励 × 倍数,填 0 表示没有。
+
+## 6. 列表新增字段
+
+`GET /admin/backflow` 每行新增:
+
+| 字段 | 含义 |
+| --- | --- |
+| `loss_amount` | 各分类亏损合计 |
+| `details` | 分类明细数组,每项见下表 |
+| `need_flow` | 需要打码量合计 |
+
+`details` 每项:
+
+| 字段 | 含义 |
+| --- | --- |
+| `category` / `label` | 游戏分类标识 / 名称 |
+| `bet_amount` | 该分类投注额 |
+| `effective_amount` | 该分类有效投注 |
+| `profit` | 该分类当月输赢 |
+| `loss_amount` | 该分类亏损(净赢时为 0) |
+| `rule_id` | 命中的规则 id,未命中为 `null` |
+| `min_loss_amount` | 命中档位的亏损门槛(未命中时没有该字段) |
+| `turnover_multiple` | 打码量倍数 |
+| `amount` | 该分类奖励 |
+| `need_flow` | 该分类需要打码量 |
+
+- `recharge_amount`、`withdrawal_amount` 仍会记录,仅供展示,不再参与回水计算。
+- 未发放记录的 `amount` 是按当前规则的预估值,发放时会按最新注单和规则重新计算。

+ 121 - 0
docs/总后台/笔笔返规则配置.md

@@ -0,0 +1,121 @@
+# 笔笔返:按游戏分类配置规则
+
+## 1. 变更概览
+
+- 笔笔返页面新增规则配置:按游戏分类、会员等级设置档位。
+
+## 2. 接口与权限
+
+| 功能 | 请求方式 | 地址 | 权限 |
+| --- | --- | --- | --- |
+| 读取规则 | GET | `/admin/rebate/rules` | `admin/rebate/submit` |
+| 保存规则(单个分类) | POST | `/admin/rebate/rules` | `admin/rebate/submit` |
+
+## 3. 读取规则
+
+```http
+GET /admin/rebate/rules
+```
+
+```json
+{
+  "code": 0,
+  "data": {
+    "type": "rebate",
+    "categories": [{"value": "third:1", "label": "真人"}, {"value": "third:2", "label": "电子"}, "..."],
+    "levels": [{"level": 1, "level_name": "VIP1"}, {"level": 2, "level_name": "VIP2"}],
+    "rules": {
+      "third:2": [
+        {"id": 3, "category": "third:2", "level": 2, "min_effective_amount": "0.00", "percentage": "0.5000", "turnover_multiple": "0.00"},
+        {"id": 4, "category": "third:2", "level": 2, "min_effective_amount": "1000.00", "percentage": "1.0000", "turnover_multiple": "3.00"}
+      ],
+      "third:1": []
+    }
+  }
+}
+```
+
+下拉选项直接用这个接口的返回,不需要另外调用接口:
+
+| 返回字段 | 用途 |
+| --- | --- |
+| `categories` | 游戏分类选项(分类 Tab / 下拉),`value` 用于保存,`label` 用于显示 |
+| `levels` | 会员等级选项,`level` 用于保存,`level_name` 用于显示,取自会员等级管理 |
+| `rules` | 已配置的档位,以分类 `value` 为键;每个分类都会返回,没有规则时为空数组 |
+
+目前的分类:
+
+| `value` | `label` |
+| --- | --- |
+| `third:1` | 真人 |
+| `third:2` | 电子 |
+| `third:3` | 彩票 |
+| `third:4` | 体育 |
+| `third:5` | 电竞 |
+| `third:6` | 捕鱼 |
+| `third:7` | 棋牌 |
+| `self:sport` | F1体育 |
+| `self:lottery` | F1彩票 |
+
+建议页面以分类为 Tab,每个 Tab 内按会员等级分组展示档位,保存时提交当前 Tab 的全部档位。
+
+## 4. 保存规则
+
+一次保存**一个分类的完整列表**,会整体替换该分类原有规则;传空数组即清空该分类。
+
+```http
+POST /admin/rebate/rules
+Content-Type: application/json
+
+{
+  "category": "third:2",
+  "rules": [
+    {"level": 2, "min_effective_amount": 0, "percentage": 0.5, "turnover_multiple": 0},
+    {"level": 2, "min_effective_amount": 1000, "percentage": 1, "turnover_multiple": 3}
+  ]
+}
+```
+
+| 字段 | 含义 | 校验 |
+| --- | --- | --- |
+| `category` | 游戏分类,取 `categories[].value` | 必填 |
+| `level` | 会员等级,取 `levels[].level` | 必填 |
+| `min_effective_amount` | 有效投注门槛 | ≥0,最多 2 位小数 |
+| `percentage` | 返还百分比,`1` 表示 1% | 0–100,最多 4 位小数 |
+| `turnover_multiple` | 需要打码量倍数,`0` 表示没有 | ≥0,最多 2 位小数 |
+
+同一分类同一等级的门槛不能重复。返回值与读取接口相同。
+
+## 5. 计算方式
+
+- 每天按游戏分类分别计算,再相加。
+- 每个分类在会员所属等级的档位中,取有效投注达到门槛的最高一档:返利 = 有效投注 × 百分比。
+- 未达到最低档的分类不返。
+- 需要打码量 = 返利 × 倍数,填 0 表示没有。
+
+## 6. 列表新增字段
+
+`GET /admin/rebate` 每行新增:
+
+| 字段 | 含义 |
+| --- | --- |
+| `details` | 分类明细数组,每项见下表 |
+| `need_flow` | 需要打码量合计 |
+
+`details` 每项:
+
+| 字段 | 含义 |
+| --- | --- |
+| `category` / `label` | 游戏分类标识 / 名称 |
+| `level` | 计算时的会员等级 |
+| `bet_amount` | 该分类投注额 |
+| `effective_amount` | 该分类有效投注 |
+| `profit` | 该分类输赢 |
+| `rule_id` | 命中的规则 id,未命中为 `null` |
+| `min_effective_amount` / `percentage` | 命中档位的门槛 / 百分比(未命中时没有这两个字段) |
+| `turnover_multiple` | 打码量倍数 |
+| `amount` | 该分类返利 |
+| `need_flow` | 该分类需要打码量 |
+
+- `betting_amount`、`effective_betting_amount` 现在来自同一份注单数据,作废和退款的注单不计入,修复了「有投注额但有效投注为 0」的问题。
+- 未发放记录的 `amount` 是按当前规则的预估值,发放时会按最新注单和规则重新计算。

+ 5 - 0
routes/admin.php

@@ -59,6 +59,7 @@ use App\Http\Controllers\admin\SiteMessage;
 use App\Http\Controllers\admin\Maintenance;
 use App\Http\Controllers\admin\Maintenance;
 use App\Http\Controllers\admin\Reporting;
 use App\Http\Controllers\admin\Reporting;
 use App\Http\Controllers\admin\RewardGrantSettings;
 use App\Http\Controllers\admin\RewardGrantSettings;
+use App\Http\Controllers\admin\RewardRules;
 use App\Http\Controllers\admin\Realtime;
 use App\Http\Controllers\admin\Realtime;
 
 
 Route::post('/login', [Admin::class, 'login']);
 Route::post('/login', [Admin::class, 'login']);
@@ -179,6 +180,8 @@ Route::middleware(['admin.jwt'])->group(function () {
             Route::post("/grant", [Backflow::class, 'grant']);
             Route::post("/grant", [Backflow::class, 'grant']);
             Route::get('/grant-settings', [RewardGrantSettings::class, 'backflow'])->defaults('permission_uri', 'admin/backflow/grant');
             Route::get('/grant-settings', [RewardGrantSettings::class, 'backflow'])->defaults('permission_uri', 'admin/backflow/grant');
             Route::post('/grant-settings', [RewardGrantSettings::class, 'saveBackflow'])->defaults('permission_uri', 'admin/backflow/grant');
             Route::post('/grant-settings', [RewardGrantSettings::class, 'saveBackflow'])->defaults('permission_uri', 'admin/backflow/grant');
+            Route::get('/rules', [RewardRules::class, 'backflow'])->defaults('permission_uri', 'admin/backflow/grant');
+            Route::post('/rules', [RewardRules::class, 'saveBackflow'])->defaults('permission_uri', 'admin/backflow/grant');
 
 
         });
         });
 
 
@@ -221,6 +224,8 @@ Route::middleware(['admin.jwt'])->group(function () {
             Route::post('/submit', [Rebate::class, 'store']);
             Route::post('/submit', [Rebate::class, 'store']);
             Route::get('/grant-settings', [RewardGrantSettings::class, 'rebate'])->defaults('permission_uri', 'admin/rebate/submit');
             Route::get('/grant-settings', [RewardGrantSettings::class, 'rebate'])->defaults('permission_uri', 'admin/rebate/submit');
             Route::post('/grant-settings', [RewardGrantSettings::class, 'saveRebate'])->defaults('permission_uri', 'admin/rebate/submit');
             Route::post('/grant-settings', [RewardGrantSettings::class, 'saveRebate'])->defaults('permission_uri', 'admin/rebate/submit');
+            Route::get('/rules', [RewardRules::class, 'rebate'])->defaults('permission_uri', 'admin/rebate/submit');
+            Route::post('/rules', [RewardRules::class, 'saveRebate'])->defaults('permission_uri', 'admin/rebate/submit');
         });
         });
 
 
 
 

+ 188 - 2
tests/Integration/AutomaticRewardGrantTest.php

@@ -6,10 +6,12 @@ use App\Console\Commands\AutoGrantRewards;
 use App\Http\Controllers\admin\Backflow as BackflowController;
 use App\Http\Controllers\admin\Backflow as BackflowController;
 use App\Http\Controllers\admin\Rebate as RebateController;
 use App\Http\Controllers\admin\Rebate as RebateController;
 use App\Http\Controllers\admin\RewardGrantSettings;
 use App\Http\Controllers\admin\RewardGrantSettings;
+use App\Http\Controllers\admin\RewardRules;
 use App\Models\Rebate;
 use App\Models\Rebate;
 use App\Services\AutomaticRewardGrantService;
 use App\Services\AutomaticRewardGrantService;
 use App\Services\BackflowService;
 use App\Services\BackflowService;
 use App\Services\RebateService;
 use App\Services\RebateService;
+use App\Services\RewardBaseSyncService;
 use App\Services\RewardGrantService;
 use App\Services\RewardGrantService;
 use App\Services\RewardGrantSettingsService;
 use App\Services\RewardGrantSettingsService;
 use Carbon\Carbon;
 use Carbon\Carbon;
@@ -64,6 +66,25 @@ class AutomaticRewardGrantTest extends TestCase
             ['field' => 'huishui_restriction', 'val' => '100'],
             ['field' => 'huishui_restriction', 'val' => '100'],
             ['field' => 'huishui_percentage', 'val' => '0.05'],
             ['field' => 'huishui_percentage', 'val' => '0.05'],
         ]);
         ]);
+        Schema::create('users', function (Blueprint $t) {
+            $t->id(); $t->string('member_id')->unique(); $t->integer('level')->default(1);
+            $t->string('username')->default(''); $t->string('first_name')->default('');
+        });
+        Schema::create('level', function (Blueprint $t) { $t->id(); $t->integer('level'); $t->string('level_name'); });
+        DB::table('level')->insert([['level' => 1, 'level_name' => 'VIP1'], ['level' => 2, 'level_name' => 'VIP2']]);
+        Schema::create('share_bet_facts', function (Blueprint $t) {
+            $t->id(); $t->string('source', 32); $t->string('source_id', 64); $t->unsignedBigInteger('user_id'); $t->string('member_id', 64);
+            $t->string('category', 32); $t->dateTime('bet_at'); $t->date('bet_date'); $t->dateTime('settled_at')->nullable();
+            foreach (['bet_amount', 'net_profit', 'effective_amount'] as $c) $t->decimal($c, 24, 10)->default(0);
+            $t->boolean('settled')->default(false); $t->boolean('voided')->default(false);
+            $t->string('currency', 8)->default('CNY'); $t->string('source_version', 64)->default(''); $t->timestamps();
+        });
+        Schema::create('share_system_state', function (Blueprint $t) { $t->string('key')->primary(); $t->text('value'); $t->timestamps(); });
+        Schema::create('wallet_bonus', function (Blueprint $t) {
+            $t->id(); $t->string('member_id'); $t->integer('type')->default(0); $t->unsignedBigInteger('activity_id')->default(0);
+            $t->unsignedBigInteger('related_id'); foreach (['amount', 'need_flow', 'remaind_flow'] as $c) $t->decimal($c, 24, 2);
+            $t->integer('status'); $t->timestamps();
+        });
         Schema::create('wallets', function (Blueprint $t) {
         Schema::create('wallets', function (Blueprint $t) {
             $t->id(); $t->string('member_id'); $t->decimal('available_balance', 24, 10)->default(0); $t->timestamps();
             $t->id(); $t->string('member_id'); $t->decimal('available_balance', 24, 10)->default(0); $t->timestamps();
         });
         });
@@ -77,6 +98,7 @@ class AutomaticRewardGrantTest extends TestCase
             $t->id(); $t->string('member_id'); $t->string('date');
             $t->id(); $t->string('member_id'); $t->string('date');
             $t->decimal('betting_amount', 24, 2); $t->decimal('effective_betting_amount', 24, 2);
             $t->decimal('betting_amount', 24, 2); $t->decimal('effective_betting_amount', 24, 2);
             $t->decimal('rebate_ratio', 12, 4)->default(0); $t->decimal('amount', 24, 2)->default(0);
             $t->decimal('rebate_ratio', 12, 4)->default(0); $t->decimal('amount', 24, 2)->default(0);
+            $t->decimal('profit', 24, 2)->default(0); $t->string('username')->default(''); $t->string('first_name')->default('');
             $t->integer('status')->default(0); $t->string('audited_by')->nullable(); $t->bigInteger('grant_time')->default(0); $t->timestamps();
             $t->integer('status')->default(0); $t->string('audited_by')->nullable(); $t->bigInteger('grant_time')->default(0); $t->timestamps();
         });
         });
         Schema::create('backflow', function (Blueprint $t) {
         Schema::create('backflow', function (Blueprint $t) {
@@ -85,6 +107,9 @@ class AutomaticRewardGrantTest extends TestCase
             $t->integer('status')->default(0); $t->bigInteger('grant_time')->default(0); $t->timestamps();
             $t->integer('status')->default(0); $t->bigInteger('grant_time')->default(0); $t->timestamps();
         });
         });
         $this->migration()->up();
         $this->migration()->up();
+        $this->rulesMigration()->up();
+        DB::table('rebate_rules')->insert(['category' => 'self:lottery', 'level' => 1, 'min_effective_amount' => 0, 'percentage' => '0.5', 'turnover_multiple' => 0]);
+        DB::table('backflow_rules')->insert(['category' => 'self:lottery', 'min_loss_amount' => 100, 'reward_amount' => 35, 'turnover_multiple' => 0]);
         $this->request('/admin/rebate');
         $this->request('/admin/rebate');
     }
     }
 
 
@@ -99,6 +124,11 @@ class AutomaticRewardGrantTest extends TestCase
         return require dirname(__DIR__, 2) . '/database/migrations/2026_10_25_180000_create_reward_grant_settings.php';
         return require dirname(__DIR__, 2) . '/database/migrations/2026_10_25_180000_create_reward_grant_settings.php';
     }
     }
 
 
+    private function rulesMigration()
+    {
+        return require dirname(__DIR__, 2) . '/database/migrations/2026_11_03_160000_create_reward_rules.php';
+    }
+
     private function request(string $path, array $params = [], string $method = 'GET'): void
     private function request(string $path, array $params = [], string $method = 'GET'): void
     {
     {
         $request = Request::create($path, $method, $params);
         $request = Request::create($path, $method, $params);
@@ -106,16 +136,39 @@ class AutomaticRewardGrantTest extends TestCase
         $this->app->instance('request', $request);
         $this->app->instance('request', $request);
     }
     }
 
 
+    private function user(string $memberId, int $level = 1): int
+    {
+        DB::table('users')->insertOrIgnore(['member_id' => $memberId, 'level' => $level]);
+        return (int) DB::table('users')->where('member_id', $memberId)->value('id');
+    }
+
+    /** Legacy PC28 rows: no facts, so the stored effective bet is the payout basis. */
     private function rebate(array $values = []): int
     private function rebate(array $values = []): int
     {
     {
+        $this->user((string) ($values['member_id'] ?? '1001'));
         return DB::table('rebates')->insertGetId($values + ['member_id' => '1001', 'date' => '2026-10-23',
         return DB::table('rebates')->insertGetId($values + ['member_id' => '1001', 'date' => '2026-10-23',
             'betting_amount' => '1000.00', 'effective_betting_amount' => '800.00']);
             'betting_amount' => '1000.00', 'effective_betting_amount' => '800.00']);
     }
     }
 
 
+    /** The month's lottery loss mirrors the old |recharge + withdrawal| fixture. */
     private function backflow(array $values = []): int
     private function backflow(array $values = []): int
     {
     {
-        return DB::table('backflow')->insertGetId($values + ['member_id' => '1001', 'date' => '2026-09',
-            'recharge_amount' => '1000.00', 'withdrawal_amount' => '-300.00']);
+        $values += ['member_id' => '1001', 'date' => '2026-09', 'recharge_amount' => '1000.00', 'withdrawal_amount' => '-300.00'];
+        if (($values['status'] ?? 0) === 0) {
+            $loss = bcadd((string) $values['recharge_amount'], (string) $values['withdrawal_amount'], 2);
+            $this->fact($values['member_id'], 'self:lottery', $values['date'] . '-10 12:00:00', '0', bcsub('0', $loss, 2));
+        }
+        return DB::table('backflow')->insertGetId($values);
+    }
+
+    private function fact(string $memberId, string $category, string $betAt, string $effective, string $profit, array $values = []): void
+    {
+        static $sequence = 0;
+        DB::table('share_bet_facts')->insert($values + ['source' => 'test', 'source_id' => (string) ++$sequence,
+            'user_id' => $this->user($memberId), 'member_id' => $memberId, 'category' => $category, 'bet_at' => $betAt,
+            'bet_date' => substr($betAt, 0, 10), 'bet_amount' => $effective, 'effective_amount' => $effective,
+            'net_profit' => $profit, 'settled' => 1, 'voided' => 0, 'currency' => 'CNY',
+            'created_at' => now()->format('Y-m-d H:i:s'), 'updated_at' => now()->format('Y-m-d H:i:s')]);
     }
     }
 
 
     private function enable(string $type, string $time = '14:30'): void
     private function enable(string $type, string $time = '14:30'): void
@@ -351,4 +404,137 @@ class AutomaticRewardGrantTest extends TestCase
         $this->rebate(['member_id' => 'missing']); $tester->execute([]);
         $this->rebate(['member_id' => 'missing']); $tester->execute([]);
         $this->assertSame(1, $tester->getStatusCode());
         $this->assertSame(1, $tester->getStatusCode());
     }
     }
+
+    public function test_rules_api_lists_categories_and_levels_replaces_one_category_and_rejects_bad_tiers(): void
+    {
+        $body = (new RewardRules())->rebate()->getData(true);
+        $this->assertSame(0, $body['code']);
+        $this->assertContains(['value' => 'third:1', 'label' => '真人'], $body['data']['categories']);
+        $this->assertSame([['level' => 1, 'level_name' => 'VIP1'], ['level' => 2, 'level_name' => 'VIP2']], $body['data']['levels']);
+        $this->assertSame([], $body['data']['rules']['third:2']);
+        $this->request('/admin/rebate/rules', ['category' => 'third:2', 'rules' => [
+            ['level' => 2, 'min_effective_amount' => 1000, 'percentage' => '1', 'turnover_multiple' => 3],
+            ['level' => 2, 'min_effective_amount' => 0, 'percentage' => '0.5', 'turnover_multiple' => 0],
+        ]], 'POST');
+        $body = (new RewardRules())->saveRebate()->getData(true);
+        $this->assertSame(0, $body['code']);
+        $this->assertSame(['0.00', '1000.00'], array_column($body['data']['rules']['third:2'], 'min_effective_amount'));
+        $this->assertSame('3.00', $body['data']['rules']['third:2'][1]['turnover_multiple']);
+        $this->assertCount(1, $body['data']['rules']['self:lottery']);
+        foreach ([['category' => 'third:9', 'rules' => []],
+            ['category' => 'third:2', 'rules' => [['level' => 7, 'min_effective_amount' => 0, 'percentage' => 1, 'turnover_multiple' => 0]]],
+            ['category' => 'third:2', 'rules' => [['level' => 1, 'min_effective_amount' => '1e3', 'percentage' => 1, 'turnover_multiple' => 0]]],
+            ['category' => 'third:2', 'rules' => [['level' => 1, 'min_effective_amount' => 0, 'percentage' => 101, 'turnover_multiple' => 0]]],
+            ['category' => 'third:2', 'rules' => [['level' => 1, 'min_effective_amount' => 5, 'percentage' => 1, 'turnover_multiple' => 0],
+                ['level' => 1, 'min_effective_amount' => 5, 'percentage' => 2, 'turnover_multiple' => 0]]]] as $bad) {
+            $this->request('/admin/rebate/rules', $bad, 'POST');
+            $this->assertSame(-3, (new RewardRules())->saveRebate()->getData(true)['code']);
+        }
+        $this->assertSame(2, DB::table('rebate_rules')->where('category', 'third:2')->count());
+        $this->request('/admin/backflow/rules', ['category' => 'third:1', 'rules' => [
+            ['min_loss_amount' => 2000, 'reward_amount' => 60, 'turnover_multiple' => 2]]], 'POST');
+        $body = (new RewardRules())->saveBackflow()->getData(true);
+        $this->assertSame([['id' => 2, 'category' => 'third:1', 'min_loss_amount' => '2000.00', 'reward_amount' => '60.00',
+            'turnover_multiple' => '2.00']], $body['data']['rules']['third:1']);
+        $this->request('/admin/backflow/rules', ['category' => 'third:1', 'rules' => []], 'POST');
+        $this->assertSame(0, (new RewardRules())->saveBackflow()->getData(true)['code']);
+        $this->assertSame(0, DB::table('backflow_rules')->where('category', 'third:1')->count());
+
+        $router = new Router(new Dispatcher($this->app), $this->app);
+        $this->app->instance('router', $router);
+        $router->group(['prefix' => 'admin'], function () { require dirname(__DIR__, 2) . '/routes/admin.php'; });
+        foreach (['rebate' => 'submit', 'backflow' => 'grant'] as $type => $permission) {
+            foreach (['GET', 'POST'] as $method) {
+                $route = $router->getRoutes()->match(Request::create('/admin/' . $type . '/rules', $method));
+                $this->assertContains('check.button.uri', $route->middleware());
+                $this->assertSame('admin/' . $type . '/' . $permission, $route->defaults['permission_uri']);
+            }
+        }
+    }
+
+    public function test_rebate_sums_every_category_by_new_york_day_and_uses_the_members_level_tier_and_turnover(): void
+    {
+        $this->user('2001', 2);
+        DB::table('wallets')->insert(['member_id' => '2001', 'available_balance' => '0']);
+        DB::table('rebate_rules')->insert([
+            ['category' => 'third:2', 'level' => 2, 'min_effective_amount' => 0, 'percentage' => '0.5', 'turnover_multiple' => 0],
+            ['category' => 'third:2', 'level' => 2, 'min_effective_amount' => 1000, 'percentage' => '1', 'turnover_multiple' => 3],
+            ['category' => 'third:2', 'level' => 1, 'min_effective_amount' => 0, 'percentage' => '2', 'turnover_multiple' => 0],
+            ['category' => 'self:sport', 'level' => 2, 'min_effective_amount' => 500, 'percentage' => '1', 'turnover_multiple' => 0],
+        ]);
+        // New York 2026-10-23 is Shanghai 2026-10-23 12:00 to 2026-10-24 11:59:59.
+        $this->fact('2001', 'third:2', '2026-10-23 20:00:00', '1000', '-1000');
+        $this->fact('2001', 'third:2', '2026-10-24 10:00:00', '500', '500');
+        $this->fact('2001', 'self:sport', '2026-10-24 11:00:00', '300', '-300');
+        $this->fact('2001', 'third:2', '2026-10-24 11:30:00', '9999', '0', ['voided' => 1]);
+        $this->fact('2001', 'third:2', '2026-10-24 13:00:00', '7777', '-7777');
+        $this->assertSame(['rebates' => 2, 'backflow' => 1, 'failed' => 0], (new RewardBaseSyncService())->run());
+        $row = DB::table('rebates')->where('member_id', '2001')->where('date', '2026-10-23')->first();
+        $this->assertEquals(1800, $row->betting_amount);
+        $this->assertEquals(1800, $row->effective_betting_amount);
+        $this->assertEquals(15, $row->amount);
+        $this->assertEquals(45, $row->need_flow);
+        $this->assertSame(['self:sport', 'third:2'], array_column(json_decode($row->details, true), 'category'));
+        $this->assertTrue(RewardGrantService::grant('rebate', (int) $row->id, 'operator-one'));
+        $this->assertEquals(15, DB::table('wallets')->where('member_id', '2001')->value('available_balance'));
+        $bonus = DB::table('wallet_bonus')->first();
+        $this->assertSame('2001', $bonus->member_id);
+        $this->assertEquals(45, $bonus->need_flow);
+        $this->assertEquals(15, $bonus->amount);
+        $this->assertSame((int) $row->id, (int) $bonus->related_id);
+        $this->assertStringContainsString('电子:1500.00×1.0000%', DB::table('balance_logs')->value('remark'));
+
+        // A paid day is never rewritten by later facts.
+        $this->fact('2001', 'third:2', '2026-10-24 09:00:00', '100', '-100');
+        (new RewardBaseSyncService())->run();
+        $this->assertEquals(1800, DB::table('rebates')->where('id', $row->id)->value('effective_betting_amount'));
+        $this->assertSame(1, DB::table('wallet_bonus')->count());
+    }
+
+    public function test_backflow_pays_fixed_rewards_for_each_categorys_monthly_net_loss(): void
+    {
+        $this->user('2001');
+        DB::table('wallets')->insert(['member_id' => '2001', 'available_balance' => '0']);
+        DB::table('backflow_rules')->insert([
+            ['category' => 'third:1', 'min_loss_amount' => 1000, 'reward_amount' => 20, 'turnover_multiple' => 0],
+            ['category' => 'third:1', 'min_loss_amount' => 2000, 'reward_amount' => 60, 'turnover_multiple' => 2],
+            ['category' => 'third:2', 'min_loss_amount' => 0, 'reward_amount' => 99, 'turnover_multiple' => 0],
+            ['category' => 'self:sport', 'min_loss_amount' => 100, 'reward_amount' => 5, 'turnover_multiple' => 0],
+        ]);
+        $this->fact('2001', 'third:1', '2026-09-03 10:00:00', '2000', '-2000');
+        $this->fact('2001', 'third:1', '2026-09-28 10:00:00', '500', '-500');
+        $this->fact('2001', 'third:2', '2026-09-05 10:00:00', '100', '100');
+        $this->fact('2001', 'self:sport', '2026-09-30 23:00:00', '50', '-50');
+        $this->fact('2001', 'self:sport', '2026-10-01 00:30:00', '900', '-900');
+        (new RewardBaseSyncService())->run();
+        $row = DB::table('backflow')->where('member_id', '2001')->where('date', '2026-09')->first();
+        $this->assertEquals(2550, $row->loss_amount);
+        $this->assertEquals(60, $row->amount);
+        $this->assertTrue(RewardGrantService::grant('backflow', (int) $row->id, 'operator-one'));
+        $this->assertEquals(60, DB::table('wallets')->where('member_id', '2001')->value('available_balance'));
+        $this->assertEquals(120, DB::table('wallet_bonus')->value('need_flow'));
+        $this->assertEquals(120, DB::table('backflow')->where('id', $row->id)->value('need_flow'));
+    }
+
+    public function test_sync_cursor_only_revisits_changed_facts_and_backfill_range_creates_missing_rows(): void
+    {
+        $this->fact('3001', 'third:1', '2026-10-20 13:00:00', '100', '-100', ['updated_at' => '2026-10-20 13:00:00']);
+        $this->assertSame(0, (new RewardBaseSyncService())->run()['rebates']);
+        $this->assertSame(['rebates' => 1, 'backflow' => 1, 'failed' => 0], (new RewardBaseSyncService())->run('2026-10-20', '2026-10-20'));
+        $this->assertEquals(100, DB::table('rebates')->where('member_id', '3001')->value('effective_betting_amount'));
+        $this->assertNotNull(DB::table('share_system_state')->where('key', 'reward_bases_cursor')->value('value'));
+        $this->expectException(\InvalidArgumentException::class);
+        (new RewardBaseSyncService())->run('2026-10-20', null);
+    }
+
+    public function test_rules_migration_removes_the_three_global_settings_and_rollback_restores_them(): void
+    {
+        $this->assertSame(['huishui_restriction'], DB::table('config')->pluck('field')->all());
+        $this->assertTrue(Schema::hasColumns('rebates', ['details', 'need_flow']));
+        $this->rulesMigration()->down();
+        $this->assertEqualsCanonicalizing(['rebate', 'huishui_restriction', 'huishui_percentage'], DB::table('config')->pluck('field')->all());
+        $this->assertSame('0.005', DB::table('config')->where('field', 'rebate')->value('val'));
+        $this->assertFalse(Schema::hasTable('rebate_rules'));
+        $this->assertFalse(Schema::hasColumn('backflow', 'loss_amount'));
+    }
 }
 }

+ 0 - 14
tests/Integration/SharingBetFactsTest.php

@@ -3,8 +3,6 @@
 namespace Tests\Integration;
 namespace Tests\Integration;
 
 
 use App\Console\Commands\RecalculateShareRebates;
 use App\Console\Commands\RecalculateShareRebates;
-use App\Models\Rebate;
-use App\Services\RebateService;
 use App\Services\Sharing\BetFactSynchronizer;
 use App\Services\Sharing\BetFactSynchronizer;
 use Carbon\Carbon;
 use Carbon\Carbon;
 use Illuminate\Config\Repository;
 use Illuminate\Config\Repository;
@@ -191,18 +189,6 @@ class SharingBetFactsTest extends TestCase
         $this->assertEquals(200, DB::table('rebates')->where('id', 3)->value('effective_betting_amount'));
         $this->assertEquals(200, DB::table('rebates')->where('id', 3)->value('effective_betting_amount'));
     }
     }
 
 
-    public function test_live_rebate_uses_betting_date_and_does_not_modify_paid_effective_basis(): void
-    {
-        DB::table('rebates')->insert(['id' => 1, 'member_id' => '10001', 'date' => '2026-10-23',
-            'betting_amount' => 100, 'effective_betting_amount' => 0, 'profit' => 0, 'status' => 0]);
-        $row = Rebate::updateProfit(['member_id' => '10001', 'date' => '2026-10-23', 'profit' => 50]);
-        $this->assertSame(1, $row->id); RebateService::updateEffectiveBettingAmount($row, '50.00');
-        $this->assertEquals(50, DB::table('rebates')->value('effective_betting_amount'));
-        DB::table('rebates')->where('id', 1)->update(['status' => 1]);
-        RebateService::updateEffectiveBettingAmount($row, '100.00');
-        $this->assertEquals(50, DB::table('rebates')->value('effective_betting_amount'));
-    }
-
     public function test_legacy_injected_bets_are_never_a_source_and_partial_refund_is_flagged_for_review(): void
     public function test_legacy_injected_bets_are_never_a_source_and_partial_refund_is_flagged_for_review(): void
     {
     {
         Schema::create('bets', function (Blueprint $t) { $t->id(); $t->integer('status'); $t->decimal('amount', 24, 10); });
         Schema::create('bets', function (Blueprint $t) { $t->id(); $t->integer('status'); $t->decimal('amount', 24, 10); });