doge 8 stundas atpakaļ
vecāks
revīzija
529392d885

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

@@ -0,0 +1,19 @@
+<?php
+
+namespace App\Console\Commands;
+
+use App\Services\AutomaticRewardGrantService;
+use Illuminate\Console\Command;
+
+class AutoGrantRewards extends Command
+{
+    protected $signature = 'rewards:auto-grant';
+    protected $description = 'Grant eligible rebate/backflow rewards only when their automatic schedule is enabled and due';
+
+    public function handle(AutomaticRewardGrantService $service): int
+    {
+        $result = $service->run();
+        $this->line(json_encode($result, JSON_UNESCAPED_UNICODE));
+        return array_sum(array_column($result, 'failed')) > 0 ? self::FAILURE : self::SUCCESS;
+    }
+}

+ 1 - 0
app/Console/Kernel.php

@@ -26,6 +26,7 @@ class Kernel extends ConsoleKernel
         //      ->onOneServer();       // 如果在多服务器环境
 
         $schedule->command('sport')->dailyAt('23:59:00');
+        $schedule->command('rewards:auto-grant')->everyMinute()->withoutOverlapping(60);
         $schedule->command('third-game:sync-orders')
             ->everyMinute()
             ->withoutOverlapping(5);

+ 39 - 0
app/Http/Controllers/admin/RewardGrantSettings.php

@@ -0,0 +1,39 @@
+<?php
+
+namespace App\Http\Controllers\admin;
+
+use App\Constants\HttpStatus;
+use App\Http\Controllers\Controller;
+use App\Services\RewardGrantSettingsService;
+use Illuminate\Support\Facades\Log;
+use Illuminate\Validation\ValidationException;
+use Throwable;
+
+class RewardGrantSettings extends Controller
+{
+    public function rebate() { return $this->read('rebate'); }
+    public function backflow() { return $this->read('backflow'); }
+    public function saveRebate() { return $this->write('rebate'); }
+    public function saveBackflow() { return $this->write('backflow'); }
+
+    private function read(string $type)
+    {
+        return $this->run(fn () => RewardGrantSettingsService::get($type));
+    }
+
+    private function write(string $type)
+    {
+        return $this->run(fn () => RewardGrantSettingsService::save($type, 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_grant_settings_failed', ['exception' => $e]);
+            return $this->error(HttpStatus::CUSTOM_ERROR, '奖励发放设置暂不可用,请稍后重试');
+        }
+    }
+}

+ 54 - 0
app/Services/AutomaticRewardGrantService.php

@@ -0,0 +1,54 @@
+<?php
+
+namespace App\Services;
+
+use App\Models\Backflow;
+use App\Models\Rebate;
+use Carbon\Carbon;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Log;
+use Throwable;
+
+class AutomaticRewardGrantService
+{
+    public function run(): array
+    {
+        $result = [];
+        foreach (RewardGrantSettingsService::TYPES as $type) {
+            $result[$type] = ['granted' => 0, 'skipped' => 0, 'failed' => 0];
+            $setting = DB::table('reward_grant_settings')->where('type', $type)->first();
+            $scheduled = RewardGrantSettingsService::dueAt($setting, Carbon::now());
+            if (!$scheduled) continue;
+
+            $model = $type === 'rebate' ? Rebate::class : Backflow::class;
+            $cutoff = $this->cutoff($type, $scheduled);
+            $model::where('status', 0)->where('date', '<', $cutoff)
+                ->chunkById(100, function ($rewards) use ($type, &$result) {
+                    foreach ($rewards as $reward) {
+                        try {
+                            $outcome = DB::transaction(function () use ($type, $reward) {
+                                // Configuration changes wait for the current single-row payout only.
+                                $setting = DB::table('reward_grant_settings')->where('type', $type)->lockForUpdate()->first();
+                                $scheduled = RewardGrantSettingsService::dueAt($setting, Carbon::now());
+                                if (!$scheduled) return 'stopped';
+                                return RewardGrantService::grant($type, (int) $reward->id, 'system:auto-grant', true, $this->cutoff($type, $scheduled))
+                                    ? 'granted' : 'skipped';
+                            }, 3);
+                            if ($outcome === 'stopped') return false;
+                            ++$result[$type][$outcome];
+                        } catch (Throwable $e) {
+                            ++$result[$type]['failed'];
+                            Log::error('automatic_reward_grant_failed', ['type' => $type, 'id' => $reward->id, 'exception' => $e]);
+                        }
+                    }
+                });
+        }
+        return $result;
+    }
+
+    private function cutoff(string $type, \Carbon\CarbonInterface $scheduled): string
+    {
+        return $type === 'rebate' ? $scheduled->copy()->setTimezone('America/New_York')->format('Y-m-d')
+            : $scheduled->copy()->setTimezone('Asia/Shanghai')->format('Y-m');
+    }
+}

+ 2 - 33
app/Services/BackflowService.php

@@ -2,7 +2,6 @@
 
 namespace App\Services;
 
-use App\Constants\HttpStatus;
 use App\Models\Backflow;
 use App\Models\Config;
 use Carbon\Carbon;
@@ -27,37 +26,7 @@ class BackflowService extends BaseService
      */
     public static function grant($id): bool
     {
-        $params = ['id' => $id];
-        $backflow = static::findOne($params);
-        if (!$backflow) throw new Exception('数据不存在', HttpStatus::CUSTOM_ERROR);
-        if ($backflow->status !== 0) throw new Exception("已回水,无需再次回水", HttpStatus::CUSTOM_ERROR);
-        $date = Carbon::now('Asia/Shanghai')->format('Y-m');
-        if (strtotime($backflow->date) >= strtotime($date)) {
-            throw new Exception('未到发放时间', HttpStatus::CUSTOM_ERROR);
-        }
-
-        //回水门槛金额
-        $restriction = Config::where('field', 'huishui_restriction')->first()->val;
-        $amount = bcadd($backflow->recharge_amount, $backflow->withdrawal_amount, 2);
-        $amount = abs($amount);
-        if ($amount >= $restriction) {
-            //回水比例
-            $ratio = Config::where('field', 'huishui_percentage')->first()->val;
-            $backflow->backflow_ratio = $ratio;
-            $backflow->amount = bcmul($amount, $backflow->backflow_ratio, 2);
-            if ($backflow->amount > 0) {
-                $percentage = bcmul($ratio, 100, 2);//返水比例百分比
-                $percentage = floatval($percentage);
-                $res = WalletService::updateBalance($backflow->member_id, $backflow->amount);
-                BalanceLogService::addLog($backflow->member_id, $backflow->amount, $res['before_balance'], $res['after_balance'], "回水", $backflow->id, "日期:$backflow->date;盈亏:-{$amount};回水比例:$percentage%");
-            }
-        } else {
-            $backflow->amount = 0;
-        }
-        $backflow->grant_time = time();
-        $backflow->status = 1;
-        if (false !== $backflow->save()) return true;
-        throw new Exception('发放失败', HttpStatus::CUSTOM_ERROR);
+        return RewardGrantService::grant('backflow', (int) $id, (string) (request()->user->username ?? 'system'));
     }
 
 
@@ -142,4 +111,4 @@ class BackflowService extends BaseService
 //        return ['total' => $count, 'data' => $list];
         return ['total' => $paginator->total(), 'data' => $paginator->items()];
     }
