doge 8 цаг өмнө
parent
commit
94cc4f12b6

+ 1 - 1
app/Console/Commands/BackfillShareCommissions.php

@@ -82,7 +82,7 @@ class BackfillShareCommissions extends Command
         $this->line('database: ' . DB::connection()->getDatabaseName());
         $this->line('table_prefix: ' . DB::getTablePrefix());
         $data = (new ShareCommissionDiagnostics())->inspect($range);
-        foreach (['recharge_rate', 'share_enabled', 'benefit_enabled', 'valid_member_min_deposit', 'valid_member_min_effective_bet', 'indirect_bonus_rate', 'first_rebate_at', 'last_rebate_at'] as $key) {
+        foreach (['recharge_rate', 'share_enabled', 'benefit_enabled', 'valid_member_min_deposit', 'valid_member_min_effective_bet', 'first_rebate_at', 'last_rebate_at'] as $key) {
             $this->line($key . ': ' . ($data[$key] ?? '(无)'));
         }
         $this->table(['检查项', '条数'], array_map(fn ($key, $count) => [$key, $count], array_keys($data['counts']), array_values($data['counts'])));

+ 59 - 21
app/Http/Controllers/admin/Banner.php

@@ -9,12 +9,13 @@ use Illuminate\Support\Facades\DB;
 use Illuminate\Validation\ValidationException;
 use Exception;
 use App\Models\Banner as BannerModel;
+use App\Services\BannerVisibilityService;
 
 class Banner extends Controller
 {
 
     /**
-     * @api {get} /admin/banner 轮播图列表
+     * @api {get} /admin/banner/index 轮播图列表
      * @apiGroup 轮播图管理
      * @apiUse result
      * @apiUse header
@@ -27,20 +28,23 @@ class Banner extends Controller
     function index()
     {
         try {
+            request()->validate([
+                'page' => ['sometimes', 'integer', 'min:1'],
+                'limit' => ['sometimes', 'integer', 'min:1', 'max:200'],
+                'type' => ['nullable', 'in:pc,app'],
+            ]);
             $page = request()->input('page', 1);
             $limit = request()->input('limit', 15);
             $type = request()->input('type', '');
             
-            $query = new BannerModel();
+            $query = BannerModel::query();
 
             if (!empty($type)) {
                 $query->where('type', $type);
             }
             $count = $query->count();
-            $list = $query
-                 ->forPage($page, $limit)
-                ->orderByDesc("sort")
-                ->get()->toArray();
+            $list = BannerVisibilityService::decorate($query->forPage($page, $limit)
+                ->orderByDesc('sort')->orderByDesc('id')->get());
         } catch (ValidationException $e) {
             return $this->error(HttpStatus::VALIDATION_FAILED, $e->validator->errors()->first());
         } catch (Exception $e) {
@@ -63,27 +67,40 @@ class Banner extends Controller
         DB::beginTransaction();
         try {
             $params = request()->validate([
-                'id' => ['nullable'],
+                'id' => ['nullable', 'integer', 'min:1'],
                 'title' => ['required'],
                 'image' => ['required'],
-                'link' => ['nullable'],
-                'type' => ['required'],
-                'sort' => ['nullable'],
+                'link' => ['nullable', function ($attribute, $value, $fail) {
+                    if ((!is_string($value) && !is_int($value)) || strlen((string) $value) > 191) {
+                        $fail('跳转ID或链接格式错误');
+                    }
+                }],
+                'type' => ['required', 'in:pc,app'],
+                'sort' => ['nullable', 'integer'],
                 'lang' => ['required'],
+                'status' => ['sometimes', 'integer', 'in:0,1'],
             ]);
             $id = $params['id'] ?? null;
+            $banner = $id === null ? new BannerModel() : BannerModel::where('id', $id)->lockForUpdate()->first();
+            if (!$banner) throw new Exception('数据不存在', HttpStatus::CUSTOM_ERROR);
 
-            BannerModel::updateOrCreate(
-                ['id' => $id],
-                [
-                    'title' => $params['title'],
-                    'image' => Util::replacePartInUrl($params['image']),
-                    'link' => $params['link'],
-                    'type' => $params['type'],
-                    'sort' => $params['sort'],
-                    'lang' => $params['lang'],
-                ],
-            );
+            $link = array_key_exists('link', $params) ? $params['link'] : $banner->link;
+            $activityId = BannerVisibilityService::activityId($link);
+            $previousActivityId = BannerVisibilityService::activityId($banner->link);
+            if ($activityId !== null && (!$banner->exists || $activityId !== $previousActivityId)) {
+                if (!DB::table('activity_rewards')->where('id', $activityId)->exists()) {
+                    throw ValidationException::withMessages(['link' => '绑定的活动不存在']);
+                }
+            }
+            $banner->fill([
+                'title' => $params['title'],
+                'image' => Util::replacePartInUrl($params['image']),
+                'link' => $activityId ?? ($link === null ? null : (string) $link),
+                'type' => $params['type'],
+                'sort' => array_key_exists('sort', $params) ? ($params['sort'] ?? 0) : ($banner->sort ?? 0),
+                'lang' => $params['lang'],
+                'status' => $params['status'] ?? ($banner->status ?? 1),
+            ])->save();
             DB::commit();
         } catch (ValidationException $e) {
             DB::rollBack();
@@ -95,6 +112,27 @@ class Banner extends Controller
         return $this->success();
     }
 
+    /** Change manual publication without resubmitting the banner form. */
+    public function status()
+    {
+        try {
+            $params = request()->validate([
+                'id' => ['required', 'integer', 'min:1'],
+                'status' => ['required', 'integer', 'in:0,1'],
+            ]);
+            $banner = BannerModel::find($params['id']);
+            if (!$banner) throw new Exception('数据不存在', HttpStatus::CUSTOM_ERROR);
+            $banner->status = $params['status'];
+            $banner->save();
+        } catch (ValidationException $e) {
+            return $this->error(HttpStatus::VALIDATION_FAILED, $e->validator->errors()->first());
+        } catch (Exception $e) {
+            return $this->error(HttpStatus::CUSTOM_ERROR, $e->getMessage());
+        }
+
+        return $this->success();
+    }
+
 
     /**
      * @api {post} /admin/banner/delete 删除

+ 2 - 2
app/Http/Middleware/CheckButtonPermission.php

@@ -26,7 +26,7 @@ class CheckButtonPermission
         }
 
         // 当前访问的路由
-        $currentUri = ltrim($request->route()->uri(), '/');
+        $currentUri = $request->route()->defaults['permission_uri'] ?? ltrim($request->route()->uri(), '/');
         
         $menuInfo = MenuService::findOne(['uri' => $currentUri, 'type' => MenuService::model()::TYPE_BUTTON ,'status' => MenuService::model()::STATUS_SHOW]);
         if(empty($menuInfo)){
@@ -56,4 +56,4 @@ class CheckButtonPermission
         }
     }
 
-}
+}

+ 3 - 1
app/Models/Banner.php

@@ -5,7 +5,9 @@ use App\Constants\Util;
 class Banner extends BaseModel
 {
     protected $table = 'banner';
-    protected $fillable = ['title', 'image', 'link' ,'type' ,'sort' ,'cny_rate','lang' ];
+    protected $fillable = ['title', 'image', 'link' ,'type' ,'sort' ,'cny_rate','lang', 'status'];
+
+    protected $casts = ['status' => 'integer'];
     
     protected function getImageAttribute($value)
     {

+ 1 - 2
app/Models/ShareSetting.php

@@ -6,10 +6,9 @@ class ShareSetting extends BaseModel
 {
     protected $table = 'share_settings';
     protected $guarded = [];
-    protected $hidden = [];
+    protected $hidden = ['indirect_bonus_rate'];
     protected $casts = [
         'enabled' => 'integer', 'benefit_enabled' => 'integer',
         'valid_member_min_deposit' => 'decimal:2', 'valid_member_min_effective_bet' => 'decimal:10',
-        'indirect_bonus_rate' => 'decimal:4',
     ];
 }

+ 66 - 0
app/Services/BannerVisibilityService.php

@@ -0,0 +1,66 @@
+<?php
+
+namespace App\Services;
+
+use App\Models\Banner;
+use Illuminate\Support\Facades\DB;
+
+class BannerVisibilityService
+{
+    /** Positive numeric links are activity IDs; empty/zero and other links are unbound. */
+    public static function activityId($link): ?string
+    {
+        $link = trim((string) $link);
+        if ($link === '' || !ctype_digit($link)) {
+            return null;
+        }
+
+        $id = ltrim($link, '0');
+        return $id === '' ? null : $id;
+    }
+
+    /** Compute display state without overwriting the operator's manual setting. */
+    public static function decorate(iterable $banners, ?int $now = null): array
+    {
+        $now = $now ?? time();
+        $items = [];
+        $activityIds = [];
+        foreach ($banners as $banner) {
+            $item = $banner instanceof Banner ? $banner->toArray() : $banner;
+            $item['status'] = (int) $item['status'];
+            $item['activity_id'] = self::activityId($item['link'] ?? null);
+            if ($item['activity_id'] !== null) {
+                $activityIds[$item['activity_id']] = $item['activity_id'];
+            }
+            $items[] = $item;
+        }
+
+        // Read raw timestamps; ActivityReward's date accessors discard the time of day.
+        $activities = empty($activityIds) ? collect() : DB::table('activity_rewards')
+            ->whereIn('id', array_values($activityIds))
+            ->get(['id', 'status', 'start_time', 'end_time'])->keyBy('id');
+
+        foreach ($items as &$item) {
+            $activity = $item['activity_id'] === null ? null : $activities->get($item['activity_id']);
+            $reason = null;
+            if ($item['status'] !== 1) {
+                $reason = 'manual';
+            } elseif ($item['activity_id'] !== null) {
+                if ($activity === null) {
+                    $reason = 'activity_missing';
+                } elseif ((int) $activity->status !== 1) {
+                    $reason = 'activity_offline';
+                } elseif ((int) $activity->start_time > $now) {
+                    $reason = 'activity_not_started';
+                } elseif ((int) $activity->end_time <= $now) {
+                    $reason = 'activity_ended';
+                }
+            }
+            $item['effective_status'] = $reason === null ? 1 : 0;
+            $item['offline_reason'] = $reason;
+        }
+        unset($item);
+
+        return $items;
+    }
+}

+ 0 - 1
app/Services/ShareCommissionDiagnostics.php

@@ -67,7 +67,6 @@ class ShareCommissionDiagnostics
             'benefit_enabled' => $setting->benefit_enabled ?? null,
             'valid_member_min_deposit' => $setting->valid_member_min_deposit ?? null,
             'valid_member_min_effective_bet' => $setting->valid_member_min_effective_bet ?? null,
-            'indirect_bonus_rate' => $setting->indirect_bonus_rate ?? null,
             'counts' => [
                 'rebate_logs_all' => (int)$rebates->total,
                 'type1_rebate_logs_all' => (int)$rebates->type1,

+ 5 - 5
app/Services/Sharing/ShareAdminService.php

@@ -12,7 +12,7 @@ use Illuminate\Validation\ValidationException;
 
 class ShareAdminService
 {
-    private const RETIRED = ['non_seller_threshold', 'tier_one_rate', 'tier_two_rate'];
+    private const RETIRED = ['non_seller_threshold', 'tier_one_rate', 'tier_two_rate', 'indirect_bonus_rate'];
     // Configuration is returned at cent precision; reject sub-cent input rather than truncate it on a later edit.
     private const MONEY = ['regex:/^\d{1,14}(?:\.\d{1,2})?$/'];
     private const RATE = ['regex:/^\d{1,3}(?:\.\d{1,4})?$/', 'numeric', 'between:0,100'];
@@ -29,7 +29,6 @@ class ShareAdminService
             'benefit_enabled' => (int)($setting->benefit_enabled ?? 0),
             'valid_member_min_deposit' => $this->money($setting->valid_member_min_deposit ?? '20'),
             'valid_member_min_effective_bet' => isset($setting->valid_member_min_effective_bet) ? $this->money($setting->valid_member_min_effective_bet) : null,
-            'indirect_bonus_rate' => isset($setting->indirect_bonus_rate) ? bcadd((string)$setting->indirect_bonus_rate, '0', 4) : null,
             'activation_date' => $setting?->activation_date ?: null,
             'initialized' => DB::table('share_system_state')->where('key', 'initialized_at')->exists(),
             'effective_date' => $pending['effective_date'] ?? null,
@@ -52,7 +51,6 @@ class ShareAdminService
             'benefit_enabled' => ['required', Rule::in([0, 1])],
             'valid_member_min_deposit' => ['required', 'regex:/^\d{1,14}(?:\.\d{1,2})?$/'],
             'valid_member_min_effective_bet' => array_merge(['nullable', 'required_if:benefit_enabled,1'], self::MONEY),
-            'indirect_bonus_rate' => array_merge(['nullable', 'required_if:benefit_enabled,1'], self::RATE),
         ])->validate();
         $data['share_domain'] = $this->origin($data['share_domain']);
         return DB::transaction(function () use ($data) {
@@ -69,7 +67,7 @@ class ShareAdminService
             $setting->fill($data + OperationAuditService::actor());
             $setting->save();
             $policy = $this->writablePolicy();
-            foreach (['enabled', 'benefit_enabled', 'valid_member_min_deposit', 'valid_member_min_effective_bet', 'indirect_bonus_rate'] as $field) {
+            foreach (['enabled', 'benefit_enabled', 'valid_member_min_deposit', 'valid_member_min_effective_bet'] as $field) {
                 $policy[$field] = $setting->getRawOriginal($field);
             }
             $this->savePolicy($policy);
@@ -208,6 +206,8 @@ class ShareAdminService
         $policy = $row ? (array)$row : ['enabled' => 0, 'benefit_enabled' => 0, 'valid_member_min_deposit' => '20',
             'valid_member_min_effective_bet' => null, 'indirect_bonus_rate' => null, 'rules' => ShareDefaults::rules()];
         unset($policy['id'], $policy['created_at'], $policy['updated_at']);
+        // Keep archived coefficients on past versions only; new versions use the receiver's bracket rate by depth.
+        $policy['indirect_bonus_rate'] = null;
         $policy['rules'] = is_string($policy['rules']) ? json_decode($policy['rules'], true, 512, JSON_THROW_ON_ERROR) : $policy['rules'];
         $policy['effective_date'] = $this->tomorrow();
         return $policy;
@@ -234,7 +234,7 @@ class ShareAdminService
         $data = (array)$row;
         foreach (['id', 'enabled', 'benefit_enabled'] as $field) $data[$field] = (int)$data[$field];
         foreach (['valid_member_min_deposit', 'valid_member_min_effective_bet'] as $field) $data[$field] = $data[$field] === null ? null : $this->money($data[$field]);
-        $data['indirect_bonus_rate'] = $data['indirect_bonus_rate'] === null ? null : bcadd((string)$data['indirect_bonus_rate'], '0', 4);
+        unset($data['indirect_bonus_rate']);
         $data['rules'] = json_decode($data['rules'], true, 512, JSON_THROW_ON_ERROR);
         foreach ($data['rules'] as &$rows) foreach ($rows as &$rule) {
             $rule['min_valid_members'] = (int)$rule['min_valid_members'];

+ 43 - 5
app/Services/Sharing/ShareSettlementService.php

@@ -15,7 +15,9 @@ class ShareSettlementService
         $empty = ['date'=>$date,'policy'=>$policy,'rows'=>[]];
         $activation=DB::table('share_settings')->value('activation_date');
         if (!$activation || $date<$activation || !DB::table('share_system_state')->where('key','initialized_at')->exists()) return $empty;
-        if (!$policy || !$policy->benefit_enabled || $policy->valid_member_min_effective_bet === null || $policy->indirect_bonus_rate === null) return $empty;
+        $depthRates = self::usesDepthRates($date);
+        if (!$policy || !$policy->benefit_enabled || $policy->valid_member_min_effective_bet === null
+            || (!$depthRates && $policy->indirect_bonus_rate === null)) return $empty;
         $end = $date . ' 23:59:59';
         $users = DB::table('users')->where('from','<>',2)->where(function ($q) use ($end) { $q->where('created_at','<=',$end)->orWhereNull('created_at'); })
             ->get(['id','member_id'])->keyBy('id');
@@ -43,18 +45,33 @@ class ShareSettlementService
             foreach ($edges[(int)$fact->user_id] ?? [] as $edge) {
                 $receiver=(int)$edge->ancestor_user_id;
                 if (!$users->has($receiver) || $edge->bound_at > $fact->bet_at) continue;
-                if (!isset($rows[$receiver][$fact->category])) $rows[$receiver][$fact->category]=['direct_performance'=>'0','indirect_performance'=>'0'];
+                if (!isset($rows[$receiver][$fact->category])) $rows[$receiver][$fact->category]=['direct_performance'=>'0','indirect_performance'=>'0','performance_by_depth'=>[]];
                 $field=(int)$edge->depth===1 ? 'direct_performance' : 'indirect_performance';
                 $rows[$receiver][$fact->category][$field]=bcadd($rows[$receiver][$fact->category][$field],(string)$fact->effective_amount,10);
+                $depth=(int)$edge->depth;
+                $byDepth=&$rows[$receiver][$fact->category]['performance_by_depth'];
+                $byDepth[$depth]=bcadd($byDepth[$depth] ?? '0',(string)$fact->effective_amount,10);
+                unset($byDepth);
             }
         }
         $rules=is_string($policy->rules) ? json_decode($policy->rules,true,512,JSON_THROW_ON_ERROR) : (array)$policy->rules;
         foreach ($rows as $id=>&$categories) foreach ($categories as $category=>&$row) {
             $row['valid_direct_members']=$counts[$id] ?? 0;
             $row['rate']=self::rate($rules[$category] ?? [],$row['valid_direct_members'],$row['direct_performance']);
-            $row['indirect_bonus_rate']=(string)$policy->indirect_bonus_rate;
+            // Keep the archived coefficient only for replay of days before the formula cutover.
+            $row['indirect_bonus_rate']=$depthRates ? '0' : (string)$policy->indirect_bonus_rate;
             $row['direct_commission']=self::percentage($row['direct_performance'],$row['rate']);
-            $row['indirect_commission']=self::percentage(self::percentage($row['indirect_performance'],$row['rate']),$row['indirect_bonus_rate']);
+            if ($depthRates) {
+                $indirect='0';
+                foreach ($row['performance_by_depth'] as $depth=>$performance) {
+                    if ($depth<2) continue;
+                    $indirect=bcadd($indirect,self::depthCommission($performance,$row['rate'],$depth),30);
+                }
+                $row['indirect_commission']=bcadd($indirect,'0',10);
+            } else {
+                $row['indirect_commission']=self::percentage(self::percentage($row['indirect_performance'],$row['rate']),$row['indirect_bonus_rate']);
+            }
+            unset($row['performance_by_depth']);
         }
         unset($categories,$row);
         return ['date'=>$date,'policy'=>$policy,'rows'=>$rows];
@@ -78,6 +95,26 @@ class ShareSettlementService
         return bcdiv(bcmul($amount,$rate,18),'100',10);
     }
 
+    public static function usesDepthRates(string $date): bool
+    {
+        $start=DB::table('share_system_state')->where('key','commission_depth_rate_start')->value('value') ?? '1970-01-01';
+        return $date >= $start;
+    }
+
+    /** Keep sub-ledger precision until all depth buckets have been added together. */
+    public static function depthCommission(string $amount,string $rate,int $depth): string
+    {
+        if ($depth<1) throw new \InvalidArgumentException('佣金层级必须为正整数');
+        if (bccomp($rate,'0',4)===0) return bcadd('0','0',30);
+        if (bccomp($rate,'100',4)===0) return bcadd($amount,'0',30);
+        $result=$amount;
+        for ($level=0;$level<$depth;$level++) {
+            $result=bcdiv(bcmul($result,$rate,40),'100',30);
+            if (bccomp($result,'0',30)===0) break;
+        }
+        return $result;
+    }
+
     public function settle(string $date,bool $write=true): array
     {
         $this->validateDate($date);
@@ -90,10 +127,11 @@ class ShareSettlementService
         return DB::transaction(function () use ($date,$write,$result) {
             DB::table('share_system_state')->where('key','relations_lock')->lockForUpdate()->first();
             $projection=$this->projection($date); $rows=$projection['rows']; $policy=$projection['policy'];
+            $depthRates=self::usesDepthRates($date);
             $previous=DB::table('share_settlements')->where('bet_date',$date)->get();
             foreach ($previous as $old) if (!isset($rows[$old->user_id][$old->category])) {
                 $rows[$old->user_id][$old->category]=['direct_performance'=>'0','indirect_performance'=>'0','valid_direct_members'=>0,
-                    'rate'=>'0','indirect_bonus_rate'=>(string)($policy->indirect_bonus_rate ?? 0),'direct_commission'=>'0','indirect_commission'=>'0'];
+                    'rate'=>'0','indirect_bonus_rate'=>$depthRates ? '0' : (string)($policy->indirect_bonus_rate ?? 0),'direct_commission'=>'0','indirect_commission'=>'0'];
             }
             $result['receivers']=count($rows);
             $now=Carbon::now(ShareDefaults::TIMEZONE)->format('Y-m-d H:i:s');

+ 22 - 0
database/migrations/2026_10_24_140000_add_status_to_banner.php

@@ -0,0 +1,22 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    public function up(): void
+    {
+        Schema::table('banner', function (Blueprint $table) {
+            $table->unsignedTinyInteger('status')->default(1)->comment('手动上下架:0下架,1上架');
+        });
+    }
+
+    public function down(): void
+    {
+        Schema::table('banner', function (Blueprint $table) {
+            $table->dropColumn('status');
+        });
+    }
+};

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

@@ -0,0 +1,30 @@
+<?php
+
+use App\Services\Sharing\ShareDefaults;
+use Carbon\Carbon;
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Support\Facades\DB;
+
+return new class extends Migration
+{
+    public function up(): void
+    {
+        DB::transaction(function () {
+            DB::table('share_system_state')->where('key', 'relations_lock')->lockForUpdate()->first();
+            $now = Carbon::now(ShareDefaults::TIMEZONE);
+            $activated = DB::table('share_settings')->whereNotNull('activation_date')->exists();
+            // Retain old days' booked arithmetic; a new installation starts directly with depth rates.
+            DB::table('share_system_state')->insertOrIgnore([
+                'key' => 'commission_depth_rate_start',
+                'value' => $activated ? $now->copy()->addDay()->toDateString() : '1970-01-01',
+                'created_at' => $now->format('Y-m-d H:i:s'),
+                'updated_at' => $now->format('Y-m-d H:i:s'),
+            ]);
+        });
+    }
+
+    public function down(): void
+    {
+        DB::table('share_system_state')->where('key', 'commission_depth_rate_start')->delete();
+    }
+};

+ 261 - 0
docs/前端接口/Banner/上下架与活动联动.md

@@ -0,0 +1,261 @@
+# Banner 上下架与活动联动-前端接入说明
+
+适用页面:后台 `/admin/banner/index` 轮播图管理,以及会员端首页 Banner。后台「跳转 ID」继续使用 `link` 绑定 `/admin/ActivityReward` 列表中的活动 `id`,不新增提交字段 `activity_id`。本文用于本次接口变更接入,不表示已在生产环境部署。
+
+后台增加 Banner 手动上下架开关;绑定活动的 Banner 还会随活动状态、开始和结束时间实时决定是否展示。活动结束后,会员接口自动排除对应 Banner;后台仍保留记录并返回下架原因。
+
+## 1. 两种状态与显示规则
+
+| 返回字段 | 类型 | 含义与前端用途 |
+|---|---|---|
+| `status` | integer,`0/1` | Banner 的手动开关。`1` 手动上架,`0` 手动下架;编辑表单和行内开关绑定此字段 |
+| `activity_id` | string 或 `null` | 从 `link` 识别出的活动 ID,只读;数字字符串会去掉前导零,例如 `"000123"` 对应 `"123"` |
+| `effective_status` | integer,`0/1` | 当前实际展示状态。`1` 可展示,`0` 已下架;后台状态标签使用此字段 |
+| `offline_reason` | string 或 `null` | 实际下架原因;可展示时为 `null`,取值见下表 |
+
+这些新增状态字段仅由后台列表返回。会员列表保持原字段,由服务端过滤后直接展示。
+
+下架原因按以下顺序判断,命中第一项即返回:
+
+| `offline_reason` | 条件 | 建议文案 |
+|---|---|---|
+| `manual` | Banner `status=0` | 手动下架 |
+| `activity_missing` | 绑定的活动已不存在 | 关联活动不存在 |
+| `activity_offline` | 关联活动未上架,活动 `status` 不为 `1` | 关联活动已下架 |
+| `activity_not_started` | 关联活动开始时间晚于服务器当前时间 | 关联活动未开始 |
+| `activity_ended` | 关联活动结束时间小于或等于服务器当前时间 | 关联活动已结束 |
+| `null` | 以上条件均未命中 | 上架中 |
+
+绑定活动的 Banner 只有在手动开关为 `1`、活动存在且已上架、并满足 `start_time <= 服务器当前时间 < end_time` 时才展示。无活动绑定的 Banner 只由手动开关决定。
+
+**后台建议同时展示「手动开关」和「实际状态 / 原因」。** 例如 `status=1`、`effective_status=0`、`offline_reason="activity_ended"` 应显示开关开启、实际状态“关联活动已结束”。不要将实际下架结果写回手动开关。手动开启也不能强制展示已结束、未开始、已下架或已删除活动的 Banner。
+
+活动重新上架、延期或调整开始时间后,手动开关仍为 `1` 的 Banner 会在条件满足时自然恢复;手动开关为 `0` 的 Banner 不会自动恢复。前端以重新请求后的结果更新状态,不自行比较活动日期。
+
+## 2. 跳转 ID / 链接约定
+
+`link` 仍是唯一的跳转提交字段:
+
+| `link` 示例 | 含义 |
+|---|---|
+| `"123"` 或 `123` | 绑定活动 ID `123`,保存后规范为字符串 `"123"` |
+| `"000123"` | 同样绑定活动 ID `123`,保存后为 `"123"` |
+| `null`、`""`、`"0"` 或 `0` | 未绑定活动,仅受 Banner 手动开关控制 |
+| `"https://example.com/promotion"` | 非纯数字链接,不绑定活动,仅受 Banner 手动开关控制;跳转方式沿用原逻辑 |
+
+只有正整数数字串会识别为活动 ID;后台列表的 `activity_id` 可用于显示关联活动。新增 Banner 或更换到另一个活动 ID 时,该活动必须存在,但允许绑定尚未开始、已经结束或已经下架的活动;保存成功不代表当前可展示。
+
+已有绑定活动被删除时,允许保持原 `link` 编辑 Banner 或手动下架;实际状态显示 `activity_missing`。清除绑定时明确提交 `link:null`,省略 `link` 则保留原值。
+
+## 3. 后台列表
+
+`GET /admin/banner/index`
+
+沿用后台登录态,请求头为 `Authorization: Bearer <后台token>`。成功码为 `code=0`,总数取 `data.total`,记录取 `data.data`。
+
+| 参数 | 类型 | 必填 | 说明 |
+|---|---|---|---|
+| `page` | integer | 否 | 默认 `1`,最小 `1` |
+| `limit` | integer | 否 | 默认 `15`,范围 `1`~`200` |
+| `type` | string | 否 | `pc` 或 `app`;省略或留空时不按类型筛选 |
+
+列表保留原有字段和分页结构,并增加第 1 节的四个字段。后台同时返回实际上架、手动下架和活动联动下架的 Banner;不新增 `status` 筛选参数。排序仍为 `sort` 倒序,再按 `id` 倒序。
+
+下例展示一条因活动结束而下架的记录,原有其他字段按实际接口保留:
+
+```json
+{
+  "code": 0,
+  "timestamp": 1792800000,
+  "msg": "OK",
+  "data": {
+    "total": 1,
+    "data": [
+      {
+        "id": 40,
+        "title": "秋季活动",
+        "image": "https://example.com/uploads/banner-40.jpg",
+        "link": "123",
+        "type": "pc",
+        "sort": 10,
+        "lang": "zh",
+        "status": 1,
+        "activity_id": "123",
+        "effective_status": 0,
+        "offline_reason": "activity_ended"
+      }
+    ]
+  }
+}
+```
+
+## 4. 新增 / 编辑
+
+`POST /admin/banner/update`
+
+沿用后台 Bearer Token。新增省略 `id`,编辑提交现有 Banner `id`。新增和编辑都必须提交完整基础字段 `title`、`image`、`type`、`lang`;只切换状态可使用第 5 节接口。
+
+| 参数 | 类型 | 必填 | 说明 |
+|---|---|---|---|
+| `id` | integer | 编辑必填 | Banner ID,最小 `1`;新增省略 |
+| `title` | 原表单标题 | 是 | 沿用现有标题字段 |
+| `image` | 原图片路径或 URL | 是 | 沿用现有上传与图片回显逻辑 |
+| `type` | string | 是 | `pc` 或 `app` |
+| `lang` | 原语种值 | 是 | 沿用现有选项,例如 `zh`、`en` |
+| `link` | string、integer 或 `null` | 否 | 最长 `191` 个字符;兼容数字 JSON 值。编辑省略保留原值,新增省略为 `null` |
+| `sort` | integer 或 `null` | 否 | 编辑省略保留原值;新增省略或显式传 `null` 为 `0` |
+| `status` | integer,`0/1` | 否 | 手动开关;编辑省略保留原值,新增省略为 `1` |
+
+不要将 `activity_id`、`effective_status`、`offline_reason` 作为保存参数。旧 Banner 的手动状态默认是 `1`,绑定活动的旧记录同样遵循实时展示规则。
+
+新增并绑定活动:
+
+```json
+{
+  "title": "秋季活动",
+  "image": "/uploads/banner-40.jpg",
+  "type": "pc",
+  "lang": "zh",
+  "link": "123",
+  "sort": 10,
+  "status": 1
+}
+```
+
+编辑为无活动绑定的 Banner,并手动下架:
+
+```json
+{
+  "id": 40,
+  "title": "首页推广图",
+  "image": "/uploads/banner-40.jpg",
+  "type": "pc",
+  "lang": "zh",
+  "link": null,
+  "sort": 10,
+  "status": 0
+}
+```
+
+保存成功不返回新增 ID 或实际展示状态,响应如下。成功后重新请求后台列表,刷新实际状态和原因。
+
+```json
+{
+  "code": 0,
+  "timestamp": 1792800000,
+  "msg": "OK",
+  "data": []
+}
+```
+
+## 5. 单独切换手动上下架
+
+`POST /admin/banner/status`
+
+沿用后台 Bearer Token,无需重新提交标题、图片等表单字段。
+
+| 参数 | 类型 | 必填 | 说明 |
+|---|---|---|---|
+| `id` | integer | 是 | Banner ID,最小 `1` |
+| `status` | integer,`0/1` | 是 | `0` 手动下架,`1` 手动上架 |
+
+下架请求:
+
+```json
+{
+  "id": 40,
+  "status": 0
+}
+```
+
+上架请求:
+
+```json
+{
+  "id": 40,
+  "status": 1
+}
+```
+
+成功响应与保存接口相同:`code=0`、`data=[]`。提交期间禁用该行开关;失败恢复原值并展示 `msg`,成功后刷新列表。若重新请求后实际仍下架,展示 `offline_reason` 对应文案,不将开关错误回滚为 `0`。
+
+## 6. 会员端 Banner
+
+`GET /api/index/banner`
+
+接口允许匿名访问。继续使用会员端现有请求封装和部署配置的请求 / 响应加解密流程;下例为解密后的业务 JSON。会员端成功码为 `code=1`,与后台的 `code=0` 分别处理。
+
+| 参数 / 请求头 | 类型 | 说明 |
+|---|---|---|
+| 请求头 `Lang` | string | 语种,例如 `zh`、`en`,默认 `zh` |
+| 查询参数 `type` | string | `pc` 或 `app`,沿用现有平台传值 |
+| 查询参数 `page` | integer | 默认 `1` |
+| 查询参数 `limit` | integer | 默认 `25`,沿用现有分页校验 |
+| 查询参数 `page_type` | integer | 默认 `1`,正常分页;`0` 使用原有不分页模式,最多取 `2000` 条 |
+
+服务端在分页和统计前过滤下架 Banner。`data.count` 是当前语种 / 类型下可展示的总数,`data.list` 是当前页可展示记录,`data.extend` 保持 `[]`。列表单项字段仍为 `id`、`type`、`image`、`link`、`title`,不要求会员前端增加状态字段或活动日期判断。
+
+```json
+{
+  "code": 1,
+  "show": 0,
+  "msg": "",
+  "data": {
+    "count": 1,
+    "list": [
+      {
+        "id": 41,
+        "type": "pc",
+        "image": "https://example.com/uploads/banner-41.jpg",
+        "link": "https://example.com/promotion",
+        "title": "首页推广图"
+      }
+    ],
+    "extend": []
+  }
+}
+```
+
+无可展示 Banner 时为 `count=0`、`list=[]`、`extend=[]`,按现有空列表逻辑收起轮播区域。已渲染页面在下一次请求后反映最新状态,不新增服务端推送约定。
+
+## 7. 常见失败与联调要点
+
+以下错误码属于后台接口;不能套用到会员接口的成功码约定。后台参数错误、资源不存在均应按业务 `code` 和 `msg` 处理,不仅判断 HTTP 状态。
+
+| `code` | 场景 | 前端处理 |
+|---|---|---|
+| `101009` | `status` 非 `0/1`、ID 或分页格式不合法、基础字段缺失、`type` 不合法、`link` 超长等 | 展示 `msg` 并保留表单 |
+| `101009` | 新增或更换绑定的活动不存在 | 提示重新选择活动;保持已存在的旧绑定不触发此校验 |
+| `-3` | 编辑或切换状态时 Banner 不存在 | 展示“数据不存在”,刷新列表 |
+
+活动绑定校验失败示例:
+
+```json
+{
+  "code": 101009,
+  "timestamp": 1792800000,
+  "msg": "参数验证失败:绑定的活动不存在",
+  "data": []
+}
+```
+
+Banner 不存在示例:
+
+```json
+{
+  "code": -3,
+  "timestamp": 1792800000,
+  "msg": "数据不存在",
+  "data": []
+}
+```
+
+联调覆盖以下场景:
+
+- 无活动绑定的 Banner 手动下架后从会员列表消失,手动上架后恢复。
+- 已上架且处于有效时间内的活动对应 Banner 可展示;达到结束时间即不再返回。
+- 活动未开始、下架或删除时,后台仍显示 Banner 和对应原因,会员列表不返回。
+- 关联活动已结束时手动上架 Banner:接口成功,手动开关为开启,实际状态仍为“关联活动已结束”。
+- 手动下架后延长活动时间,Banner 仍保持下架;手动开关开启时延长到有效时间可恢复展示。
+- 下架记录不占会员分页名额,`count` 与可展示记录一致;后台总数仍包含下架记录。
+- 编辑仅修改标题 / 图片并省略 `link`、`sort`、`status`,原关联、排序和手动状态保留。

+ 8 - 9
docs/前端接口/新分享系统/后台返佣配置.md

@@ -14,14 +14,13 @@
 | benefit_enabled | 已保存的佣金开关目标值;当日实际状态以 current_policy 为准 |
 | valid_member_min_deposit | 已保存的累计成功充值最低门槛 |
 | valid_member_min_effective_bet | 已保存的累计全游戏有效投注最低门槛;未填为null |
-| indirect_bonus_rate | 已保存的间接佣金附加系数,百分数;未填为null |
 | activation_date | 新佣金首次启用日期,首次启用时定为次日,之后不重置 |
 | initialized | 分享会员关系是否已完成初始化;为false时不能启用佣金 |
 | effective_date | 当前待生效配置的日期,无待生效版本时为null |
 | current_policy | 今日已生效版本,没有版本时为null |
 | pending_policy | 次日待生效版本,没有修改时为null |
 
-版本包含 `id`(版本ID)、`effective_date`(生效日)、`enabled`、`benefit_enabled`、两项有效会员门槛、`indirect_bonus_rate`、`rules`及创建/更新时间。`rules`以分类标识为键,值为规则数组,每条包含 `min_valid_members`、`min_performance`、`rate`。下例为九类规则均已各调整为一档后的完整合法示例:
+版本包含 `id`(版本ID)、`effective_date`(生效日)、`enabled`、`benefit_enabled`、两项有效会员门槛、`rules`及创建/更新时间。`rules`以分类标识为键,值为规则数组,每条包含 `min_valid_members`、`min_performance`、`rate`。下例为九类规则均已各调整为一档后的完整合法示例:
 
 ```json
 {
@@ -35,7 +34,6 @@
     "benefit_enabled": 1,
     "valid_member_min_deposit": "20.00",
     "valid_member_min_effective_bet": "100.00",
-    "indirect_bonus_rate": "25.0000",
     "activation_date": "2026-10-25",
     "initialized": true,
     "effective_date": "2026-10-26",
@@ -46,7 +44,6 @@
       "benefit_enabled": 1,
       "valid_member_min_deposit": "20.00",
       "valid_member_min_effective_bet": "100.00",
-      "indirect_bonus_rate": "30.0000",
       "rules": {
         "third:1": [
           {
@@ -122,7 +119,6 @@
       "benefit_enabled": 1,
       "valid_member_min_deposit": "20.00",
       "valid_member_min_effective_bet": "100.00",
-      "indirect_bonus_rate": "25.0000",
       "rules": {
         "third:1": [
           {
@@ -208,16 +204,17 @@
 | benefit_enabled | 是 | 0或1;开启前须 initialized=true |
 | valid_member_min_deposit | 是 | 非负十进制金额,最多14位整数、2位小数;超过2位小数会拒绝 |
 | valid_member_min_effective_bet | 开佣金时必填 | 非负十进制金额,最多14位整数、2位小数;关闭时可传null,不能依靠隐含默认值启用 |
-| indirect_bonus_rate | 开佣金时必填 | 0~100的百分数,最多4位小数,必须明确设置;无自动30%默认值 |
 
 ```json
-{"enabled":1,"share_domain":"https://pc.example.com","benefit_enabled":1,"valid_member_min_deposit":"20.00","valid_member_min_effective_bet":"100.00","indirect_bonus_rate":"30.0000"}
+{"enabled":1,"share_domain":"https://pc.example.com","benefit_enabled":1,"valid_member_min_deposit":"20.00","valid_member_min_effective_bet":"100.00"}
 ```
 
 返回保存后的完整配置,结构同读取接口。不要把只读字段 `activation_date`、`effective_date`、`current_policy`、`pending_policy` 原样提交;接口会拒绝这些字段。旧参数 `non_seller_threshold`、`tier_one_rate`、`tier_two_rate` 已移除,提交会明确报错。
 
 首次启用的 `activation_date` 固定为次日,不能指定过去日期;再停用、再启用也不重置。历史业务记录可用于查询和资格累计,首次启用日前不产生新分享佣金。
 
+已启用系统的本次计佣新规则自次日北京时间 `00:00` 起生效;已结算历史日保留原入账金额,后续补结算或退款按原投注日规则修正,不以新规则重写历史佣金。开启佣金仍需关系初始化完成并明确设置累计有效投注门槛;前端仅保留本页列出的配置项。
+
 ## 读取分类规则
 
 `GET /admin/shareEarning/rateRules`
@@ -262,9 +259,11 @@
 
 返回该分类的规则读取结构。保存只修改该分类的次日规则,其他分类、已保存的次日系统条件保留。系统按业绩门槛、同业绩门槛下的人数要求升序返回规则。计算取人数和业绩同时满足的最高业绩门槛档;业绩门槛相同时取最高人数档,使用该档对应比例,均不满足时比例为0。规则中人数要求与比例均须随档位提高而不降低,同一业绩门槛和人数组合不可重复,否则拒绝保存。
 
-有效会员须截至原投注日末累计成功充值和全游戏有效投注同时达标;不达标会员的业绩也不计入佣金。直属佣金为直属有效业绩×本人比例÷100;全部更深层下级合计的间接有效业绩×本人比例÷100×附加系数÷100。间接佣金使用收款人本人的比例,不逐层递归。
+有效会员须截至原投注日末累计成功充值和全游戏有效投注同时达标;不达标会员的业绩也不计入佣金。分享等级仍由累计佣金决定,与本页人数和业绩共同决定的比例档位独立,不改等级配置规则。
+
+新规则令 `r = 收款人本人当日该分类命中的比例 / 100`,直属佣金=合格直属业绩 × `r`;间接佣金=对全部 `n >= 2` 求和:第 `n` 层合格业绩 × `r^n`。每层只使用同一收款人的同一比例,不取中间会员或下级各自的比例。全部间接业绩的统计仍为原值相加,不按层数折算。
 
-例:一个有效直属会员、直属分类业绩100万元,满足上例最高档,使用该档对应的0.25%。间接业绩20000元、本人比例0.10%、附加系数30%时,间接佣金为6元。
+例如本人比例为 `10%`,第 1、2、3 层各有 `1000.00` 合格业绩,对应佣金为 `100.00`、`10.00`、`1.00`;`10%` 仅是算法示例,不修改实际初始档位。一个有效直属会员、直属分类业绩 100 万元,按上例规则仍命中 `0.25%`。本人比例为 `0.10%`、第 2 层业绩 `20000.00` 时,间接佣金为 `20000 × (0.10 / 100)² = 0.02`。
 
 九类初始规则均为以下12档,可由配置接口分别调整:
 

+ 12 - 17
docs/前端接口/新分享系统/我的数据.md

@@ -16,7 +16,7 @@ URL 相对会员网站 API 域名。请求头 `Authorization: <登录接口返
 
 可选 `category`:不传查全部;支持third:1真人、third:2电子、third:3第三方彩票、third:4第三方体育、third:5电竞、third:6捕鱼、third:7棋牌、self:sport自有体育、self:lottery自有彩票。分类键为字符串。资格累计始终包含全部游戏;分类筛选只限制投注相关金额及佣金行,不把充值、提现、奖励改为分类金额。
 
-请求示例:`GET /api/share/data?period=today&page=1&limit=20`
+请求示例:`GET /api/share/data?period=today&page=1&limit=20`。以下为新规则已生效后的示意数据:电子分类本人比例为 `0.2000%`,合格间接业绩 `20200.00` 全部来自第 2 层,因此间接佣金为 `20200 × (0.2 / 100)² = 0.0808`,显示为 `0.08`;与直属佣金 `40.00` 合计显示 `40.08`。
 
 ```json
 {
@@ -37,7 +37,6 @@ URL 相对会员网站 API 域名。请求头 `Authorization: <登录接口返
         "category_name": "自有彩票",
         "valid_direct_members": 1,
         "rate": "0.0000",
-        "indirect_bonus_rate": "30.0000",
         "commission_status": "estimated",
         "updated_at": "2026-10-24 12:00:00",
         "direct_performance": "0.00",
@@ -52,7 +51,6 @@ URL 相对会员网站 API 域名。请求头 `Authorization: <登录接口返
         "category_name": "自有体育",
         "valid_direct_members": 1,
         "rate": "0.0000",
-        "indirect_bonus_rate": "30.0000",
         "commission_status": "estimated",
         "updated_at": "2026-10-24 12:00:00",
         "direct_performance": "0.00",
@@ -67,7 +65,6 @@ URL 相对会员网站 API 域名。请求头 `Authorization: <登录接口返
         "category_name": "真人",
         "valid_direct_members": 1,
         "rate": "0.0000",
-        "indirect_bonus_rate": "30.0000",
         "commission_status": "estimated",
         "updated_at": "2026-10-24 12:00:00",
         "direct_performance": "0.00",
@@ -82,14 +79,13 @@ URL 相对会员网站 API 域名。请求头 `Authorization: <登录接口返
         "category_name": "电子",
         "valid_direct_members": 1,
         "rate": "0.2000",
-        "indirect_bonus_rate": "30.0000",
         "commission_status": "estimated",
         "updated_at": "2026-10-24 12:00:00",
         "direct_performance": "20000.00",
         "indirect_performance": "20200.00",
         "direct_commission": "40.00",
-        "indirect_commission": "12.12",
-        "total_commission": "52.12"
+        "indirect_commission": "0.08",
+        "total_commission": "40.08"
       },
       {
         "bet_date": "2026-10-24",
@@ -97,7 +93,6 @@ URL 相对会员网站 API 域名。请求头 `Authorization: <登录接口返
         "category_name": "第三方彩票",
         "valid_direct_members": 1,
         "rate": "0.0000",
-        "indirect_bonus_rate": "30.0000",
         "commission_status": "estimated",
         "updated_at": "2026-10-24 12:00:00",
         "direct_performance": "0.00",
@@ -112,7 +107,6 @@ URL 相对会员网站 API 域名。请求头 `Authorization: <登录接口返
         "category_name": "第三方体育",
         "valid_direct_members": 1,
         "rate": "0.0000",
-        "indirect_bonus_rate": "30.0000",
         "commission_status": "estimated",
         "updated_at": "2026-10-24 12:00:00",
         "direct_performance": "0.00",
@@ -127,7 +121,6 @@ URL 相对会员网站 API 域名。请求头 `Authorization: <登录接口返
         "category_name": "电竞",
         "valid_direct_members": 1,
         "rate": "0.0000",
-        "indirect_bonus_rate": "30.0000",
         "commission_status": "estimated",
         "updated_at": "2026-10-24 12:00:00",
         "direct_performance": "0.00",
@@ -142,7 +135,6 @@ URL 相对会员网站 API 域名。请求头 `Authorization: <登录接口返
         "category_name": "捕鱼",
         "valid_direct_members": 1,
         "rate": "0.0000",
-        "indirect_bonus_rate": "30.0000",
         "commission_status": "estimated",
         "updated_at": "2026-10-24 12:00:00",
         "direct_performance": "0.00",
@@ -157,7 +149,6 @@ URL 相对会员网站 API 域名。请求头 `Authorization: <登录接口返
         "category_name": "棋牌",
         "valid_direct_members": 1,
         "rate": "0.0000",
-        "indirect_bonus_rate": "30.0000",
         "commission_status": "estimated",
         "updated_at": "2026-10-24 12:00:00",
         "direct_performance": "0.00",
@@ -170,9 +161,9 @@ URL 相对会员网站 API 域名。请求头 `Authorization: <登录接口返
     "currency": "CNY",
     "summary": {
       "direct_commission": "40.00",
-      "indirect_commission": "12.12",
+      "indirect_commission": "0.08",
       "settled_commission": "0.00",
-      "estimated_commission": "52.12",
+      "estimated_commission": "40.08",
       "direct_performance": "20000.00",
       "indirect_performance": "20200.00"
     },
@@ -401,9 +392,9 @@ URL 相对会员网站 API 域名。请求头 `Authorization: <登录接口返
       "cumulative_other_commission": "0.00",
       "cumulative_paid_count": 0,
       "period_direct_commission": "40.00",
-      "period_other_commission": "12.12",
+      "period_other_commission": "0.08",
       "period_settled_commission": "0.00",
-      "period_estimated_commission": "52.12",
+      "period_estimated_commission": "40.08",
       "period_paid_amount": "0.00",
       "period_paid_count": 0
     }
@@ -412,10 +403,14 @@ URL 相对会员网站 API 域名。请求头 `Authorization: <登录接口返
 ```
 ## 日期分类佣金列表
 
-`data.data`每行对应本人一个原投注日、一个游戏分类。行字段:`bet_date`原投注日YYYY-MM-DD;`category/category_name`分类键与名称;`valid_direct_members`原日合格直属人数;`rate`本人的返佣百分比;`indirect_bonus_rate`间接佣金附加系数;`direct_performance/indirect_performance`合格直属/全部间接业绩;`direct_commission/indirect_commission`两类佣金;`total_commission`两类佣金之和;`commission_status`为settled已日结或estimated今日预估;`updated_at`为结算修正或规则最近更新时间,可能为null。
+`data.data`每行对应本人一个原投注日、一个游戏分类。行字段:`bet_date`原投注日YYYY-MM-DD;`category/category_name`分类键与名称;`valid_direct_members`原日合格直属人数;`rate`本人的返佣百分比;`direct_performance/indirect_performance`合格直属/全部间接业绩;`direct_commission/indirect_commission`两类佣金;`total_commission`两类佣金之和;`commission_status`为settled已日结或estimated今日预估;`updated_at`为结算修正或规则最近更新时间,可能为null。
 
 该列表的total是日期分类行数,不是会员人数。summary包含direct_commission、indirect_commission、direct_performance、indirect_performance、settled_commission、estimated_commission,统计整个日期及分类筛选范围,不限当前页。晚到结算或退款会修正原投注日,今日预估未进入可领取账户。
 
+本次新规则自次日北京时间 `00:00` 起生效。生效后的直属佣金=合格直属业绩 × 本人当日该分类比例 ÷ 100;间接佣金=对全部 `n >= 2` 的下级层数分别计算并求和:第 `n` 层合格业绩 ×(本人当日该分类比例 ÷ 100)^`n`。每层均使用收款人本人命中的同一比例;中间会员和下级自己的比例不参与本人的计算。例如本人比例 `10%`,第 1、2、3 层各有 `1000.00` 业绩时,对应佣金分别为 `100.00`、`10.00`、`1.00`。
+
+`indirect_performance` 仍是全部合格间接业绩的原始合计,不按层数折算;因为各层权重不同,不能直接把该合计乘一次本人比例得到间接佣金。已结算历史日保留当时入账金额,退款或补结算继续按原投注日规则修正。跨生效日期查询时,金额统一展示接口结果,不用今日比例重算历史佣金。
+
 ## 期间与累计统计
 
 `period_stats`为所选期间事件统计;`cumulative_stats`为截至当前的全历史统计,不受所选期间限制。两者均包含三组:`total`全部下级、`direct`直属下级、`other`全部更深层间接下级,均不含本人。每一组使用下表完整字段,金额为字符串、次数及人数为整数。category仅影响投注、有效、业绩和盈亏相关统计;资格判断仍累计全游戏。

+ 10 - 5
docs/前端接口/新分享系统/返佣比例.md

@@ -17,7 +17,6 @@ URL 相对会员网站 API 域名。请求头 `Authorization: <登录接口返
     "date": "2026-10-24",
     "currency": "CNY",
     "effective_date": "1970-01-01",
-    "indirect_bonus_rate": "30.0000",
     "valid_member_min_deposit": "100.00",
     "valid_member_min_effective_bet": "100.00",
     "commission_enabled": true,
@@ -249,7 +248,6 @@ URL 相对会员网站 API 域名。请求头 `Authorization: <登录接口返
 | date | 当前计算日期,YYYY-MM-DD |
 | currency | 当前钱包和佣金的币种代码,例如CNY |
 | effective_date | 当前规则版本生效日,没有版本时为null |
-| indirect_bonus_rate | 间接佣金附加系数百分数;尚未明确配置时为null |
 | valid_member_min_deposit | 截至原投注日末累计成功充值最低门槛 |
 | valid_member_min_effective_bet | 截至原投注日末累计全游戏有效投注最低门槛,未配置时为null |
 | commission_enabled | 今日是否允许按已生效的benefit_enabled、必填参数、启动日及政策版本产生新佣金;入口展示开关enabled不影响此值,布尔值 |
@@ -265,8 +263,15 @@ URL 相对会员网站 API 域名。请求头 `Authorization: <登录接口返
 | data[].direct_performance | 本人合格直属会员今日在该分类的有效业绩 |
 | data[].valid_direct_members | 本人合格直属人数 |
 
-两项资格条件须同时满足,不合格下级的业绩也不计入。档位按本人合格直属人数和直属分类业绩判断,先取已满足的最高业绩门槛,门槛相同再取最高人数要求;全部更深层下级的业绩使用本人命中的比例计算间接佣金,不使用下级自己的比例,也不逐层递归。
+两项资格条件须同时满足,不合格下级的业绩也不计入。档位按本人合格直属人数和直属分类业绩判断,先取已满足的最高业绩门槛,门槛相同再取最高人数要求。分享等级按累计佣金升级,与这里的返佣比例档位分别计算;前端不要用等级号代替 `current_rate`。
 
-直属佣金=直属有效业绩×current_rate÷100。间接佣金=全部间接有效业绩×current_rate÷100×indirect_bonus_rate÷100。例:一名有效直属会员、100万元业绩,在默认档位中命中0.25%。本人比例0.10%、间接业绩20000元、附加系数30%时,间接佣金为6元。
+新规则对同一收款人的每一层均使用其本人当日、该分类命中的 `current_rate`,按距离逐层相乘。令 `r = current_rate / 100`,直属为第 1 层,则:
 
-后台今天保存的金钱条件和比例在次日00:00生效;本接口显示今日已生效版本。今日预估不进入可领取金额,前端不要按本接口金额或比例自行生成领取申请金额。
+- 直属佣金=合格直属业绩 × `r`。
+- 间接佣金=对全部 `n >= 2` 求和:第 `n` 层合格业绩 × `r^n`。
+
+例如收款人比例为 `10%`,第 1、2、3 层各有 `1000.00` 合格业绩,则分别产生 `100.00`、`10.00`、`1.00` 佣金,对应实际比例 `10%`、`1%`、`0.1%`;更深层继续按本人的 `10%` 相乘。中间会员或下级自己的返佣比例不参与该收款人的计算。这个 `10%` 仅用于说明计算方式,不改变现有默认档位。
+
+按现有默认档位,一名有效直属会员、100 万元直属分类业绩仍命中 `0.25%`。另一个计算示例:收款人比例为 `0.10%`、第 2 层合格业绩为 `20000.00`,则间接佣金为 `20000 × (0.10 / 100)² = 0.02`。全部间接业绩仍按原值合计展示,实际计佣须按各层分别计算。
+
+本次按层数相乘的新规则自次日北京时间 `00:00` 起生效;后台今天保存的金钱条件和比例也在次日 `00:00` 生效。本接口显示今日已生效版本。已结算历史佣金保留原入账金额,后续补结算或退款按原投注日规则修正,不能用本接口今日比例重算历史金额。今日预估不进入可领取金额,前端不要按本接口金额或比例自行生成领取申请金额。

+ 5 - 4
docs/前端接口/新分享系统/首页.md

@@ -36,7 +36,7 @@ URL 相对会员网站 API 域名。请求头 `Authorization: <登录接口返
     "direct_members": 2,
     "team_members": 5,
     "valid_direct_members": 1,
-    "today_estimated_commission": "52.12",
+    "today_estimated_commission": "40.08",
     "yesterday_direct_performance": "250.00",
     "settlement_date": "2026/10/23",
     "commission_status": "estimated",
@@ -72,6 +72,8 @@ URL 相对会员网站 API 域名。请求头 `Authorization: <登录接口返
 
 今日金额会随有效投注、资格及结算修正变化。只有已经日结的佣金可进入可申请金额;页面领取按钮以 `claimable_amount>=0.01` 且 `reviewing_amount=0` 为启用条件,提交后仍以服务端校验为准。
 
+本次计佣规则自次日北京时间 `00:00` 起生效:每个分类使用收款人本人命中的返佣比例,按下级距离逐层相乘。本人比例为 `10%` 时,直属按 `10%`、第 2 层按 `1%`、第 3 层按 `0.1%` 计算,继续逐层递减;所有层都使用收款人的同一比例。分享等级独立按累计佣金升级,不作为返佣比例档位。已结算历史佣金保留原入账金额,后续补结算或退款按原投注日规则修正;首页金额直接使用服务端返回值。
+
 ## 提交本人领取申请
 
 `POST /api/share/claim`,`Content-Type: application/json`。
@@ -166,11 +168,10 @@ URL 相对会员网站 API 域名。请求头 `Authorization: <登录接口返
     "share_domain": "https://pc.example.com",
     "activation_date": "2026-10-01",
     "valid_member_min_deposit": "20.00",
-    "valid_member_min_effective_bet": "100.00",
-    "indirect_bonus_rate": "30.0000"
+    "valid_member_min_effective_bet": "100.00"
   }
 }
 ```
-`benefit_enabled`、门槛和附加系数读取当前已生效版本;后台今日保存的修改次日才反映于本接口。实际今日开佣金看home的 `commission_enabled`,今日金额条件及比例看 `/api/share/rates`。两项新参数未配置时为null。旧三级分享参数不再返回。已有 `/api/share/profile` 为首页兼容入口,前端新接入使用home。
+`benefit_enabled` 和资格门槛读取当前已生效版本;后台今日保存的修改次日才反映于本接口。实际今日开佣金看home的 `commission_enabled`,今日金额条件及比例看 `/api/share/rates`。累计有效投注门槛未配置时为null,开启佣金前仍须明确设置。旧三级分享参数不再返回。已有 `/api/share/profile` 为首页兼容入口,前端新接入使用home。
 
 `enabled`仅控制入口显示,与资金开关独立;即使其为0,只要已生效的`benefit_enabled`为1且其他计佣条件满足,仍产生佣金。前端不得以隐藏入口推断计佣停止。

+ 1 - 0
routes/admin.php

@@ -395,6 +395,7 @@ Route::middleware(['admin.jwt'])->group(function () {
         Route::prefix('/banner')->group(function () {
             Route::get('/index', [Banner::class, 'index']);
             Route::post('/update', [Banner::class, 'update']);
+            Route::post('/status', [Banner::class, 'status'])->defaults('permission_uri', 'admin/banner/update');
             Route::get('/delete', [Banner::class, 'delete']);
         });
         

+ 432 - 0
tests/Integration/BannerPublicationTest.php

@@ -0,0 +1,432 @@
+<?php
+
+namespace Tests\Integration;
+
+use App\Constants\HttpStatus;
+use App\Http\Controllers\admin\Banner as AdminBanner;
+use App\Http\Middleware\CheckButtonPermission;
+use App\Models\Banner;
+use App\Services\BannerVisibilityService;
+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;
+
+/** Exercises real banner controllers and queries against an isolated, prefixed SQLite database. */
+class BannerPublicationTest extends TestCase
+{
+    private Application $app;
+
+    protected function setUp(): void
+    {
+        Facade::clearResolvedInstances();
+        $this->app = new Application(dirname(__DIR__, 2));
+        $this->app->instance('config', new Repository([
+            'app' => ['locale' => 'zh', 'url' => 'https://banner.example.test', 'timezone' => 'Asia/Shanghai'],
+        ]));
+        Facade::setFacadeApplication($this->app);
+        $db = new Manager($this->app);
+        $db->addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => 'bot_']);
+        $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(\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();
+        });
+
+        $db->schema()->create('banner', function (Blueprint $t) {
+            $t->id(); $t->string('title'); $t->string('image'); $t->string('link')->nullable();
+            $t->string('type'); $t->integer('sort')->default(0); $t->string('lang'); $t->timestamps();
+        });
+        $this->migration()->up();
+        $db->schema()->create('activity_rewards', function (Blueprint $t) {
+            $t->id(); $t->integer('status')->default(1);
+            $t->integer('start_time'); $t->integer('end_time');
+        });
+        $this->request('index');
+    }
+
+    protected function tearDown(): void
+    {
+        DB::disconnect();
+        Facade::clearResolvedInstances();
+        parent::tearDown();
+    }
+
+    private function migration()
+    {
+        return require dirname(__DIR__, 2) . '/database/migrations/2026_10_24_140000_add_status_to_banner.php';
+    }
+
+    private function request(string $action, array $params = []): void
+    {
+        $this->app->instance('request', Request::create('/admin/banner/' . $action,
+            $action === 'index' ? 'GET' : 'POST', $params));
+    }
+
+    private function form(array $overrides = []): array
+    {
+        return $overrides + [
+            'title' => '轮播图', 'image' => '/banner.png', 'type' => 'pc', 'lang' => 'zh',
+        ];
+    }
+
+    private function save(array $params = []): array
+    {
+        $this->request('update', $this->form($params));
+        return (new AdminBanner())->update()->getData(true);
+    }
+
+    private function status(array $params): array
+    {
+        $this->request('status', $params);
+        return (new AdminBanner())->status()->getData(true);
+    }
+
+    private function listing(array $params = []): array
+    {
+        $this->request('index', $params);
+        $result = (new AdminBanner())->index()->getData(true);
+        $this->assertSame(HttpStatus::OK, $result['code'], $result['msg']);
+        return $result['data'];
+    }
+
+    private function banner(array $params = []): int
+    {
+        return DB::table('banner')->insertGetId($params + $this->form() + ['link' => null, 'sort' => 0, 'status' => 1]);
+    }
+
+    private function activity(int $id, int $start, int $end, int $status = 1): void
+    {
+        DB::table('activity_rewards')->insert([
+            'id' => $id, 'status' => $status, 'start_time' => $start, 'end_time' => $end,
+        ]);
+    }
+
+    private function decorated(int $now): array
+    {
+        return collect(BannerVisibilityService::decorate(Banner::query()->orderBy('id')->get(), $now))->keyBy('id')->all();
+    }
+
+    private function assertState(array $row, int $manual, int $effective, ?string $reason, ?string $activityId): void
+    {
+        $this->assertSame($manual, $row['status']);
+        $this->assertSame($effective, $row['effective_status']);
+        $this->assertSame($reason, $row['offline_reason']);
+        $this->assertSame($activityId, $row['activity_id']);
+    }
+
+    public function test_migration_publishes_existing_banners_by_default_and_rolls_back_only_its_column(): void
+    {
+        $this->migration()->down();
+        $this->assertFalse(Schema::hasColumn('banner', 'status'));
+        $id = DB::table('banner')->insertGetId($this->form(['title' => '历史轮播图', 'sort' => 23]) + ['link' => 'https://example.test']);
+        $this->migration()->up();
+        $row = DB::table('banner')->find($id);
+        $this->assertSame(1, (int) $row->status);
+        $this->assertSame('历史轮播图', $row->title);
+        $this->assertSame(23, (int) $row->sort);
+        $this->assertSame('https://example.test', $row->link);
+        $newId = DB::table('banner')->insertGetId($this->form());
+        $this->assertSame(1, (int) DB::table('banner')->where('id', $newId)->value('status'));
+        $this->migration()->down();
+        $this->assertFalse(Schema::hasColumn('banner', 'status'));
+        $this->assertSame(2, DB::table('banner')->count());
+        $this->assertSame('历史轮播图', DB::table('banner')->where('id', $id)->value('title'));
+    }
+
+    public function test_create_defaults_to_manual_on_and_legacy_edit_preserves_omitted_status_link_and_sort(): void
+    {
+        $result = $this->save(['sort' => 27, 'link' => 'https://example.test/promotion', 'type' => 'app', 'lang' => 'en']);
+        $this->assertSame(HttpStatus::OK, $result['code'], $result['msg']);
+        $banner = Banner::query()->firstOrFail();
+        $this->assertSame(1, $banner->status);
+        $this->assertSame('app', $banner->type);
+        $this->assertSame('en', $banner->lang);
+        $this->assertSame(27, (int) $banner->sort);
+        $this->assertSame(HttpStatus::OK, $this->status(['id' => $banner->id, 'status' => 0])['code']);
+        $result = $this->save(['id' => $banner->id, 'title' => '编辑标题', 'type' => 'app', 'lang' => 'en']);
+        $this->assertSame(HttpStatus::OK, $result['code'], $result['msg']);
+        $banner->refresh();
+        $this->assertSame('编辑标题', $banner->title);
+        $this->assertSame(0, $banner->status);
+        $this->assertSame('https://example.test/promotion', $banner->link);
+        $this->assertSame(27, (int) $banner->sort);
+        $this->assertSame('app', $banner->type);
+        $this->assertSame('en', $banner->lang);
+        $this->assertSame('/banner.png', $banner->getRawOriginal('image'));
+        $this->assertSame('https://banner.example.test/banner.png', $banner->image);
+    }
+
+    public function test_status_endpoint_accepts_repeated_zero_and_one_without_changing_banner_content(): void
+    {
+        $id = $this->banner(['sort' => 19, 'lang' => 'en', 'link' => 'https://example.test']);
+        foreach ([0, 0, 1, 1, '0', '1'] as $status) {
+            $result = $this->status(['id' => $id, 'status' => $status]);
+            $this->assertSame(HttpStatus::OK, $result['code'], $result['msg']);
+            $row = DB::table('banner')->find($id);
+            $this->assertSame((int) $status, (int) $row->status);
+            $this->assertSame('https://example.test', $row->link);
+            $this->assertSame(19, (int) $row->sort);
+            $this->assertSame('en', $row->lang);
+        }
+    }
+
+    public function test_create_accepts_manual_off_and_invalid_publication_values_leave_rows_unchanged(): void
+    {
+        $result = $this->save(['status' => 0]);
+        $this->assertSame(HttpStatus::OK, $result['code'], $result['msg']);
+        $id = DB::table('banner')->value('id');
+        $this->assertSame(0, (int) DB::table('banner')->where('id', $id)->value('status'));
+        foreach ([null, -1, 2, 'invalid'] as $status) {
+            $result = $this->save(['id' => $id, 'title' => '不应保存', 'status' => $status]);
+            $this->assertSame(HttpStatus::VALIDATION_FAILED, $result['code']);
+            $row = DB::table('banner')->find($id);
+            $this->assertSame(0, (int) $row->status);
+            $this->assertSame('轮播图', $row->title);
+        }
+        $this->assertSame(1, DB::table('banner')->count());
+    }
+
+    public function test_status_endpoint_rejects_missing_invalid_status_and_missing_banner(): void
+    {
+        $id = $this->banner();
+        foreach ([[], ['id' => $id], ['status' => 0], ['id' => $id, 'status' => -1],
+            ['id' => $id, 'status' => 2], ['id' => $id, 'status' => 'invalid'],
+            ['id' => $id, 'status' => null], ['id' => 0, 'status' => 0],
+            ['id' => 'invalid', 'status' => 0]] as $params) {
+            $this->assertSame(HttpStatus::VALIDATION_FAILED, $this->status($params)['code']);
+            $this->assertSame(1, (int) DB::table('banner')->where('id', $id)->value('status'));
+        }
+        $result = $this->status(['id' => 999999, 'status' => 0]);
+        $this->assertSame(HttpStatus::CUSTOM_ERROR, $result['code']);
+        $this->assertStringContainsString('数据不存在', $result['msg']);
+        $this->assertSame(1, DB::table('banner')->count());
+    }
+
+    public function test_bound_visibility_uses_raw_seconds_and_includes_start_but_excludes_end(): void
+    {
+        $now = strtotime('2026-10-24 12:30:00 UTC');
+        $expected = [];
+        foreach ([
+            1 => [$now - 1, $now + 1, 1, null],
+            2 => [$now + 1, $now + 120, 1, 'activity_not_started'],
+            3 => [$now - 120, $now - 1, 1, 'activity_ended'],
+            4 => [$now, $now + 1, 1, null],
+            5 => [$now - 1, $now, 1, 'activity_ended'],
+            6 => [$now - 1, $now + 1, 0, 'activity_offline'],
+        ] as $activityId => [$start, $end, $status, $reason]) {
+            $this->activity($activityId, $start, $end, $status);
+            $id = $this->banner(['link' => (string) $activityId]);
+            $expected[$id] = [$reason, (string) $activityId];
+        }
+        $id = $this->banner(['link' => '999']);
+        $expected[$id] = ['activity_missing', '999'];
+        $rows = $this->decorated($now);
+        foreach ($expected as $id => [$reason, $activityId]) {
+            $this->assertState($rows[$id], 1, $reason === null ? 1 : 0, $reason, $activityId);
+            $this->assertSame(1, (int) DB::table('banner')->where('id', $id)->value('status'));
+        }
+    }
+
+    public function test_null_empty_zero_and_url_links_follow_only_the_manual_setting(): void
+    {
+        $expected = [];
+        foreach ([null, '', '0', '000', 'https://example.test/activity/7'] as $link) {
+            foreach ([0, 1] as $manual) {
+                $id = $this->banner(['link' => $link, 'status' => $manual]);
+                $expected[$id] = $manual;
+            }
+        }
+        DB::connection()->enableQueryLog();
+        $rows = $this->decorated(1000);
+        $queries = DB::connection()->getQueryLog();
+        DB::connection()->disableQueryLog();
+        foreach ($expected as $id => $manual) {
+            $this->assertState($rows[$id], $manual, $manual, $manual ? null : 'manual', null);
+        }
+        $this->assertCount(1, $queries, 'Unbound banners should not query activities.');
+    }
+
+    public function test_manual_on_cannot_override_ended_activity_and_reopening_respects_manual_off(): void
+    {
+        $now = time();
+        $this->activity(7, $now - 100, $now - 10);
+        $on = $this->banner(['link' => '7', 'status' => 0]);
+        $off = $this->banner(['link' => '7', 'status' => 0]);
+        $this->assertSame(HttpStatus::OK, $this->status(['id' => $on, 'status' => 1])['code']);
+        $rows = $this->decorated($now);
+        $this->assertState($rows[$on], 1, 0, 'activity_ended', '7');
+        $this->assertState($rows[$off], 0, 0, 'manual', '7');
+        DB::table('activity_rewards')->where('id', 7)->update(['end_time' => $now + 100]);
+        $rows = $this->decorated($now);
+        $this->assertState($rows[$on], 1, 1, null, '7');
+        $this->assertState($rows[$off], 0, 0, 'manual', '7');
+        DB::table('activity_rewards')->where('id', 7)->update(['status' => 0]);
+        $this->assertState($this->decorated($now)[$on], 1, 0, 'activity_offline', '7');
+        DB::table('activity_rewards')->where('id', 7)->update(['status' => 1]);
+        $this->assertState($this->decorated($now)[$on], 1, 1, null, '7');
+        $this->assertSame(0, (int) DB::table('banner')->where('id', $off)->value('status'));
+    }
+
+    public function test_integer_and_leading_zero_activity_links_are_validated_and_canonicalized(): void
+    {
+        $this->activity(7, 1, 4102444799);
+        foreach ([7, '7', '0007'] as $link) {
+            $result = $this->save(['link' => $link]);
+            $this->assertSame(HttpStatus::OK, $result['code'], $result['msg']);
+            $this->assertSame('7', DB::table('banner')->latest('id')->value('link'));
+        }
+        foreach ([999, '000999'] as $link) {
+            $result = $this->save(['link' => $link]);
+            $this->assertSame(HttpStatus::VALIDATION_FAILED, $result['code']);
+            $this->assertStringContainsString('绑定的活动不存在', $result['msg']);
+        }
+        $this->assertSame(3, DB::table('banner')->count());
+        $id = DB::table('banner')->value('id');
+        $result = $this->save(['id' => $id, 'link' => '999']);
+        $this->assertSame(HttpStatus::VALIDATION_FAILED, $result['code']);
+        $this->assertSame('7', DB::table('banner')->where('id', $id)->value('link'));
+        $result = $this->save(['link' => ['id' => 7]]);
+        $this->assertSame(HttpStatus::VALIDATION_FAILED, $result['code']);
+        $this->assertSame(3, DB::table('banner')->count());
+    }
+
+    public function test_stale_missing_activity_link_can_be_edited_unchanged_and_taken_offline(): void
+    {
+        $id = $this->banner(['link' => '500', 'sort' => 31]);
+        $result = $this->save(['id' => $id, 'title' => '已删除活动的轮播图']);
+        $this->assertSame(HttpStatus::OK, $result['code'], $result['msg']);
+        $this->assertSame('500', DB::table('banner')->where('id', $id)->value('link'));
+        $this->assertSame(31, (int) DB::table('banner')->where('id', $id)->value('sort'));
+        $result = $this->save(['id' => $id, 'link' => '000500', 'status' => 0]);
+        $this->assertSame(HttpStatus::OK, $result['code'], $result['msg']);
+        $this->assertState($this->decorated(time())[$id], 0, 0, 'manual', '500');
+        $this->assertSame(HttpStatus::OK, $this->status(['id' => $id, 'status' => 1])['code']);
+        $this->assertState($this->decorated(time())[$id], 1, 0, 'activity_missing', '500');
+        $this->assertSame(HttpStatus::OK, $this->status(['id' => $id, 'status' => 0])['code']);
+    }
+
+    public function test_admin_pagination_counts_hidden_rows_and_batches_activity_queries(): void
+    {
+        $now = time();
+        $this->activity(7, $now - 100, $now + 100);
+        $this->activity(8, $now - 100, $now - 10);
+        for ($i = 0; $i < 24; $i++) {
+            $this->banner(['title' => '轮播图 ' . $i, 'link' => $i % 2 ? '7' : '8',
+                'status' => $i % 3 ? 1 : 0, 'sort' => $i]);
+        }
+        DB::connection()->enableQueryLog();
+        $page = $this->listing(['limit' => 20, 'page' => 1]);
+        $queries = DB::connection()->getQueryLog();
+        DB::connection()->disableQueryLog();
+        $this->assertSame(24, $page['total']);
+        $this->assertCount(20, $page['data']);
+        $this->assertCount(3, $queries, 'Admin list should use one count, one page, and one batched activity query.');
+        $this->assertSame(range(23, 4), array_column($page['data'], 'sort'));
+        $this->assertNotEmpty(array_filter($page['data'], fn ($row) => $row['effective_status'] === 0));
+        $this->assertNotEmpty(array_filter($page['data'], fn ($row) => $row['effective_status'] === 1));
+        $last = $this->listing(['limit' => 20, 'page' => 2]);
+        $this->assertSame(24, $last['total']);
+        $this->assertCount(4, $last['data']);
+        $this->assertSame(range(3, 0), array_column($last['data'], 'sort'));
+    }
+
+    public function test_admin_type_filter_and_sort_order_preserve_language_and_hidden_rows(): void
+    {
+        $first = $this->banner(['type' => 'app', 'lang' => 'en', 'sort' => 9, 'status' => 0]);
+        $second = $this->banner(['type' => 'app', 'lang' => 'zh', 'sort' => 9]);
+        $third = $this->banner(['type' => 'app', 'lang' => 'zh', 'sort' => 2]);
+        $this->banner(['type' => 'pc', 'lang' => 'en', 'sort' => 100]);
+        $page = $this->listing(['type' => 'app', 'limit' => 20]);
+        $this->assertSame(3, $page['total']);
+        $this->assertSame([$second, $first, $third], array_column($page['data'], 'id'));
+        $this->assertSame(['zh', 'en', 'zh'], array_column($page['data'], 'lang'));
+        $this->assertSame(['app', 'app', 'app'], array_column($page['data'], 'type'));
+        $this->assertSame([1, 0, 1], array_column($page['data'], 'effective_status'));
+    }
+
+    public function test_status_route_requires_existing_banner_edit_permission_and_preserves_superadmin_access(): void
+    {
+        Schema::create('menus', function (Blueprint $t) {
+            $t->id(); $t->string('uri'); $t->integer('type'); $t->integer('status');
+        });
+        Schema::create('roles', function (Blueprint $t) {
+            $t->id(); $t->string('name');
+        });
+        Schema::create('role_user', function (Blueprint $t) {
+            $t->id(); $t->integer('user_id'); $t->integer('role_id');
+        });
+        Schema::create('role_menu', function (Blueprint $t) {
+            $t->id(); $t->integer('role_id'); $t->integer('menu_id');
+        });
+        $editMenu = DB::table('menus')->insertGetId([
+            'uri' => 'admin/banner/update', 'type' => 2, 'status' => 1,
+        ]);
+        $otherMenu = DB::table('menus')->insertGetId([
+            'uri' => 'admin/banner/delete', 'type' => 2, 'status' => 1,
+        ]);
+        $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';
+        });
+        $route = $router->getRoutes()->match(Request::create('/admin/banner/status', 'POST'));
+        $this->assertSame('admin/banner/update', $route->defaults['permission_uri']);
+        $this->assertContains('admin.jwt', $route->middleware());
+        $this->assertContains('check.button.uri', $route->middleware());
+        $this->assertSame(AdminBanner::class . '@status', $route->getAction('uses'));
+        $id = $this->banner();
+        $changeStatus = function (int $userId, int $status) use ($router, $id): array {
+            $this->request('status', ['id' => $id, 'status' => $status, 'user' => (object) ['id' => $userId]]);
+            $request = $this->app['request'];
+            $route = $router->getRoutes()->match($request);
+            $request->setRouteResolver(fn () => $route);
+            return (new CheckButtonPermission())->handle($request,
+                fn () => (new AdminBanner())->status())->getData(true);
+        };
+
+        // The status URI has no separate menu: it must inherit the existing edit button gate.
+        $this->assertSame(0, DB::table('menus')->where('uri', 'admin/banner/status')->count());
+        $result = $changeStatus(20, 0);
+        $this->assertSame(-1, $result['code']);
+        $this->assertSame('无权限访问', $result['msg']);
+        $this->assertSame(1, (int) DB::table('banner')->where('id', $id)->value('status'));
+
+        DB::table('roles')->insert(['id' => 5, 'name' => 'editor']);
+        DB::table('role_user')->insert(['user_id' => 20, 'role_id' => 5]);
+        DB::table('role_menu')->insert(['role_id' => 5, 'menu_id' => $otherMenu]);
+        $this->assertSame(-1, $changeStatus(20, 0)['code']);
+        $this->assertSame(1, (int) DB::table('banner')->where('id', $id)->value('status'));
+
+        DB::table('role_menu')->insert(['role_id' => 5, 'menu_id' => $editMenu]);
+        $result = $changeStatus(20, 0);
+        $this->assertSame(HttpStatus::OK, $result['code'], $result['msg']);
+        $this->assertSame(0, (int) DB::table('banner')->where('id', $id)->value('status'));
+
+        $result = $changeStatus(1, 1);
+        $this->assertSame(HttpStatus::OK, $result['code'], $result['msg']);
+        $this->assertSame(1, (int) DB::table('banner')->where('id', $id)->value('status'));
+        $this->assertSame(0, DB::table('role_user')->where('user_id', 1)->count());
+    }
+}

+ 88 - 3
tests/Integration/ShareAdminManagementTest.php

@@ -2,10 +2,13 @@
 
 namespace Tests\Integration;
 
+use App\Console\Commands\BackfillShareCommissions;
 use App\Http\Controllers\admin\ShareEarning;
 use App\Http\Controllers\admin\SharingManagement;
 use App\Models\BalanceLog;
+use App\Models\ShareSetting;
 use App\Services\BalanceLogService;
+use App\Services\ShareCommissionDiagnostics;
 use App\Services\Sharing\ShareAdminService;
 use App\Services\Sharing\ShareClaimAuditService;
 use Carbon\Carbon;
@@ -22,6 +25,7 @@ use Illuminate\Translation\Translator;
 use Illuminate\Validation\Factory;
 use Illuminate\Validation\ValidationException;
 use PHPUnit\Framework\TestCase;
+use Symfony\Component\Console\Tester\CommandTester;
 
 /** Only an isolated, prefixed SQLite database; never boots project providers or reads .env. */
 class ShareAdminManagementTest extends TestCase
@@ -86,7 +90,7 @@ class ShareAdminManagementTest extends TestCase
     private function config(array $extra = []): array
     {
         return array_replace(['enabled' => 1, 'benefit_enabled' => 1, 'share_domain' => 'https://PC.example.com/',
-            'valid_member_min_deposit' => '20.00', 'valid_member_min_effective_bet' => '100.00', 'indirect_bonus_rate' => '30.0000'], $extra);
+            'valid_member_min_deposit' => '20.00', 'valid_member_min_effective_bet' => '100.00'], $extra);
     }
 
     private function initialized(): void
@@ -106,7 +110,7 @@ class ShareAdminManagementTest extends TestCase
         $this->fails(fn () => $this->admin->saveSettings($this->config()), '初始化');
         $this->assertSame(0, DB::table('share_settings')->count());
         $this->initialized();
-        $incomplete = $this->config(); unset($incomplete['indirect_bonus_rate']);
+        $incomplete = $this->config(); unset($incomplete['valid_member_min_effective_bet']);
         $this->fails(fn () => $this->admin->saveSettings($incomplete), 'required_if');
         $this->fails(fn () => $this->admin->saveSettings($this->config(['tier_one_rate' => 4])), '已移除');
         $this->fails(fn () => $this->admin->saveSettings($this->config(['activation_date' => '2020-01-01'])), '不可指定');
@@ -117,6 +121,9 @@ class ShareAdminManagementTest extends TestCase
         $this->assertSame(0, $data['current_policy']['benefit_enabled']);
         $this->assertSame(1, $data['pending_policy']['benefit_enabled']);
         $this->assertArrayNotHasKey('tier_one_rate', $data);
+        $this->assertArrayNotHasKey('indirect_bonus_rate', $data);
+        $this->assertArrayNotHasKey('indirect_bonus_rate', $data['current_policy']);
+        $this->assertArrayNotHasKey('indirect_bonus_rate', $data['pending_policy']);
         $this->assertSame(12, count($data['pending_policy']['rules']['third:1']));
     }
 
@@ -126,7 +133,7 @@ class ShareAdminManagementTest extends TestCase
         $first = $this->admin->saveSettings($this->config());
         $pendingId = $first['pending_policy']['id'];
         $this->admin->saveRateRules('third:1', [['min_valid_members' => 1, 'min_performance' => '1000000', 'rate' => '0.25']]);
-        $again = $this->admin->saveSettings($this->config(['indirect_bonus_rate' => '25']));
+        $again = $this->admin->saveSettings($this->config(['valid_member_min_effective_bet' => '250']));
         $this->assertSame($pendingId, $again['pending_policy']['id']);
         $this->assertSame('0.2500', $again['pending_policy']['rules']['third:1'][0]['rate']);
         Carbon::setTestNow(Carbon::parse('2026-10-25 01:00:00', 'Asia/Shanghai'));
@@ -140,6 +147,84 @@ class ShareAdminManagementTest extends TestCase
         $this->assertSame(3, DB::table('share_policies')->count());
     }
 
+    public function test_retired_indirect_coefficient_is_rejected_even_when_null_without_saving(): void
+    {
+        $this->initialized();
+        foreach ([null, 0, '30.0000'] as $value) {
+            $this->fails(fn () => $this->admin->saveSettings($this->config(['indirect_bonus_rate' => $value])), '已移除');
+        }
+        $this->assertSame(0, DB::table('share_settings')->count());
+        $this->assertSame(1, DB::table('share_policies')->count());
+        $this->assertSame(0, DB::table('operation_audits')->count());
+        $request = Request::create('/admin/shareEarning/settings', 'POST', $this->config(['indirect_bonus_rate' => '30']));
+        $this->app->instance('request', $request);
+        $body = (new ShareEarning())->saveSettings($this->admin)->getData(true);
+        $this->assertSame(-3, $body['code']);
+        $this->assertStringContainsString('已移除', $body['msg']);
+        $this->assertSame(0, DB::table('share_settings')->count());
+    }
+
+    public function test_rule_edits_need_no_coefficient_and_preserve_archived_policy_values(): void
+    {
+        $this->initialized();
+        DB::table('share_settings')->insert(['indirect_bonus_rate' => '25.0000']);
+        DB::table('share_policies')->update(['indirect_bonus_rate' => '30.0000']);
+        $archived = (array) DB::table('share_policies')->where('effective_date', '1970-01-01')->first();
+        $settings = $this->admin->saveSettings($this->config());
+        $this->assertArrayNotHasKey('indirect_bonus_rate', $settings);
+        $this->assertArrayNotHasKey('indirect_bonus_rate', $settings['current_policy']);
+        $this->assertArrayNotHasKey('indirect_bonus_rate', $settings['pending_policy']);
+        $this->assertNull(DB::table('share_policies')->where('id', $settings['pending_policy']['id'])->value('indirect_bonus_rate'));
+        $this->assertSame('25.0000', bcadd((string) DB::table('share_settings')->value('indirect_bonus_rate'), '0', 4));
+        $model = ShareSetting::query()->firstOrFail();
+        $this->assertArrayNotHasKey('indirect_bonus_rate', $model->getCasts());
+        $this->assertArrayNotHasKey('indirect_bonus_rate', $model->toArray());
+
+        $rules = $this->admin->saveRateRules('third:1', [['min_valid_members' => 0, 'min_performance' => 0, 'rate' => '10']]);
+        $this->assertSame('10.0000', $rules['categories'][0]['pending_rules'][0]['rate']);
+        $this->admin->saveSettings($this->config(['valid_member_min_effective_bet' => '250']));
+        $this->admin->saveLevel(['level' => 2, 'name' => '二级', 'image' => '', 'incremental_commission' => '10']);
+        $settings = $this->admin->settings();
+        $this->assertSame('10.0000', $settings['pending_policy']['rules']['third:1'][0]['rate']);
+        $this->assertSame('250.00', $settings['pending_policy']['valid_member_min_effective_bet']);
+        $this->assertArrayNotHasKey('indirect_bonus_rate', $settings['pending_policy']);
+        $this->assertNull(DB::table('share_policies')->where('id', $settings['pending_policy']['id'])->value('indirect_bonus_rate'));
+        $this->assertSame($archived, (array) DB::table('share_policies')->where('id', $archived['id'])->first());
+        Carbon::setTestNow(Carbon::parse('2026-10-25 01:00:00', 'Asia/Shanghai'));
+        $this->assertSame('10.0000', $this->admin->rateRules('third:1')['categories'][0]['current_rules'][0]['rate']);
+        $this->assertArrayNotHasKey('indirect_bonus_rate', $this->admin->settings()['current_policy']);
+    }
+
+    public function test_diagnostic_and_backfill_output_hide_archived_coefficient_without_writes(): void
+    {
+        $schema = DB::connection()->getSchemaBuilder();
+        $schema->table('users', function (Blueprint $t) {
+            $t->string('user_code')->default(''); $t->string('agent_user_code')->nullable(); $t->integer('from')->default(1);
+        });
+        $schema->create('config', function (Blueprint $t) {
+            $t->id(); $t->string('field'); $t->string('val');
+        });
+        $schema->create('share_commission_records', function (Blueprint $t) {
+            $t->id(); $t->date('stat_date'); $t->integer('status');
+        });
+        DB::table('config')->insert(['field' => 'recharge_rate', 'val' => '2.5000']);
+        DB::table('share_settings')->insert(['indirect_bonus_rate' => '30', 'valid_member_min_effective_bet' => '100']);
+        $before = (array) DB::table('share_settings')->first();
+        $data = (new ShareCommissionDiagnostics())->inspect(null);
+        $this->assertArrayNotHasKey('indirect_bonus_rate', $data);
+        $this->assertEquals('100', $data['valid_member_min_effective_bet']);
+        $command = new BackfillShareCommissions();
+        $command->setLaravel($this->app);
+        $tester = new CommandTester($command);
+        $tester->execute(['--diagnose' => true, '--all' => true]);
+        $this->assertSame(0, $tester->getStatusCode(), $tester->getDisplay());
+        $this->assertStringNotContainsString('indirect_bonus_rate', $tester->getDisplay());
+        $this->assertStringContainsString('valid_member_min_effective_bet: 100', $tester->getDisplay());
+        $this->assertSame($before, (array) DB::table('share_settings')->first());
+        $this->assertSame(0, DB::table('share_commission_records')->count());
+        $this->assertSame(0, DB::table('balance_logs')->count());
+    }
+
     public function test_deposit_precision_and_display_switch_do_not_silently_change_money_configuration(): void
     {
         $this->initialized();

+ 84 - 12
tests/Integration/SharingSettlementTest.php

@@ -46,9 +46,9 @@ class SharingSettlementTest extends TestCase
             DB::table('balance_logs')->insert(['member_id'=>'M'.$id,'change_type'=>'充值','amount'=>20,'created_at'=>'2026-10-20 00:00:00']);
             DB::table('wallets')->insert(['member_id'=>'M'.$id]);
         }
-        DB::table('share_settings')->insert(['activation_date'=>'2026-10-24','enabled'=>1,'benefit_enabled'=>1,'valid_member_min_effective_bet'=>100,'indirect_bonus_rate'=>30]);
+        DB::table('share_settings')->insert(['activation_date'=>'2026-10-24','enabled'=>1,'benefit_enabled'=>1,'valid_member_min_effective_bet'=>100]);
         DB::table('share_policies')->insert(['effective_date'=>'2026-10-24','enabled'=>1,'benefit_enabled'=>1,
-            'valid_member_min_deposit'=>20,'valid_member_min_effective_bet'=>100,'indirect_bonus_rate'=>30,'rules'=>json_encode(ShareDefaults::rules())]);
+            'valid_member_min_deposit'=>20,'valid_member_min_effective_bet'=>100,'rules'=>json_encode(ShareDefaults::rules())]);
         $this->relations=new ShareRelations(); $this->relations->initialize(true); $this->service=new ShareSettlementService();
     }
 
@@ -76,17 +76,17 @@ class SharingSettlementTest extends TestCase
         $this->assertSame('0.70',ShareSettlementService::rate($rules,5,'1700000'));
     }
 
-    public function test_receiver_own_rate_controls_unlimited_indirect_bonus_and_retries_are_idempotent(): void
+    public function test_receiver_own_rate_is_compounded_by_depth_and_retries_are_idempotent(): void
     {
         $this->fact(2,'200'); $this->fact(3,'20000');
         $projection=$this->service->projection('2026-10-24');
         $a=$projection['rows'][1]['third:2']; $b=$projection['rows'][2]['third:2'];
         $this->assertEquals(0.1,$a['rate']); $this->assertEquals(0.2,$b['rate']);
-        $this->assertSame('6.0000000000',$a['indirect_commission']);
+        $this->assertSame('0.0200000000',$a['indirect_commission']);
         $this->assertSame('40.0000000000',$b['direct_commission']);
         $before=DB::table('wallets')->get()->toJson();
         $first=$this->service->settle('2026-10-24');
-        $this->assertSame(2,$first['receivers']); $this->assertSame('6.2000000000',$this->amount(1));
+        $this->assertSame(2,$first['receivers']); $this->assertSame('0.2200000000',$this->amount(1));
         $this->assertSame('40.0000000000',$this->amount(2));
         $entries=DB::table('share_ledger')->count();
         $this->assertSame(0,$this->service->settle('2026-10-24')['changed']);
@@ -94,15 +94,87 @@ class SharingSettlementTest extends TestCase
         $this->assertSame($before,DB::table('wallets')->get()->toJson());
     }
 
-    public function test_infinite_depth_is_flat_not_repeatedly_discounted(): void
+    public function test_third_depth_adds_another_receiver_rate_factor(): void
     {
         $this->fact(2,'200'); $this->fact(3,'20000'); $this->fact(4,'1000');
         $row=$this->service->projection('2026-10-24')['rows'][1]['third:2'];
         $this->assertSame('21000.0000000000',$row['indirect_performance']);
-        $this->assertSame('6.3000000000',$row['indirect_commission']);
+        $this->assertSame('0.0200010000',$row['indirect_commission']);
         $this->assertSame(3,DB::table('share_tree')->where('ancestor_user_id',1)->where('descendant_user_id',4)->value('depth'));
     }
 
+    public function test_ten_percent_compounds_through_four_levels_using_only_the_receiver_rate(): void
+    {
+        DB::table('users')->insert(['id'=>8,'member_id'=>'M8','user_code'=>'F','agent_user_code'=>'D','created_at'=>'2026-10-01 00:00:00']);
+        DB::table('balance_logs')->insert(['member_id'=>'M8','change_type'=>'充值','amount'=>20,'created_at'=>'2026-10-20 00:00:00']);
+        $this->relations->initialize(true);
+        $rules=ShareDefaults::rules();
+        $rules['third:2']=[
+            ['min_valid_members'=>0,'min_performance'=>'0','rate'=>'10'],
+            ['min_valid_members'=>0,'min_performance'=>'2000','rate'=>'20'],
+        ];
+        DB::table('share_policies')->where('effective_date','2026-10-24')->update(['rules'=>json_encode($rules)]);
+        $this->fact(2,'1000'); $this->fact(3,'2000'); $this->fact(4,'1000'); $this->fact(8,'1000');
+        $rows=$this->service->projection('2026-10-24')['rows'];
+        $a=$rows[1]['third:2'];
+        $this->assertSame('10',$a['rate']);
+        $this->assertSame('20',$rows[2]['third:2']['rate']);
+        $this->assertSame('100.0000000000',$a['direct_commission']);
+        $this->assertSame('4000.0000000000',$a['indirect_performance']);
+        // 2000*10%*10% + 1000*10%^3 + 1000*10%^4 = 20+1+0.1.
+        $this->assertSame('21.1000000000',$a['indirect_commission']);
+        $this->service->settle('2026-10-24');
+        $this->assertSame('121.1000000000',$this->amount(1));
+        $this->assertSame(0,$this->service->settle('2026-10-24')['changed']);
+    }
+
+    public function test_depth_buckets_keep_precision_until_the_final_aggregate(): void
+    {
+        $second=ShareSettlementService::depthCommission('0.000000006','10',2);
+        $third=ShareSettlementService::depthCommission('0.00000006','10',3);
+        $this->assertSame('0.000000000060000000000000000000',$second);
+        $this->assertSame('0.000000000060000000000000000000',$third);
+        $this->assertSame('0.0000000001',bcadd(bcadd($second,$third,30),'0',10));
+        $this->assertSame('0.000000000000000000000000000000',ShareSettlementService::depthCommission('1000','0',99));
+        $this->assertSame('1000.000000000000000000000000000000',ShareSettlementService::depthCommission('1000','100',999));
+        $this->assertSame('0.000000000000000000000000000000',ShareSettlementService::depthCommission('1000','0.1',999));
+    }
+
+    public function test_formula_cutover_preserves_booked_history_and_changes_only_following_days(): void
+    {
+        $this->fact(2,'200'); $this->fact(3,'20000');
+        DB::table('share_policies')->where('effective_date','2026-10-24')->update(['indirect_bonus_rate'=>30]);
+        DB::table('share_system_state')->insert(['key'=>'commission_depth_rate_start','value'=>'2099-01-01']);
+        $this->service->settle('2026-10-24');
+        $this->assertSame('6.2000000000',$this->amount(1));
+        DB::table('share_system_state')->where('key','commission_depth_rate_start')->delete();
+        $before=DB::table('share_accounts')->get()->toJson();
+        $ledger=DB::table('share_ledger')->get()->toJson();
+        $wallets=DB::table('wallets')->get()->toJson();
+        $migration=require dirname(__DIR__,2).'/database/migrations/2026_10_24_150000_use_depth_rates_for_member_sharing.php';
+        $migration->up();
+        $this->assertSame('2026-10-26',DB::table('share_system_state')->where('key','commission_depth_rate_start')->value('value'));
+        $this->assertSame($before,DB::table('share_accounts')->get()->toJson());
+        $this->assertSame($ledger,DB::table('share_ledger')->get()->toJson());
+        $this->assertSame($wallets,DB::table('wallets')->get()->toJson());
+        $this->assertSame(0,$this->service->settle('2026-10-24')['changed']);
+        $this->fact(2,'200','2026-10-26'); $this->fact(3,'20000','2026-10-26');
+        $this->assertSame('0.0200000000',$this->service->projection('2026-10-26')['rows'][1]['third:2']['indirect_commission']);
+        Carbon::setTestNow(Carbon::parse('2026-10-27 12:00:00','Asia/Shanghai'));
+        $migration->up();
+        $this->assertSame('2026-10-26',DB::table('share_system_state')->where('key','commission_depth_rate_start')->value('value'));
+    }
+
+    public function test_fresh_installation_requires_no_legacy_bonus_or_waiting_day(): void
+    {
+        DB::table('share_settings')->update(['activation_date'=>null]);
+        $migration=require dirname(__DIR__,2).'/database/migrations/2026_10_24_150000_use_depth_rates_for_member_sharing.php';
+        $migration->up();
+        $this->assertSame('1970-01-01',DB::table('share_system_state')->where('key','commission_depth_rate_start')->value('value'));
+        $migration->down();
+        $this->assertFalse(DB::table('share_system_state')->where('key','commission_depth_rate_start')->exists());
+    }
+
     public function test_qualification_excludes_performance_and_later_deposits_do_not_requalify_previous_days(): void
     {
         $this->fact(2,'200'); $this->fact(3,'20000');
@@ -133,23 +205,23 @@ class SharingSettlementTest extends TestCase
         $this->fact(3,'1','2026-10-23');
         DB::table('share_system_state')->insert(['key'=>'facts_dirty_dates','value'=>'["2026-10-23"]']);
         $result=$this->service->reconcile();
-        $this->assertSame(1,$result['dates']); $this->assertSame('0.2297000000',$this->amount(1));
+        $this->assertSame(1,$result['dates']); $this->assertSame('0.2000990000',$this->amount(1));
         $this->assertSame('[]',DB::table('share_system_state')->where('key','facts_dirty_dates')->value('value'));
     }
 
     public function test_paid_reversal_offsets_future_earnings_without_touching_main_wallet_or_reserved_claim(): void
     {
         $this->fact(2,'200'); $c=$this->fact(3,'20000'); $this->service->settle('2026-10-24');
-        DB::table('share_accounts')->where('user_id',1)->update(['paid_amount'=>'6.2']);
+        DB::table('share_accounts')->where('user_id',1)->update(['paid_amount'=>'0.22']);
         $wallet=DB::table('wallets')->get()->toJson();
         DB::table('share_bet_facts')->where('id',$c)->update(['voided'=>1,'effective_amount'=>0,'source_version'=>'canceled']);
         $this->service->settle('2026-10-24');
-        $this->assertSame('0.2000000000',$this->amount(1)); $this->assertSame('6.2000000000',$this->amount(1,'paid_amount'));
+        $this->assertSame('0.2000000000',$this->amount(1)); $this->assertSame('0.2200000000',$this->amount(1,'paid_amount'));
         $this->assertSame('0.0000000000',$this->amount(2));
         Carbon::setTestNow(Carbon::parse('2026-10-26 12:00:00','Asia/Shanghai'));
         $this->fact(2,'10000','2026-10-25'); $this->service->settle('2026-10-25');
         $this->assertSame('10.2000000000',$this->amount(1));
-        $this->assertSame('4.0000000000',bcsub($this->amount(1),$this->amount(1,'paid_amount'),10));
+        $this->assertSame('9.9800000000',bcsub($this->amount(1),$this->amount(1,'paid_amount'),10));
         $this->assertSame($wallet,DB::table('wallets')->get()->toJson());
     }
 
@@ -158,7 +230,7 @@ class SharingSettlementTest extends TestCase
         $fact=$this->fact(2,'200'); $this->service->settle('2026-10-24');
         $rules=ShareDefaults::rules(); $rules['third:2']=[['min_valid_members'=>0,'min_performance'=>'0','rate'=>'99']];
         DB::table('share_policies')->insert(['effective_date'=>'2026-10-25','enabled'=>1,'benefit_enabled'=>1,'valid_member_min_deposit'=>20,
-            'valid_member_min_effective_bet'=>100,'indirect_bonus_rate'=>30,'rules'=>json_encode($rules)]);
+            'valid_member_min_effective_bet'=>100,'rules'=>json_encode($rules)]);
         DB::table('share_bet_facts')->where('id',$fact)->update(['effective_amount'=>'300']);
         $before=DB::table('share_accounts')->get()->toJson();
         $this->assertSame('0.1000000000',$this->service->settle('2026-10-24',false)['delta']);

+ 125 - 0
tests/Regression/banner_site_publication.php

@@ -0,0 +1,125 @@
+<?php
+
+namespace app\api\lists {
+    // Isolate list infrastructure; run the real list implementation and ThinkPHP ORM below.
+    class BaseApiDataLists {
+        protected string $lang = 'zh';
+        protected array $searchWhere = [];
+        public int $limitOffset = 0;
+        public int $limitLength = 2;
+        public function __construct(array $params = []) { $this->setParams($params); }
+        public function setParams($params): self {
+            $this->lang = $params['lang'] ?? 'zh';
+            $this->searchWhere = isset($params['type']) ? [['type', '=', $params['type']]] : [];
+            return $this;
+        }
+    }
+    function time(): int { return $GLOBALS['banner_test_now']; }
+}
+
+namespace {
+    error_reporting(E_ALL & ~E_DEPRECATED);
+    function env($key, $default = '') {
+        return ['app.host' => 'https://site.example.test', 'app.BOT_HOST' => 'https://bot.example.test'][$key] ?? $default;
+    }
+    $repo = getenv('BANNER_SITE_REPO') ?: dirname(__DIR__, 3) . '/melbet_sport-api';
+    $stage = getenv('BANNER_SITE_STAGE') ?: $repo;
+    $loader = require $repo . '/vendor/autoload.php';
+    $loader->setPsr4('app\\', [$stage . '/app/', $repo . '/app/']);
+    require $repo . '/app/common.php';
+    $app = new \think\App($repo . '/');
+    \think\Container::setInstance($app);
+    $db = new \think\DbManager();
+    $db->setConfig(['default' => 'isolated', 'connections' => ['isolated' => [
+        'type' => 'sqlite', 'database' => ':memory:', 'prefix' => '', 'fields_strict' => false, 'debug' => false,
+    ]], 'auto_timestamp' => false]);
+    $app->instance(\think\DbManager::class, $db);
+    $app->instance('log', new \Psr\Log\NullLogger());
+    $selectCount = 0;
+    $db->event('before_select', static function () use (&$selectCount) { ++$selectCount; });
+    $GLOBALS['banner_test_now'] = 1792800000;
+    $now = $GLOBALS['banner_test_now'];
+    $db->execute('CREATE TABLE bot_banner (id INTEGER PRIMARY KEY,title TEXT,image TEXT,link TEXT,type TEXT,sort INTEGER,lang TEXT,status INTEGER DEFAULT 1)');
+    $db->execute('CREATE TABLE bot_activity_rewards (id INTEGER PRIMARY KEY,status INTEGER,start_time INTEGER,end_time INTEGER)');
+    foreach ([
+        [101, 1, $now - 100, $now + 100],
+        [102, 1, $now - 100, $now],
+        [103, 1, $now + 1, $now + 100],
+        [104, 0, $now - 100, $now + 100],
+        [105, 1, $now, $now + 100],
+    ] as [$id, $status, $start, $end]) {
+        \think\facade\Db::table('bot_activity_rewards')->insert(['id' => $id, 'status' => $status, 'start_time' => $start, 'end_time' => $end]);
+    }
+    function bannerRow(int $id, $link, array $overrides = []): void {
+        \think\facade\Db::table('bot_banner')->insert($overrides + [
+            'id' => $id, 'title' => 'Banner ' . $id, 'image' => 'https://site.example.test/banner.png',
+            'link' => $link, 'type' => 'pc', 'sort' => 100 - $id, 'lang' => 'zh', 'status' => 1,
+        ]);
+    }
+    bannerRow(1, '102'); // Expired first-ranked item must not create a pagination gap.
+    bannerRow(2, '', ['status' => 0]);
+    bannerRow(3, '101');
+    bannerRow(4, '');
+    bannerRow(5, '0');
+    bannerRow(6, 'https://external.example.test/path');
+    bannerRow(7, '999');
+    bannerRow(8, '103');
+    bannerRow(9, '104');
+    bannerRow(10, '105');
+    bannerRow(11, '', ['lang' => 'en']);
+    bannerRow(12, '', ['type' => 'app']);
+    bannerRow(13, null);
+    bannerRow(14, '000101');
+    bannerRow(15, '000');
+    bannerRow(16, '101', ['status' => 0]);
+    $checks = 0;
+    function check($actual, $expected, string $label): void {
+        global $checks;
+        ++$checks;
+        if ($actual !== $expected) throw new \RuntimeException($label . ': got ' . json_encode($actual) . ', expected ' . json_encode($expected));
+    }
+
+    $before = \think\facade\Db::table('bot_banner')->order('id')->select()->toArray();
+    $list = new \app\api\lists\BannerLists(['type' => 'pc']);
+    $queryCount = $selectCount;
+    check($list->count(), 8, 'Only manual-on unbound and active bound banners are counted');
+    check($selectCount - $queryCount, 2, 'One banner scan and one activity lookup');
+    $queryCount = $selectCount;
+    check(array_column($list->lists(), 'id'), [3, 4], 'Lifecycle filtering precedes pagination');
+    check($selectCount, $queryCount, 'Count and page share cached visibility');
+    $list->limitOffset = 2;
+    check(array_column($list->lists(), 'id'), [5, 6], 'Zero and external URL are unbound');
+    $list->limitOffset = 4;
+    check(array_column($list->lists(), 'id'), [10, 13], 'Start boundary and null link are visible');
+    $list->limitOffset = 6;
+    check(array_column($list->lists(), 'id'), [14, 15], 'Leading zeros match the correct positive ID or unbound zero');
+    $list->limitOffset = 0;
+    $item = $list->lists()[0];
+    check(array_keys($item), ['id', 'type', 'image', 'link', 'title'], 'Public fields remain compatible');
+    check($item['image'], 'https://bot.example.test/banner.png', 'Existing image host replacement is preserved');
+    check($item['link'], '101', 'Public jump target preserved');
+    check(\think\facade\Db::table('bot_banner')->order('id')->select()->toArray(), $before, 'Reads do not mutate manual status or other original fields');
+
+    $english = new \app\api\lists\BannerLists(['type' => 'pc', 'lang' => 'en']);
+    check(array_column($english->lists(), 'id'), [11], 'Language filter retained');
+    $appList = new \app\api\lists\BannerLists(['type' => 'app']);
+    check(array_column($appList->lists(), 'id'), [12], 'Platform filter retained');
+    $english->setParams(['type' => 'app']);
+    check(array_column($english->lists(), 'id'), [12], 'setParams clears cached rows and restores query filters');
+
+    // Reopen an activity: its automatically hidden banner returns, manually disabled banners do not.
+    \think\facade\Db::table('bot_activity_rewards')->where('id', 102)->update(['end_time' => $now + 100]);
+    $fresh = new \app\api\lists\BannerLists(['type' => 'pc']);
+    check($fresh->count(), 9, 'Activity extension immediately restores eligible banner on the next request');
+    check(array_column($fresh->lists(), 'id'), [1, 3], 'Extended activity returns to original order');
+    check($list->count(), 8, 'One response keeps count and lists consistent across concurrent changes');
+    $GLOBALS['banner_test_now'] = $now + 100;
+    $ended = new \app\api\lists\BannerLists(['type' => 'pc']);
+    $ended->limitLength = 100;
+    check(array_column($ended->lists(), 'id'), [4, 5, 6, 13, 15], 'All bound banners stop exactly at end time while unbound remain');
+    \think\facade\Db::table('bot_banner')->where('id', 4)->update(['status' => 0]);
+    check((new \app\api\lists\BannerLists(['type' => 'pc']))->count(), 4, 'Manual off takes effect on the next request');
+    \think\facade\Db::table('bot_banner')->where('id', 4)->update(['status' => 1]);
+    check((new \app\api\lists\BannerLists(['type' => 'pc']))->count(), 5, 'Manual on restores unbound banner');
+    echo 'PASS ' . $checks . " banner site assertions\n";
+}

+ 2 - 2
tests/fixtures/sharing_mysql_support.php

@@ -97,10 +97,10 @@ final class SharingMysqlSupport
             'created_at' => $old, 'updated_at' => $old]);
         $db::table('share_system_state')->insert(['key' => 'initialized_at', 'value' => $old, 'created_at' => $old, 'updated_at' => $old]);
         $db::table('share_settings')->insert(['enabled' => 0, 'benefit_enabled' => 1, 'share_domain' => 'https://fixture.example',
-            'activation_date' => '2026-10-24', 'valid_member_min_deposit' => 0, 'valid_member_min_effective_bet' => 0, 'indirect_bonus_rate' => 30]);
+            'activation_date' => '2026-10-24', 'valid_member_min_deposit' => 0, 'valid_member_min_effective_bet' => 0]);
         $rules = array_fill_keys(array_keys(\App\Services\Sharing\ShareDefaults::categories()), [['min_valid_members' => 0, 'min_performance' => '0', 'rate' => '10']]);
         $db::table('share_policies')->insert(['effective_date' => '2026-10-24', 'enabled' => 0, 'benefit_enabled' => 1,
-            'valid_member_min_deposit' => 0, 'valid_member_min_effective_bet' => 0, 'indirect_bonus_rate' => 30, 'rules' => json_encode($rules)]);
+            'valid_member_min_deposit' => 0, 'valid_member_min_effective_bet' => 0, 'rules' => json_encode($rules)]);
         $db::table('share_bet_facts')->insert(['source' => 'fixture', 'source_id' => 'fact-1', 'user_id' => 2, 'member_id' => 'M2',
             'category' => 'third:2', 'bet_at' => '2026-10-24 12:00:00', 'bet_date' => '2026-10-24', 'settled_at' => '2026-10-25 10:00:00',
             'bet_amount' => 100, 'net_profit' => -100, 'effective_amount' => 100, 'settled' => 1, 'voided' => 0, 'currency' => 'CNY',