-}
+}

+ 2 - 19
app/Services/RebateService.php

@@ -2,7 +2,6 @@
 
 namespace App\Services;
 
-use App\Models\Config;
 use App\Models\Rebate;
 use App\Models\Rebate as RebateModel;
 use Carbon\Carbon;
@@ -102,24 +101,8 @@ class RebateService extends BaseService
     //笔笔返 根据某天的有效投注额,和反水比例,进行笔笔返
     public static function BibiReturn(Rebate $rebate, $amount): bool
     {
-        //比比返   返水比例
-        $rebate_ratio = Config::where('field', 'rebate')->first()->val;
-        //比比返   返水比例百分比
-        $percentage = bcmul($rebate_ratio, 100, 2);
-        $percentage = floatval($percentage);
-        $rebateAmount = bcmul($amount, $rebate_ratio, 2); //
-        $rebateAmount = floatval($rebateAmount);
-        if ($rebateAmount > 0) {
-            $res = WalletService::updateBalance($rebate->member_id, $rebateAmount);
-            BalanceLogService::addLog($rebate->member_id, $rebateAmount, $res['before_balance'], $res['after_balance'], "笔笔返", $rebate->id, "日期:$rebate->date;有效投注:{$amount};比例:$percentage%");
-        }
-        $rebate->rebate_ratio = $rebate_ratio;
-        $rebate->status = RebateModel::STATUS_FAN_YONG;
-        $rebate->amount = $rebateAmount;
-        $rebate->audited_by = request()->user->username;
-        $rebate->grant_time = time();
-        if (false !== $rebate->save()) return true;
-        return false;
+        // Read the current effective amount under lock rather than paying the supplied stale snapshot.
+        return RewardGrantService::grant('rebate', (int) $rebate->id, (string) (request()->user->username ?? 'system'));
     }
 
 }

+ 88 - 0
app/Services/RewardGrantService.php

@@ -0,0 +1,88 @@
+<?php
+
+namespace App\Services;
+
+use App\Constants\HttpStatus;
+use App\Models\Backflow;
+use App\Models\Config;
+use App\Models\Rebate;
+use App\Models\Wallet;
+use Carbon\Carbon;
+use Illuminate\Support\Facades\DB;
+use RuntimeException;
+
+/** Shared transaction for manual and automatic payouts. Never trusts a previously loaded reward row. */
+class RewardGrantService
+{
+    public static function grant(string $type, int $id, string $operator, bool $skipPaid = false, ?string $scheduledCutoff = null): bool
+    {
+        if (!in_array($type, RewardGrantSettingsService::TYPES, true)) {
+            throw new RuntimeException('奖励类型错误', HttpStatus::CUSTOM_ERROR);
+        }
+
+        return DB::transaction(function () use ($type, $id, $operator, $skipPaid, $scheduledCutoff) {
+            $model = $type === 'rebate' ? Rebate::class : Backflow::class;
+            $reward = $model::whereKey($id)->lockForUpdate()->first();
+            if (!$reward) throw new RuntimeException('数据不存在', HttpStatus::CUSTOM_ERROR);
+            if ((int) $reward->status !== 0) {
+                if ($skipPaid) return false;
+                throw new RuntimeException('奖励已发放,无需再次发放', HttpStatus::CUSTOM_ERROR);
+            }
+            $cutoff = $type === 'rebate'
+                ? Carbon::now('America/New_York')->format('Y-m-d')
+                : Carbon::now('Asia/Shanghai')->format('Y-m');
+            if ((string) $reward->date >= $cutoff) {
+                throw new RuntimeException('未到发放时间', HttpStatus::CUSTOM_ERROR);
+            }
+            if ($scheduledCutoff !== null && (string) $reward->date >= $scheduledCutoff) return false;
+
+            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;
+                $reward->audited_by = $operator;
+                $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);
+                }
+                $changeType = '回水';
+                $remark = "日期:{$reward->date};盈亏:-{$basis};回水比例:" . bcmul($ratio ?: '0', '100', 2) . '%';
+            }
+
+            if (bccomp($amount, '0', 2) > 0) {
+                $wallet = Wallet::where('member_id', $reward->member_id)->lockForUpdate()->first();
+                if (!$wallet) throw new RuntimeException('会员钱包不存在', HttpStatus::CUSTOM_ERROR);
+                $before = (string) $wallet->available_balance;
+                $wallet->available_balance = bcadd($before, $amount, 10);
+                if (!$wallet->save()) throw new RuntimeException('余额更新失败', HttpStatus::CUSTOM_ERROR);
+                BalanceLogService::addLog($reward->member_id, $amount, $before, $wallet->available_balance,
+                    $changeType, $reward->id, $remark);
+            }
+            $reward->amount = $amount;
+            $reward->status = 1;
+            $reward->grant_time = Carbon::now()->timestamp;
+            if (!$reward->save()) throw new RuntimeException('发放失败', HttpStatus::CUSTOM_ERROR);
+            return true;
+        }, 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;
+    }
+}

+ 66 - 0
app/Services/RewardGrantSettingsService.php

@@ -0,0 +1,66 @@
+<?php
+
+namespace App\Services;
+
+use Carbon\CarbonInterface;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Validation\Rule;
+use InvalidArgumentException;
+
+class RewardGrantSettingsService
+{
+    public const TYPES = ['rebate', 'backflow'];
+    public const TIMEZONE = 'Asia/Shanghai';
+
+    public static function get(string $type): array
+    {
+        self::checkType($type);
+        $row = DB::table('reward_grant_settings')->where('type', $type)->first();
+        return [
+            'type' => $type,
+            'auto_enabled' => (int) ($row->auto_enabled ?? 0),
+            'grant_time' => $row->grant_time ?? '03:00',
+            'timezone' => self::TIMEZONE,
+        ];
+    }
+
+    public static function save(string $type, array $params): array
+    {
+        self::checkType($type);
+        // Validate here so HTTP and other callers share the same write rules.
+        $params = validator($params, [
+            'auto_enabled' => ['required', 'boolean'],
+            'grant_time' => [Rule::requiredIf(fn () => in_array($params['auto_enabled'] ?? null, [1, '1', true], true)),
+                'nullable', 'date_format:H:i'],
+        ])->validate();
+
+        return DB::transaction(function () use ($type, $params) {
+            $values = ['type' => $type, 'auto_enabled' => (bool) $params['auto_enabled'],
+                'created_at' => now(), 'updated_at' => now()];
+            $updates = ['auto_enabled', 'updated_at'];
+            if (!empty($params['grant_time'])) {
+                $values['grant_time'] = $params['grant_time'];
+                $updates[] = 'grant_time';
+            }
+            DB::table('reward_grant_settings')->upsert([$values], ['type'], $updates);
+            return self::get($type);
+        });
+    }
+
+    public static function dueAt(?object $setting, CarbonInterface $now): ?CarbonInterface
+    {
+        if (!$setting || (int) $setting->auto_enabled !== 1
+            || !preg_match('/^(?:[01]\d|2[0-3]):[0-5]\d$/D', (string) $setting->grant_time)) {
+            return null;
+        }
+        $local = $now->copy()->setTimezone(self::TIMEZONE);
+        [$hour, $minute] = array_map('intval', explode(':', $setting->grant_time));
+        $scheduled = $local->copy()->setTime($hour, $minute, 0);
+        return $local->greaterThanOrEqualTo($scheduled) ? $scheduled : null;
+    }
+
+    private static function checkType(string $type): void
+    {
+        if (!in_array($type, self::TYPES, true)) throw new InvalidArgumentException('奖励类型错误');
+    }
+}

+ 30 - 0
database/migrations/2026_10_25_180000_create_reward_grant_settings.php

@@ -0,0 +1,30 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration {
+    public function up()
+    {
+        Schema::create('reward_grant_settings', function (Blueprint $table) {
+            $table->engine = 'InnoDB';
+            $table->string('type', 16)->primary();
+            $table->boolean('auto_enabled')->default(false);
+            $table->char('grant_time', 5)->default('03:00');
+            $table->timestamps();
+        });
+        foreach (['rebate', 'backflow'] as $type) {
+            DB::table('reward_grant_settings')->insert([
+                'type' => $type, 'auto_enabled' => false, 'grant_time' => '03:00',
+                'created_at' => now(), 'updated_at' => now(),
+            ]);
+        }
+    }
+
+    public function down()
+    {
+        Schema::dropIfExists('reward_grant_settings');
+    }
+};

+ 178 - 0
docs/总后台/回水与返佣发放设置.md

@@ -0,0 +1,178 @@
+# 回水与返佣:手动 / 自动发放设置
+
+本文供后台管理前端对接。接口由 **`bot-28` 后台**提供,使用后台 API 域名和管理员登录态。
+
+## 1. 接口与权限
+
+| 功能 | 请求方式 | 地址 |
+| --- | --- | --- |
+| 读取笔笔返设置 | GET | `/admin/rebate/grant-settings` |
+| 保存笔笔返设置 | POST | `/admin/rebate/grant-settings` |
+| 读取回水设置 | GET | `/admin/backflow/grant-settings` |
+| 保存回水设置 | POST | `/admin/backflow/grant-settings` |
+
+请求头:
+
+```http
+Authorization: Bearer <admin_token>
+Content-Type: application/json
+```
+
+四个接口均需要后台登录,并沿用对应奖励的手动发放按钮权限:笔笔返为 `admin/rebate/submit`,回水为 `admin/backflow/grant`。角色在现有按钮权限体系中无对应发放权限时,不能修改该奖励的自动发放设置。
+
+成功响应沿用后台约定:**`code=0`**。本接口与前台 `melbet_sport-api` 中会员查询接口的成功码不同。
+
+## 2. 读取设置
+
+```http
+GET /admin/rebate/grant-settings
+```
+
+```json
+{
+  "code": 0,
+  "timestamp": 1792913400,
+  "msg": "OK",
+  "data": {
+    "type": "rebate",
+    "auto_enabled": 0,
+    "grant_time": "03:00",
+    "timezone": "Asia/Shanghai"
+  }
+}
+```
+
+回水读取接口返回相同结构,`type` 为 `backflow`。
+
+| 返回字段 | 含义 |
+| --- | --- |
+| `type` | `rebate`:笔笔返;`backflow`:回水 |
+| `auto_enabled` | 整数 `0`:手动模式;整数 `1`:开启自动发放 |
+| `grant_time` | 每日自动任务的执行时间,24 小时制 `HH:mm` |
+| `timezone` | 固定为 `Asia/Shanghai`,北京时间;前端不能修改 |
+
+两类奖励**独立保存**设置,互不影响。首次安装本功能时均默认为手动模式,保存的默认执行时间为 `03:00`;关闭自动时不会执行这个时间。
+
+## 3. 保存设置
+
+开启笔笔返自动发放:
+
+```http
+POST /admin/rebate/grant-settings
+```
+
+```json
+{
+  "auto_enabled": 1,
+  "grant_time": "14:30"
+}
+```
+
+开启回水自动发放:
+
+```http
+POST /admin/backflow/grant-settings
+```
+
+```json
+{
+  "auto_enabled": 1,
+  "grant_time": "03:00"
+}
+```
+
+改为手动模式:
+
+```json
+{
+  "auto_enabled": 0
+}
+```
+
+| 参数 | 必填 | 校验规则 |
+| --- | --- | --- |
+| `auto_enabled` | 是 | `0` 或 `1`;也支持 JSON `false` / `true`,推荐前端统一传整数 |
+| `grant_time` | 开启自动时必填 | 严格 `HH:mm`,范围 `00:00`~`23:59`;例如 `09:00`,不能传 `9:00`、`24:00` 或带秒的时间 |
+
+关闭自动时可以省略 `grant_time`,服务端保留之前保存的时间;如果传了非空时间,仍会校验并保存。每次开启自动时都需要提交执行时间。
+
+保存成功返回 `code=0`,`data` 为保存后的完整设置,字段与读取接口一致。前端应使用返回数据更新开关和时间选择器。
+
+**保存设置本身不会发钱**,自动发放由服务器定时任务执行。
+
+## 4. 自动发放的实际规则
+
+- 服务器每分钟检查一次设置,自动开关开启且到达每日设定时间后开始处理,调度精度为分钟。
+- `rebate` 只发放在**本次设定时间对应的纽约日期**之前、且 `status=0` 的日记录,沿用后台笔笔返的日口径。
+- `backflow` 只发放在**本次设定时间对应的上海月份**之前、且 `status=0` 的月记录,沿用后台回水的月口径。即使每天检查,当月记录也要等下个月才具备发放资格。
+- 开启自动后,符合条件的**历史未发放记录也会处理**。已经发放的记录不会再次入账。
+- 当天执行时间已过时,开启开关或重启任务后,下一轮检查会补处理当天应发的记录。当天时间未到则等待设定时间。
+- 单条发放失败不影响其他记录,失败记录保留未发放状态,在后续检查中重试;成功记录不会重复发放。
+- 关闭开关或将时间改到当天尚未到达的时间后,后续自动发放停止;已在提交中的单条发放会完成,不撤回已入账奖励。
+- 自动模式下仍可使用原有手动发放按钮。手动与自动共用发放事务、奖励记录锁和钱包锁,重复处理同一记录不会重复入账。
+
+时间口径示例:笔笔返设为北京时间每天 `03:00`。`2026-11-01 03:00` 对应纽约 `2026-10-31`,本轮只处理 `2026-10-30` 及以前的未发放日记录;`2026-10-31` 的日记录留到后续符合条件的计划轮次。当天下午补跑仍按当天 `03:00` 的截止范围处理。
+
+奖励金额继续使用发放时配置的比例和原有计算公式:
+
+- 笔笔返:有效投注金额 × `rebate` 比例,保留两位小数。
+- 回水:`abs(充值金额 + 带符号的提现金额)` 达到 `huishui_restriction` 门槛后,乘以 `huishui_percentage` 比例,保留两位小数。
+
+金额为零的记录也会标记为已处理,但不增加余额、不生成正额奖励流水。自动笔笔返记录的操作人标记为 `system:auto-grant`。金额、状态及发放时间通过原有 `/admin/rebate`、`/admin/backflow` 列表查看。
+
+## 5. 前端交互建议
+
+1. 进入对应奖励页面时调用 GET 设置接口。
+2. 显示“自动发放”开关和每日时间选择器;开关关闭时禁用时间选择器即可。
+3. 页面标明“北京时间”,时间组件固定输出 `HH:mm`,不转换成浏览器本地时区。
+4. 保存时始终提交 `auto_enabled`;开启时同时提交 `grant_time`。
+5. 请求失败时保留原设置并展示错误信息,不提前把页面状态当作保存成功。
+
+```javascript
+// 使用后台已有的请求工具,管理员 token 由现有拦截器携带。
+const result = await request.post('/admin/rebate/grant-settings', {
+  auto_enabled: 1,
+  grant_time: '14:30'
+});
+
+if (result.code === 0) {
+  // 使用 result.data 回填表单,再提示保存成功。
+} else {
+  // 展示 result.msg。
+}
+```
+
+## 6. 错误响应
+
+参数校验失败:HTTP `200`,业务 `code=-3`,`msg` 为参数提示。
+
+```json
+{
+  "code": -3,
+  "timestamp": 1792913400,
+  "msg": "grant_time字段是必须的。",
+  "data": []
+}
+```
+
+错误提示文本可能随后台语言包变化。管理员登录失效、无按钮权限等错误继续按后台现有拦截器处理。设置服务异常返回 `code=-3`,提示“奖励发放设置暂不可用,请稍后重试”。
+
+## 7. 后端上线条件
+
+接口与任务位于 `bot-28`,不需要修改前台 `melbet_sport-api`。
+
+先执行本次新增迁移,创建独立的发放设置表;迁移不会修改会员余额,也不会默认打开自动发放:
+
+```bash
+php artisan migrate --path=database/migrations/2026_10_25_180000_create_reward_grant_settings.php --force
+```
+
+服务器须已有 Laravel 每分钟调度入口;若已配置,不要重复添加:
+
+```cron
+* * * * * cd /你的项目路径/bot-28 && php artisan schedule:run >> /dev/null 2>&1
+```
+
+调度器每分钟运行 `rewards:auto-grant`。此命令始终检查开关和时间,没有绕过设置强制发钱的参数。单条失败会写入应用日志 `automatic_reward_grant_failed`,本次命令以非零状态退出以供监控识别,后续调度继续重试。
+
+若服务器未执行迁移或未运行调度器,仅保存开关不能完成自动发放。

+ 5 - 0
routes/admin.php

@@ -54,6 +54,7 @@ use App\Http\Controllers\admin\PaymentConfiguration;
 use App\Http\Controllers\admin\AppConfiguration;
 use App\Http\Controllers\admin\ShareEarning;
 use App\Http\Controllers\admin\SharingManagement;
+use App\Http\Controllers\admin\RewardGrantSettings;
 
 Route::post('/login', [Admin::class, 'login']);
 Route::get('/test', [Wallet::class, 'test']);
@@ -107,6 +108,8 @@ Route::middleware(['admin.jwt'])->group(function () {
         Route::prefix('/backflow')->group(function () {
             Route::get('/', [Backflow::class, 'index']);
             Route::post("/grant", [Backflow::class, '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');
 
         });
 
@@ -147,6 +150,8 @@ Route::middleware(['admin.jwt'])->group(function () {
         Route::prefix('/rebate')->group(function () {
             Route::get('/', [Rebate::class, 'index']);
             Route::post('/submit', [Rebate::class, 'store']);
+            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');
         });
 
 

+ 357 - 0
tests/Integration/AutomaticRewardGrantTest.php

@@ -0,0 +1,357 @@
+<?php
+
+namespace Tests\Integration;
+
+use App\Console\Commands\AutoGrantRewards;
+use App\Http\Controllers\admin\Backflow as BackflowController;
+use App\Http\Controllers\admin\Rebate as RebateController;
+use App\Http\Controllers\admin\RewardGrantSettings;
+use App\Models\Rebate;
+use App\Services\AutomaticRewardGrantService;
+use App\Services\BackflowService;
+use App\Services\RebateService;
+use App\Services\RewardGrantService;
+use App\Services\RewardGrantSettingsService;
+use Carbon\Carbon;
+use Illuminate\Config\Repository;
+use Illuminate\Database\Capsule\Manager;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Events\Dispatcher;
+use Illuminate\Foundation\Application;
+use Illuminate\Http\JsonResponse;
+use Illuminate\Http\Request;
+use Illuminate\Routing\Router;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Facade;
+use Illuminate\Support\Facades\Schema;
+use Illuminate\Translation\ArrayLoader;
+use Illuminate\Translation\Translator;
+use Illuminate\Validation\Factory;
+use PHPUnit\Framework\TestCase;
+use Symfony\Component\Console\Tester\CommandTester;
+
+/** Real payout controllers, command, wallets and ledgers; no project boot, .env or live database. */
+class AutomaticRewardGrantTest extends TestCase
+{
+    private Application $app;
+
+    protected function setUp(): void
+    {
+        parent::setUp();
+        Carbon::setTestNow(Carbon::parse('2026-10-25 14:30:00', 'Asia/Shanghai'));
+        Facade::clearResolvedInstances();
+        $this->app = new Application(dirname(__DIR__, 2));
+        $this->app->instance('config', new Repository(['app' => ['locale' => 'zh', 'timezone' => 'Asia/Shanghai']]));
+        Facade::setFacadeApplication($this->app);
+        $db = new Manager($this->app);
+        $db->addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => 'bot_']);
+        $db->setEventDispatcher(new Dispatcher($this->app));
+        $db->setAsGlobal(); $db->bootEloquent();
+        $this->app->instance('db', $db->getDatabaseManager());
+        $this->app->bind('db.schema', fn () => $db->schema());
+        $translator = new Translator(new ArrayLoader(), 'zh');
+        $this->app->instance('translator', $translator);
+        $this->app->instance('validator', new Factory($translator, $this->app));
+        $this->app->instance('log', new \Psr\Log\NullLogger());
+        $this->app->instance(\Illuminate\Contracts\Routing\ResponseFactory::class, new class {
+            public function json($data, $status = 200, $headers = [], $options = 0) {
+                return new JsonResponse($data, $status, $headers, $options);
+            }
+        });
+        Request::macro('validate', function (array $rules) {
+            return app('validator')->make($this->all(), $rules)->validate();
+        });
+        Schema::create('config', function (Blueprint $t) { $t->id(); $t->string('field'); $t->string('val'); });
+        DB::table('config')->insert([
+            ['field' => 'rebate', 'val' => '0.005'],
+            ['field' => 'huishui_restriction', 'val' => '100'],
+            ['field' => 'huishui_percentage', 'val' => '0.05'],
+        ]);
+        Schema::create('wallets', function (Blueprint $t) {
+            $t->id(); $t->string('member_id'); $t->decimal('available_balance', 24, 10)->default(0); $t->timestamps();
+        });
+        DB::table('wallets')->insert(['member_id' => '1001', 'available_balance' => '20.00']);
+        Schema::create('balance_logs', function (Blueprint $t) {
+            $t->id(); $t->string('member_id'); $t->string('change_type'); $t->unsignedBigInteger('related_id');
+            foreach (['amount', 'before_balance', 'after_balance'] as $field) $t->decimal($field, 24, 10);
+            $t->string('remark'); $t->timestamps();
+        });
+        Schema::create('rebates', function (Blueprint $t) {
+            $t->id(); $t->string('member_id'); $t->string('date');
+            $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->integer('status')->default(0); $t->string('audited_by')->nullable(); $t->bigInteger('grant_time')->default(0); $t->timestamps();
+        });
+        Schema::create('backflow', function (Blueprint $t) {
+            $t->id(); $t->string('member_id'); $t->string('date');
+            foreach (['recharge_amount', 'withdrawal_amount', 'backflow_ratio', 'amount'] as $field) $t->decimal($field, 24, 4)->default(0);
+            $t->integer('status')->default(0); $t->bigInteger('grant_time')->default(0); $t->timestamps();
+        });
+        $this->migration()->up();
+        $this->request('/admin/rebate');
+    }
+
+    protected function tearDown(): void
+    {
+        Carbon::setTestNow(); DB::disconnect(); Facade::clearResolvedInstances();
+        parent::tearDown();
+    }
+
+    private function migration()
+    {
+        return require dirname(__DIR__, 2) . '/database/migrations/2026_10_25_180000_create_reward_grant_settings.php';
+    }
+
+    private function request(string $path, array $params = [], string $method = 'GET'): void
+    {
+        $request = Request::create($path, $method, $params);
+        $request->user = (object) ['id' => 1, 'username' => 'operator-one'];
+        $this->app->instance('request', $request);
+    }
+
+    private function rebate(array $values = []): int
+    {
+        return DB::table('rebates')->insertGetId($values + ['member_id' => '1001', 'date' => '2026-10-23',
+            'betting_amount' => '1000.00', 'effective_betting_amount' => '800.00']);
+    }
+
+    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']);
+    }
+
+    private function enable(string $type, string $time = '14:30'): void
+    {
+        RewardGrantSettingsService::save($type, ['auto_enabled' => 1, 'grant_time' => $time]);
+    }
+
+    private function runRewards(): array { return (new AutomaticRewardGrantService())->run(); }
+
+    public function test_console_scheduler_registers_the_automatic_payout_command_every_minute_with_overlap_protection(): void
+    {
+        $this->app['config']->set('cache', ['default' => 'array', 'stores' => ['array' => ['driver' => 'array']]]);
+        $this->app->register(\Illuminate\Cache\CacheServiceProvider::class);
+        $schedule = new \Illuminate\Console\Scheduling\Schedule('Asia/Shanghai');
+        $kernel = new \App\Console\Kernel($this->app, new Dispatcher($this->app));
+        $method = new \ReflectionMethod($kernel, 'schedule');
+        $method->setAccessible(true);
+        $method->invoke($kernel, $schedule);
+        $events = array_values(array_filter($schedule->events(), fn ($event) => str_contains($event->command ?? '', 'rewards:auto-grant')));
+        $this->assertCount(1, $events);
+        $this->assertSame('* * * * *', $events[0]->expression);
+        $this->assertTrue($events[0]->withoutOverlapping);
+        $this->assertSame(60, $events[0]->expiresAt);
+    }
+
+    public function test_migration_defaults_to_manual_and_both_settings_routes_keep_admin_and_existing_grant_permissions(): void
+    {
+        $id = $this->rebate(); $this->backflow();
+        foreach (['rebate', 'backflow'] as $type) {
+            $this->assertSame(['type' => $type, 'auto_enabled' => 0, 'grant_time' => '03:00', 'timezone' => 'Asia/Shanghai'], RewardGrantSettingsService::get($type));
+        }
+        $this->assertSame(0, $this->runRewards()['rebate']['granted']);
+        $this->assertSame(0, DB::table('rebates')->where('id', $id)->value('status'));
+        $this->assertSame(0, DB::table('balance_logs')->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 . '/grant-settings', $method));
+                $this->assertContains('admin.jwt', $route->middleware());
+                $this->assertContains('check.button.uri', $route->middleware());
+                $this->assertSame('admin/' . $type . '/' . $permission, $route->defaults['permission_uri']);
+            }
+        }
+        $this->migration()->down();
+        $this->assertFalse(Schema::hasTable('reward_grant_settings'));
+    }
+
+    public function test_settings_api_persists_independent_times_and_disabling_preserves_the_previous_time_without_paying(): void
+    {
+        $this->rebate();
+        $this->request('/admin/rebate/grant-settings', ['auto_enabled' => true, 'grant_time' => '14:30'], 'POST');
+        $body = (new RewardGrantSettings())->saveRebate()->getData(true);
+        $this->assertSame(0, $body['code']);
+        $this->assertSame(1, $body['data']['auto_enabled']);
+        $this->assertSame('14:30', $body['data']['grant_time']);
+        $this->assertSame(0, RewardGrantSettingsService::get('backflow')['auto_enabled']);
+        $this->request('/admin/backflow/grant-settings', ['auto_enabled' => 1, 'grant_time' => '20:15'], 'POST');
+        $this->assertSame(0, (new RewardGrantSettings())->saveBackflow()->getData(true)['code']);
+        $this->assertSame('20:15', RewardGrantSettingsService::get('backflow')['grant_time']);
+        $this->request('/admin/rebate/grant-settings', ['auto_enabled' => 0], 'POST');
+        $body = (new RewardGrantSettings())->saveRebate()->getData(true);
+        $this->assertSame(0, $body['data']['auto_enabled']);
+        $this->assertSame('14:30', $body['data']['grant_time']);
+        $this->assertSame(0, DB::table('balance_logs')->count());
+        $this->assertSame(0, (new RewardGrantSettings())->rebate()->getData(true)['code']);
+    }
+
+    /** @dataProvider invalidSettings */
+    public function test_invalid_settings_do_not_change_the_switch_or_time(array $values): void
+    {
+        $this->request('/admin/rebate/grant-settings', $values, 'POST');
+        $this->assertSame(-3, (new RewardGrantSettings())->saveRebate()->getData(true)['code']);
+        $this->assertSame(0, RewardGrantSettingsService::get('rebate')['auto_enabled']);
+        $this->assertSame('03:00', RewardGrantSettingsService::get('rebate')['grant_time']);
+    }
+
+    public static function invalidSettings(): array
+    {
+        return [[[]], [['auto_enabled' => 2]], [['auto_enabled' => 'auto']], [['auto_enabled' => 1]],
+            [['auto_enabled' => true]], [['auto_enabled' => 1, 'grant_time' => '']],
+            [['auto_enabled' => 1, 'grant_time' => '24:00']], [['auto_enabled' => 1, 'grant_time' => '9:00']],
+            [['auto_enabled' => 1, 'grant_time' => '12:60']], [['auto_enabled' => 0, 'grant_time' => ['03:00']]]];
+    }
+
+    public function test_exact_minute_pays_both_types_once_and_retries_do_not_duplicate_wallet_or_ledger(): void
+    {
+        $rebate = $this->rebate(); $backflow = $this->backflow();
+        $this->rebate(['date' => '2026-10-25']); $this->backflow(['date' => '2026-10']);
+        $this->rebate(['status' => 1]); $this->backflow(['status' => 1]);
+        $this->enable('rebate'); $this->enable('backflow');
+        Carbon::setTestNow(Carbon::parse('2026-10-25 14:29:59', 'Asia/Shanghai'));
+        $this->assertSame(0, $this->runRewards()['rebate']['granted']);
+        $this->assertSame(0, DB::table('balance_logs')->count());
+        Carbon::setTestNow(Carbon::parse('2026-10-25 14:30:00', 'Asia/Shanghai'));
+        $result = $this->runRewards();
+        $this->assertSame(1, $result['rebate']['granted']); $this->assertSame(1, $result['backflow']['granted']);
+        $this->assertEquals(59, DB::table('wallets')->value('available_balance'));
+        $this->assertEquals(4, DB::table('rebates')->where('id', $rebate)->value('amount'));
+        $this->assertEquals(35, DB::table('backflow')->where('id', $backflow)->value('amount'));
+        $this->assertSame('system:auto-grant', DB::table('rebates')->where('id', $rebate)->value('audited_by'));
+        $this->assertSame(now()->timestamp, DB::table('backflow')->where('id', $backflow)->value('grant_time'));
+        $this->assertSame(2, DB::table('balance_logs')->count());
+        $this->assertSame(0, $this->runRewards()['rebate']['granted']);
+        $this->assertSame(2, DB::table('balance_logs')->count());
+        $this->assertEquals(59, DB::table('wallets')->value('available_balance'));
+    }
+
+    public function test_scheduled_cutoffs_keep_new_york_days_and_shanghai_months_even_when_catching_up_later(): void
+    {
+        Carbon::setTestNow(Carbon::parse('2026-11-01 03:00:00', 'Asia/Shanghai'));
+        $old = $this->rebate(['date' => '2026-10-30']);
+        $notYetEndedAtSchedule = $this->rebate(['date' => '2026-10-31']);
+        $lastMonth = $this->backflow(['date' => '2026-10']);
+        $currentMonth = $this->backflow(['date' => '2026-11']);
+        $this->enable('rebate', '03:00'); $this->enable('backflow', '03:00');
+        // At 14:00 the New York day has changed, but this run still belongs to the 03:00 schedule.
+        Carbon::setTestNow(Carbon::parse('2026-11-01 14:00:00', 'Asia/Shanghai'));
+        $this->runRewards();
+        $this->assertSame(1, DB::table('rebates')->where('id', $old)->value('status'));
+        $this->assertSame(0, DB::table('rebates')->where('id', $notYetEndedAtSchedule)->value('status'));
+        $this->assertSame(1, DB::table('backflow')->where('id', $lastMonth)->value('status'));
+        $this->assertSame(0, DB::table('backflow')->where('id', $currentMonth)->value('status'));
+        Carbon::setTestNow(Carbon::parse('2026-11-02 02:59:59', 'Asia/Shanghai'));
+        $this->assertSame(0, $this->runRewards()['rebate']['granted']);
+        Carbon::setTestNow(Carbon::parse('2026-11-02 03:00:00', 'Asia/Shanghai'));
+        $this->assertSame(1, $this->runRewards()['rebate']['granted']);
+        $this->assertSame(1, DB::table('rebates')->where('id', $notYetEndedAtSchedule)->value('status'));
+    }
+
+    public function test_disabled_switch_stops_auto_but_both_manual_controllers_still_work_and_stale_calls_cannot_pay_twice(): void
+    {
+        $id = $this->rebate(); $backflow = $this->backflow();
+        $stale = Rebate::findOrFail($id);
+        $this->enable('rebate');
+        RewardGrantSettingsService::save('rebate', ['auto_enabled' => 0]);
+        $this->assertSame(0, $this->runRewards()['rebate']['granted']);
+        $this->request('/admin/rebate/submit', ['id' => $id], 'POST');
+        $this->assertSame(0, (new RebateController())->store()->getData(true)['code']);
+        $this->assertSame(-3, (new RebateController())->store()->getData(true)['code']);
+        $this->assertSame('operator-one', DB::table('rebates')->where('id', $id)->value('audited_by'));
+        $this->request('/admin/backflow/grant', ['id' => $backflow], 'POST');
+        $this->assertSame(0, (new BackflowController())->grant()->getData(true)['code']);
+        $this->assertSame(-3, (new BackflowController())->grant()->getData(true)['code']);
+        $this->enable('rebate'); $this->enable('backflow');
+        $this->runRewards();
+        $this->assertFalse(RewardGrantService::grant('rebate', $id, 'auto', true));
+        $this->assertSame(2, DB::table('balance_logs')->count());
+        try { RebateService::BibiReturn($stale, '999999'); $this->fail('A stale row must not pay again'); }
+        catch (\RuntimeException $e) { $this->assertSame(-3, $e->getCode()); }
+        $this->assertEquals(59, DB::table('wallets')->value('available_balance'));
+    }
+
+    public function test_payout_reads_fresh_effective_bet_and_zero_rewards_are_finalized_without_ledger_credits(): void
+    {
+        $id = $this->rebate(); $stale = Rebate::findOrFail($id);
+        DB::table('rebates')->where('id', $id)->update(['effective_betting_amount' => 100]);
+        RebateService::BibiReturn($stale, '800');
+        $this->assertEquals(0.5, DB::table('rebates')->where('id', $id)->value('amount'));
+        $this->rebate(['effective_betting_amount' => 0]);
+        $below = $this->backflow(['recharge_amount' => 50, 'withdrawal_amount' => 0]);
+        $this->enable('rebate'); $this->enable('backflow'); $this->runRewards();
+        $this->assertSame(1, DB::table('backflow')->where('id', $below)->value('status'));
+        $this->assertEquals(0, DB::table('backflow')->where('id', $below)->value('amount'));
+        $this->assertSame(1, DB::table('balance_logs')->count());
+        $this->assertEquals(20.5, DB::table('wallets')->value('available_balance'));
+    }
+
+    public function test_ledger_failure_rolls_back_wallet_and_reward_then_automatic_retry_credits_once(): void
+    {
+        $id = $this->rebate(); $this->enable('rebate');
+        DB::statement("CREATE TRIGGER reject_reward_ledger BEFORE INSERT ON bot_balance_logs BEGIN SELECT RAISE(ABORT, 'ledger unavailable'); END");
+        $this->assertSame(1, $this->runRewards()['rebate']['failed']);
+        $this->assertEquals(20, DB::table('wallets')->value('available_balance'));
+        $this->assertSame(0, DB::table('rebates')->where('id', $id)->value('status'));
+        $this->assertSame(0, DB::table('rebates')->where('id', $id)->value('grant_time'));
+        $this->assertSame(0, DB::table('balance_logs')->count());
+        DB::statement('DROP TRIGGER reject_reward_ledger');
+        $this->assertSame(1, $this->runRewards()['rebate']['granted']);
+        $this->assertEquals(24, DB::table('wallets')->value('available_balance'));
+        $this->assertSame(1, DB::table('balance_logs')->count());
+    }
+
+    public function test_status_save_failure_rolls_back_backflow_wallet_and_ledger_and_missing_wallet_does_not_stop_other_rows(): void
+    {
+        $id = $this->backflow(); $this->enable('backflow');
+        DB::statement("CREATE TRIGGER reject_backflow_status BEFORE UPDATE ON bot_backflow BEGIN SELECT RAISE(ABORT, 'reward unavailable'); END");
+        $this->assertSame(1, $this->runRewards()['backflow']['failed']);
+        $this->assertSame(0, DB::table('backflow')->where('id', $id)->value('status'));
+        $this->assertSame(0, DB::table('balance_logs')->count());
+        $this->assertEquals(20, DB::table('wallets')->value('available_balance'));
+        DB::statement('DROP TRIGGER reject_backflow_status');
+        $bad = $this->rebate(['member_id' => 'no-wallet']); $good = $this->rebate(); $this->enable('rebate');
+        $result = $this->runRewards();
+        $this->assertSame(1, $result['rebate']['failed']); $this->assertSame(1, $result['rebate']['granted']);
+        $this->assertSame(0, DB::table('rebates')->where('id', $bad)->value('status'));
+        $this->assertSame(1, DB::table('rebates')->where('id', $good)->value('status'));
+        $this->assertSame(1, $result['backflow']['granted']);
+    }
+
+    public function test_switch_and_time_are_rechecked_between_records_and_before_retrying(): void
+    {
+        $this->rebate(); $second = $this->rebate(); $this->enable('rebate');
+        $changed = false;
+        $this->app['db']->connection()->listen(function ($event) use (&$changed) {
+            if (!$changed && str_starts_with(strtolower($event->sql), 'update "bot_rebates"')) {
+                $changed = true;
+                RewardGrantSettingsService::save('rebate', ['auto_enabled' => 0]);
+            }
+        });
+        $this->assertSame(1, $this->runRewards()['rebate']['granted']);
+        $this->assertSame(0, DB::table('rebates')->where('id', $second)->value('status'));
+        $this->assertSame(1, DB::table('balance_logs')->count());
+        $this->enable('rebate', '15:00');
+        $this->assertSame(0, $this->runRewards()['rebate']['granted']);
+        Carbon::setTestNow(Carbon::parse('2026-10-25 15:00:00', 'Asia/Shanghai'));
+        $this->assertSame(1, $this->runRewards()['rebate']['granted']);
+    }
+
+    public function test_chunked_processing_does_not_skip_rows_and_command_reports_failure_without_forcing_disabled_rewards(): void
+    {
+        for ($i = 0; $i < 105; ++$i) $this->rebate(['effective_betting_amount' => 0]);
+        $command = new AutoGrantRewards(); $command->setLaravel($this->app);
+        $tester = new CommandTester($command);
+        $tester->execute([]);
+        $this->assertSame(0, $tester->getStatusCode());
+        $this->assertSame(105, DB::table('rebates')->where('status', 0)->count());
+        $this->enable('rebate'); $tester->execute([]);
+        $this->assertSame(0, $tester->getStatusCode());
+        $this->assertStringContainsString('"granted":105', $tester->getDisplay());
+        $this->assertSame(105, DB::table('rebates')->where('status', 1)->count());
+        $this->rebate(['member_id' => 'missing']); $tester->execute([]);
+        $this->assertSame(1, $tester->getStatusCode());
+    }
+}