doge 15 часов назад
Родитель
Сommit
5d7d0999de
46 измененных файлов с 4885 добавлено и 65 удалено
  1. 2 2
      app/Console/Commands/BackfillShareCommissions.php
  2. 17 0
      app/Console/Commands/InitializeSharing.php
  3. 83 0
      app/Console/Commands/RecalculateShareRebates.php
  4. 22 0
      app/Console/Commands/SettleSharing.php
  5. 19 0
      app/Console/Commands/SyncShareFacts.php
  6. 3 0
      app/Console/Kernel.php
  7. 10 40
      app/Http/Controllers/admin/ShareEarning.php
  8. 71 0
      app/Http/Controllers/admin/SharingManagement.php
  9. 4 3
      app/Models/Rebate.php
  10. 2 2
      app/Models/ShareSetting.php
  11. 1 1
      app/Services/BalanceLogService.php
  12. 10 5
      app/Services/BetService.php
  13. 8 1
      app/Services/PublicService.php
  14. 4 2
      app/Services/RebateService.php
  15. 5 3
      app/Services/ShareCommissionDiagnostics.php
  16. 3 3
      app/Services/ShareEarningReportService.php
  17. 316 0
      app/Services/Sharing/BetFactSynchronizer.php
  18. 67 0
      app/Services/Sharing/EffectiveBet.php
  19. 272 0
      app/Services/Sharing/ShareAdminService.php
  20. 118 0
      app/Services/Sharing/ShareClaimAuditService.php
  21. 26 0
      app/Services/Sharing/ShareDefaults.php
  22. 152 0
      app/Services/Sharing/ShareRelations.php
  23. 196 0
      app/Services/Sharing/ShareSettlementService.php
  24. 8 1
      app/Services/ThirdGameBalanceService.php
  25. 92 0
      database/migrations/2026_10_24_120000_create_member_sharing_system.php
  26. 132 0
      docs/前端接口/新分享系统/下级信息.md
  27. 157 0
      docs/前端接口/新分享系统/下级投注.md
  28. 162 0
      docs/前端接口/新分享系统/下级财务.md
  29. 167 0
      docs/前端接口/新分享系统/下级领取.md
  30. 55 0
      docs/前端接口/新分享系统/后台等级配置.md
  31. 284 0
      docs/前端接口/新分享系统/后台返佣配置.md
  32. 67 0
      docs/前端接口/新分享系统/后台领取审核.md
  33. 483 0
      docs/前端接口/新分享系统/我的数据.md
  34. 272 0
      docs/前端接口/新分享系统/返佣比例.md
  35. 176 0
      docs/前端接口/新分享系统/首页.md
  36. 9 0
      routes/admin.php
  37. 289 0
      tests/Integration/ShareAdminManagementTest.php
  38. 3 2
      tests/Integration/ShareCommissionRecordingTest.php
  39. 266 0
      tests/Integration/SharingBetFactsTest.php
  40. 270 0
      tests/Integration/SharingMysqlConcurrencyTest.php
  41. 276 0
      tests/Integration/SharingSettlementTest.php
  42. 53 0
      tests/Unit/SharingEffectiveBetTest.php
  43. 42 0
      tests/Unit/SharingFootballSettlementTest.php
  44. 30 0
      tests/Unit/SharingThirdGameOrderPrecisionTest.php
  45. 119 0
      tests/fixtures/sharing_mysql_support.php
  46. 62 0
      tests/fixtures/sharing_mysql_worker.php

+ 2 - 2
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', 'tier_one_rate', 'tier_two_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', 'indirect_bonus_rate', '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'])));
@@ -97,7 +97,7 @@ class BackfillShareCommissions extends Command
         if ($counts['real_invited_members_all'] && !$counts['linked_real_invited_members_all']) $this->warn('真实被邀请会员的邀请码未匹配到有效推广人。');
         if ($counts['real_invited_members_all'] && !$counts['real_invitee_deposit_logs_all']) $this->warn('真实被邀请会员没有正数“充值/三方充值”流水;人工充值不触发现有邀请返现。');
         if ($counts['linked_real_invitee_deposit_logs_all'] && !$counts['with_promoter_wallet_deposit_logs_all']) $this->warn('有推荐充值,但推广人钱包缺失。');
-        $this->line('实际旧返现使用 config.recharge_rate;分享设定中的一级/二级比例是另一套配置。');
+        $this->line('实际旧充值返现使用 config.recharge_rate;新分享投注佣金与其独立。');
         $this->line('上游数量按当前关系统计,仅供排查,不代表历史应付佣金;本诊断未写入记录或发钱。');
         return self::SUCCESS;
     }

+ 17 - 0
app/Console/Commands/InitializeSharing.php

@@ -0,0 +1,17 @@
+<?php
+
+namespace App\Console\Commands;
+
+use App\Services\Sharing\ShareRelations;
+use Illuminate\Console\Command;
+
+class InitializeSharing extends Command
+{
+    protected $signature='share:initialize {--write : 实际固化推荐关系,默认只读预览}';
+    protected $description='初始化会员分享关系、账户和资格事实准备;异常关系隔离,不发钱';
+    public function handle(ShareRelations $relations): int
+    {
+        try { $this->line(json_encode($relations->initialize((bool)$this->option('write')),JSON_UNESCAPED_UNICODE|JSON_THROW_ON_ERROR)); return self::SUCCESS; }
+        catch (\Throwable $e) { $this->error($e->getMessage()); return self::FAILURE; }
+    }
+}

+ 83 - 0
app/Console/Commands/RecalculateShareRebates.php

@@ -0,0 +1,83 @@
+<?php
+
+namespace App\Console\Commands;
+
+use App\Services\Sharing\BetFactSynchronizer;
+use App\Services\Sharing\EffectiveBet;
+use Illuminate\Console\Command;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+
+class RecalculateShareRebates extends Command
+{
+    protected $signature = 'share:recalculate-rebates {--write : Correct unpaid PC28 daily bases; default previews all history}';
+    protected $description = 'Recalculate unpaid PC28 rebate bases without paying any wallet or changing paid rows';
+
+    public function handle(BetFactSynchronizer $synchronizer): int
+    {
+        $result = $this->recalculate($synchronizer, (bool) $this->option('write'));
+        $this->line(json_encode($result, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
+        return $result['skipped'] === 0 ? self::SUCCESS : self::FAILURE;
+    }
+
+    public function recalculate(BetFactSynchronizer $synchronizer, bool $write = false): array
+    {
+        $result = ['write' => $write, 'reviewed' => 0, 'changed' => 0, 'unchanged' => 0, 'skipped' => 0, 'paid_untouched' => 0, 'rows' => []];
+        if (!Schema::hasTable('rebates') || !Schema::hasTable('lhc_order') || !Schema::hasTable('lhc_order_item')) {
+            throw new \RuntimeException('Rebate and current lottery source tables are required');
+        }
+        $groups = [];
+        $undatedMembers = [];
+        DB::table('lhc_order')->whereIn('type', [5, 6])->orderBy('id')->chunkById(200, function ($orders) use ($synchronizer, &$groups, &$undatedMembers) {
+            foreach ($orders as $order) {
+                $member = (string) $order->member_id;
+                try { $date = EffectiveBet::newYorkDate($order->created_at ?? null); }
+                catch (\Throwable $e) { $undatedMembers[$member] = 'An original betting date is missing or invalid'; continue; }
+                $key = $member . '\0' . $date;
+                $groups[$key] ??= ['total' => EffectiveBet::zero(), 'effective' => EffectiveBet::zero(), 'errors' => []];
+                try {
+                    $snapshot = $synchronizer->lotteryOrder($order);
+                    if ($snapshot['facts'] === []) throw new \RuntimeException('Source member is demo or unavailable');
+                    if ($snapshot['unsettled']) throw new \RuntimeException('Original betting day still has unfinished bets');
+                    $groups[$key]['total'] = bcadd($groups[$key]['total'], $snapshot['total_bet'], EffectiveBet::SCALE);
+                    foreach ($snapshot['facts'] as $fact) {
+                        $groups[$key]['effective'] = bcadd($groups[$key]['effective'], $fact['effective_amount'], EffectiveBet::SCALE);
+                    }
+                } catch (\Throwable $e) { $groups[$key]['errors'][] = 'order ' . $order->id . ': ' . $e->getMessage(); }
+            }
+        });
+        $result['paid_untouched'] = DB::table('rebates')->where('status', 1)->count();
+        DB::table('rebates')->where('status', 0)->orderBy('id')->chunkById(200, function ($rebates) use ($write, $groups, $undatedMembers, &$result) {
+            foreach ($rebates as $rebate) {
+                $result['reviewed']++;
+                $key = (string) $rebate->member_id . '\0' . (string) $rebate->date;
+                $group = $groups[$key] ?? null;
+                $reason = $undatedMembers[(string) $rebate->member_id] ?? null;
+                if ($reason === null && $group === null) $reason = 'No original-date source; legacy date cannot be inferred';
+                if ($reason === null && $group['errors'] !== []) $reason = implode('; ', $group['errors']);
+                if ($reason === null && bccomp($group['total'], (string) $rebate->betting_amount, 2) !== 0) {
+                    $reason = 'Original-date stake total differs from stored betting_amount; date/refund history needs review';
+                }
+                $row = ['id' => $rebate->id, 'member_id' => (string) $rebate->member_id, 'date' => $rebate->date,
+                    'before' => bcadd((string) $rebate->effective_betting_amount, '0', 2), 'after' => null, 'status' => 'skipped', 'reason' => $reason];
+                if ($reason !== null) { $result['skipped']++; $result['rows'][] = $row; continue; }
+                $row['after'] = bcadd($group['effective'], '0', 2);
+                if (bccomp($row['before'], $row['after'], 2) === 0) {
+                    $row['status'] = 'unchanged'; $result['unchanged']++;
+                } elseif (!$write) {
+                    $row['status'] = 'would_change'; $result['changed']++;
+                } else {
+                    // Compare-and-set keeps a concurrent settlement/payment from being overwritten.
+                    $updated = DB::table('rebates')->where('id', $rebate->id)->where('status', 0)
+                        ->where('betting_amount', $rebate->betting_amount)
+                        ->where('effective_betting_amount', $rebate->effective_betting_amount)
+                        ->update(['effective_betting_amount' => $row['after']]);
+                    if ($updated === 1) { $row['status'] = 'changed'; $result['changed']++; }
+                    else { $row['status'] = 'skipped'; $row['reason'] = 'Rebate changed during recalculation; retry preview'; $result['skipped']++; }
+                }
+                $result['rows'][] = $row;
+            }
+        });
+        return $result;
+    }
+}

+ 22 - 0
app/Console/Commands/SettleSharing.php

@@ -0,0 +1,22 @@
+<?php
+
+namespace App\Console\Commands;
+
+use App\Services\Sharing\ShareDefaults;
+use App\Services\Sharing\ShareSettlementService;
+use Carbon\Carbon;
+use Illuminate\Console\Command;
+
+class SettleSharing extends Command
+{
+    protected $signature='share:settle {date? : 原下注日期Y-m-d,默认北京时间昨日} {--preview : 只读预览,不登记应付佣金} {--reconcile : 处理迟到结算和改判}';
+    protected $description='每日结算新分享应付佣金,不直接入会员钱包';
+    public function handle(ShareSettlementService $service): int
+    {
+        try {
+            if ($this->option('reconcile') && $this->option('preview')) throw new \InvalidArgumentException('reconcile不支持preview,请使用日期预览');
+            $result=$this->option('reconcile') ? $service->reconcile() : $service->settle($this->argument('date') ?: Carbon::now(ShareDefaults::TIMEZONE)->subDay()->toDateString(),!(bool)$this->option('preview'));
+            $this->line(json_encode($result,JSON_UNESCAPED_UNICODE|JSON_THROW_ON_ERROR)); return self::SUCCESS;
+        } catch (\Throwable $e) { $this->error($e->getMessage()); return self::FAILURE; }
+    }
+}

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

@@ -0,0 +1,19 @@
+<?php
+
+namespace App\Console\Commands;
+
+use App\Services\Sharing\BetFactSynchronizer;
+use Illuminate\Console\Command;
+
+class SyncShareFacts extends Command
+{
+    protected $signature = 'share:sync-facts {--from= : UTC+8 original betting date Y-m-d} {--to= : UTC+8 original betting date Y-m-d} {--write : Persist facts; default is read-only preview}';
+    protected $description = 'Preview or synchronize current real betting facts for the sharing system';
+
+    public function handle(BetFactSynchronizer $synchronizer): int
+    {
+        $result = $synchronizer->sync($this->option('from') ?: null, $this->option('to') ?: null, (bool) $this->option('write'));
+        $this->line(json_encode($result, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR));
+        return $result['errors'] === [] ? self::SUCCESS : self::FAILURE;
+    }
+}

+ 3 - 0
app/Console/Kernel.php

@@ -32,6 +32,9 @@ class Kernel extends ConsoleKernel
         $schedule->command('agent:settle')
             ->dailyAt('04:10')
             ->withoutOverlapping(180);
+        $schedule->command('share:sync-facts --write')->everyMinute()->withoutOverlapping(30);
+        $schedule->command('share:settle --reconcile')->everyFiveMinutes()->withoutOverlapping(120);
+        $schedule->command('share:settle')->timezone('Asia/Shanghai')->dailyAt('00:10')->withoutOverlapping(180);
     }
 
     /**

+ 10 - 40
app/Http/Controllers/admin/ShareEarning.php

@@ -5,13 +5,15 @@ namespace App\Http\Controllers\admin;
 use App\Constants\HttpStatus;
 use App\Http\Controllers\Controller;
 use App\Models\ShareBlacklist;
-use App\Models\ShareSetting;
+use App\Services\Sharing\ShareAdminService;
 use App\Models\User;
 use App\Services\OperationAuditService;
 use App\Services\ShareEarningReportService;
 use Illuminate\Support\Facades\DB;
 use Illuminate\Validation\Rule;
 use Illuminate\Validation\ValidationException;
+use Illuminate\Database\QueryException;
+use Illuminate\Support\Facades\Log;
 use Throwable;
 
 class ShareEarning extends Controller
@@ -38,49 +40,14 @@ class ShareEarning extends Controller
         ]));
     }
 
-    public function settings()
+    public function settings(ShareAdminService $service)
     {
-        return $this->run(fn () => ShareSetting::query()->first() ?: [
-            'id' => null,
-            'enabled' => 0,
-            'share_domain' => '',
-            'valid_member_min_deposit' => '20.00',
-            'benefit_enabled' => 0,
-            'non_seller_threshold' => '200.00',
-            'tier_one_rate' => '4.0000',
-            'tier_two_rate' => '6.0000',
-        ]);
+        return $this->run(fn () => $service->settings());
     }
 
-    public function saveSettings()
+    public function saveSettings(ShareAdminService $service)
     {
-        return $this->run(function () {
-            $params = request()->validate([
-                'enabled' => ['required', Rule::in([0, 1])],
-                'share_domain' => ['required', 'string', 'max:500'],
-                'valid_member_min_deposit' => ['required', 'numeric', 'gte:0'],
-                'benefit_enabled' => ['required', Rule::in([0, 1])],
-                'non_seller_threshold' => ['required', 'numeric', 'gte:0'],
-                'tier_one_rate' => ['required', 'numeric', 'between:0,100'],
-                'tier_two_rate' => ['required', 'numeric', 'between:0,100'],
-            ]);
-            $domain = trim($params['share_domain']);
-            if (!preg_match('~^https?://~i', $domain)) $domain = 'https://' . $domain;
-            if (!filter_var($domain, FILTER_VALIDATE_URL)) {
-                throw ValidationException::withMessages(['share_domain' => '分享赚钱域名格式错误']);
-            }
-            $params['share_domain'] = rtrim($domain, '/');
-            return DB::transaction(function () use ($params) {
-                $settingId = ShareSetting::query()->value('id');
-                $setting = $settingId
-                    ? ShareSetting::query()->lockForUpdate()->findOrFail($settingId)
-                    : ShareSetting::query()->create([]);
-                $before = $setting->toArray();
-                $setting->fill($params + OperationAuditService::actor())->save();
-                OperationAuditService::record('share_setting', (int)$setting->id, 'update', $before, $setting);
-                return $setting->fresh();
-            });
-        });
+        return $this->run(fn () => $service->saveSettings(request()->all()));
     }
 
     public function blacklists()
@@ -205,6 +172,9 @@ class ShareEarning extends Controller
             return $this->success($callback());
         } catch (ValidationException $e) {
             return $this->error(HttpStatus::CUSTOM_ERROR, $e->validator->errors()->first());
+        } catch (QueryException $e) {
+            Log::error('share_earning_database_error', ['exception' => $e]);
+            return $this->error(HttpStatus::CUSTOM_ERROR, '分享服务暂不可用,请稍后重试');
         } catch (Throwable $e) {
             return $this->error(HttpStatus::CUSTOM_ERROR, $e->getMessage());
         }

+ 71 - 0
app/Http/Controllers/admin/SharingManagement.php

@@ -0,0 +1,71 @@
+<?php
+
+namespace App\Http\Controllers\admin;
+
+use App\Constants\HttpStatus;
+use App\Http\Controllers\Controller;
+use App\Services\Sharing\ShareAdminService;
+use App\Services\Sharing\ShareClaimAuditService;
+use Illuminate\Validation\Rule;
+use Illuminate\Validation\ValidationException;
+use Illuminate\Database\QueryException;
+use Illuminate\Support\Facades\Log;
+use Throwable;
+
+class SharingManagement extends Controller
+{
+    public function levels(ShareAdminService $service) { return $this->run(fn () => $service->levels()); }
+    public function saveLevel(ShareAdminService $service) { return $this->run(fn () => $service->saveLevel(request()->all())); }
+    public function deleteLevel(ShareAdminService $service)
+    {
+        return $this->run(function () use ($service) {
+            $params = request()->validate(['id' => ['required', 'integer', 'min:1']]);
+            $service->deleteLevel((int)$params['id']);
+            return [];
+        });
+    }
+    public function rateRules(ShareAdminService $service)
+    {
+        return $this->run(function () use ($service) {
+            $params = request()->validate(['category' => ['nullable', 'string']]);
+            return $service->rateRules($params['category'] ?? null);
+        });
+    }
+    public function saveRateRules(ShareAdminService $service)
+    {
+        return $this->run(function () use ($service) {
+            $params = request()->validate(['category' => ['required', 'string'], 'rules' => ['required', 'array']]);
+            return $service->saveRateRules($params['category'], $params['rules']);
+        });
+    }
+    public function claims(ShareClaimAuditService $service) { return $this->run(fn () => $service->list(request()->all())); }
+    public function claimDetail(ShareClaimAuditService $service)
+    {
+        return $this->run(function () use ($service) {
+            $params = request()->validate(['id' => ['required', 'integer', 'min:1']]);
+            return $service->detail((int)$params['id']);
+        });
+    }
+    public function auditClaim(ShareClaimAuditService $service)
+    {
+        return $this->run(function () use ($service) {
+            $params = request()->validate(['id' => ['required', 'integer', 'min:1'],
+                'action' => ['required', Rule::in(['approve', 'reject'])], 'remark' => ['nullable', 'string', 'max:2000']]);
+            return $service->audit((int)$params['id'], $params['action'], (int)(request()->user->id ?? 0), (string)($params['remark'] ?? ''));
+        });
+    }
+    private function run(callable $callback)
+    {
+        try { return $this->success($callback()); }
+        catch (ValidationException $e) { return $this->error(HttpStatus::CUSTOM_ERROR, $e->validator->errors()->first()); }
+        catch (QueryException $e) {
+            Log::error('sharing_admin_database_error', ['exception' => $e]);
+            return $this->error(HttpStatus::CUSTOM_ERROR, '分享管理服务暂不可用,请稍后重试');
+        }
+        catch (\RuntimeException | \InvalidArgumentException $e) { return $this->error(HttpStatus::CUSTOM_ERROR, $e->getMessage()); }
+        catch (Throwable $e) {
+            Log::error('sharing_admin_error', ['exception' => $e]);
+            return $this->error(HttpStatus::CUSTOM_ERROR, '分享管理服务暂不可用,请稍后重试');
+        }
+    }
+}

+ 4 - 3
app/Models/Rebate.php

@@ -36,7 +36,8 @@ class Rebate extends BaseModel
 
     static function updateProfit($data)
     {
-        $data['date'] = Carbon::now('America/New_York')->format('Y-m-d');
+        // Settlement belongs to the original betting day, including overnight draws.
+        $data['date'] = $data['date'] ?? Carbon::now('America/New_York')->format('Y-m-d');
         $rebate = static::where('date', $data['date'])
             ->where('member_id', $data['member_id'])->first();
         if ($rebate) {
@@ -49,7 +50,7 @@ class Rebate extends BaseModel
 
     static function addOrUpdate($data)
     {
-        $data['date'] = Carbon::now('America/New_York')->format('Y-m-d');
+        $data['date'] = $data['date'] ?? Carbon::now('America/New_York')->format('Y-m-d');
         $data['rebate_ratio'] = Config::where('field', 'rebate')->first()->val;
         $data['huishui_restriction'] = Config::where('field', 'huishui_restriction')->first()->val;
         $data['huishui_percentage'] = Config::where('field', 'huishui_percentage')->first()->val;
@@ -106,4 +107,4 @@ class Rebate extends BaseModel
         }
         return $value;
     }
-}
+}

+ 2 - 2
app/Models/ShareSetting.php

@@ -9,7 +9,7 @@ class ShareSetting extends BaseModel
     protected $hidden = [];
     protected $casts = [
         'enabled' => 'integer', 'benefit_enabled' => 'integer',
-        'valid_member_min_deposit' => 'decimal:2', 'non_seller_threshold' => 'decimal:2',
-        'tier_one_rate' => 'decimal:4', 'tier_two_rate' => 'decimal:4',
+        'valid_member_min_deposit' => 'decimal:2', 'valid_member_min_effective_bet' => 'decimal:10',
+        'indirect_bonus_rate' => 'decimal:4',
     ];
 }

+ 1 - 1
app/Services/BalanceLogService.php

@@ -38,7 +38,7 @@ class BalanceLogService extends BaseService
         'SG飞艇投注','SG飞艇退款','SG飞艇中奖',
         'SG快3投注','SG快3退款','SG快3中奖',
         //'足球游戏投注','足球游戏退款','足球游戏中奖',
-        '资产转移', '比比返', '返水', '回水', '笔笔返', '投注退分','充值返现','即充即送','老用户回归','余额宝转入','转出至余额宝','余额宝利息','流水解冻'
+        '资产转移', '比比返', '返水', '回水', '笔笔返', '投注退分','充值返现','即充即送','老用户回归','余额宝转入','转出至余额宝','余额宝利息','流水解冻','分享佣金领取'
     ];
 
     public static function init($telegram, $data, $chatId, $firstName, $messageId, $callbackId): void

+ 10 - 5
app/Services/BetService.php

@@ -22,6 +22,7 @@ use Illuminate\Support\Facades\Log;
 use Telegram\Bot\Api;
 use Telegram\Bot\Exceptions\TelegramSDKException;
 use App\Services\ActivityRewardService;
+use App\Services\Sharing\EffectiveBet;
 
 /**
  * 投注
@@ -175,7 +176,7 @@ class BetService extends BaseService
                 $where[] = ['lottery_status', '=', $search['status']];
             }
         }
-        if (isset($search['lottery_status']) && !empty($search['lottery_status'])) {
+        if (isset($search['lottery_status']) && $search['lottery_status'] !== '') {
             $where[] = ['lottery_status', '=', $search['lottery_status']];
         }
         $where[] = ['type', '>=', 5];
@@ -447,6 +448,7 @@ class BetService extends BaseService
         //记录投注金额
         $rebate = Rebate::addOrUpdate([
             'member_id' => $memberId,
+            'date' => EffectiveBet::newYorkDate(($betInfo ?: $newBet)->getRawOriginal('created_at')),
             'betting_amount' => $amount,
             'first_name' => $userInfo->first_name,
             'username' => $userInfo->username,
@@ -821,10 +823,11 @@ class BetService extends BaseService
         $openList = [];
         $memberList = [];
         
-        foreach ($list->toArray() as $k => $v) {
-
+        foreach ($list as $k => $settlementOrder) {
+            $v = $settlementOrder->toArray();
             $order_item = LhcOrderItem::where('ordernum', $v['ordernum'])->get()->toArray();
             $total_amount = 0;
+            $rebateDate = EffectiveBet::newYorkDate($settlementOrder->getRawOriginal('created_at'));
             foreach($order_item as $value) {
                 $number = $value['number'];
 
@@ -870,10 +873,11 @@ class BetService extends BaseService
                     $rebate = Rebate::updateProfit([
                         'member_id' => $v['member_id'],
                         'profit' => $yl,
+                        'date' => $rebateDate,
                     ]);
 
+                    RebateService::updateEffectiveBettingAmount($rebate, EffectiveBet::amount($v['amount'], $yl));
                     if (!in_array('13操', $awards) && !in_array('14操', $awards)) {
-                        RebateService::updateEffectiveBettingAmount($rebate, $v['amount']);
                         ActivityUserService::updateBettingAmount($v['member_id'],$v['amount']);
                     }
 
@@ -914,10 +918,11 @@ class BetService extends BaseService
                     $rebate = Rebate::updateProfit([
                         'member_id' => $v['member_id'],
                         'profit' => ($v['amount'] * -1),
+                        'date' => $rebateDate,
                     ]);
 
+                    RebateService::updateEffectiveBettingAmount($rebate, EffectiveBet::amount($v['amount'], bcsub('0', (string) $v['amount'], 2)));
                     if (!in_array('13操', $awards) && !in_array('14操', $awards)) {
-                        RebateService::updateEffectiveBettingAmount($rebate, $v['amount']);
                         ActivityUserService::updateBettingAmount($v['member_id'],$v['amount']);
                     }
                     // 未命中的组合不应用 13/14 特殊赔率。

+ 8 - 1
app/Services/PublicService.php

@@ -6,6 +6,8 @@ use App\Constants\HttpStatus;
 use App\Models\User;
 use Exception;
 use Illuminate\Support\Facades\App;
+use Illuminate\Support\Facades\Schema;
+use App\Services\Sharing\ShareRelations;
 
 class PublicService extends BaseService
 {
@@ -48,6 +50,7 @@ class PublicService extends BaseService
     {
 
         $user = User::where('member_id', $chatId)->first();
+        $newMember = !$user;
         if (!$user) {
             $user = new User();
             $user->from = -1;
@@ -63,6 +66,10 @@ class PublicService extends BaseService
 
         //给每个用户生成一个专属的钱包
         WalletService::getUserWallet($chatId);
+        // Telegram real-money members can arrive after the bulk sharing initialization.
+        if ($newMember && Schema::hasTable('share_system_state')) {
+            (new ShareRelations())->bind((int)$user->id, null, (string)$user->user_code, 'root');
+        }
 
         return $user;
     }
@@ -72,4 +79,4 @@ class PublicService extends BaseService
         // TODO: Implement getWhere() method.
         return [];
     }
-}
+}

+ 4 - 2
app/Services/RebateService.php

@@ -93,7 +93,9 @@ class RebateService extends BaseService
     public static function updateEffectiveBettingAmount($rebate, $amount): void
     {
         if ($rebate) {
-            $rebate->increment('effective_betting_amount', $amount);
+            // Never change the basis of an already paid daily rebate.
+            Rebate::whereKey($rebate->id)->where('status', Rebate::STATUS_WEI_FAN_YONG)
+                ->increment('effective_betting_amount', $amount);
         }
     }
 
@@ -120,4 +122,4 @@ class RebateService extends BaseService
         return false;
     }
 
-}
+}

+ 5 - 3
app/Services/ShareCommissionDiagnostics.php

@@ -58,14 +58,16 @@ class ShareCommissionDiagnostics
         }
         $rate = Config::where('field', 'recharge_rate')->value('val');
         $setting = DB::connection()->getSchemaBuilder()->hasTable('share_settings')
-            ? DB::table('share_settings')->orderBy('id')->first(['enabled', 'tier_one_rate', 'tier_two_rate']) : null;
+            ? DB::table('share_settings')->orderBy('id')->first() : null;
         return [
             'database' => DB::connection()->getDatabaseName(),
             'table_prefix' => DB::getTablePrefix(),
             'recharge_rate' => $rate,
             'share_enabled' => $setting->enabled ?? null,
-            'tier_one_rate' => $setting->tier_one_rate ?? null,
-            'tier_two_rate' => $setting->tier_two_rate ?? null,
+            '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,

+ 3 - 3
app/Services/ShareEarningReportService.php

@@ -128,9 +128,8 @@ class ShareEarningReportService
             ->get(['id', 'member_id', 'agent_user_code']);
         $records = ShareCommissionRecord::query()->where('stat_date', $day)->where('status', 1)
             ->whereIn('promoter_user_id', $promoters->pluck('promoter_id')->filter())->get()->groupBy('promoter_user_id');
-        $setting = ShareSetting::query()->first();
         $metricsByMember = $this->moneyMetricsByMember($invitees->pluck('member_id')->all());
-        $rows = $promoters->map(function ($promoter) use ($invitees, $records, $setting, $day, $metricsByMember) {
+        $rows = $promoters->map(function ($promoter) use ($invitees, $records, $day, $metricsByMember) {
             $children = $invitees->whereStrict('agent_user_code', (string)$promoter->agent_user_code);
             $metrics = $this->sumMetrics($metricsByMember, $children->pluck('member_id')->all());
             $commission = $records->get($promoter->promoter_id, collect())->where('type', 'commission')->sum('amount');
@@ -145,7 +144,8 @@ class ShareEarningReportService
                 'deposit_total' => $metrics['deposit'],
                 'registered_count' => $children->count(),
                 'commission' => number_format((float)$commission, 4, '.', ''),
-                'current_fee_rate' => $setting ? (string)$setting->tier_one_rate : '0.0000',
+                // Legacy report has no single rate after category-specific sharing rules replace the old setting.
+                'current_fee_rate' => '0.0000',
             ];
         });
         return ['total' => $total, 'data' => $rows->values()];

+ 316 - 0
app/Services/Sharing/BetFactSynchronizer.php

@@ -0,0 +1,316 @@
+<?php
+
+namespace App\Services\Sharing;
+
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+use InvalidArgumentException;
+use RuntimeException;
+
+/** The only importer of current real-money orders into the new sharing facts. */
+class BetFactSynchronizer
+{
+    private array $users = [];
+    private const LOTTERY_NAMES = [1 => '新澳门六合彩', 2 => '香港六合彩', 3 => '澳门六合彩', 4 => '极速六合彩',
+        5 => '加拿大28', 6 => '极速28', 7 => '极速赛车', 8 => '极速时时彩', 9 => '极速飞艇',
+        10 => 'SG时时彩', 11 => 'SG飞艇', 12 => '极速快3', 13 => 'SG快3'];
+
+    public function sync(?string $from = null, ?string $to = null, bool $write = false): array
+    {
+        foreach ([$from, $to] as $date) {
+            if ($date !== null && (!preg_match('/^\d{4}-\d{2}-\d{2}$/D', $date)
+                || \Carbon\Carbon::parse($date)->toDateString() !== $date)) {
+                throw new InvalidArgumentException('Dates must use Y-m-d');
+            }
+        }
+        if ($from !== null && $to !== null && $from > $to) throw new InvalidArgumentException('Invalid date range');
+        $result = ['scanned' => 0, 'created' => 0, 'updated' => 0, 'unchanged' => 0, 'skipped' => 0,
+            'errors' => [], 'changed_dates' => [], 'write' => $write];
+        $this->users = [];
+        foreach (['lhc_order', 'sport_game_order', 'sport_order', 'third_game_orders'] as $table) {
+            if (!Schema::hasTable($table)) continue;
+            $cursorColumn = $table === 'sport_order' ? 'update_time' : 'updated_at';
+            $cursorEnabled = $write && $from === null && $to === null && Schema::hasColumn($table, $cursorColumn);
+            $startedAt = now()->setTimezone('Asia/Shanghai')->format('Y-m-d H:i:s');
+            $highestIdAtStart = $cursorEnabled ? (int) DB::table($table)->max('id') : 0;
+            $errorCount = count($result['errors']);
+            $query = DB::table($table);
+            if ($cursorEnabled) {
+                $lastRun = DB::table('share_system_state')->where('key', 'facts_cursor:' . $table)->value('value');
+                if ($lastRun) {
+                    $cursor = json_decode($lastRun, true, 512, JSON_THROW_ON_ERROR);
+                    $cutoff = EffectiveBet::time($cursor['updated_at'])->subMinutes(5);
+                    $cutoffValue = in_array($table, ['lhc_order', 'sport_order'], true)
+                        ? $cutoff->timestamp : $cutoff->setTimezone('Asia/Shanghai')->format('Y-m-d H:i:s');
+                    $query->where(function ($query) use ($cursorColumn, $cutoffValue, $cursor) {
+                        $query->where($cursorColumn, '>=', $cutoffValue)->orWhereNull($cursorColumn)
+                            ->orWhere('id', '>', (int) $cursor['last_id']);
+                    });
+                }
+            }
+            $query->orderBy('id')->chunkById(200, function ($orders) use ($table, $from, $to, $write, &$result) {
+                foreach ($orders as $order) {
+                    $result['scanned']++;
+                    try {
+                        $changes = $this->synchronizeOrder($table, (int) $order->id, $from, $to, $write);
+                        foreach (['created', 'updated', 'unchanged', 'skipped'] as $key) $result[$key] += $changes[$key];
+                        foreach ($changes['changed_dates'] as $date) $result['changed_dates'][$date] = $date;
+                        $result['errors'] = array_merge($result['errors'], $changes['errors']);
+                    } catch (\Throwable $e) {
+                        $result['skipped']++;
+                        $result['errors'][] = ['source' => $table, 'source_id' => (string) $order->id, 'reason' => $e->getMessage()];
+                    }
+                }
+            });
+            if ($cursorEnabled && count($result['errors']) === $errorCount) {
+                DB::table('share_system_state')->updateOrInsert(['key' => 'facts_cursor:' . $table],
+                    ['value' => json_encode(['updated_at' => $startedAt, 'last_id' => $highestIdAtStart]),
+                        'updated_at' => $startedAt, 'created_at' => $startedAt]);
+            }
+        }
+        $result['changed_dates'] = array_values($result['changed_dates']);
+        sort($result['changed_dates']);
+        return $result;
+    }
+
+    /** A source order and all of its child facts are one publication unit. */
+    private function synchronizeOrder(string $table, int $id, ?string $from, ?string $to, bool $write): array
+    {
+        $operation = function () use ($table, $id, $from, $to, $write) {
+            if ($write) {
+                // Use the same first lock as settlement, relation binding and policy changes.
+                $guard = DB::table('share_system_state')->where('key', 'relations_lock')->lockForUpdate()->first();
+                if (!$guard) throw new RuntimeException('Sharing migration/relations lock is missing');
+                $this->users = [];
+            }
+            // The chunk row may predate a lock wait or a concurrent source correction.
+            $query = DB::table($table)->where('id', $id);
+            $order = ($write ? $query->lockForUpdate() : $query)->first();
+            if (!$order) throw new RuntimeException('Source order disappeared before synchronization');
+            $result = ['created' => 0, 'updated' => 0, 'unchanged' => 0, 'skipped' => 0, 'changed_dates' => [], 'errors' => []];
+            if ((int) ($order->is_faker ?? 0) === 1) { $result['skipped']++; return $result; }
+            try {
+                $facts = $table === 'lhc_order' ? $this->lotteryOrder($order, $write)['facts'] : [$this->singleOrder($table, $order)];
+            } catch (\Throwable $e) {
+                $isolated = $this->quarantine($table, $order, $e->getMessage(), $write);
+                $result['updated'] = $isolated['updated'];
+                $result['changed_dates'] = $isolated['changed_dates'];
+                $result['skipped']++;
+                $result['errors'][] = ['source' => $table, 'source_id' => (string) $id, 'reason' => $e->getMessage(),
+                    'isolated_facts' => $isolated['updated']];
+                return $result;
+            }
+            if ($facts === []) { $result['skipped']++; return $result; }
+            foreach ($facts as $fact) {
+                if ($fact === null || ($from !== null && $fact['bet_date'] < $from)
+                    || ($to !== null && $fact['bet_date'] > $to)) { $result['skipped']++; continue; }
+                $change = $this->persist($fact, $write);
+                $result[$change['status']]++;
+                $result['changed_dates'] = array_merge($result['changed_dates'], $change['dates']);
+            }
+            return $result;
+        };
+        return $write ? DB::transaction($operation, 3) : $operation();
+    }
+
+    /** Also used by the guarded historical PC28 rebate recalculation. */
+    public function lotteryOrder(object $order, bool $lock = false): array
+    {
+        $type = (int) ($order->type ?? 0);
+        if (!isset(self::LOTTERY_NAMES[$type])) throw new RuntimeException('Unknown lottery type');
+        $betAt = $this->time($order->created_at ?? null);
+        $base = ['member_id' => (string) $order->member_id, 'bet_at' => $betAt, 'date' => EffectiveBet::newYorkDate($order->created_at ?? null),
+            'total_bet' => EffectiveBet::zero(), 'facts' => [], 'unsettled' => false];
+        if (trim((string) ($order->ordernum ?? '')) === '') throw new RuntimeException('Missing lottery order number');
+        if (DB::table('lhc_order')->where('ordernum', $order->ordernum)->count() !== 1) {
+            throw new RuntimeException('Ambiguous lottery parent order number');
+        }
+        $itemQuery = DB::table('lhc_order_item')->where('ordernum', $order->ordernum)->orderBy('id');
+        $items = ($lock ? $itemQuery->lockForUpdate() : $itemQuery)->get();
+        if ($items->isEmpty()) throw new RuntimeException('Lottery order has no identifiable child bets');
+        $bet = EffectiveBet::decimal($order->amount);
+        if (bccomp($bet, '0', EffectiveBet::SCALE) <= 0) throw new RuntimeException('Lottery stake is not positive');
+        $total = bcmul($bet, (string) $items->count(), EffectiveBet::SCALE);
+        if (isset($order->total_amount) && bccomp((string) $order->total_amount, $total, EffectiveBet::SCALE) !== 0) {
+            throw new RuntimeException('Lottery total does not match child stakes');
+        }
+        $base['total_bet'] = $total;
+        $refund = $this->refundAmount($order, [self::LOTTERY_NAMES[$type] . '退款', '投注退分']);
+        if (bccomp($refund, '0', EffectiveBet::SCALE) > 0 && bccomp($refund, $total, EffectiveBet::SCALE) !== 0
+            && !in_array((int) $order->lottery_status, [3, 4], true)) {
+            throw new RuntimeException('Partial or inconsistent lottery refund requires review');
+        }
+        $parentVoid = in_array((int) $order->lottery_status, [3, 4], true) || bccomp($refund, '0', EffectiveBet::SCALE) > 0;
+        $user = $this->user((string) $order->member_id);
+        if ($user === null) return $base;
+        foreach ($items as $item) {
+            if ((string) ($item->member_id ?? '') !== (string) $order->member_id) throw new RuntimeException('Lottery child member mismatch');
+            $void = $parentVoid || in_array((int) $item->lottery_status, [3, 4], true);
+            $settled = !$void && in_array((int) $order->lottery_status, [1, 2], true)
+                && in_array((int) $item->lottery_status, [1, 2], true);
+            $base['unsettled'] = $base['unsettled'] || (!$settled && !$void);
+            $profit = $settled ? ((int) $item->lottery_status === 1 ? bcsub('0', $bet, EffectiveBet::SCALE)
+                : bcsub(EffectiveBet::decimal($item->win_amount ?? null), $bet, EffectiveBet::SCALE)) : EffectiveBet::zero();
+            $base['facts'][] = $this->fact('lhc_order_item', (string) $item->id, $user, 'self:lottery', $betAt,
+                $settled || $void ? $this->time($item->updated_at ?? $order->updated_at ?? null) : null,
+                $bet, $profit, $settled, $void, $this->nativeCurrency());
+        }
+        return $base;
+    }
+
+    private function singleOrder(string $table, object $order): ?array
+    {
+        $previous = null;
+        if ($table === 'third_game_orders' && !empty($order->order_key)) {
+            $previous = DB::table('share_bet_facts')->where('source', $table)->where('source_id', $order->order_key)->first();
+        }
+        $member = (string) ($table === 'sport_order' ? $order->user_id : ($order->member_id ?? ''));
+        $user = $this->user($member, $table === 'third_game_orders' ? ($order->user_id ?? $previous->user_id ?? null) : null);
+        if ($user === null) return null;
+        $sourceId = (string) $order->id;
+        $void = false;
+        if ($table === 'third_game_orders') {
+            $sourceId = (string) ($order->order_key ?? '');
+            if (!preg_match('/^[a-f0-9]{64}$/D', $sourceId)) throw new RuntimeException('Third-party order key is invalid');
+            $type = (int) $order->game_type;
+            $settled = (int) $order->status === 1;
+            $void = in_array((int) $order->status, [2, 3], true);
+            if (($type < 1 || $type > 7) && !$previous) throw new RuntimeException('Unknown third-party category');
+            $category = $type >= 1 && $type <= 7 ? 'third:' . $type : $previous->category;
+            $bet = EffectiveBet::decimal($order->bet_amount ?? (!$settled ? ($previous->bet_amount ?? '0') : null));
+            $profit = $settled ? EffectiveBet::decimal($order->settled_amount) : EffectiveBet::zero();
+            $betAt = $this->time($order->bet_time ?? $previous->bet_at ?? null);
+            $settledAt = $settled || $void ? $this->time($order->last_update_time ?? $order->updated_at ?? null) : null;
+            $currency = strtoupper(trim((string) ($order->currency ?? $previous->currency ?? '')));
+            if ($currency === '') throw new RuntimeException('Third-party currency is missing');
+        } else {
+            $category = 'self:sport';
+            $bet = EffectiveBet::decimal($order->amount);
+            $betAt = $this->time($table === 'sport_order' ? ($order->create_time ?? null) : ($order->created_at ?? null));
+            if ($table === 'sport_order') {
+                $void = (int) $order->status === 2 || (int) ($order->return_status ?? 0) === 2
+                    || bccomp($this->refundAmount($order, ['体彩退款'], $member), '0', EffectiveBet::SCALE) > 0;
+                $settled = !$void && (int) $order->status === 1 && (int) ($order->pay_status ?? 0) === 1
+                    && (int) ($order->settlement_status ?? 0) === 1;
+                $profit = $settled ? bcsub(bcadd(EffectiveBet::decimal($order->win_amount ?? '0'),
+                    EffectiveBet::decimal($order->return_amount ?? '0'), EffectiveBet::SCALE), $bet, EffectiveBet::SCALE) : EffectiveBet::zero();
+                $settledAt = $settled || $void ? $this->time($void
+                    ? (($order->return_operation_time ?? null) ?: ($order->update_time ?? null)) : ($order->settlement_time ?? null)) : null;
+            } else {
+                $void = (int) $order->status === 3 || bccomp($this->refundAmount($order, ['足球游戏退款']), '0', EffectiveBet::SCALE) > 0;
+                $settled = !$void && in_array((int) $order->status, [1, 2], true);
+                $profit = !$settled ? EffectiveBet::zero() : ((int) $order->status === 1 ? bcsub('0', $bet, EffectiveBet::SCALE)
+                    : bcsub(EffectiveBet::decimal($order->win_amount), $bet, EffectiveBet::SCALE));
+                $settledAt = $settled || $void ? $this->time($order->updated_at ?? null) : null;
+            }
+            $currency = $this->nativeCurrency();
+        }
+        return $this->fact($table, $sourceId, $user, $category, $betAt, $settledAt, $bet, $profit, $settled, $void, $currency);
+    }
+
+    private function fact(string $source, string $id, object $user, string $category, string $betAt, ?string $settledAt,
+        string $bet, string $profit, bool $settled, bool $void, string $currency): array
+    {
+        if (bccomp($bet, '0', EffectiveBet::SCALE) < 0) throw new RuntimeException('Negative stake');
+        $fact = ['source' => $source, 'source_id' => $id, 'user_id' => (int) $user->id,
+            'member_id' => (string) $user->member_id, 'category' => $category, 'bet_at' => $betAt,
+            'bet_date' => substr($betAt, 0, 10), 'settled_at' => $settledAt, 'bet_amount' => $bet,
+            'net_profit' => $profit, 'effective_amount' => EffectiveBet::amount($bet, $profit, $settled, $void),
+            'settled' => $settled, 'voided' => $void, 'currency' => $currency];
+        $fact['source_version'] = hash('sha256', json_encode($fact, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
+        return $fact;
+    }
+
+    private function user(string $member, $userId = null): ?object
+    {
+        $key = $userId !== null ? 'id:' . $userId : 'member:' . $member;
+        if (array_key_exists($key, $this->users)) return $this->users[$key];
+        $query = DB::table('users');
+        $rows = ($userId !== null ? $query->where('id', $userId) : $query->where('member_id', $member))->limit(2)->get();
+        if ($rows->count() !== 1) throw new RuntimeException('User mapping is missing or ambiguous');
+        $user = $rows->first();
+        if ($member !== '' && (string) $user->member_id !== $member) throw new RuntimeException('User identity mismatch');
+        return $this->users[$key] = ((int) ($user->from ?? 0) === 2 ? null : $user);
+    }
+
+    private function refundAmount(object $order, array $types, ?string $member = null): string
+    {
+        if (!Schema::hasTable('balance_logs')) return EffectiveBet::zero();
+        return EffectiveBet::decimal(DB::table('balance_logs')->where('member_id', $member ?? $order->member_id)
+            ->where('related_id', $order->id)->whereIn('change_type', $types)->where('amount', '>', 0)->sum('amount'));
+    }
+
+    private function time($value): string
+    {
+        return EffectiveBet::time($value)->setTimezone('Asia/Shanghai')->format('Y-m-d H:i:s');
+    }
+
+    private function nativeCurrency(): string
+    {
+        return strtoupper(trim((string) config('third_game.currency', 'CNY')));
+    }
+
+    private function persist(array $fact, bool $write): array
+    {
+        if ($write && DB::transactionLevel() === 0) {
+            throw new RuntimeException('Fact publication requires its source order transaction');
+        }
+        $query = DB::table('share_bet_facts')->where('source', $fact['source'])->where('source_id', $fact['source_id']);
+        $existing = ($write ? $query->lockForUpdate() : $query)->first();
+        if ($existing && hash_equals((string) $existing->source_version, $fact['source_version'])) {
+            return ['status' => 'unchanged', 'dates' => []];
+        }
+        $dates = array_values(array_unique(array_filter([$existing->bet_date ?? null, $fact['bet_date']])));
+        if ($write) {
+            $now = now()->setTimezone('Asia/Shanghai')->format('Y-m-d H:i:s');
+            if ($existing) DB::table('share_bet_facts')->where('id', $existing->id)->update($fact + ['updated_at' => $now]);
+            else DB::table('share_bet_facts')->insert($fact + ['created_at' => $now, 'updated_at' => $now]);
+            $this->markDirty($dates, $now);
+        }
+        return ['status' => $existing ? 'updated' : 'created', 'dates' => $dates];
+    }
+
+    /** Only quarantine previously published facts identifiable from the current source row. */
+    private function quarantine(string $table, object $order, string $reason, bool $write): array
+    {
+        if ($table === 'lhc_order') {
+            if (trim((string) ($order->ordernum ?? '')) === '' || !Schema::hasTable('lhc_order_item')) {
+                return ['updated' => 0, 'changed_dates' => []];
+            }
+            $items = DB::table('lhc_order_item')->where('ordernum', $order->ordernum)->orderBy('id');
+            $ids = ($write ? $items->lockForUpdate() : $items)->pluck('id')->map(fn ($id) => (string) $id)->all();
+            $source = 'lhc_order_item';
+        } else {
+            $ids = [(string) ($table === 'third_game_orders' ? ($order->order_key ?? '') : $order->id)];
+            $source = $table;
+        }
+        $ids = array_values(array_filter($ids, fn ($id) => $id !== ''));
+        if ($ids === []) return ['updated' => 0, 'changed_dates' => []];
+        $query = DB::table('share_bet_facts')->where('source', $source)->whereIn('source_id', $ids)->orderBy('id');
+        $facts = ($write ? $query->lockForUpdate() : $query)->get();
+        $now = now()->setTimezone('Asia/Shanghai')->format('Y-m-d H:i:s');
+        $updated = 0; $dates = [];
+        foreach ($facts as $fact) {
+            $version = hash('sha256', json_encode(['invalid_source' => $source, 'source_id' => $fact->source_id,
+                'reason' => $reason, 'order' => $order], JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE | JSON_THROW_ON_ERROR));
+            if ((string) $fact->source_version === $version && !(bool) $fact->settled
+                && bccomp((string) $fact->effective_amount, '0', EffectiveBet::SCALE) === 0) continue;
+            $updated++; $dates[] = $fact->bet_date;
+            if ($write) DB::table('share_bet_facts')->where('id', $fact->id)->update([
+                'settled' => false, 'effective_amount' => EffectiveBet::zero(), 'source_version' => $version, 'updated_at' => $now]);
+        }
+        $dates = array_values(array_unique($dates));
+        if ($write && $dates !== []) $this->markDirty($dates, $now);
+        return ['updated' => $updated, 'changed_dates' => $dates];
+    }
+
+    private function markDirty(array $dates, string $now): void
+    {
+        DB::table('share_system_state')->insertOrIgnore(['key' => 'facts_dirty_dates', 'value' => '[]', 'created_at' => $now, 'updated_at' => $now]);
+        $state = DB::table('share_system_state')->where('key', 'facts_dirty_dates')->lockForUpdate()->first();
+        $dirty = json_decode($state->value, true, 512, JSON_THROW_ON_ERROR);
+        $dirty = array_values(array_unique(array_merge($dirty, $dates)));
+        sort($dirty);
+        DB::table('share_system_state')->where('key', 'facts_dirty_dates')->update(['value' => json_encode($dirty), 'updated_at' => $now]);
+    }
+}

+ 67 - 0
app/Services/Sharing/EffectiveBet.php

@@ -0,0 +1,67 @@
+<?php
+
+namespace App\Services\Sharing;
+
+use Carbon\Carbon;
+use InvalidArgumentException;
+
+/** Shared arithmetic only: callers retain their own eligibility and accounting dates. */
+final class EffectiveBet
+{
+    public const SCALE = 10;
+
+    public static function amount($betAmount, $netProfit, bool $settled = true, bool $voided = false): string
+    {
+        if (!$settled || $voided) return self::zero();
+        $bet = self::decimal($betAmount);
+        $profit = self::decimal($netProfit);
+        if (bccomp($bet, '0', self::SCALE) < 0) {
+            throw new InvalidArgumentException('Bet amount cannot be negative');
+        }
+        $absolute = bccomp($profit, '0', self::SCALE) < 0
+            ? bcsub('0', $profit, self::SCALE) : $profit;
+        return bccomp($bet, $absolute, self::SCALE) <= 0 ? $bet : $absolute;
+    }
+
+    public static function decimal($value): string
+    {
+        $value = trim((string) $value);
+        if (!preg_match('/^-?\d+(?:\.\d+)?$/D', $value)) {
+            throw new InvalidArgumentException('Expected a plain decimal amount');
+        }
+        return bcadd($value, '0', self::SCALE);
+    }
+
+    public static function zero(): string
+    {
+        return '0.0000000000';
+    }
+
+    public static function newYorkDate($sourceTime, string $sourceTimezone = 'Asia/Shanghai'): string
+    {
+        return self::time($sourceTime, $sourceTimezone)->setTimezone('America/New_York')->toDateString();
+    }
+
+    /** Reject missing dates rather than Carbon's implicit current-time fallback. */
+    public static function time($sourceTime, string $sourceTimezone = 'Asia/Shanghai'): Carbon
+    {
+        if ($sourceTime instanceof \DateTimeInterface) return Carbon::instance($sourceTime);
+        if ($sourceTime === null || trim((string) $sourceTime) === '') {
+            throw new InvalidArgumentException('Source time is missing');
+        }
+        if (is_numeric($sourceTime)) {
+            if (!preg_match('/^\d{9,11}$/D', (string) $sourceTime)) {
+                throw new InvalidArgumentException('Invalid source timestamp');
+            }
+            return Carbon::createFromTimestamp((int) $sourceTime, 'UTC');
+        }
+        if (!preg_match('/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?$/D', (string) $sourceTime)) {
+            throw new InvalidArgumentException('Invalid source datetime');
+        }
+        $parsed = Carbon::parse($sourceTime, $sourceTimezone);
+        if ($parsed->format('Y-m-d H:i:s') !== str_replace('T', ' ', substr((string) $sourceTime, 0, 19))) {
+            throw new InvalidArgumentException('Source datetime is not a valid calendar date');
+        }
+        return $parsed;
+    }
+}

+ 272 - 0
app/Services/Sharing/ShareAdminService.php

@@ -0,0 +1,272 @@
+<?php
+
+namespace App\Services\Sharing;
+
+use App\Models\ShareSetting;
+use App\Services\OperationAuditService;
+use Carbon\Carbon;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Validator;
+use Illuminate\Validation\Rule;
+use Illuminate\Validation\ValidationException;
+
+class ShareAdminService
+{
+    private const RETIRED = ['non_seller_threshold', 'tier_one_rate', 'tier_two_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'];
+
+    public function settings(): array
+    {
+        $setting = ShareSetting::query()->first();
+        $current = $this->policy(false);
+        $pending = $this->policy(true);
+        return [
+            'id' => $setting ? (int)$setting->id : null,
+            'enabled' => (int)($setting->enabled ?? 0),
+            'share_domain' => (string)($setting->share_domain ?? ''),
+            '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,
+            'current_policy' => $current,
+            'pending_policy' => $pending,
+        ];
+    }
+
+    public function saveSettings(array $input): array
+    {
+        foreach (self::RETIRED as $key) {
+            if (array_key_exists($key, $input)) $this->invalid($key, '旧分享参数已移除,请使用有效会员条件及分类返佣规则');
+        }
+        foreach (['activation_date', 'effective_date', 'current_policy', 'pending_policy'] as $key) {
+            if (array_key_exists($key, $input)) $this->invalid($key, '生效日期及版本由系统生成,不可指定');
+        }
+        $data = Validator::make($input, [
+            'enabled' => ['required', Rule::in([0, 1])],
+            'share_domain' => ['required', 'string', 'max:500'],
+            '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) {
+            $this->lockConfiguration();
+            $setting = ShareSetting::query()->lockForUpdate()->first() ?: new ShareSetting();
+            $before = $setting->exists ? $setting->toArray() : [];
+            if ((int)$data['benefit_enabled'] === 1) {
+                if (!DB::table('share_system_state')->where('key', 'initialized_at')->exists()) {
+                    $this->invalid('benefit_enabled', '会员分享关系初始化完成后才可启用佣金');
+                }
+                if (!$setting->activation_date) $setting->activation_date = $this->tomorrow();
+            }
+            // Blank nullable fields may clear unfinished setup, but can never enable it.
+            $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) {
+                $policy[$field] = $setting->getRawOriginal($field);
+            }
+            $this->savePolicy($policy);
+            OperationAuditService::record('share_setting', (int)$setting->id, 'update', $before, $setting);
+            return $this->settings();
+        });
+    }
+
+    public function rateRules(?string $category = null): array
+    {
+        if ($category !== null && !array_key_exists($category, ShareDefaults::categories())) $this->invalid('category', '无效的游戏分类');
+        $current = $this->policy(false);
+        $pending = $this->policy(true);
+        $categories = [];
+        foreach (ShareDefaults::categories() as $key => $label) {
+            if ($category !== null && $category !== $key) continue;
+            $categories[] = ['category' => $key, 'name' => $label,
+                'current_rules' => $current['rules'][$key] ?? [],
+                'pending_rules' => $pending['rules'][$key] ?? null];
+        }
+        return ['current_policy_id' => $current['id'] ?? null, 'current_effective_date' => $current['effective_date'] ?? null,
+            'pending_policy_id' => $pending['id'] ?? null, 'pending_effective_date' => $pending['effective_date'] ?? null,
+            'categories' => $categories];
+    }
+
+    public function saveRateRules(string $category, array $rules): array
+    {
+        $data = Validator::make(['category' => $category, 'rules' => $rules], [
+            'category' => ['required', Rule::in(array_keys(ShareDefaults::categories()))],
+            'rules' => ['required', 'array', 'min:1', 'max:100'],
+            'rules.*.min_valid_members' => ['required', 'integer', 'min:0', 'max:2147483647'],
+            'rules.*.min_performance' => array_merge(['required'], self::MONEY),
+            'rules.*.rate' => array_merge(['required'], self::RATE),
+        ])->validate();
+        $normalized = [];
+        foreach ($data['rules'] as $row) {
+            $normalized[] = ['min_valid_members' => (int)$row['min_valid_members'],
+                'min_performance' => bcadd((string)$row['min_performance'], '0', 10), 'rate' => bcadd((string)$row['rate'], '0', 4)];
+        }
+        usort($normalized, fn ($a, $b) => bccomp($a['min_performance'], $b['min_performance'], 10)
+            ?: ($a['min_valid_members'] <=> $b['min_valid_members']));
+        $previous = null;
+        foreach ($normalized as $row) {
+            if ($previous !== null) {
+                if (bccomp($row['min_performance'], $previous['min_performance'], 10) === 0
+                    && $row['min_valid_members'] === $previous['min_valid_members']) {
+                    $this->invalid('rules', '同一业绩门槛和人数门槛的档位不可重复');
+                }
+                if ($row['min_valid_members'] < $previous['min_valid_members']) {
+                    $this->invalid('rules', '随业绩档位提高,有效直属人数门槛不可降低');
+                }
+                if (bccomp($row['rate'], $previous['rate'], 4) < 0) {
+                    $this->invalid('rules', '随业绩和人数档位提高,返佣比例不可降低');
+                }
+            }
+            $previous = $row;
+        }
+        return DB::transaction(function () use ($category, $normalized) {
+            $this->lockConfiguration();
+            $policy = $this->writablePolicy();
+            $before = $policy;
+            $policy['rules'][$category] = $normalized;
+            $id = $this->savePolicy($policy);
+            OperationAuditService::record('share_policy', $id, 'rate_rules', $before, $policy);
+            return $this->rateRules($category);
+        });
+    }
+
+    public function levels(): array
+    {
+        $total = '0';
+        $rows = [];
+        foreach (DB::table('share_levels')->orderBy('level')->get() as $row) {
+            $total = bcadd($total, (string)$row->incremental_commission, 10);
+            $rows[] = ['id' => (int)$row->id, 'level' => (int)$row->level, 'name' => $row->name, 'image' => $row->image,
+                'incremental_commission' => $this->money($row->incremental_commission), 'cumulative_commission' => $this->money($total)];
+        }
+        return ['data' => $rows];
+    }
+
+    public function saveLevel(array $input): array
+    {
+        $data = Validator::make($input, [
+            'id' => ['nullable', 'integer', 'min:1'], 'level' => ['required', 'integer', 'min:1', 'max:2147483647'],
+            'name' => ['required', 'string', 'max:255'], 'image' => ['present', 'nullable', 'string', 'max:1000'],
+            'incremental_commission' => array_merge(['required'], self::MONEY),
+        ])->validate();
+        $data['image'] = (string)($data['image'] ?? '');
+        return DB::transaction(function () use ($data) {
+            $this->lockConfiguration();
+            $id = (int)($data['id'] ?? 0);
+            $old = $id ? DB::table('share_levels')->where('id', $id)->lockForUpdate()->first() : null;
+            if ($id && !$old) $this->invalid('id', '分享等级不存在');
+            if ($old && (int)$old->level !== (int)$data['level']) $this->invalid('level', '已有等级不可更改等级编号');
+            if ((int)$data['level'] === 1 && bccomp((string)$data['incremental_commission'], '0', 10) !== 0) $this->invalid('incremental_commission', '等级1的增量佣金必须为0');
+            if (DB::table('share_levels')->where('level', $data['level'])->when($id, fn ($q) => $q->where('id', '!=', $id))->exists()) $this->invalid('level', '分享等级编号已存在');
+            unset($data['id']);
+            $now = $this->now();
+            if ($id) DB::table('share_levels')->where('id', $id)->update($data + ['updated_at' => $now]);
+            else $id = DB::table('share_levels')->insertGetId($data + ['created_at' => $now, 'updated_at' => $now]);
+            $this->promoteMembers();
+            OperationAuditService::record('share_level', $id, $old ? 'update' : 'create', (array)$old, $data);
+            return $this->levels();
+        });
+    }
+
+    public function deleteLevel(int $id): void
+    {
+        DB::transaction(function () use ($id) {
+            $this->lockConfiguration();
+            $row = DB::table('share_levels')->where('id', $id)->lockForUpdate()->first();
+            if (!$row) $this->invalid('id', '分享等级不存在');
+            if ((int)$row->level === 1) $this->invalid('id', '初始等级不可删除');
+            if (DB::table('share_members')->where('level', $row->level)->exists()) $this->invalid('id', '该等级已有会员,不可删除');
+            DB::table('share_levels')->where('id', $id)->delete();
+            $this->promoteMembers();
+            OperationAuditService::record('share_level', $id, 'delete', (array)$row, []);
+        });
+    }
+
+    private function promoteMembers(): void
+    {
+        $threshold = '0';
+        foreach (DB::table('share_levels')->orderBy('level')->get() as $row) {
+            $threshold = bcadd($threshold, (string)$row->incremental_commission, 10);
+            DB::table('share_members')->where('level', '<', $row->level)
+                ->whereIn('user_id', DB::table('share_accounts')->select('user_id')->where('earned_amount', '>=', $threshold))
+                ->update(['level' => $row->level, 'updated_at' => $this->now()]);
+        }
+    }
+
+    private function writablePolicy(): array
+    {
+        $row = DB::table('share_policies')->where('effective_date', $this->tomorrow())->lockForUpdate()->first()
+            ?: DB::table('share_policies')->where('effective_date', '<=', $this->today())->orderByDesc('effective_date')->first();
+        $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']);
+        $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;
+    }
+
+    private function savePolicy(array $policy): int
+    {
+        $policy['rules'] = json_encode($policy['rules'], JSON_THROW_ON_ERROR);
+        $old = DB::table('share_policies')->where('effective_date', $this->tomorrow())->first();
+        $policy['updated_at'] = $this->now();
+        if ($old) {
+            DB::table('share_policies')->where('id', $old->id)->where('effective_date', '>', $this->today())->update($policy);
+            return (int)$old->id;
+        }
+        return (int)DB::table('share_policies')->insertGetId($policy + ['created_at' => $this->now()]);
+    }
+
+    private function policy(bool $pending): ?array
+    {
+        $query = DB::table('share_policies');
+        $row = $pending ? $query->where('effective_date', $this->tomorrow())->first()
+            : $query->where('effective_date', '<=', $this->today())->orderByDesc('effective_date')->first();
+        if (!$row) return null;
+        $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);
+        $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'];
+            $rule['min_performance'] = $this->money($rule['min_performance']);
+            $rule['rate'] = bcadd((string)$rule['rate'], '0', 4);
+        }
+        unset($rule, $rows);
+        return $data;
+    }
+
+    private function lockConfiguration(): void
+    {
+        if (!DB::table('share_system_state')->where('key', 'relations_lock')->lockForUpdate()->first()) {
+            throw new \RuntimeException('分享系统尚未初始化配置表');
+        }
+    }
+
+    private function origin(string $value): string
+    {
+        $value = trim($value);
+        if (!preg_match('~^https?://~i', $value)) $value = 'https://' . $value;
+        $parts = parse_url($value);
+        if (!filter_var($value, FILTER_VALIDATE_URL) || !$parts || !in_array(strtolower($parts['scheme'] ?? ''), ['http', 'https'], true)
+            || isset($parts['user'], $parts['pass']) || isset($parts['user']) || isset($parts['query']) || isset($parts['fragment']) || !in_array($parts['path'] ?? '', ['', '/'], true)) {
+            $this->invalid('share_domain', '请填写PC网站域名源地址,不可包含路径、凭证或查询参数');
+        }
+        return strtolower($parts['scheme']) . '://' . strtolower($parts['host']) . (isset($parts['port']) ? ':' . $parts['port'] : '');
+    }
+
+    private function money($value): string { return bcadd((string)$value, '0', 2); }
+    private function today(): string { return Carbon::now(ShareDefaults::TIMEZONE)->toDateString(); }
+    private function tomorrow(): string { return Carbon::now(ShareDefaults::TIMEZONE)->addDay()->toDateString(); }
+    private function now(): string { return Carbon::now(ShareDefaults::TIMEZONE)->format('Y-m-d H:i:s'); }
+    private function invalid(string $key, string $message): void { throw ValidationException::withMessages([$key => $message]); }
+}

+ 118 - 0
app/Services/Sharing/ShareClaimAuditService.php

@@ -0,0 +1,118 @@
+<?php
+
+namespace App\Services\Sharing;
+
+use App\Models\User;
+use App\Models\Wallet;
+use App\Services\BalanceLogService;
+use App\Services\OperationAuditService;
+use Carbon\Carbon;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Validator;
+use Illuminate\Validation\Rule;
+use Illuminate\Validation\ValidationException;
+
+class ShareClaimAuditService
+{
+    public function list(array $filters): array
+    {
+        $params = Validator::make($filters, [
+            'page' => ['nullable', 'integer', 'min:1'], 'limit' => ['nullable', 'integer', 'min:1', 'max:200'],
+            'status' => ['nullable', Rule::in(['pending', 'approved', 'rejected'])],
+            'account' => ['nullable', 'string', 'max:100'], 'user_id' => ['nullable', 'integer', 'min:1'],
+            'start_date' => ['nullable', 'date_format:Y-m-d'],
+            'end_date' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:start_date'],
+        ])->validate();
+        $query = DB::table('share_claims as c')->leftJoin('users as u', 'u.id', '=', 'c.user_id');
+        if (!empty($params['status'])) $query->where('c.status', $params['status']);
+        if (!empty($params['account'])) $query->where('u.account', 'like', '%' . $params['account'] . '%');
+        if (!empty($params['user_id'])) $query->where('c.user_id', $params['user_id']);
+        if (!empty($params['start_date'])) $query->where('c.created_at', '>=', $params['start_date'] . ' 00:00:00');
+        if (!empty($params['end_date'])) $query->where('c.created_at', '<=', $params['end_date'] . ' 23:59:59');
+        $total = $query->count();
+        $rows = $query->select(['c.*', 'u.account', 'u.member_id', 'u.first_name as nickname'])->orderByDesc('c.id')
+            ->forPage((int)($params['page'] ?? 1), (int)($params['limit'] ?? 20))->get();
+        return ['total' => $total, 'page' => (int)($params['page'] ?? 1), 'limit' => (int)($params['limit'] ?? 20),
+            'data' => $rows->map(fn ($row) => $this->claim($row))->all()];
+    }
+
+    public function detail(int $id): array
+    {
+        $row = DB::table('share_claims as c')->leftJoin('users as u', 'u.id', '=', 'c.user_id')
+            ->where('c.id', $id)->select(['c.*', 'u.account', 'u.member_id', 'u.first_name as nickname'])->first();
+        if (!$row) $this->invalid('id', '领取申请不存在');
+        $account = DB::table('share_accounts')->where('user_id', $row->user_id)->first();
+        $earned = (string)($account->earned_amount ?? '0');
+        $paid = (string)($account->paid_amount ?? '0');
+        $reserved = (string)($account->reserved_amount ?? '0');
+        $balance = bcsub($earned, $paid, 10);
+        $spendable = bcsub($balance, $reserved, 10);
+        return $this->claim($row) + ['commission_account' => [
+            'earned_amount' => $this->money($earned), 'paid_amount' => $this->money($paid), 'reserved_amount' => $this->money($reserved),
+            'pending_amount' => $this->money($this->positive($balance)), 'spendable_amount' => $this->money($this->positive($spendable)),
+            'debt_amount' => $this->money($this->positive(bcsub($paid, $earned, 10))),
+        ], 'can_approve' => $row->status === 'pending' && bccomp($balance, (string)$row->amount, 10) >= 0];
+    }
+
+    public function audit(int $id, string $action, int $adminId, string $remark = ''): array
+    {
+        Validator::make(compact('id', 'action', 'remark'), ['id' => ['required', 'integer', 'min:1'],
+            'action' => ['required', Rule::in(['approve', 'reject'])], 'remark' => ['string', 'max:2000']])->validate();
+        if ($adminId <= 0) $this->invalid('admin', '请使用后台管理员身份审核');
+        $userId = DB::table('share_claims')->where('id', $id)->value('user_id');
+        if (!$userId) $this->invalid('id', '领取申请不存在');
+        return DB::transaction(function () use ($id, $action, $adminId, $remark, $userId) {
+            // Every claim mutation uses the same lock order as member requests and settlement corrections.
+            $account = DB::table('share_accounts')->where('user_id', $userId)->lockForUpdate()->first();
+            if (!$account) $this->invalid('id', '会员佣金账户不存在');
+            $claim = DB::table('share_claims')->where('id', $id)->lockForUpdate()->first();
+            if (!$claim || (int)$claim->user_id !== (int)$userId) $this->invalid('id', '领取申请不存在');
+            $targetStatus = $action === 'approve' ? 'approved' : 'rejected';
+            if ($claim->status === $targetStatus) return $this->detail($id);
+            if ($claim->status !== 'pending') $this->invalid('id', '领取申请已审核,不可更改结果');
+            $amount = (string)$claim->amount;
+            if (bccomp($amount, '0.01', 10) < 0 || bccomp((string)$account->reserved_amount, $amount, 10) < 0) {
+                $this->invalid('id', '申请金额或预留佣金异常,请核对');
+            }
+            $now = Carbon::now(ShareDefaults::TIMEZONE)->format('Y-m-d H:i:s');
+            $changes = ['reserved_amount' => bcsub((string)$account->reserved_amount, $amount, 10), 'updated_at' => $now];
+            $claimChanges = ['status' => $targetStatus, 'active_user_id' => null, 'audit_admin_id' => $adminId,
+                'audit_remark' => $remark, 'audited_at' => $now, 'updated_at' => $now];
+            if ($action === 'approve') {
+                if (bccomp(bcsub((string)$account->earned_amount, (string)$account->paid_amount, 10), $amount, 10) < 0) {
+                    $this->invalid('amount', '佣金修正后余额不足,请驳回原申请后由会员重新申请');
+                }
+                $user = User::query()->find($userId);
+                if (!$user) $this->invalid('user_id', '会员不存在');
+                $wallet = Wallet::query()->where('member_id', $user->member_id)->orderBy('id')->lockForUpdate()->first();
+                if (!$wallet) $this->invalid('user_id', '会员钱包不存在');
+                $before = (string)$wallet->available_balance;
+                $after = bcadd($before, $amount, 10);
+                $wallet->available_balance = $after;
+                $wallet->save();
+                $log = BalanceLogService::addLog($user->member_id, $amount, $before, $after, '分享佣金领取', $id, $remark);
+                $claimChanges['credited_log_id'] = (int)$log->id;
+                $changes['paid_amount'] = bcadd((string)$account->paid_amount, $amount, 10);
+            }
+            DB::table('share_accounts')->where('user_id', $userId)->update($changes);
+            DB::table('share_claims')->where('id', $id)->update($claimChanges);
+            OperationAuditService::record('share_claim', $id, $action, (array)$claim, (array)DB::table('share_claims')->where('id', $id)->first());
+            return $this->detail($id);
+        });
+    }
+
+    private function claim(object $row): array
+    {
+        return ['id' => (int)$row->id, 'user_id' => (int)$row->user_id, 'member_id' => (string)($row->member_id ?? ''),
+            'account' => (string)($row->account ?? ''), 'nickname' => (string)($row->nickname ?? ''), 'request_id' => (string)$row->request_id,
+            'amount' => $this->money($row->amount), 'status' => $row->status,
+            'audit_admin_id' => $row->audit_admin_id === null ? null : (int)$row->audit_admin_id,
+            'audit_remark' => (string)($row->audit_remark ?? ''), 'audited_at' => $row->audited_at,
+            'credited_log_id' => $row->credited_log_id === null ? null : (int)$row->credited_log_id,
+            'created_at' => $row->created_at, 'updated_at' => $row->updated_at];
+    }
+
+    private function positive(string $value): string { return bccomp($value, '0', 10) > 0 ? $value : '0'; }
+    private function money($value): string { return bcadd((string)$value, '0', 2); }
+    private function invalid(string $key, string $message): void { throw ValidationException::withMessages([$key => $message]); }
+}

+ 26 - 0
app/Services/Sharing/ShareDefaults.php

@@ -0,0 +1,26 @@
+<?php
+
+namespace App\Services\Sharing;
+
+final class ShareDefaults
+{
+    public const TIMEZONE = 'Asia/Shanghai';
+
+    public static function categories(): array
+    {
+        return ['third:1' => '第三方真人', 'third:2' => '第三方电子', 'third:3' => '第三方彩票',
+            'third:4' => '第三方体育', 'third:5' => '第三方电竞', 'third:6' => '第三方捕鱼',
+            'third:7' => '第三方棋牌', 'self:sport' => '自有体育', 'self:lottery' => '自有彩票'];
+    }
+
+    public static function rules(): array
+    {
+        $rows = [];
+        foreach ([[0,100,'0.10'],[1,20000,'0.20'],[1,50000,'0.25'],[2,100000,'0.30'],
+            [2,200000,'0.35'],[3,300000,'0.40'],[3,500000,'0.45'],[3,600000,'0.50'],
+            [5,900000,'0.55'],[5,1100000,'0.60'],[5,1500000,'0.65'],[5,1700000,'0.70']] as [$people,$amount,$rate]) {
+            $rows[] = ['min_valid_members' => $people, 'min_performance' => (string)$amount, 'rate' => $rate];
+        }
+        return array_fill_keys(array_keys(self::categories()), $rows);
+    }
+}

+ 152 - 0
app/Services/Sharing/ShareRelations.php

@@ -0,0 +1,152 @@
+<?php
+
+namespace App\Services\Sharing;
+
+use Carbon\Carbon;
+use App\Models\User;
+use Illuminate\Support\Facades\DB;
+
+class ShareRelations
+{
+    public function initialize(bool $write = false): array
+    {
+        if (!$write) return $this->inspect(false);
+        return DB::transaction(function () {
+            $this->lock();
+            return $this->inspect(true);
+        });
+    }
+
+    private function inspect(bool $write): array
+    {
+        $users = DB::table('users')->get(['id','user_code','agent_user_code','from','created_at'])->keyBy('id');
+        $existing = DB::table('share_members')->get()->keyBy('user_id');
+        $assigned = 0;
+        foreach ($users as $id=>$user) {
+            if ((int)$user->from === 2) continue;
+            $old=(string)($user->user_code ?? '');
+            $stable=(string)($existing[$id]->invitation_code ?? '');
+            if ($old === '' || $old === '0') {
+                $assigned++;
+                if (!$write) continue;
+                $code=($stable !== '' && $stable !== '0') ? $stable : User::generateUserCode();
+                if (DB::table('users')->where('user_code',$code)->where('id','<>',$id)->exists()) throw new \RuntimeException('固化邀请码已被其他会员占用,请人工核对:'.$id);
+                DB::table('users')->where('id',$id)->update(['user_code'=>$code]);
+                $user->user_code=$code;
+            }
+            if ($write && $existing->has($id) && ($stable === '' || $stable === '0')) {
+                DB::table('share_members')->where('user_id',$id)->update(['invitation_code'=>(string)$user->user_code]);
+                $existing[$id]->invitation_code=(string)$user->user_code;
+            }
+        }
+        $codes = [];
+        foreach ($users as $u) if ((string)$u->user_code !== '' && (string)$u->user_code !== '0') $codes[strtoupper(trim((string)$u->user_code))][] = (int)$u->id;
+        $parents = []; $statuses = []; $errors = [];
+        foreach ($users as $u) {
+            $id = (int)$u->id;
+            if ($existing->has($id)) {
+                $parents[$id] = $existing[$id]->parent_user_id === null ? null : (int)$existing[$id]->parent_user_id;
+                $statuses[$id] = $existing[$id]->binding_status;
+                continue;
+            }
+            $code = strtoupper(trim((string)($u->agent_user_code ?? '')));
+            $matches = $codes[$code] ?? [];
+            $parents[$id] = null;
+            $statuses[$id] = 'root';
+            if ((int)$u->from === 2 || $code === '') continue;
+            if ($code === '0' || count($matches) === 0) $statuses[$id] = 'missing';
+            elseif (count($matches) > 1) $statuses[$id] = 'ambiguous';
+            elseif ((int)$users[$matches[0]]->from === 2) $statuses[$id] = 'missing';
+            else { $parents[$id] = $matches[0]; $statuses[$id] = 'matched'; }
+        }
+        // Only actual cycle edges are removed. Confirmed children outside the cycle keep their identity.
+        $done = [];
+        foreach (array_keys($parents) as $id) {
+            $path = []; $at = $id;
+            while ($at !== null && isset($parents[$at]) && !isset($done[$at])) {
+                if (isset($path[$at])) {
+                    $cycle = array_slice(array_keys($path), $path[$at]);
+                    foreach ($cycle as $member) {
+                        if ($existing->has($member)) throw new \RuntimeException('已固化的推荐关系存在循环,禁止自动改绑:' . $member);
+                        $parents[$member] = null; $statuses[$member] = 'cycle';
+                    }
+                    break;
+                }
+                $path[$at] = count($path); $at = $parents[$at];
+            }
+            foreach ($path as $member => $_) $done[$member] = true;
+        }
+        foreach ($statuses as $id => $status) if (in_array($status,['missing','ambiguous','cycle'],true)) $errors[] = ['user_id'=>$id,'status'=>$status];
+        $result = ['users'=>$users->count(),'created'=>0,'existing'=>$existing->count(),'invitation_codes_assigned'=>$assigned,'errors'=>$errors,'written'=>$write];
+        if (!$write) { $result['created'] = $users->count() - $existing->count(); return $result; }
+        $now = Carbon::now(ShareDefaults::TIMEZONE)->format('Y-m-d H:i:s');
+        $initial = DB::table('share_system_state')->where('key','initialized_at')->value('value') === null;
+        foreach ($users as $u) {
+            $id = (int)$u->id;
+            if ($existing->has($id)) continue;
+            DB::table('share_members')->insert(['user_id'=>$id,'parent_user_id'=>$parents[$id],
+                'invitation_code'=>(string)($u->user_code ?? ''),'binding_status'=>$statuses[$id],
+                'bound_at'=>$initial ? '1970-01-01 00:00:00' : ($u->created_at ?: $now), 'created_at'=>$now,'updated_at'=>$now]);
+            $result['created']++;
+        }
+        $members = DB::table('share_members')->get()->keyBy('user_id');
+        foreach ($members as $member) {
+            $id = (int)$member->user_id; $at = $id; $depth = 0; $seen = [];
+            $bound = '1970-01-01 00:00:00'; $rows = [];
+            while ($at !== null && $members->has($at)) {
+                if (isset($seen[$at])) throw new \RuntimeException('推荐关系循环');
+                $seen[$at] = true;
+                $rows[] = ['ancestor_user_id'=>$at,'descendant_user_id'=>$id,'depth'=>$depth,'bound_at'=>$bound];
+                $edge = $members[$at];
+                if ($edge->parent_user_id === null) break;
+                $bound = max($bound,(string)$edge->bound_at);
+                $at = (int)$edge->parent_user_id; $depth++;
+            }
+            foreach (array_chunk($rows,500) as $chunk) DB::table('share_tree')->insertOrIgnore($chunk);
+            DB::table('share_accounts')->insertOrIgnore(['user_id'=>$id,'created_at'=>$now,'updated_at'=>$now]);
+        }
+        DB::table('share_system_state')->updateOrInsert(['key'=>'initialized_at'],['value'=>$now,'created_at'=>$now,'updated_at'=>$now]);
+        return $result;
+    }
+
+    public function bind(int $userId, ?int $parentId, string $code, string $status = 'matched'): void
+    {
+        DB::transaction(function () use ($userId,$parentId,$code,$status) {
+            $this->lock();
+            $now = Carbon::now(ShareDefaults::TIMEZONE)->format('Y-m-d H:i:s');
+            $member = DB::table('share_members')->where('user_id',$userId)->lockForUpdate()->first();
+            if ($member && $member->parent_user_id !== null) {
+                if ((int)$member->parent_user_id !== $parentId) throw new \RuntimeException('上级关系已固定,不能改绑');
+                return;
+            }
+            if ($parentId !== null) {
+                $parent = DB::table('share_members')->where('user_id',$parentId)->first();
+                if (!$parent) throw new \RuntimeException('推广会员关系尚未初始化');
+                if ($parentId === $userId || DB::table('share_tree')->where('ancestor_user_id',$userId)->where('descendant_user_id',$parentId)->exists()) throw new \RuntimeException('不能绑定自己或自己的下级');
+                $user = DB::table('users')->where('id',$parentId)->first();
+                if (!$user || (int)$user->from === 2) throw new \RuntimeException('试玩会员不能作为推广人');
+            }
+            if (!$member) {
+                DB::table('share_members')->insert(['user_id'=>$userId,'parent_user_id'=>null,'invitation_code'=>$code,'binding_status'=>'root','bound_at'=>$now,'created_at'=>$now,'updated_at'=>$now]);
+                DB::table('share_tree')->insertOrIgnore(['ancestor_user_id'=>$userId,'descendant_user_id'=>$userId,'depth'=>0,'bound_at'=>'1970-01-01 00:00:00']);
+                DB::table('share_accounts')->insertOrIgnore(['user_id'=>$userId,'created_at'=>$now,'updated_at'=>$now]);
+            }
+            if ($parentId === null) return;
+            $ancestors = DB::table('share_tree')->where('descendant_user_id',$parentId)->get();
+            $descendants = DB::table('share_tree')->where('ancestor_user_id',$userId)->get();
+            foreach ($ancestors as $ancestor) {
+                $rows = [];
+                foreach ($descendants as $descendant) $rows[] = ['ancestor_user_id'=>$ancestor->ancestor_user_id,
+                    'descendant_user_id'=>$descendant->descendant_user_id,'depth'=>$ancestor->depth+1+$descendant->depth,
+                    'bound_at'=>max($now,(string)$ancestor->bound_at,(string)$descendant->bound_at)];
+                foreach (array_chunk($rows,500) as $chunk) DB::table('share_tree')->insertOrIgnore($chunk);
+            }
+            DB::table('share_members')->where('user_id',$userId)->update(['parent_user_id'=>$parentId,'binding_status'=>$status,'bound_at'=>$now,'updated_at'=>$now]);
+        });
+    }
+
+    private function lock(): void
+    {
+        if (!DB::table('share_system_state')->where('key','relations_lock')->lockForUpdate()->first()) throw new \RuntimeException('分享关系表尚未初始化,请执行迁移');
+    }
+}

+ 196 - 0
app/Services/Sharing/ShareSettlementService.php

@@ -0,0 +1,196 @@
+<?php
+
+namespace App\Services\Sharing;
+
+use Carbon\Carbon;
+use Illuminate\Support\Facades\DB;
+
+class ShareSettlementService
+{
+    /** A deterministic original-bet-day projection. Reading it never creates a payable balance. */
+    public function projection(string $date): array
+    {
+        $this->validateDate($date);
+        $policy = DB::table('share_policies')->where('effective_date','<=',$date)->orderByDesc('effective_date')->first();
+        $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;
+        $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');
+        $deposits = DB::table('balance_logs')->where('type',1)->where('amount','>',0)
+            ->whereIn('change_type',['充值','三方充值'])->where('created_at','<=',$end)
+            ->select('member_id')->selectRaw('SUM(amount) AS total')->groupBy('member_id')->pluck('total','member_id');
+        $currency=(string)config('third_game.currency','CNY');
+        $effective = DB::table('share_bet_facts')->where('settled',1)->where('voided',0)->where('currency',$currency)->where('bet_date','<=',$date)
+            ->select('user_id')->selectRaw('SUM(effective_amount) AS total')->groupBy('user_id')->pluck('total','user_id');
+        $qualified = [];
+        foreach ($users as $id=>$user) if (bccomp((string)($deposits[$user->member_id] ?? 0),(string)$policy->valid_member_min_deposit,10)>=0
+            && bccomp((string)($effective[$id] ?? 0),(string)$policy->valid_member_min_effective_bet,10)>=0) $qualified[(int)$id]=true;
+        $counts = [];
+        foreach (DB::table('share_tree')->where('depth',1)->where('bound_at','<=',$end)->cursor() as $edge) {
+            if (isset($qualified[(int)$edge->descendant_user_id]) && $users->has($edge->ancestor_user_id)) $counts[(int)$edge->ancestor_user_id]=($counts[(int)$edge->ancestor_user_id] ?? 0)+1;
+        }
+        $facts = DB::table('share_bet_facts')->where('bet_date',$date)->where('settled',1)->where('voided',0)->where('currency',$currency)->where('effective_amount','>',0)->get();
+        $edges = [];
+        foreach (array_chunk($facts->pluck('user_id')->unique()->all(),500) as $ids) {
+            foreach (DB::table('share_tree')->whereIn('descendant_user_id',$ids)->where('depth','>',0)->get() as $edge) $edges[(int)$edge->descendant_user_id][]=$edge;
+        }
+        $rows = [];
+        foreach ($facts as $fact) {
+            if (!isset($qualified[(int)$fact->user_id]) || !array_key_exists($fact->category,ShareDefaults::categories())) continue;
+            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'];
+                $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);
+            }
+        }
+        $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;
+            $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']);
+        }
+        unset($categories,$row);
+        return ['date'=>$date,'policy'=>$policy,'rows'=>$rows];
+    }
+
+    public static function rate(array $rules,int $members,string $performance): string
+    {
+        $rate='0'; $bestPerformance='-1'; $bestPeople=-1;
+        foreach ($rules as $rule) {
+            if ($members < (int)$rule['min_valid_members'] || bccomp($performance,(string)$rule['min_performance'],10)<0) continue;
+            $comparison=bccomp((string)$rule['min_performance'],$bestPerformance,10);
+            if ($comparison>0 || ($comparison===0 && (int)$rule['min_valid_members']>$bestPeople)) {
+                $bestPerformance=(string)$rule['min_performance']; $bestPeople=(int)$rule['min_valid_members']; $rate=(string)$rule['rate'];
+            }
+        }
+        return $rate;
+    }
+
+    public static function percentage(string $amount,string $rate): string
+    {
+        return bcdiv(bcmul($amount,$rate,18),'100',10);
+    }
+
+    public function settle(string $date,bool $write=true): array
+    {
+        $this->validateDate($date);
+        if ($date>=Carbon::now(ShareDefaults::TIMEZONE)->toDateString()) throw new \InvalidArgumentException('当天及未来佣金只能预估,不能结算到账本');
+        $activation=DB::table('share_settings')->value('activation_date');
+        $result=['date'=>$date,'receivers'=>0,'changed'=>0,'delta'=>'0.0000000000','written'=>$write];
+        if (!$activation || $date<$activation) return $result;
+        if (!DB::table('share_system_state')->where('key','initialized_at')->exists()) throw new \RuntimeException('分享关系尚未初始化');
+        // Facts, membership closure and policy must not change in the middle of this projection.
+        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'];
+            $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'];
+            }
+            $result['receivers']=count($rows);
+            $now=Carbon::now(ShareDefaults::TIMEZONE)->format('Y-m-d H:i:s');
+            ksort($rows);
+            foreach ($rows as $userId=>$categories) {
+                if ($write) {
+                    DB::table('share_accounts')->insertOrIgnore(['user_id'=>$userId,'created_at'=>$now,'updated_at'=>$now]);
+                    $account=DB::table('share_accounts')->where('user_id',$userId)->lockForUpdate()->first();
+                } else $account=DB::table('share_accounts')->where('user_id',$userId)->first();
+                foreach ($categories as $category=>$target) {
+                    $key=['user_id'=>$userId,'bet_date'=>$date,'category'=>$category];
+                    $target['policy_id']=$policy->id ?? null;
+                    $hash=hash('sha256',json_encode($target,JSON_PRESERVE_ZERO_FRACTION|JSON_THROW_ON_ERROR));
+                    $query=DB::table('share_settlements')->where($key);
+                    $old=$write ? $query->lockForUpdate()->first() : $query->first();
+                    if ($old && $old->input_hash===$hash) continue;
+                    $directDelta=bcsub($target['direct_commission'],(string)($old->direct_commission ?? 0),10);
+                    $indirectDelta=bcsub($target['indirect_commission'],(string)($old->indirect_commission ?? 0),10);
+                    $delta=bcadd($directDelta,$indirectDelta,10);
+                    $result['changed']++; $result['delta']=bcadd($result['delta'],$delta,10);
+                    if (!$write) continue;
+                    $revision=(int)($old->revision ?? 0)+1;
+                    $values=$target+['revision'=>$revision,'input_hash'=>$hash,'updated_at'=>$now];
+                    if ($old) { DB::table('share_settlements')->where('id',$old->id)->update($values); $settlementId=$old->id; }
+                    else $settlementId=DB::table('share_settlements')->insertGetId($key+$values+['created_at'=>$now]);
+                    foreach (['direct'=>$directDelta,'indirect'=>$indirectDelta] as $type=>$amount) {
+                        if (bccomp($amount,'0',10)===0) continue;
+                        DB::table('share_ledger')->insert(['user_id'=>$userId,'settlement_id'=>$settlementId,'revision'=>$revision,
+                            'type'=>$type,'amount'=>$amount,'bet_date'=>$date,'category'=>$category,'created_at'=>$now,'updated_at'=>$now]);
+                    }
+                    $account->earned_amount=bcadd((string)$account->earned_amount,$delta,10);
+                    DB::table('share_accounts')->where('user_id',$userId)->update(['earned_amount'=>$account->earned_amount,'updated_at'=>$now]);
+                }
+                if ($write && $account) $this->upgrade((int)$userId,(string)$account->earned_amount,$now);
+            }
+            if ($write) {
+                $last=DB::table('share_system_state')->where('key','last_settlement_date')->value('value');
+                if (!$last || $date>$last) DB::table('share_system_state')->updateOrInsert(['key'=>'last_settlement_date'],['value'=>$date,'created_at'=>$now,'updated_at'=>$now]);
+            }
+            return $result;
+        });
+    }
+
+    private function upgrade(int $userId,string $earned,string $now): void
+    {
+        $threshold='0'; $eligible=1;
+        foreach (DB::table('share_levels')->orderBy('level')->get() as $level) {
+            $threshold=bcadd($threshold,(string)$level->incremental_commission,10);
+            if (bccomp($earned,$threshold,10)>=0) $eligible=(int)$level->level;
+        }
+        DB::table('share_members')->where('user_id',$userId)->where('level','<',$eligible)->update(['level'=>$eligible,'updated_at'=>$now]);
+    }
+
+    public function reconcile(): array
+    {
+        $dirty=DB::transaction(function () {
+            $row=DB::table('share_system_state')->where('key','facts_dirty_dates')->lockForUpdate()->first();
+            if (!$row) return [];
+            $dates=json_decode($row->value,true,512,JSON_THROW_ON_ERROR);
+            DB::table('share_system_state')->where('key','facts_dirty_dates')->update(['value'=>'[]']);
+            return $dates;
+        });
+        if (!$dirty) return ['dates'=>0,'changed'=>0];
+        try {
+            $today=Carbon::now(ShareDefaults::TIMEZONE)->toDateString();
+            $open=array_values(array_filter($dirty,fn($date)=>$date >= $today));
+            // Keep open betting days until a later run can close them, even if the midnight cron is missed.
+            $this->requeue($open);
+            $dirty=array_values(array_filter($dirty,fn($date)=>$date < $today));
+            if (!$dirty) return ['dates'=>0,'changed'=>0];
+            $activation=DB::table('share_settings')->value('activation_date');
+            if (!$activation) return ['dates'=>0,'changed'=>0];
+            $start=max(min($dirty),(string)$activation);
+            $dates=DB::table('share_bet_facts')->where('bet_date','>=',$start)->where('bet_date','<',$today)->distinct()->pluck('bet_date')->all();
+            $old=DB::table('share_settlements')->where('bet_date','>=',$start)->where('bet_date','<',$today)->distinct()->pluck('bet_date')->all();
+            $dates=array_unique(array_merge($dates,$old)); sort($dates); $changed=0;
+            foreach ($dates as $date) $changed+=$this->settle((string)$date)['changed'];
+            return ['dates'=>count($dates),'changed'=>$changed];
+        } catch (\Throwable $e) {
+            $this->requeue($dirty);
+            throw $e;
+        }
+    }
+
+    private function requeue(array $dates): void
+    {
+        if (!$dates) return;
+        DB::transaction(function () use ($dates) {
+            $row=DB::table('share_system_state')->where('key','facts_dirty_dates')->lockForUpdate()->first();
+            $pending=$row ? json_decode($row->value,true,512,JSON_THROW_ON_ERROR) : [];
+            DB::table('share_system_state')->updateOrInsert(['key'=>'facts_dirty_dates'],['value'=>json_encode(array_values(array_unique(array_merge($pending,$dates))))]);
+        });
+    }
+
+    private function validateDate(string $date): void
+    {
+        $value=Carbon::createFromFormat('!Y-m-d',$date,ShareDefaults::TIMEZONE);
+        if (!$value || $value->toDateString()!==$date) throw new \InvalidArgumentException('日期格式必须为Y-m-d');
+    }
+}

+ 8 - 1
app/Services/ThirdGameBalanceService.php

@@ -587,7 +587,14 @@ class ThirdGameBalanceService
 
     private function numericValue($value)
     {
-        return is_numeric($value) ? $value + 0 : null;
+        // Order amounts must retain the provider's decimal precision before persistence.
+        // This helper is used only by formatOrder, never by balance/transfer handling.
+        if (is_string($value)) {
+            $value = trim($value);
+            return preg_match('/^-?\d+(?:\.\d+)?$/D', $value) ? $value : null;
+        }
+        if (is_int($value)) return $value;
+        return is_float($value) && is_finite($value) ? $value : null;
     }
 
     private function historyRangeError(string $startTime, string $endTime): ?string

+ 92 - 0
database/migrations/2026_10_24_120000_create_member_sharing_system.php

@@ -0,0 +1,92 @@
+<?php
+
+use App\Services\Sharing\ShareDefaults;
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration {
+    public function up(): void
+    {
+        Schema::table('share_settings', function (Blueprint $t) {
+            $t->decimal('valid_member_min_effective_bet', 24, 10)->nullable();
+            $t->decimal('indirect_bonus_rate', 8, 4)->nullable();
+            $t->date('activation_date')->nullable();
+        });
+        Schema::create('share_policies', function (Blueprint $t) {
+            $t->id(); $t->date('effective_date')->unique();
+            $t->boolean('enabled')->default(false); $t->boolean('benefit_enabled')->default(false);
+            $t->decimal('valid_member_min_deposit', 24, 10)->default(20);
+            $t->decimal('valid_member_min_effective_bet', 24, 10)->nullable();
+            $t->decimal('indirect_bonus_rate', 8, 4)->nullable(); $t->json('rules'); $t->timestamps();
+        });
+        Schema::create('share_levels', function (Blueprint $t) {
+            $t->id(); $t->unsignedInteger('level')->unique(); $t->string('name'); $t->string('image', 1000)->default('');
+            $t->decimal('incremental_commission', 24, 10)->default(0); $t->timestamps();
+        });
+        Schema::create('share_members', function (Blueprint $t) {
+            $t->unsignedBigInteger('user_id')->primary(); $t->unsignedBigInteger('parent_user_id')->nullable()->index();
+            $t->string('invitation_code', 100)->default(''); $t->string('binding_status', 24)->default('root');
+            $t->unsignedInteger('level')->default(1); $t->dateTime('bound_at'); $t->timestamps();
+        });
+        Schema::create('share_tree', function (Blueprint $t) {
+            $t->unsignedBigInteger('ancestor_user_id'); $t->unsignedBigInteger('descendant_user_id');
+            $t->unsignedInteger('depth'); $t->dateTime('bound_at')->default('1970-01-01 00:00:00');
+            $t->primary(['ancestor_user_id','descendant_user_id']);
+            $t->index(['descendant_user_id','depth']);
+        });
+        Schema::create('share_bet_facts', function (Blueprint $t) {
+            $t->id(); $t->string('source',32); $t->string('source_id',64); $t->unique(['source','source_id']);
+            $t->unsignedBigInteger('user_id')->index(); $t->string('member_id',64); $t->string('category',32);
+            $t->dateTime('bet_at'); $t->date('bet_date')->index(); $t->dateTime('settled_at')->nullable();
+            foreach (['bet_amount','net_profit','effective_amount'] as $c) $t->decimal($c,24,10)->default(0);
+            $t->boolean('settled')->default(false); $t->boolean('voided')->default(false);
+            $t->string('currency',8)->default('CNY'); $t->string('source_version',64); $t->timestamps();
+            $t->index(['user_id','bet_date','category']);
+        });
+        Schema::create('share_accounts', function (Blueprint $t) {
+            $t->unsignedBigInteger('user_id')->primary();
+            foreach (['earned_amount','paid_amount','reserved_amount'] as $c) $t->decimal($c,24,10)->default(0);
+            $t->timestamps();
+        });
+        Schema::create('share_settlements', function (Blueprint $t) {
+            $t->id(); $t->unsignedBigInteger('user_id'); $t->date('bet_date'); $t->string('category',32);
+            $t->unique(['user_id','bet_date','category']);
+            foreach (['direct_performance','indirect_performance','direct_commission','indirect_commission'] as $c) $t->decimal($c,24,10)->default(0);
+            $t->unsignedInteger('valid_direct_members')->default(0); $t->decimal('rate',8,4)->default(0);
+            $t->decimal('indirect_bonus_rate',8,4)->default(0); $t->unsignedBigInteger('policy_id')->nullable();
+            $t->unsignedInteger('revision')->default(0); $t->string('input_hash',64)->default(''); $t->timestamps();
+        });
+        Schema::create('share_ledger', function (Blueprint $t) {
+            $t->id(); $t->unsignedBigInteger('user_id')->index(); $t->unsignedBigInteger('settlement_id');
+            $t->unsignedInteger('revision'); $t->string('type',16); $t->decimal('amount',24,10);
+            $t->date('bet_date'); $t->string('category',32); $t->timestamps();
+            $t->unique(['settlement_id','revision','type']);
+        });
+        Schema::create('share_claims', function (Blueprint $t) {
+            $t->id(); $t->unsignedBigInteger('user_id')->index(); $t->string('request_id',64);
+            $t->unique(['user_id','request_id']); $t->unsignedBigInteger('active_user_id')->nullable()->unique();
+            $t->decimal('amount',24,10); $t->string('status',16)->default('pending')->index();
+            $t->unsignedBigInteger('audit_admin_id')->nullable(); $t->text('audit_remark')->nullable();
+            $t->dateTime('audited_at')->nullable(); $t->unsignedBigInteger('credited_log_id')->nullable(); $t->timestamps();
+        });
+        Schema::create('share_system_state', function (Blueprint $t) {
+            $t->string('key',64)->primary(); $t->text('value'); $t->timestamps();
+        });
+        $now = date('Y-m-d H:i:s');
+        $minimum = DB::table('share_settings')->value('valid_member_min_deposit') ?? 20;
+        // Legacy benefit_enabled never funded these rules. Explicitly configure and enable the new system.
+        DB::table('share_settings')->update(['benefit_enabled'=>0]);
+        DB::table('share_policies')->insert(['effective_date'=>'1970-01-01','enabled'=>0,'benefit_enabled'=>0,
+            'valid_member_min_deposit'=>$minimum,'rules'=>json_encode(ShareDefaults::rules()),'created_at'=>$now,'updated_at'=>$now]);
+        DB::table('share_levels')->insert(['level'=>1,'name'=>'等级1','image'=>'','incremental_commission'=>0,'created_at'=>$now,'updated_at'=>$now]);
+        DB::table('share_system_state')->insert(['key'=>'relations_lock','value'=>'1','created_at'=>$now,'updated_at'=>$now]);
+    }
+
+    public function down(): void
+    {
+        foreach (['share_system_state','share_claims','share_ledger','share_settlements','share_accounts','share_bet_facts','share_tree','share_members','share_levels','share_policies'] as $table) Schema::dropIfExists($table);
+        Schema::table('share_settings', function (Blueprint $t) { $t->dropColumn(['valid_member_min_effective_bet','indirect_bonus_rate','activation_date']); });
+    }
+};

+ 132 - 0
docs/前端接口/新分享系统/下级信息.md

@@ -0,0 +1,132 @@
+# 下级信息
+
+URL 相对会员网站 API 域名。请求头 `Authorization: <登录接口返回的原始 token>`,不要自行添加 `Bearer ` 前缀;可传 `lang: zh`。成功为 `code=1, show=0`;参数或业务失败为 `code=0, show=1`,展示 `msg`;登录失效为 `code=401`。金额为两位小数字符串、比例为四位小数字符串、次数及人数为整数,时间为 UTC+8。当前会员身份取登录令牌,游客不能使用真实分享功能。
+
+`GET /api/share/subordinateInfo`,日期筛选按下级注册时间。
+
+| 参数 | 必填 | 说明 |
+| --- | --- | --- |
+| period | 否 | 默认today;支持today今天、yesterday昨天、this_week本周、last_week上周、this_month本月、last_month上月、all全部、custom自定义 |
+| start_date | custom时必填 | YYYY-MM-DD |
+| end_date | custom时必填 | YYYY-MM-DD,不早于start_date、不晚于今天;含首尾最多366天 |
+| page | 否 | 默认1,最小1 |
+| limit | 否 | 默认20,范围1~200 |
+
+本周从周一开始,当前周、月截至今天;只有custom使用请求中的起止日。响应的period/start_date/end_date为实际筛选范围,all的起止日为null;page/limit为实际分页,total为全部匹配行数,data为本页数组。summary统计整个筛选范围,不局限当前页。currency为当前钱包币种,例如CNY。
+
+另支持 `scope=all/direct/indirect`(默认all):全部下级、直属下级、全部间接下级。可选 `user_id` 精确指定某下级,必须属于本人下级且符合scope,树外会员会报错。`depth=1`为直属,大于1为间接,不含本人。账号已掩码;已注销下级保留合法历史数据,账号为空字符串、昵称为“已注销会员”。
+
+支持 `sort_by`(默认registered_at)和 `sort_order=asc/desc`(默认desc)。sort_by可用:last_login_at、subordinate_count、team_member_count、deposit_amount、effective_amount、registered_at、user_id。前端提交列名和方向,不提交sort或排序表达式;未知列、未知方向会明确报错。金额以原始十位小数精度排序后分页,再显示两位小数,禁止前端只对本页金额字符串重新排序。主排序值相同时固定user_id降序,日期空值排末尾。响应sort_by/sort_order为实际排序条件。
+
+请求示例:`GET /api/share/subordinateInfo?period=today&scope=all&sort_by=registered_at&sort_order=desc&page=1&limit=20`
+
+```json
+{
+  "code": 1,
+  "show": 0,
+  "msg": "",
+  "data": {
+    "period": "today",
+    "start_date": "2026-10-24",
+    "end_date": "2026-10-24",
+    "page": 1,
+    "limit": 20,
+    "total": 4,
+    "data": [
+      {
+        "user_id": 9,
+        "member_id": "109",
+        "account": "re***n",
+        "nickname": "telegram-new",
+        "registered_at": "2026-10-24 12:00:00",
+        "depth": 1,
+        "is_valid": false,
+        "qualification_date": "2026-10-24",
+        "last_login_at": null,
+        "subordinate_count": 0,
+        "team_member_count": 0,
+        "deposit_amount": "0.00",
+        "effective_amount": "0.00",
+        "deposit_count": 0,
+        "effective_bet_count": 0
+      },
+      {
+        "user_id": 7,
+        "member_id": "107",
+        "account": "me***7",
+        "nickname": "name7",
+        "registered_at": "2026-10-24 12:00:00",
+        "depth": 4,
+        "is_valid": false,
+        "qualification_date": "2026-10-24",
+        "last_login_at": null,
+        "subordinate_count": 0,
+        "team_member_count": 0,
+        "deposit_amount": "0.00",
+        "effective_amount": "0.00",
+        "deposit_count": 0,
+        "effective_bet_count": 0
+      },
+      {
+        "user_id": 6,
+        "member_id": "106",
+        "account": "me***6",
+        "nickname": "name6",
+        "registered_at": "2026-10-24 12:00:00",
+        "depth": 3,
+        "is_valid": false,
+        "qualification_date": "2026-10-24",
+        "last_login_at": null,
+        "subordinate_count": 1,
+        "team_member_count": 1,
+        "deposit_amount": "0.00",
+        "effective_amount": "0.00",
+        "deposit_count": 0,
+        "effective_bet_count": 0
+      },
+      {
+        "user_id": 3,
+        "member_id": "103",
+        "account": "me***3",
+        "nickname": "name3",
+        "registered_at": "2026-10-24 12:00:00",
+        "depth": 2,
+        "is_valid": true,
+        "qualification_date": "2026-10-24",
+        "last_login_at": "2026-10-24 11:00:00",
+        "subordinate_count": 1,
+        "team_member_count": 2,
+        "deposit_amount": "100.00",
+        "effective_amount": "20200.00",
+        "deposit_count": 1,
+        "effective_bet_count": 2
+      }
+    ],
+    "sort_by": "registered_at",
+    "sort_order": "desc",
+    "currency": "CNY"
+  }
+}
+```
+| 会员公共字段 | 含义 |
+| --- | --- |
+| user_id / member_id | 下级数字ID、会员编号;筛选参数user_id使用数字ID |
+| account / nickname | 掩码账号、昵称;已注销会员按上述规则显示 |
+| registered_at | 注册时间;除下级信息页外,仅供展示,不是这些报表的日期筛选条件 |
+| depth | 本人与该下级的关系层级 |
+
+| 字段 | 含义 |
+| --- | --- |
+| is_valid | 截至qualification_date末累计成功充值与全游戏有效投注是否同时达标,布尔值 |
+| qualification_date | 资格判断截止日;所选范围末日,all时为今天 |
+| last_login_at | 最近成功登录时间,优先成功登录记录并兼容旧登录时间;无可用时间为null |
+| subordinate_count | 该下级自己的直属下级人数 |
+| team_member_count | 该下级自己的全部团队人数,不含其本人 |
+| deposit_amount | 该下级累计成功充值到账金额,不限所选注册日期区间 |
+| deposit_count | 该下级累计成功充值笔数 |
+| effective_amount | 该下级累计真实有效投注,不限所选注册日期区间 |
+| effective_bet_count | 该下级累计正有效金额注单数 |
+
+total为符合注册日期及下级范围的会员人数。注册日期只筛选返回哪些会员,累计充值、有效金额及下级人数仍为这些会员截至当前的累计数据;is_valid则按qualification_date末及该日已生效条件判断,不是仅看所选期间内是否充值或下注。
+
+已有 `/api/share/invitees` 是兼容入口,新接入使用subordinateInfo。列表所有会员ID均受当前登录会员的下级范围约束。

+ 157 - 0
docs/前端接口/新分享系统/下级投注.md

@@ -0,0 +1,157 @@
+# 下级投注
+
+URL 相对会员网站 API 域名。请求头 `Authorization: <登录接口返回的原始 token>`,不要自行添加 `Bearer ` 前缀;可传 `lang: zh`。成功为 `code=1, show=0`;参数或业务失败为 `code=0, show=1`,展示 `msg`;登录失效为 `code=401`。金额为两位小数字符串、比例为四位小数字符串、次数及人数为整数,时间为 UTC+8。当前会员身份取登录令牌,游客不能使用真实分享功能。
+
+`GET /api/share/subordinateBets`,按下级会员聚合投注统计,每个下级一行。
+
+| 参数 | 必填 | 说明 |
+| --- | --- | --- |
+| period | 否 | 默认today;支持today今天、yesterday昨天、this_week本周、last_week上周、this_month本月、last_month上月、all全部、custom自定义 |
+| start_date | custom时必填 | YYYY-MM-DD |
+| end_date | custom时必填 | YYYY-MM-DD,不早于start_date、不晚于今天;含首尾最多366天 |
+| page | 否 | 默认1,最小1 |
+| limit | 否 | 默认20,范围1~200 |
+
+本周从周一开始,当前周、月截至今天;只有custom使用请求中的起止日。响应的period/start_date/end_date为实际筛选范围,all的起止日为null;page/limit为实际分页,total为全部匹配行数,data为本页数组。summary统计整个筛选范围,不局限当前页。currency为当前钱包币种,例如CNY。
+
+另支持 `scope=all/direct/indirect`(默认all):全部下级、直属下级、全部间接下级。可选 `user_id` 精确指定某下级,必须属于本人下级且符合scope,树外会员会报错。`depth=1`为直属,大于1为间接,不含本人。账号已掩码;已注销下级保留合法历史数据,账号为空字符串、昵称为“已注销会员”。
+
+可选 `category`:不传查全部;支持third:1真人、third:2电子、third:3第三方彩票、third:4第三方体育、third:5电竞、third:6捕鱼、third:7棋牌、self:sport自有体育、self:lottery自有彩票。分类键为字符串。资格累计始终包含全部游戏;分类筛选只限制投注相关金额及佣金行,不把充值、提现、奖励改为分类金额。
+
+另支持 `status=settled/unsettled/voided`(已结算、未结算、已取消或作废);不传查全部。状态用于筛选参与聚合的注单,不是行状态。
+
+支持 `sort_by`(默认effective_amount)和 `sort_order=asc/desc`(默认desc)。sort_by可用:effective_amount、net_profit、bet_amount、bet_count、user_id。前端提交列名和方向,不提交sort或排序表达式;未知列、未知方向会明确报错。金额以原始十位小数精度排序后分页,再显示两位小数,禁止前端只对本页金额字符串重新排序。主排序值相同时固定user_id降序,日期空值排末尾。响应sort_by/sort_order为实际排序条件。
+
+请求示例:`GET /api/share/subordinateBets?period=today&scope=all&sort_by=effective_amount&sort_order=desc&page=1&limit=20`
+
+```json
+{
+  "code": 1,
+  "show": 0,
+  "msg": "",
+  "data": {
+    "period": "today",
+    "start_date": "2026-10-24",
+    "end_date": "2026-10-24",
+    "page": 1,
+    "limit": 20,
+    "total": 5,
+    "data": [
+      {
+        "user_id": 3,
+        "member_id": "103",
+        "account": "me***3",
+        "nickname": "name3",
+        "registered_at": "2026-10-24 12:00:00",
+        "depth": 2,
+        "bet_amount": "21500.00",
+        "effective_amount": "20200.00",
+        "performance_amount": "20200.00",
+        "net_profit": "-19800.00",
+        "bet_count": 3,
+        "effective_bet_count": 2,
+        "performance_count": 2,
+        "net_profit_count": 2
+      },
+      {
+        "user_id": 2,
+        "member_id": "102",
+        "account": "",
+        "nickname": "已注销会员",
+        "registered_at": "2026-10-23 12:00:00",
+        "depth": 1,
+        "bet_amount": "20000.00",
+        "effective_amount": "20000.00",
+        "performance_amount": "20000.00",
+        "net_profit": "-20000.00",
+        "bet_count": 1,
+        "effective_bet_count": 1,
+        "performance_count": 1,
+        "net_profit_count": 1
+      },
+      {
+        "user_id": 9,
+        "member_id": "109",
+        "account": "re***n",
+        "nickname": "telegram-new",
+        "registered_at": "2026-10-24 12:00:00",
+        "depth": 1,
+        "bet_amount": "0.00",
+        "effective_amount": "0.00",
+        "performance_amount": "0.00",
+        "net_profit": "0.00",
+        "bet_count": 0,
+        "effective_bet_count": 0,
+        "performance_count": 0,
+        "net_profit_count": 0
+      },
+      {
+        "user_id": 7,
+        "member_id": "107",
+        "account": "me***7",
+        "nickname": "name7",
+        "registered_at": "2026-10-24 12:00:00",
+        "depth": 4,
+        "bet_amount": "0.00",
+        "effective_amount": "0.00",
+        "performance_amount": "0.00",
+        "net_profit": "0.00",
+        "bet_count": 0,
+        "effective_bet_count": 0,
+        "performance_count": 0,
+        "net_profit_count": 0
+      },
+      {
+        "user_id": 6,
+        "member_id": "106",
+        "account": "me***6",
+        "nickname": "name6",
+        "registered_at": "2026-10-24 12:00:00",
+        "depth": 3,
+        "bet_amount": "0.00",
+        "effective_amount": "0.00",
+        "performance_amount": "0.00",
+        "net_profit": "0.00",
+        "bet_count": 0,
+        "effective_bet_count": 0,
+        "performance_count": 0,
+        "net_profit_count": 0
+      }
+    ],
+    "sort_by": "effective_amount",
+    "sort_order": "desc",
+    "currency": "CNY",
+    "summary": {
+      "bet_amount": "41500.00",
+      "effective_amount": "40200.00",
+      "performance_amount": "40200.00",
+      "net_profit": "-39800.00",
+      "bet_count": 4,
+      "effective_bet_count": 3,
+      "performance_count": 3,
+      "net_profit_count": 3
+    }
+  }
+}
+```
+| 会员公共字段 | 含义 |
+| --- | --- |
+| user_id / member_id | 下级数字ID、会员编号;筛选参数user_id使用数字ID |
+| account / nickname | 掩码账号、昵称;已注销会员按上述规则显示 |
+| registered_at | 注册时间;除下级信息页外,仅供展示,不是这些报表的日期筛选条件 |
+| depth | 本人与该下级的关系层级 |
+
+| 字段 | 含义 |
+| --- | --- |
+| bet_amount | 可识别原注金额,包含未结算、取消或零有效注单 |
+| effective_amount | 历史真实有效投注;已结算且未作废,包含启动日前可用于资格累计的记录 |
+| performance_amount | 可计佣业绩;仅计启动日起、原日佣金开关及必填条件有效且会员原日双达标的有效投注 |
+| net_profit | 已结算且未作废注单净盈亏,会员盈利为正、亏损为负 |
+| bet_count | 原注记录数,包含未结算和取消记录 |
+| effective_bet_count | 已结算、未作废且有效金额大于0的注单数 |
+| performance_count | 满足原日资格及启动、政策条件的有效注单数 |
+| net_profit_count | 已结算且未作废、可计算净输赢的注单数,零输赢也计数 |
+
+total为符合下级范围的会员人数;即使所选日期、分类和状态内没有下注,该下级仍可返回一行零值。summary包含表中八项指标,合计整个筛选下级范围。响应currency是所有金额的币种,接口只统计与当前钱包相同币种的注单。
+
+本接口按会员汇总,不返回逐注source、source_id、bet_at、status等旧明细字段。有效投注和计佣业绩分开展示:启动前有效投注可供资格累计,原日未达资格或资金开关未生效时不能成为performance_amount。原注金额也不能直接当作有效金额。筛选日期按原投注日,后绑定的上级不能看到或获得绑定前下注。

+ 162 - 0
docs/前端接口/新分享系统/下级财务.md

@@ -0,0 +1,162 @@
+# 下级财务
+
+URL 相对会员网站 API 域名。请求头 `Authorization: <登录接口返回的原始 token>`,不要自行添加 `Bearer ` 前缀;可传 `lang: zh`。成功为 `code=1, show=0`;参数或业务失败为 `code=0, show=1`,展示 `msg`;登录失效为 `code=401`。金额为两位小数字符串、比例为四位小数字符串、次数及人数为整数,时间为 UTC+8。当前会员身份取登录令牌,游客不能使用真实分享功能。
+
+`GET /api/share/subordinateFinance`
+
+| 参数 | 必填 | 说明 |
+| --- | --- | --- |
+| period | 否 | 默认today;支持today今天、yesterday昨天、this_week本周、last_week上周、this_month本月、last_month上月、all全部、custom自定义 |
+| start_date | custom时必填 | YYYY-MM-DD |
+| end_date | custom时必填 | YYYY-MM-DD,不早于start_date、不晚于今天;含首尾最多366天 |
+| page | 否 | 默认1,最小1 |
+| limit | 否 | 默认20,范围1~200 |
+
+本周从周一开始,当前周、月截至今天;只有custom使用请求中的起止日。响应的period/start_date/end_date为实际筛选范围,all的起止日为null;page/limit为实际分页,total为全部匹配行数,data为本页数组。summary统计整个筛选范围,不局限当前页。currency为当前钱包币种,例如CNY。
+
+另支持 `scope=all/direct/indirect`(默认all):全部下级、直属下级、全部间接下级。可选 `user_id` 精确指定某下级,必须属于本人下级且符合scope,树外会员会报错。`depth=1`为直属,大于1为间接,不含本人。账号已掩码;已注销下级保留合法历史数据,账号为空字符串、昵称为“已注销会员”。
+
+支持 `sort_by`(默认deposit_amount)和 `sort_order=asc/desc`(默认desc)。sort_by可用:deposit_amount、deposit_count、withdraw_amount、withdraw_count、balance、user_id。前端提交列名和方向,不提交sort或排序表达式;未知列、未知方向会明确报错。金额以原始十位小数精度排序后分页,再显示两位小数,禁止前端只对本页金额字符串重新排序。主排序值相同时固定user_id降序,日期空值排末尾。响应sort_by/sort_order为实际排序条件。
+
+请求示例:`GET /api/share/subordinateFinance?period=today&scope=all&sort_by=deposit_amount&sort_order=desc&page=1&limit=20`
+
+```json
+{
+  "code": 1,
+  "show": 0,
+  "msg": "",
+  "data": {
+    "period": "today",
+    "start_date": "2026-10-24",
+    "end_date": "2026-10-24",
+    "page": 1,
+    "limit": 20,
+    "total": 5,
+    "data": [
+      {
+        "user_id": 2,
+        "member_id": "102",
+        "account": "",
+        "nickname": "已注销会员",
+        "registered_at": "2026-10-23 12:00:00",
+        "depth": 1,
+        "deposit_amount": "110.00",
+        "withdraw_amount": "1460.00",
+        "manual_credit_amount": "0.00",
+        "manual_debit_amount": "0.00",
+        "balance": "123.45",
+        "deposit_count": 2,
+        "withdraw_count": 3,
+        "manual_credit_count": 0,
+        "manual_debit_count": 0
+      },
+      {
+        "user_id": 3,
+        "member_id": "103",
+        "account": "me***3",
+        "nickname": "name3",
+        "registered_at": "2026-10-24 12:00:00",
+        "depth": 2,
+        "deposit_amount": "100.00",
+        "withdraw_amount": "0.00",
+        "manual_credit_amount": "0.00",
+        "manual_debit_amount": "0.00",
+        "balance": "234.56",
+        "deposit_count": 1,
+        "withdraw_count": 0,
+        "manual_credit_count": 0,
+        "manual_debit_count": 0
+      },
+      {
+        "user_id": 9,
+        "member_id": "109",
+        "account": "re***n",
+        "nickname": "telegram-new",
+        "registered_at": "2026-10-24 12:00:00",
+        "depth": 1,
+        "deposit_amount": "0.00",
+        "withdraw_amount": "0.00",
+        "manual_credit_amount": "0.00",
+        "manual_debit_amount": "0.00",
+        "balance": "0.00",
+        "deposit_count": 0,
+        "withdraw_count": 0,
+        "manual_credit_count": 0,
+        "manual_debit_count": 0
+      },
+      {
+        "user_id": 7,
+        "member_id": "107",
+        "account": "me***7",
+        "nickname": "name7",
+        "registered_at": "2026-10-24 12:00:00",
+        "depth": 4,
+        "deposit_amount": "0.00",
+        "withdraw_amount": "0.00",
+        "manual_credit_amount": "0.00",
+        "manual_debit_amount": "0.00",
+        "balance": "0.00",
+        "deposit_count": 0,
+        "withdraw_count": 0,
+        "manual_credit_count": 0,
+        "manual_debit_count": 0
+      },
+      {
+        "user_id": 6,
+        "member_id": "106",
+        "account": "me***6",
+        "nickname": "name6",
+        "registered_at": "2026-10-24 12:00:00",
+        "depth": 3,
+        "deposit_amount": "0.00",
+        "withdraw_amount": "0.00",
+        "manual_credit_amount": "0.00",
+        "manual_debit_amount": "0.00",
+        "balance": "0.00",
+        "deposit_count": 0,
+        "withdraw_count": 0,
+        "manual_credit_count": 0,
+        "manual_debit_count": 0
+      }
+    ],
+    "sort_by": "deposit_amount",
+    "sort_order": "desc",
+    "currency": "CNY",
+    "summary": {
+      "deposit_amount": "210.00",
+      "withdraw_amount": "1460.00",
+      "manual_credit_amount": "0.00",
+      "manual_debit_amount": "0.00",
+      "balance": "358.01",
+      "deposit_count": 3,
+      "withdraw_count": 3,
+      "manual_credit_count": 0,
+      "manual_debit_count": 0
+    }
+  }
+}
+```
+| 会员公共字段 | 含义 |
+| --- | --- |
+| user_id / member_id | 下级数字ID、会员编号;筛选参数user_id使用数字ID |
+| account / nickname | 掩码账号、昵称;已注销会员按上述规则显示 |
+| registered_at | 注册时间;除下级信息页外,仅供展示,不是这些报表的日期筛选条件 |
+| depth | 本人与该下级的关系层级 |
+
+| 字段 | 含义 |
+| --- | --- |
+| deposit_amount | 成功充值、三方充值实际到账金额 |
+| deposit_count | 成功充值到账笔数 |
+| withdraw_amount | 成功提现金额,普通提现按该单历史汇率换算为钱包单位 |
+| withdraw_count | 成功提现笔数 |
+| balance | 当前会员主钱包可用余额;历史区间也显示当前余额,不是区间末余额 |
+| manual_credit_amount | 人工充值金额,排除关联业务造成的重复入账 |
+| manual_credit_count | 人工充值笔数 |
+| manual_debit_amount | 人工扣款绝对金额,非负 |
+| manual_debit_count | 人工扣款笔数 |
+
+充值与人工操作按实际资金流水创建时间,提现只统计成功记录并按成功状态更新时间。普通提现按该单保存的历史汇率转换成钱包单位;三方已完成提现按钱包金额统计,人工充值不会误算成提现。所有金额以currency指示的当前钱包币种展示。
+
+summary含上述九个财务指标,是整个筛选范围的合计,不限当前页。balance始终是当前主钱包可用余额,与佣金审核到账使用同一主钱包口径,不是筛选日期末余额;查询历史日期也不能将它理解成历史余额。total为符合scope的会员人数,没有期间财务事件的会员仍可返回零值行及其当前主钱包可用余额。
+
+成功充值与人工加款分别展示,人工加款不计入有效会员的成功充值门槛。

+ 167 - 0
docs/前端接口/新分享系统/下级领取.md

@@ -0,0 +1,167 @@
+# 下级领取
+
+URL 相对会员网站 API 域名。请求头 `Authorization: <登录接口返回的原始 token>`,不要自行添加 `Bearer ` 前缀;可传 `lang: zh`。成功为 `code=1, show=0`;参数或业务失败为 `code=0, show=1`,展示 `msg`;登录失效为 `code=401`。金额为两位小数字符串、比例为四位小数字符串、次数及人数为整数,时间为 UTC+8。当前会员身份取登录令牌,游客不能使用真实分享功能。
+
+`GET /api/share/subordinateRewards`,汇总下级实际获得的返利、活动、新分享佣金及原充值返现。
+
+| 参数 | 必填 | 说明 |
+| --- | --- | --- |
+| period | 否 | 默认today;支持today今天、yesterday昨天、this_week本周、last_week上周、this_month本月、last_month上月、all全部、custom自定义 |
+| start_date | custom时必填 | YYYY-MM-DD |
+| end_date | custom时必填 | YYYY-MM-DD,不早于start_date、不晚于今天;含首尾最多366天 |
+| page | 否 | 默认1,最小1 |
+| limit | 否 | 默认20,范围1~200 |
+
+本周从周一开始,当前周、月截至今天;只有custom使用请求中的起止日。响应的period/start_date/end_date为实际筛选范围,all的起止日为null;page/limit为实际分页,total为全部匹配行数,data为本页数组。summary统计整个筛选范围,不局限当前页。currency为当前钱包币种,例如CNY。
+
+另支持 `scope=all/direct/indirect`(默认all):全部下级、直属下级、全部间接下级。可选 `user_id` 精确指定某下级,必须属于本人下级且符合scope,树外会员会报错。`depth=1`为直属,大于1为间接,不含本人。账号已掩码;已注销下级保留合法历史数据,账号为空字符串、昵称为“已注销会员”。
+
+支持 `sort_by`(默认total_reward_amount)和 `sort_order=asc/desc`(默认desc)。sort_by可用:total_reward_amount、total_reward_count、rebate_amount、activity_amount、share_commission_amount、legacy_recharge_rebate_amount、user_id。兼容短别名total→total_reward_amount、rebate→rebate_amount、activity→activity_amount、share_commission→share_commission_amount;响应会返回标准字段名。前端提交列名和方向,不提交sort或排序表达式;未知列、未知方向会明确报错。金额以原始十位小数精度排序后分页,再显示两位小数,禁止前端只对本页金额字符串重新排序。主排序值相同时固定user_id降序,日期空值排末尾。响应sort_by/sort_order为实际排序条件。
+
+请求示例:`GET /api/share/subordinateRewards?period=today&scope=all&sort_by=total_reward_amount&sort_order=desc&page=1&limit=20`
+
+```json
+{
+  "code": 1,
+  "show": 0,
+  "msg": "",
+  "data": {
+    "period": "today",
+    "start_date": "2026-10-24",
+    "end_date": "2026-10-24",
+    "page": 1,
+    "limit": 20,
+    "total": 5,
+    "data": [
+      {
+        "user_id": 3,
+        "member_id": "103",
+        "account": "me***3",
+        "nickname": "name3",
+        "registered_at": "2026-10-24 12:00:00",
+        "depth": 2,
+        "rebate_amount": "0.00",
+        "activity_amount": "48.00",
+        "share_commission_amount": "0.00",
+        "legacy_recharge_rebate_amount": "0.00",
+        "total_reward_amount": "48.00",
+        "rebate_count": 0,
+        "activity_count": 2,
+        "share_commission_count": 0,
+        "legacy_recharge_rebate_count": 0,
+        "total_reward_count": 2
+      },
+      {
+        "user_id": 2,
+        "member_id": "102",
+        "account": "",
+        "nickname": "已注销会员",
+        "registered_at": "2026-10-23 12:00:00",
+        "depth": 1,
+        "rebate_amount": "3.00",
+        "activity_amount": "15.00",
+        "share_commission_amount": "8.00",
+        "legacy_recharge_rebate_amount": "7.00",
+        "total_reward_amount": "33.00",
+        "rebate_count": 1,
+        "activity_count": 2,
+        "share_commission_count": 1,
+        "legacy_recharge_rebate_count": 1,
+        "total_reward_count": 5
+      },
+      {
+        "user_id": 9,
+        "member_id": "109",
+        "account": "re***n",
+        "nickname": "telegram-new",
+        "registered_at": "2026-10-24 12:00:00",
+        "depth": 1,
+        "rebate_amount": "0.00",
+        "activity_amount": "0.00",
+        "share_commission_amount": "0.00",
+        "legacy_recharge_rebate_amount": "0.00",
+        "total_reward_amount": "0.00",
+        "rebate_count": 0,
+        "activity_count": 0,
+        "share_commission_count": 0,
+        "legacy_recharge_rebate_count": 0,
+        "total_reward_count": 0
+      },
+      {
+        "user_id": 7,
+        "member_id": "107",
+        "account": "me***7",
+        "nickname": "name7",
+        "registered_at": "2026-10-24 12:00:00",
+        "depth": 4,
+        "rebate_amount": "0.00",
+        "activity_amount": "0.00",
+        "share_commission_amount": "0.00",
+        "legacy_recharge_rebate_amount": "0.00",
+        "total_reward_amount": "0.00",
+        "rebate_count": 0,
+        "activity_count": 0,
+        "share_commission_count": 0,
+        "legacy_recharge_rebate_count": 0,
+        "total_reward_count": 0
+      },
+      {
+        "user_id": 6,
+        "member_id": "106",
+        "account": "me***6",
+        "nickname": "name6",
+        "registered_at": "2026-10-24 12:00:00",
+        "depth": 3,
+        "rebate_amount": "0.00",
+        "activity_amount": "0.00",
+        "share_commission_amount": "0.00",
+        "legacy_recharge_rebate_amount": "0.00",
+        "total_reward_amount": "0.00",
+        "rebate_count": 0,
+        "activity_count": 0,
+        "share_commission_count": 0,
+        "legacy_recharge_rebate_count": 0,
+        "total_reward_count": 0
+      }
+    ],
+    "sort_by": "total_reward_amount",
+    "sort_order": "desc",
+    "currency": "CNY",
+    "summary": {
+      "rebate_amount": "3.00",
+      "activity_amount": "63.00",
+      "share_commission_amount": "8.00",
+      "legacy_recharge_rebate_amount": "7.00",
+      "total_reward_amount": "81.00",
+      "rebate_count": 1,
+      "activity_count": 4,
+      "share_commission_count": 1,
+      "legacy_recharge_rebate_count": 1,
+      "total_reward_count": 7
+    }
+  }
+}
+```
+| 会员公共字段 | 含义 |
+| --- | --- |
+| user_id / member_id | 下级数字ID、会员编号;筛选参数user_id使用数字ID |
+| account / nickname | 掩码账号、昵称;已注销会员按上述规则显示 |
+| registered_at | 注册时间;除下级信息页外,仅供展示,不是这些报表的日期筛选条件 |
+| depth | 本人与该下级的关系层级 |
+
+| 字段 | 含义 |
+| --- | --- |
+| rebate_amount | 实际发放的笔笔返、比比返、返水、回水金额 |
+| rebate_count | 返利发放笔数 |
+| activity_amount | 实际发放的注册赠送、优惠活动、即充即送、老用户回归金额 |
+| activity_count | 活动发放笔数 |
+| share_commission_amount | 下级本人已批准领取到账的新分享佣金 |
+| share_commission_count | 新分享佣金已批准领取笔数 |
+| legacy_recharge_rebate_amount | 原有充值返现实际发放金额 |
+| legacy_recharge_rebate_count | 原充值返现发放笔数 |
+| total_reward_amount | 返利、活动、新分享佣金、原充值返现四类金额之和 |
+| total_reward_count | 上述四类奖励发放笔数之和 |
+
+summary含上述十项指标,是整个筛选范围合计,不限当前页。新分享佣金按实际批准到账时间筛选,只计approved;待审、驳回和今日预估不计入。其他奖励按实际发放流水时间筛选,活动之后流水解冻不会再次计入。奖励总次数为发放笔数之和,不是领取会员人数。
+
+total为符合scope的会员人数,没有奖励时该行金额与次数为0。只显示实际存在的四类奖励,不补造推广活动、代理活动或任务奖励栏目。本人申请领取及领取记录见[首页](首页.md)的 `/api/share/claim` 与 `/api/share/claims`。

+ 55 - 0
docs/前端接口/新分享系统/后台等级配置.md

@@ -0,0 +1,55 @@
+# 后台等级配置
+
+所有 URL 相对后台 API 域名。请求头 `Authorization: Bearer <后台登录令牌>`;POST 使用 `Content-Type: application/json`。需要运营配置权限及对应菜单按钮权限。成功 `code=0`,业务或参数失败 `code=-3`,未登录 `code=101011`,无运营配置权限 `code=-1`;失败时展示 `msg`。
+
+## 读取等级
+
+`GET /admin/shareEarning/levels`,无参数。
+
+```json
+{"code":0,"timestamp":1792828800,"msg":"OK","data":{"data":[{"id":1,"level":1,"name":"等级1","image":"","incremental_commission":"0.00","cumulative_commission":"0.00"},{"id":2,"level":2,"name":"等级2","image":"https://static.example.com/share/2.png","incremental_commission":"100.00","cumulative_commission":"100.00"},{"id":3,"level":3,"name":"等级3","image":"","incremental_commission":"300.00","cumulative_commission":"400.00"}]}}
+```
+
+| 字段 | 含义 |
+| --- | --- |
+| data.data | 按 level 升序排列的等级数组 |
+| id | 配置记录 ID,编辑、删除使用 |
+| level | 正整数等级编号,与会员分享等级对应 |
+| name | 等级名称 |
+| image | 等级图片 URL 或前端可展示的图片地址,空字符串表示无图 |
+| incremental_commission | 到达本级比前一级新增的累计佣金要求,金额字符串 |
+| cumulative_commission | 从初始等级起,全部增量相加得到的实际门槛,金额字符串 |
+
+金额均为两位小数字符串。示例中到达三级需要累计佣金 `100.00 + 300.00 = 400.00`。已有会员等级不会因提高门槛、佣金修正而自动降低。
+
+## 新增或编辑
+
+`POST /admin/shareEarning/levels/save`
+
+| 参数 | 必填 | 说明 |
+| --- | --- | --- |
+| id | 否 | 编辑时传原记录 ID;新增不传 |
+| level | 是 | 正整数;已有记录不可改编号 |
+| name | 是 | 最长255字符 |
+| image | 是 | 最长1000字符;无图传空字符串 |
+| incremental_commission | 是 | 非负十进制金额,最多14位整数、2位小数;一级必须为0 |
+
+```json
+{"level":2,"name":"等级2","image":"https://static.example.com/share/2.png","incremental_commission":"100.00"}
+```
+
+成功响应与读取等级相同,返回保存后的完整列表。保存后按累计门槛更新可升级会员,既有等级不自动下降。等级编号重复、一级门槛不为0等情况返回 `code=-3`。
+
+## 删除
+
+`POST /admin/shareEarning/levels/delete`
+
+```json
+{"id":3}
+```
+
+```json
+{"code":0,"timestamp":1792828800,"msg":"OK","data":[]}
+```
+
+初始一级不可删除;该等级已有会员时不可删除。前端收到失败后保留原列表并展示 `msg`;删除成功后重新读取列表及累计门槛。

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

@@ -0,0 +1,284 @@
+# 后台返佣配置
+
+所有 URL 相对后台 API 域名。请求头 `Authorization: Bearer <后台登录令牌>`;POST 使用 `Content-Type: application/json`。需要运营配置权限及对应菜单按钮权限。成功 `code=0`;参数、业务失败 `code=-3`,未登录 `code=101011`,无运营配置权限 `code=-1`。金额两位小数字符串,比例四位小数字符串,日期和时间均为 UTC+8。
+
+## 读取系统配置
+
+`GET /admin/shareEarning/settings`,无参数。
+
+| 字段 | 含义 |
+| --- | --- |
+| id | 配置ID,未保存时为null |
+| enabled | 分享页面展示开关,0关闭、1开启;关闭展示不停止佣金计提 |
+| share_domain | PC网站源地址,例如 `https://pc.example.com` |
+| 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`。下例为九类规则均已各调整为一档后的完整合法示例:
+
+```json
+{
+  "code": 0,
+  "timestamp": 1792886400,
+  "msg": "OK",
+  "data": {
+    "id": 1,
+    "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": "25.0000",
+    "activation_date": "2026-10-25",
+    "initialized": true,
+    "effective_date": "2026-10-26",
+    "current_policy": {
+      "id": 2,
+      "effective_date": "2026-10-25",
+      "enabled": 1,
+      "benefit_enabled": 1,
+      "valid_member_min_deposit": "20.00",
+      "valid_member_min_effective_bet": "100.00",
+      "indirect_bonus_rate": "30.0000",
+      "rules": {
+        "third:1": [
+          {
+            "min_valid_members": 1,
+            "min_performance": "1000000.00",
+            "rate": "0.2500"
+          }
+        ],
+        "third:2": [
+          {
+            "min_valid_members": 1,
+            "min_performance": "1000000.00",
+            "rate": "0.2500"
+          }
+        ],
+        "third:3": [
+          {
+            "min_valid_members": 1,
+            "min_performance": "1000000.00",
+            "rate": "0.2500"
+          }
+        ],
+        "third:4": [
+          {
+            "min_valid_members": 1,
+            "min_performance": "1000000.00",
+            "rate": "0.2500"
+          }
+        ],
+        "third:5": [
+          {
+            "min_valid_members": 1,
+            "min_performance": "1000000.00",
+            "rate": "0.2500"
+          }
+        ],
+        "third:6": [
+          {
+            "min_valid_members": 1,
+            "min_performance": "1000000.00",
+            "rate": "0.2500"
+          }
+        ],
+        "third:7": [
+          {
+            "min_valid_members": 1,
+            "min_performance": "1000000.00",
+            "rate": "0.2500"
+          }
+        ],
+        "self:sport": [
+          {
+            "min_valid_members": 1,
+            "min_performance": "1000000.00",
+            "rate": "0.2500"
+          }
+        ],
+        "self:lottery": [
+          {
+            "min_valid_members": 1,
+            "min_performance": "1000000.00",
+            "rate": "0.2500"
+          }
+        ]
+      },
+      "created_at": "2026-10-24 18:00:00",
+      "updated_at": "2026-10-24 18:00:00"
+    },
+    "pending_policy": {
+      "id": 3,
+      "effective_date": "2026-10-26",
+      "enabled": 1,
+      "benefit_enabled": 1,
+      "valid_member_min_deposit": "20.00",
+      "valid_member_min_effective_bet": "100.00",
+      "indirect_bonus_rate": "25.0000",
+      "rules": {
+        "third:1": [
+          {
+            "min_valid_members": 1,
+            "min_performance": "1000000.00",
+            "rate": "0.2500"
+          }
+        ],
+        "third:2": [
+          {
+            "min_valid_members": 1,
+            "min_performance": "1000000.00",
+            "rate": "0.2500"
+          }
+        ],
+        "third:3": [
+          {
+            "min_valid_members": 1,
+            "min_performance": "1000000.00",
+            "rate": "0.2500"
+          }
+        ],
+        "third:4": [
+          {
+            "min_valid_members": 1,
+            "min_performance": "1000000.00",
+            "rate": "0.2500"
+          }
+        ],
+        "third:5": [
+          {
+            "min_valid_members": 1,
+            "min_performance": "1000000.00",
+            "rate": "0.2500"
+          }
+        ],
+        "third:6": [
+          {
+            "min_valid_members": 1,
+            "min_performance": "1000000.00",
+            "rate": "0.2500"
+          }
+        ],
+        "third:7": [
+          {
+            "min_valid_members": 1,
+            "min_performance": "1000000.00",
+            "rate": "0.2500"
+          }
+        ],
+        "self:sport": [
+          {
+            "min_valid_members": 1,
+            "min_performance": "1000000.00",
+            "rate": "0.2500"
+          }
+        ],
+        "self:lottery": [
+          {
+            "min_valid_members": 1,
+            "min_performance": "1000000.00",
+            "rate": "0.2500"
+          }
+        ]
+      },
+      "created_at": "2026-10-25 12:00:00",
+      "updated_at": "2026-10-25 12:00:00"
+    }
+  }
+}
+```
+
+顶层字段用于编辑表单回显。金额条件、佣金开关和分类规则保存后在次日00:00生效;展示开关和分享域名可立即供页面使用。展示开关仅控制入口显示,新佣金计提由benefit_enabled、必填条件及首次启用日决定。当日计算使用 `current_policy`。同日反复保存只更新同一个次日 `pending_policy`,不会改写已经生效的版本。
+
+## 保存系统配置
+
+`POST /admin/shareEarning/settings/save`
+
+| 参数 | 必填 | 说明 |
+| --- | --- | --- |
+| enabled | 是 | 0或1 |
+| share_domain | 是 | PC网站源地址,最长500字符;可省略协议,默认补https;不可含路径、查询、片段、用户名密码 |
+| 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"}
+```
+
+返回保存后的完整配置,结构同读取接口。不要把只读字段 `activation_date`、`effective_date`、`current_policy`、`pending_policy` 原样提交;接口会拒绝这些字段。旧参数 `non_seller_threshold`、`tier_one_rate`、`tier_two_rate` 已移除,提交会明确报错。
+
+首次启用的 `activation_date` 固定为次日,不能指定过去日期;再停用、再启用也不重置。历史业务记录可用于查询和资格累计,首次启用日前不产生新分享佣金。
+
+## 读取分类规则
+
+`GET /admin/shareEarning/rateRules`
+
+可选 `category`:不传返回九类,传分类只返回该类。支持值:
+
+| category | 分类 |
+| --- | --- |
+| third:1 | 第三方真人 |
+| third:2 | 第三方电子 |
+| third:3 | 第三方彩票 |
+| third:4 | 第三方体育 |
+| third:5 | 第三方电竞 |
+| third:6 | 第三方捕鱼 |
+| third:7 | 第三方棋牌 |
+| self:sport | 自有体育 |
+| self:lottery | 自有彩票 |
+
+请求示例:`GET /admin/shareEarning/rateRules?category=third%3A1`
+
+```json
+{"code":0,"timestamp":1792886400,"msg":"OK","data":{"current_policy_id":2,"current_effective_date":"2026-10-25","pending_policy_id":3,"pending_effective_date":"2026-10-26","categories":[{"category":"third:1","name":"第三方真人","current_rules":[{"min_valid_members":1,"min_performance":"1000000.00","rate":"0.2500"}],"pending_rules":[{"min_valid_members":1,"min_performance":"1000000.00","rate":"0.3000"}]}]}}
+```
+
+`current_policy_id/current_effective_date`为当前生效版本;`pending_policy_id/pending_effective_date`为次日版本,没有则null。`categories`每项含分类键、名称、`current_rules`及 `pending_rules`,后者无待生效版本时为null。规则字段与保存接口一致。
+
+## 保存某分类规则
+
+`POST /admin/shareEarning/rateRules/save`
+
+| 参数 | 必填 | 说明 |
+| --- | --- | --- |
+| category | 是 | 上表九类之一 |
+| rules | 是 | 完整替换该类次日规则,1~100条 |
+| rules[].min_valid_members | 是 | 达档要求的有效直属人数,非负整数 |
+| rules[].min_performance | 是 | 达档要求的直属当日分类有效业绩,非负金额,最多14位整数、2位小数 |
+| rules[].rate | 是 | 0~100百分数,最多4位小数;`0.25`表示0.25% |
+
+```json
+{"category":"third:1","rules":[{"min_valid_members":0,"min_performance":"100.00","rate":"0.10"},{"min_valid_members":1,"min_performance":"20000.00","rate":"0.20"},{"min_valid_members":1,"min_performance":"1000000.00","rate":"0.25"}]}
+```
+
+返回该分类的规则读取结构。保存只修改该分类的次日规则,其他分类、已保存的次日系统条件保留。系统按业绩门槛、同业绩门槛下的人数要求升序返回规则。计算取人数和业绩同时满足的最高业绩门槛档;业绩门槛相同时取最高人数档,使用该档对应比例,均不满足时比例为0。规则中人数要求与比例均须随档位提高而不降低,同一业绩门槛和人数组合不可重复,否则拒绝保存。
+
+有效会员须截至原投注日末累计成功充值和全游戏有效投注同时达标;不达标会员的业绩也不计入佣金。直属佣金为直属有效业绩×本人比例÷100;全部更深层下级合计的间接有效业绩×本人比例÷100×附加系数÷100。间接佣金使用收款人本人的比例,不逐层递归。
+
+例:一个有效直属会员、直属分类业绩100万元,满足上例最高档,使用该档对应的0.25%。间接业绩20000元、本人比例0.10%、附加系数30%时,间接佣金为6元。
+
+九类初始规则均为以下12档,可由配置接口分别调整:
+
+| 有效直属人数 | 直属当日有效业绩 | 比例(%) |
+| --- | --- | --- |
+| 0 | 100.00 | 0.10 |
+| 1 | 20000.00 | 0.20 |
+| 1 | 50000.00 | 0.25 |
+| 2 | 100000.00 | 0.30 |
+| 2 | 200000.00 | 0.35 |
+| 3 | 300000.00 | 0.40 |
+| 3 | 500000.00 | 0.45 |
+| 3 | 600000.00 | 0.50 |
+| 5 | 900000.00 | 0.55 |
+| 5 | 1100000.00 | 0.60 |
+| 5 | 1500000.00 | 0.65 |
+| 5 | 1700000.00 | 0.70 |

+ 67 - 0
docs/前端接口/新分享系统/后台领取审核.md

@@ -0,0 +1,67 @@
+# 后台领取审核
+
+所有 URL 相对后台 API 域名。请求头 `Authorization: Bearer <后台登录令牌>`;POST 使用 `Content-Type: application/json`。需要运营配置权限及对应菜单按钮权限。成功 `code=0`;参数、业务失败 `code=-3`,未登录 `code=101011`,无运营配置权限 `code=-1`。金额均为两位小数字符串,时间均为 UTC+8。
+
+## 申请列表
+
+`GET /admin/shareEarning/claims`
+
+| 参数 | 必填 | 说明 |
+| --- | --- | --- |
+| page | 否 | 默认1,最小1 |
+| limit | 否 | 默认20,范围1~200 |
+| status | 否 | `pending`待审核、`approved`已批准、`rejected`已驳回;不传查全部 |
+| account | 否 | 会员账号模糊搜索,最长100字符 |
+| user_id | 否 | 会员数字 ID,精确匹配 |
+| start_date | 否 | 申请日期起点,`YYYY-MM-DD`,含当天 |
+| end_date | 否 | 申请日期终点,`YYYY-MM-DD`,配合 start_date 使用且不早于它 |
+
+请求示例:`GET /admin/shareEarning/claims?status=pending&page=1&limit=20&start_date=2026-10-25&end_date=2026-10-25`
+
+```json
+{"code":0,"timestamp":1792886400,"msg":"OK","data":{"total":1,"page":1,"limit":20,"data":[{"id":17,"user_id":7,"member_id":"M7","account":"member7","nickname":"会员","request_id":"share-20261025-001","amount":"50.00","status":"pending","audit_admin_id":null,"audit_remark":"","audited_at":null,"credited_log_id":null,"created_at":"2026-10-25 12:00:00","updated_at":"2026-10-25 12:00:00"}]}}
+```
+
+`total` 是当前筛选总数。列表项字段:`id`申请ID、`user_id`会员ID、`member_id`会员编号、`account`账号、`nickname`昵称、`request_id`会员提交的幂等请求标识、`amount`本次申请固定金额、`status`审核状态、`audit_admin_id`审核管理员ID、`audit_remark`审核备注、`audited_at`审核时间、`credited_log_id`批准后对应余额流水ID、`created_at`申请时间、`updated_at`最近状态更新时间。未审核的审核人、时间和流水ID为 null;备注为空字符串。
+
+## 申请详情
+
+`GET /admin/shareEarning/claims/detail?id=17`,`id` 为必填正整数。
+
+```json
+{"code":0,"timestamp":1792886400,"msg":"OK","data":{"id":17,"user_id":7,"member_id":"M7","account":"member7","nickname":"会员","request_id":"share-20261025-001","amount":"50.00","status":"pending","audit_admin_id":null,"audit_remark":"","audited_at":null,"credited_log_id":null,"created_at":"2026-10-25 12:00:00","updated_at":"2026-10-25 12:00:00","commission_account":{"earned_amount":"100.00","paid_amount":"10.00","reserved_amount":"50.00","pending_amount":"90.00","spendable_amount":"40.00","debt_amount":"0.00"},"can_approve":true}}
+```
+
+| 详情新增字段 | 含义 |
+| --- | --- |
+| commission_account.earned_amount | 新分享系统累计净结算佣金,含后续修正 |
+| commission_account.paid_amount | 已批准转入会员主钱包的累计金额 |
+| commission_account.reserved_amount | 待审核申请预留金额 |
+| commission_account.pending_amount | `max(0,earned_amount-paid_amount)`,包含预留 |
+| commission_account.spendable_amount | `max(0,earned_amount-paid_amount-reserved_amount)`,可重新申请部分 |
+| commission_account.debt_amount | `max(0,paid_amount-earned_amount)`,历史修正后待后续佣金抵扣部分 |
+| can_approve | 当前仍待审核且净佣金足够支付原申请;提交时还会重新校验 |
+
+## 批准或驳回
+
+`POST /admin/shareEarning/claims/audit`
+
+| 参数 | 必填 | 说明 |
+| --- | --- | --- |
+| id | 是 | 申请ID,正整数 |
+| action | 是 | `approve`批准或 `reject`驳回 |
+| remark | 否 | 审核备注,最长2000字符 |
+
+```json
+{"id":17,"action":"approve","remark":"已核对"}
+```
+
+成功返回与详情相同的结构,`status`变为 `approved` 或 `rejected`,同时给出审核人和时间。批准成功后产生 `credited_log_id`,佣金转入该会员主钱包可用余额;无手续费、无流水要求。驳回不转账,释放预留佣金,会员可以发起新的申请。
+
+同一申请重复提交相同审核动作返回原结果,不会重复入账;已批准再驳回、已驳回再批准会失败。佣金后续修正使余额低于原申请金额时,不会调整申请金额,批准返回:
+
+```json
+{"code":-3,"timestamp":1792886400,"msg":"佣金修正后余额不足,请驳回原申请后由会员重新申请","data":[]}
+```
+
+此时只能驳回原申请,由会员按新余额重新申请。已发入主钱包的金额不会因为历史佣金修正自动从主钱包扣回。前端在提交后刷新当前申请、账户数据和列表,不依据 `can_approve` 乐观判定到账。

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

@@ -0,0 +1,483 @@
+# 我的数据
+
+URL 相对会员网站 API 域名。请求头 `Authorization: <登录接口返回的原始 token>`,不要自行添加 `Bearer ` 前缀;可传 `lang: zh`。成功为 `code=1, show=0`;参数或业务失败为 `code=0, show=1`,展示 `msg`;登录失效为 `code=401`。金额为两位小数字符串、比例为四位小数字符串、次数及人数为整数,时间为 UTC+8。当前会员身份取登录令牌,游客不能使用真实分享功能。
+
+`GET /api/share/data`,支持以下日期、分页及游戏分类参数。
+
+| 参数 | 必填 | 说明 |
+| --- | --- | --- |
+| period | 否 | 默认today;支持today今天、yesterday昨天、this_week本周、last_week上周、this_month本月、last_month上月、all全部、custom自定义 |
+| start_date | custom时必填 | YYYY-MM-DD |
+| end_date | custom时必填 | YYYY-MM-DD,不早于start_date、不晚于今天;含首尾最多366天 |
+| page | 否 | 默认1,最小1 |
+| limit | 否 | 默认20,范围1~200 |
+
+本周从周一开始,当前周、月截至今天;只有custom使用请求中的起止日。响应的period/start_date/end_date为实际筛选范围,all的起止日为null;page/limit为实际分页,total为全部匹配行数,data为本页数组。summary统计整个筛选范围,不局限当前页。currency为当前钱包币种,例如CNY。
+
+可选 `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`
+
+```json
+{
+  "code": 1,
+  "show": 0,
+  "msg": "",
+  "data": {
+    "period": "today",
+    "start_date": "2026-10-24",
+    "end_date": "2026-10-24",
+    "page": 1,
+    "limit": 20,
+    "total": 9,
+    "data": [
+      {
+        "bet_date": "2026-10-24",
+        "category": "self:lottery",
+        "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",
+        "indirect_performance": "0.00",
+        "direct_commission": "0.00",
+        "indirect_commission": "0.00",
+        "total_commission": "0.00"
+      },
+      {
+        "bet_date": "2026-10-24",
+        "category": "self:sport",
+        "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",
+        "indirect_performance": "0.00",
+        "direct_commission": "0.00",
+        "indirect_commission": "0.00",
+        "total_commission": "0.00"
+      },
+      {
+        "bet_date": "2026-10-24",
+        "category": "third:1",
+        "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",
+        "indirect_performance": "0.00",
+        "direct_commission": "0.00",
+        "indirect_commission": "0.00",
+        "total_commission": "0.00"
+      },
+      {
+        "bet_date": "2026-10-24",
+        "category": "third:2",
+        "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"
+      },
+      {
+        "bet_date": "2026-10-24",
+        "category": "third:3",
+        "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",
+        "indirect_performance": "0.00",
+        "direct_commission": "0.00",
+        "indirect_commission": "0.00",
+        "total_commission": "0.00"
+      },
+      {
+        "bet_date": "2026-10-24",
+        "category": "third:4",
+        "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",
+        "indirect_performance": "0.00",
+        "direct_commission": "0.00",
+        "indirect_commission": "0.00",
+        "total_commission": "0.00"
+      },
+      {
+        "bet_date": "2026-10-24",
+        "category": "third:5",
+        "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",
+        "indirect_performance": "0.00",
+        "direct_commission": "0.00",
+        "indirect_commission": "0.00",
+        "total_commission": "0.00"
+      },
+      {
+        "bet_date": "2026-10-24",
+        "category": "third:6",
+        "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",
+        "indirect_performance": "0.00",
+        "direct_commission": "0.00",
+        "indirect_commission": "0.00",
+        "total_commission": "0.00"
+      },
+      {
+        "bet_date": "2026-10-24",
+        "category": "third:7",
+        "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",
+        "indirect_performance": "0.00",
+        "direct_commission": "0.00",
+        "indirect_commission": "0.00",
+        "total_commission": "0.00"
+      }
+    ],
+    "currency": "CNY",
+    "summary": {
+      "direct_commission": "40.00",
+      "indirect_commission": "12.12",
+      "settled_commission": "0.00",
+      "estimated_commission": "52.12",
+      "direct_performance": "20000.00",
+      "indirect_performance": "20200.00"
+    },
+    "period_stats": {
+      "total": {
+        "deposit_amount": "210.00",
+        "first_deposit_amount": "100.00",
+        "registered_first_deposit_amount": "100.00",
+        "withdraw_amount": "1460.00",
+        "manual_credit_amount": "0.00",
+        "manual_debit_amount": "0.00",
+        "rebate_amount": "3.00",
+        "activity_amount": "63.00",
+        "share_commission_amount": "8.00",
+        "legacy_recharge_rebate_amount": "7.00",
+        "total_reward_amount": "81.00",
+        "bet_amount": "41500.00",
+        "effective_amount": "40200.00",
+        "performance_amount": "40200.00",
+        "net_profit": "-39800.00",
+        "balance": "358.01",
+        "member_count": 5,
+        "new_members": 4,
+        "deposit_count": 3,
+        "first_deposit_members": 1,
+        "registered_first_deposit_members": 1,
+        "withdraw_count": 3,
+        "manual_credit_count": 0,
+        "manual_debit_count": 0,
+        "rebate_count": 1,
+        "activity_count": 4,
+        "share_commission_count": 1,
+        "legacy_recharge_rebate_count": 1,
+        "total_reward_count": 7,
+        "bet_count": 4,
+        "effective_bet_count": 3,
+        "performance_count": 3,
+        "net_profit_count": 3
+      },
+      "direct": {
+        "deposit_amount": "110.00",
+        "first_deposit_amount": "0.00",
+        "registered_first_deposit_amount": "0.00",
+        "withdraw_amount": "1460.00",
+        "manual_credit_amount": "0.00",
+        "manual_debit_amount": "0.00",
+        "rebate_amount": "3.00",
+        "activity_amount": "15.00",
+        "share_commission_amount": "8.00",
+        "legacy_recharge_rebate_amount": "7.00",
+        "total_reward_amount": "33.00",
+        "bet_amount": "20000.00",
+        "effective_amount": "20000.00",
+        "performance_amount": "20000.00",
+        "net_profit": "-20000.00",
+        "balance": "123.45",
+        "member_count": 2,
+        "new_members": 1,
+        "deposit_count": 2,
+        "first_deposit_members": 0,
+        "registered_first_deposit_members": 0,
+        "withdraw_count": 3,
+        "manual_credit_count": 0,
+        "manual_debit_count": 0,
+        "rebate_count": 1,
+        "activity_count": 2,
+        "share_commission_count": 1,
+        "legacy_recharge_rebate_count": 1,
+        "total_reward_count": 5,
+        "bet_count": 1,
+        "effective_bet_count": 1,
+        "performance_count": 1,
+        "net_profit_count": 1
+      },
+      "other": {
+        "deposit_amount": "100.00",
+        "first_deposit_amount": "100.00",
+        "registered_first_deposit_amount": "100.00",
+        "withdraw_amount": "0.00",
+        "manual_credit_amount": "0.00",
+        "manual_debit_amount": "0.00",
+        "rebate_amount": "0.00",
+        "activity_amount": "48.00",
+        "share_commission_amount": "0.00",
+        "legacy_recharge_rebate_amount": "0.00",
+        "total_reward_amount": "48.00",
+        "bet_amount": "21500.00",
+        "effective_amount": "20200.00",
+        "performance_amount": "20200.00",
+        "net_profit": "-19800.00",
+        "balance": "234.56",
+        "member_count": 3,
+        "new_members": 3,
+        "deposit_count": 1,
+        "first_deposit_members": 1,
+        "registered_first_deposit_members": 1,
+        "withdraw_count": 0,
+        "manual_credit_count": 0,
+        "manual_debit_count": 0,
+        "rebate_count": 0,
+        "activity_count": 2,
+        "share_commission_count": 0,
+        "legacy_recharge_rebate_count": 0,
+        "total_reward_count": 2,
+        "bet_count": 3,
+        "effective_bet_count": 2,
+        "performance_count": 2,
+        "net_profit_count": 2
+      }
+    },
+    "cumulative_stats": {
+      "total": {
+        "deposit_amount": "310.00",
+        "first_deposit_amount": "200.00",
+        "registered_first_deposit_amount": "200.00",
+        "withdraw_amount": "1460.00",
+        "manual_credit_amount": "0.00",
+        "manual_debit_amount": "0.00",
+        "rebate_amount": "3.00",
+        "activity_amount": "63.00",
+        "share_commission_amount": "8.00",
+        "legacy_recharge_rebate_amount": "7.00",
+        "total_reward_amount": "81.00",
+        "bet_amount": "41750.00",
+        "effective_amount": "40450.00",
+        "performance_amount": "40450.00",
+        "net_profit": "-40050.00",
+        "balance": "358.01",
+        "member_count": 5,
+        "new_members": 5,
+        "deposit_count": 4,
+        "first_deposit_members": 2,
+        "registered_first_deposit_members": 2,
+        "withdraw_count": 3,
+        "manual_credit_count": 0,
+        "manual_debit_count": 0,
+        "rebate_count": 1,
+        "activity_count": 4,
+        "share_commission_count": 1,
+        "legacy_recharge_rebate_count": 1,
+        "total_reward_count": 7,
+        "bet_count": 5,
+        "effective_bet_count": 4,
+        "performance_count": 4,
+        "net_profit_count": 4
+      },
+      "direct": {
+        "deposit_amount": "210.00",
+        "first_deposit_amount": "100.00",
+        "registered_first_deposit_amount": "100.00",
+        "withdraw_amount": "1460.00",
+        "manual_credit_amount": "0.00",
+        "manual_debit_amount": "0.00",
+        "rebate_amount": "3.00",
+        "activity_amount": "15.00",
+        "share_commission_amount": "8.00",
+        "legacy_recharge_rebate_amount": "7.00",
+        "total_reward_amount": "33.00",
+        "bet_amount": "20250.00",
+        "effective_amount": "20250.00",
+        "performance_amount": "20250.00",
+        "net_profit": "-20250.00",
+        "balance": "123.45",
+        "member_count": 2,
+        "new_members": 2,
+        "deposit_count": 3,
+        "first_deposit_members": 1,
+        "registered_first_deposit_members": 1,
+        "withdraw_count": 3,
+        "manual_credit_count": 0,
+        "manual_debit_count": 0,
+        "rebate_count": 1,
+        "activity_count": 2,
+        "share_commission_count": 1,
+        "legacy_recharge_rebate_count": 1,
+        "total_reward_count": 5,
+        "bet_count": 2,
+        "effective_bet_count": 2,
+        "performance_count": 2,
+        "net_profit_count": 2
+      },
+      "other": {
+        "deposit_amount": "100.00",
+        "first_deposit_amount": "100.00",
+        "registered_first_deposit_amount": "100.00",
+        "withdraw_amount": "0.00",
+        "manual_credit_amount": "0.00",
+        "manual_debit_amount": "0.00",
+        "rebate_amount": "0.00",
+        "activity_amount": "48.00",
+        "share_commission_amount": "0.00",
+        "legacy_recharge_rebate_amount": "0.00",
+        "total_reward_amount": "48.00",
+        "bet_amount": "21500.00",
+        "effective_amount": "20200.00",
+        "performance_amount": "20200.00",
+        "net_profit": "-19800.00",
+        "balance": "234.56",
+        "member_count": 3,
+        "new_members": 3,
+        "deposit_count": 1,
+        "first_deposit_members": 1,
+        "registered_first_deposit_members": 1,
+        "withdraw_count": 0,
+        "manual_credit_count": 0,
+        "manual_debit_count": 0,
+        "rebate_count": 0,
+        "activity_count": 2,
+        "share_commission_count": 0,
+        "legacy_recharge_rebate_count": 0,
+        "total_reward_count": 2,
+        "bet_count": 3,
+        "effective_bet_count": 2,
+        "performance_count": 2,
+        "net_profit_count": 2
+      }
+    },
+    "income": {
+      "earned_amount": "0.00",
+      "paid_amount": "1.00",
+      "pending_amount": "0.00",
+      "claimable_amount": "0.00",
+      "reviewing_amount": "0.00",
+      "debt_amount": "1.00",
+      "cumulative_direct_commission": "0.00",
+      "cumulative_other_commission": "0.00",
+      "cumulative_paid_count": 0,
+      "period_direct_commission": "40.00",
+      "period_other_commission": "12.12",
+      "period_settled_commission": "0.00",
+      "period_estimated_commission": "52.12",
+      "period_paid_amount": "0.00",
+      "period_paid_count": 0
+    }
+  }
+}
+```
+## 日期分类佣金列表
+
+`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。
+
+该列表的total是日期分类行数,不是会员人数。summary包含direct_commission、indirect_commission、direct_performance、indirect_performance、settled_commission、estimated_commission,统计整个日期及分类筛选范围,不限当前页。晚到结算或退款会修正原投注日,今日预估未进入可领取账户。
+
+## 期间与累计统计
+
+`period_stats`为所选期间事件统计;`cumulative_stats`为截至当前的全历史统计,不受所选期间限制。两者均包含三组:`total`全部下级、`direct`直属下级、`other`全部更深层间接下级,均不含本人。每一组使用下表完整字段,金额为字符串、次数及人数为整数。category仅影响投注、有效、业绩和盈亏相关统计;资格判断仍累计全游戏。
+
+| 字段 | 含义 |
+| --- | --- |
+| deposit_amount | 成功充值、三方充值实际到账金额 |
+| first_deposit_amount | 每名下级首笔成功充值金额;期间统计仅在该首笔发生于期间时计入 |
+| registered_first_deposit_amount | 注册同一UTC+8自然日完成首充的首笔金额 |
+| withdraw_amount | 成功提现金额,普通提现按该单历史汇率换算为钱包单位 |
+| manual_credit_amount | 人工充值金额,排除关联业务造成的重复入账 |
+| manual_debit_amount | 人工扣款绝对金额,非负 |
+| rebate_amount | 实际发放的笔笔返、比比返、返水、回水金额 |
+| activity_amount | 实际发放的注册赠送、优惠活动、即充即送、老用户回归金额 |
+| share_commission_amount | 下级本人已批准领取到账的新分享佣金 |
+| legacy_recharge_rebate_amount | 原有充值返现实际发放金额 |
+| total_reward_amount | 返利、活动、新分享佣金、原充值返现四类金额之和 |
+| bet_amount | 可识别原注金额,包含未结算、取消或零有效注单 |
+| effective_amount | 历史真实有效投注;已结算且未作废,包含启动日前可用于资格累计的记录 |
+| performance_amount | 可计佣业绩;仅计启动日起、原日佣金开关及必填条件有效且会员原日双达标的有效投注 |
+| net_profit | 已结算且未作废注单净盈亏,会员盈利为正、亏损为负 |
+| balance | 当前会员主钱包可用余额;历史区间也显示当前余额,不是区间末余额 |
+| member_count | 当前统计下级总人数,不限其注册时间是否在所选期间 |
+| new_members | 所选期间内注册人数;累计组为已有注册时间的累计人数 |
+| deposit_count | 成功充值到账笔数 |
+| first_deposit_members | 首笔成功充值在统计期间内的会员人数 |
+| registered_first_deposit_members | 在注册自然日完成首充、且该首充落入统计期间的人数 |
+| withdraw_count | 成功提现笔数 |
+| manual_credit_count | 人工充值笔数 |
+| manual_debit_count | 人工扣款笔数 |
+| rebate_count | 返利发放笔数 |
+| activity_count | 活动发放笔数 |
+| share_commission_count | 新分享佣金已批准领取笔数 |
+| legacy_recharge_rebate_count | 原充值返现发放笔数 |
+| total_reward_count | 上述四类奖励发放笔数之和 |
+| bet_count | 原注记录数,包含未结算和取消记录 |
+| effective_bet_count | 已结算、未作废且有效金额大于0的注单数 |
+| performance_count | 满足原日资格及启动、政策条件的有效注单数 |
+| net_profit_count | 已结算且未作废、可计算净输赢的注单数,零输赢也计数 |
+
+充值、首充及人工操作按实际钱包流水创建时间;提现按成功状态更新时间;四类奖励按实际发放时间,其中新分享佣金按批准时间;投注按原投注日。首充只识别每人全历史第一笔成功充值,不会把所选期间首笔当作历史首充。流水解冻不会再次算奖励;当前余额也不能与期间充值相加当作期间收益。
+
+## 本人收入
+
+`income`仅描述当前登录会员自己的新分享佣金,和period_stats/cumulative_stats中下级实际领取的奖励严格分开。
+
+| 字段 | 含义 |
+| --- | --- |
+| earned_amount | 本人累计净日结佣金,含修正;不含今日预估 |
+| paid_amount | 本人累计已批准到账金额 |
+| pending_amount | max(0,earned_amount-paid_amount),包含预留 |
+| claimable_amount | 扣除预留后的可申请金额,截断到分 |
+| reviewing_amount | 本人待审核申请预留金额 |
+| debt_amount | max(0,paid_amount-earned_amount),待后续佣金抵扣的差额 |
+| cumulative_direct_commission | 本人累计已日结直属佣金,含修正,全部分类 |
+| cumulative_other_commission | 本人累计已日结间接佣金,含修正,全部分类 |
+| cumulative_paid_count | 本人累计已批准领取笔数 |
+| period_direct_commission | 所选期间本人直属佣金,包含今日预估,随category筛选 |
+| period_other_commission | 所选期间本人间接佣金,包含今日预估,随category筛选 |
+| period_settled_commission | 所选期间本人已日结佣金,随category筛选 |
+| period_estimated_commission | 所选期间包含的本人今日预估佣金,随category筛选 |
+| period_paid_amount | 本人在所选期间实际批准到账金额,按审核时间,全部分类 |
+| period_paid_count | 本人在所选期间已批准领取笔数,按审核时间 |
+
+本人佣金只有已日结部分进入账户;今日预估仅展示。显示入口开关enabled不影响计佣;新佣金依原日benefit_enabled、初始化、必填条件和启动日约束。所有业绩按当时真实祖先链归属,后绑定不追溯此前下注。

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

@@ -0,0 +1,272 @@
+# 返佣比例
+
+URL 相对会员网站 API 域名。请求头 `Authorization: <登录接口返回的原始 token>`,不要自行添加 `Bearer ` 前缀;可传 `lang: zh`。成功为 `code=1, show=0`;业务或参数失败为 `code=0, show=1`,展示 `msg`;登录失效为 `code=401`,回到登录流程。以下会员接口均使用当前登录身份,不传当前会员ID。金额为两位小数字符串,比例为四位小数字符串,日期时间为 UTC+8。游客账号不能使用真实分享佣金功能。
+
+`GET /api/share/rates`,返回今日已生效条件和本人今日暂时命中的返佣比例。无日期、分页参数。
+
+分类参数 `category` 可选,不传查全部;可选值为 `third:1`真人、`third:2`电子、`third:3`第三方彩票、`third:4`第三方体育、`third:5`电竞、`third:6`捕鱼、`third:7`棋牌、`self:sport`自有体育、`self:lottery`自有彩票。分类键使用字符串,勿转为数字。
+
+请求示例:`GET /api/share/rates?category=third%3A1`
+
+```json
+{
+  "code": 1,
+  "show": 0,
+  "msg": "",
+  "data": {
+    "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,
+    "commission_status": "estimated",
+    "updated_at": "2026-10-24 12:00:00",
+    "data": [
+      {
+        "category": "third:1",
+        "category_name": "真人",
+        "tiers": [
+          {
+            "min_valid_members": 0,
+            "min_performance": "100.00",
+            "rate": "0.1000"
+          },
+          {
+            "min_valid_members": 1,
+            "min_performance": "20000.00",
+            "rate": "0.2000"
+          },
+          {
+            "min_valid_members": 1,
+            "min_performance": "50000.00",
+            "rate": "0.2500"
+          }
+        ],
+        "current_rate": "0.0000",
+        "direct_performance": "0.00",
+        "valid_direct_members": 1
+      },
+      {
+        "category": "third:2",
+        "category_name": "电子",
+        "tiers": [
+          {
+            "min_valid_members": 0,
+            "min_performance": "100.00",
+            "rate": "0.1000"
+          },
+          {
+            "min_valid_members": 1,
+            "min_performance": "20000.00",
+            "rate": "0.2000"
+          },
+          {
+            "min_valid_members": 1,
+            "min_performance": "50000.00",
+            "rate": "0.2500"
+          }
+        ],
+        "current_rate": "0.2000",
+        "direct_performance": "20000.00",
+        "valid_direct_members": 1
+      },
+      {
+        "category": "third:3",
+        "category_name": "第三方彩票",
+        "tiers": [
+          {
+            "min_valid_members": 0,
+            "min_performance": "100.00",
+            "rate": "0.1000"
+          },
+          {
+            "min_valid_members": 1,
+            "min_performance": "20000.00",
+            "rate": "0.2000"
+          },
+          {
+            "min_valid_members": 1,
+            "min_performance": "50000.00",
+            "rate": "0.2500"
+          }
+        ],
+        "current_rate": "0.0000",
+        "direct_performance": "0.00",
+        "valid_direct_members": 1
+      },
+      {
+        "category": "third:4",
+        "category_name": "第三方体育",
+        "tiers": [
+          {
+            "min_valid_members": 0,
+            "min_performance": "100.00",
+            "rate": "0.1000"
+          },
+          {
+            "min_valid_members": 1,
+            "min_performance": "20000.00",
+            "rate": "0.2000"
+          },
+          {
+            "min_valid_members": 1,
+            "min_performance": "50000.00",
+            "rate": "0.2500"
+          }
+        ],
+        "current_rate": "0.0000",
+        "direct_performance": "0.00",
+        "valid_direct_members": 1
+      },
+      {
+        "category": "third:5",
+        "category_name": "电竞",
+        "tiers": [
+          {
+            "min_valid_members": 0,
+            "min_performance": "100.00",
+            "rate": "0.1000"
+          },
+          {
+            "min_valid_members": 1,
+            "min_performance": "20000.00",
+            "rate": "0.2000"
+          },
+          {
+            "min_valid_members": 1,
+            "min_performance": "50000.00",
+            "rate": "0.2500"
+          }
+        ],
+        "current_rate": "0.0000",
+        "direct_performance": "0.00",
+        "valid_direct_members": 1
+      },
+      {
+        "category": "third:6",
+        "category_name": "捕鱼",
+        "tiers": [
+          {
+            "min_valid_members": 0,
+            "min_performance": "100.00",
+            "rate": "0.1000"
+          },
+          {
+            "min_valid_members": 1,
+            "min_performance": "20000.00",
+            "rate": "0.2000"
+          },
+          {
+            "min_valid_members": 1,
+            "min_performance": "50000.00",
+            "rate": "0.2500"
+          }
+        ],
+        "current_rate": "0.0000",
+        "direct_performance": "0.00",
+        "valid_direct_members": 1
+      },
+      {
+        "category": "third:7",
+        "category_name": "棋牌",
+        "tiers": [
+          {
+            "min_valid_members": 0,
+            "min_performance": "100.00",
+            "rate": "0.1000"
+          },
+          {
+            "min_valid_members": 1,
+            "min_performance": "20000.00",
+            "rate": "0.2000"
+          },
+          {
+            "min_valid_members": 1,
+            "min_performance": "50000.00",
+            "rate": "0.2500"
+          }
+        ],
+        "current_rate": "0.0000",
+        "direct_performance": "0.00",
+        "valid_direct_members": 1
+      },
+      {
+        "category": "self:sport",
+        "category_name": "自有体育",
+        "tiers": [
+          {
+            "min_valid_members": 0,
+            "min_performance": "100.00",
+            "rate": "0.1000"
+          },
+          {
+            "min_valid_members": 1,
+            "min_performance": "20000.00",
+            "rate": "0.2000"
+          },
+          {
+            "min_valid_members": 1,
+            "min_performance": "50000.00",
+            "rate": "0.2500"
+          }
+        ],
+        "current_rate": "0.0000",
+        "direct_performance": "0.00",
+        "valid_direct_members": 1
+      },
+      {
+        "category": "self:lottery",
+        "category_name": "自有彩票",
+        "tiers": [
+          {
+            "min_valid_members": 0,
+            "min_performance": "100.00",
+            "rate": "0.1000"
+          },
+          {
+            "min_valid_members": 1,
+            "min_performance": "20000.00",
+            "rate": "0.2000"
+          },
+          {
+            "min_valid_members": 1,
+            "min_performance": "50000.00",
+            "rate": "0.2500"
+          }
+        ],
+        "current_rate": "0.0000",
+        "direct_performance": "0.00",
+        "valid_direct_members": 1
+      }
+    ]
+  }
+}
+```
+| 字段 | 含义 |
+| --- | --- |
+| 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不影响此值,布尔值 |
+| commission_status | 今日比例与业绩仍为estimated,最终以日结为准 |
+| updated_at | 当前规则版本更新时间,可能为null |
+| data | 不传category返回九类;传category只返回该类 |
+| data[].category / category_name | 分类键、分类名称 |
+| data[].tiers | 该分类的全部比例档位 |
+| tiers[].min_valid_members | 本人合格直属人数最低要求 |
+| tiers[].min_performance | 合格直属会员在今日该分类的有效业绩最低要求 |
+| tiers[].rate | 满足该档条件时的百分数,0.2500表示0.25% |
+| data[].current_rate | 人数与直属业绩双条件同时满足时,最高业绩门槛档对应比例;业绩门槛相同取更高人数档,均不满足为0.0000 |
+| data[].direct_performance | 本人合格直属会员今日在该分类的有效业绩 |
+| data[].valid_direct_members | 本人合格直属人数 |
+
+两项资格条件须同时满足,不合格下级的业绩也不计入。档位按本人合格直属人数和直属分类业绩判断,先取已满足的最高业绩门槛,门槛相同再取最高人数要求;全部更深层下级的业绩使用本人命中的比例计算间接佣金,不使用下级自己的比例,也不逐层递归。
+
+直属佣金=直属有效业绩×current_rate÷100。间接佣金=全部间接有效业绩×current_rate÷100×indirect_bonus_rate÷100。例:一名有效直属会员、100万元业绩,在默认档位中命中0.25%。本人比例0.10%、间接业绩20000元、附加系数30%时,间接佣金为6元。
+
+后台今天保存的金钱条件和比例在次日00:00生效;本接口显示今日已生效版本。今日预估不进入可领取金额,前端不要按本接口金额或比例自行生成领取申请金额。

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

@@ -0,0 +1,176 @@
+# 分享首页
+
+URL 相对会员网站 API 域名。请求头 `Authorization: <登录接口返回的原始 token>`,不要自行添加 `Bearer ` 前缀;可传 `lang: zh`。成功为 `code=1, show=0`;业务或参数失败为 `code=0, show=1`,展示 `msg`;登录失效为 `code=401`,回到登录流程。以下会员接口均使用当前登录身份,不传当前会员ID。金额为两位小数字符串,比例为四位小数字符串,日期时间为 UTC+8。游客账号不能使用真实分享佣金功能。
+
+## 首页数据
+
+`GET /api/share/home`,无参数。
+
+```json
+{
+  "code": 1,
+  "show": 0,
+  "msg": "",
+  "data": {
+    "earned_amount": "0.00",
+    "paid_amount": "1.00",
+    "pending_amount": "0.00",
+    "claimable_amount": "0.00",
+    "reviewing_amount": "0.00",
+    "debt_amount": "1.00",
+    "level": 1,
+    "level_name": "等级1",
+    "level_image": "",
+    "next_level": {
+      "level": 2,
+      "name": "等级2",
+      "required_total": "100.00",
+      "remaining_commission": "100.00"
+    },
+    "enabled": 1,
+    "commission_enabled": true,
+    "activation_date": "2026-10-23",
+    "currency": "CNY",
+    "invitation_code": "CODE1",
+    "share_url": "https://pc.example.test/register?invitationCode=CODE1",
+    "direct_members": 2,
+    "team_members": 5,
+    "valid_direct_members": 1,
+    "today_estimated_commission": "52.12",
+    "yesterday_direct_performance": "250.00",
+    "settlement_date": "2026/10/23",
+    "commission_status": "estimated",
+    "updated_at": "2026-10-24 13:44:09"
+  }
+}
+```
+| 字段 | 含义 |
+| --- | --- |
+| earned_amount | 新分享系统累计净结算佣金,包含历史结算修正 |
+| paid_amount | 累计已批准转入主钱包的分享佣金 |
+| pending_amount | `max(0,earned_amount-paid_amount)`,包含正在审核的预留金额 |
+| claimable_amount | `max(0,earned_amount-paid_amount-reviewing_amount)`截断到分后可申请金额 |
+| reviewing_amount | 待审核申请预留金额 |
+| debt_amount | `max(0,paid_amount-earned_amount)`,后续佣金先抵扣的修正差额 |
+| level / level_name / level_image | 本人分享等级、等级名、图片地址;无图为空字符串 |
+| next_level | 下一级信息;最高等级时为null |
+| next_level.level / name | 下一等级编号、名称 |
+| next_level.required_total | 达到下一级的累计佣金门槛,由各级增量相加 |
+| next_level.remaining_commission | 距下一等级仍需累计的佣金,最低0 |
+| enabled | 分享入口展示开关,0隐藏、1显示;隐藏不停止计佣 |
+| commission_enabled | 当前日期是否按已生效规则产生新佣金,布尔值 |
+| activation_date | 新佣金首次启用日;未启用时为null |
+| currency | 佣金与钱包的币种代码,例如CNY |
+| invitation_code / share_url | 本人邀请码、完整PC站分享链接;未配置域名时链接为空字符串 |
+| direct_members / team_members | 直属人数、全部下级人数;团队不含本人 |
+| valid_direct_members | 截至今日按已生效门槛合格的直属会员数 |
+| today_estimated_commission | 今日预估佣金,尚不能申请 |
+| yesterday_direct_performance | 本人昨天合格直属的计佣业绩合计,按原投注日和原日条件判断 |
+| settlement_date | 系统全局最近完成日结的日期,YYYY/MM/DD;没有全局日结标记时为null,不回退为本人最近有佣金的日期 |
+| commission_status | 首页今日佣金状态,固定estimated,不表示历史账户未结算 |
+| updated_at | 佣金账户最近更新时间,尚无账户时为null |
+
+今日金额会随有效投注、资格及结算修正变化。只有已经日结的佣金可进入可申请金额;页面领取按钮以 `claimable_amount>=0.01` 且 `reviewing_amount=0` 为启用条件,提交后仍以服务端校验为准。
+
+## 提交本人领取申请
+
+`POST /api/share/claim`,`Content-Type: application/json`。
+
+以下申请示例用于另一个已有可申请余额的时刻;首页示例中的debt_amount表示当时仍须先抵扣修正差额。
+
+唯一参数 `request_id` 必填:1~64字符,首位为字母或数字,其余允许字母、数字、`.`、`_`、`:`、`-`。每次新申请生成新标识;超时重试沿用原标识。
+
+```json
+{
+  "request_id": "share-20261025-001"
+}
+```
+```json
+{
+  "code": 1,
+  "show": 0,
+  "msg": "",
+  "data": {
+    "id": 17,
+    "request_id": "share-20261025-001",
+    "amount": "50.12",
+    "status": "pending",
+    "created_at": "2026-10-25 12:00:00",
+    "audited_at": null,
+    "audit_remark": "",
+    "updated_at": "2026-10-25 12:00:00"
+  }
+}
+```
+`amount` 由服务端按全额可申请金额截断到分生成,不接受前端指定 `amount`、`user_id`、`member_id`。不足一分的余数留在佣金账户。一次只能存在一笔待审核申请;相同request_id重试返回同一申请,另一个request_id在已有待审时失败。
+
+返回字段:`id`申请ID、`request_id`幂等标识、`amount`固定申请金额、`status`状态、`created_at`申请时间、`audited_at`审核时间(未审为null)、`audit_remark`审核备注、`updated_at`最新更新时间。状态为 `pending`待审核、`approved`已批准到账、`rejected`已驳回。
+
+提交申请只预留佣金,后台批准后才转入本人主钱包可用余额,无手续费、无流水要求。若审核前佣金修正导致余额不足,后台须驳回后才能重新申请;前端不得自行缩小或改写已提交金额。历史修正不会自动扣回已经进入主钱包的余额。
+
+## 本人领取记录
+
+`GET /api/share/claims`,支持以下日期及分页参数,并支持可选 `status=pending/approved/rejected`;不传状态查全部。
+
+| 参数 | 必填 | 说明 |
+| --- | --- | --- |
+| period | 否 | 默认 `today`;支持 `today`今天、`yesterday`昨天、`this_week`本周、`last_week`上周、`this_month`本月、`last_month`上月、`all`全部、`custom`自定义 |
+| start_date | custom时必填 | `YYYY-MM-DD` |
+| end_date | custom时必填 | `YYYY-MM-DD`,不早于起始日、不晚于今天;含首尾最多366天 |
+| page | 否 | 默认1,最小1 |
+| limit | 否 | 默认20,范围1~200 |
+
+本周从周一开始,当前周和月截至今天。`all`的返回起止日均为null;其他预设返回实际起止日。只有 `period=custom` 使用请求中的起止日。返回 `period/start_date/end_date/page/limit` 为实际查询条件,`total` 为筛选后的全部行数,`data` 为本页数组。
+
+```json
+{
+  "code": 1,
+  "show": 0,
+  "msg": "",
+  "data": {
+    "period": "today",
+    "start_date": "2026-10-25",
+    "end_date": "2026-10-25",
+    "page": 1,
+    "limit": 20,
+    "total": 1,
+    "data": [
+      {
+        "id": 17,
+        "request_id": "share-20261025-001",
+        "amount": "50.12",
+        "status": "pending",
+        "created_at": "2026-10-25 12:00:00",
+        "audited_at": null,
+        "audit_remark": "",
+        "updated_at": "2026-10-25 12:00:00"
+      }
+    ]
+  }
+}
+```
+已批准记录按实际审核到账时间筛选;待审和驳回记录按申请时间筛选。`data`中每项字段与提交申请返回字段相同。成功提交或审核状态变化后重新读取首页和记录。重复使用一个已终结申请的request_id仍返回旧申请;新申请必须用新request_id。
+
+## 未登录配置读取
+
+`GET /api/share/config` 可免登录,返回展示和当前已生效配置,供页面入口使用。
+
+```json
+{
+  "code": 1,
+  "show": 0,
+  "msg": "",
+  "data": {
+    "enabled": 1,
+    "benefit_enabled": 1,
+    "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"
+  }
+}
+```
+`benefit_enabled`、门槛和附加系数读取当前已生效版本;后台今日保存的修改次日才反映于本接口。实际今日开佣金看home的 `commission_enabled`,今日金额条件及比例看 `/api/share/rates`。两项新参数未配置时为null。旧三级分享参数不再返回。已有 `/api/share/profile` 为首页兼容入口,前端新接入使用home。
+
+`enabled`仅控制入口显示,与资金开关独立;即使其为0,只要已生效的`benefit_enabled`为1且其他计佣条件满足,仍产生佣金。前端不得以隐藏入口推断计佣停止。

+ 9 - 0
routes/admin.php

@@ -53,6 +53,7 @@ use App\Http\Controllers\admin\Agent as AgentAdmin;
 use App\Http\Controllers\admin\PaymentConfiguration;
 use App\Http\Controllers\admin\AppConfiguration;
 use App\Http\Controllers\admin\ShareEarning;
+use App\Http\Controllers\admin\SharingManagement;
 
 Route::post('/login', [Admin::class, 'login']);
 Route::get('/test', [Wallet::class, 'test']);
@@ -274,6 +275,14 @@ Route::middleware(['admin.jwt'])->group(function () {
             Route::get('/summary', [ShareEarning::class, 'summary']);
             Route::get('/settings', [ShareEarning::class, 'settings']);
             Route::post('/settings/save', [ShareEarning::class, 'saveSettings']);
+            Route::get('/levels', [SharingManagement::class, 'levels']);
+            Route::post('/levels/save', [SharingManagement::class, 'saveLevel']);
+            Route::post('/levels/delete', [SharingManagement::class, 'deleteLevel']);
+            Route::get('/claims', [SharingManagement::class, 'claims']);
+            Route::get('/claims/detail', [SharingManagement::class, 'claimDetail']);
+            Route::post('/claims/audit', [SharingManagement::class, 'auditClaim']);
+            Route::get('/rateRules', [SharingManagement::class, 'rateRules']);
+            Route::post('/rateRules/save', [SharingManagement::class, 'saveRateRules']);
             Route::get('/blacklists', [ShareEarning::class, 'blacklists']);
             Route::post('/blacklists/save', [ShareEarning::class, 'saveBlacklist']);
             Route::post('/blacklists/status', [ShareEarning::class, 'blacklistStatus']);

+ 289 - 0
tests/Integration/ShareAdminManagementTest.php

@@ -0,0 +1,289 @@
+<?php
+
+namespace Tests\Integration;
+
+use App\Http\Controllers\admin\ShareEarning;
+use App\Http\Controllers\admin\SharingManagement;
+use App\Models\BalanceLog;
+use App\Services\BalanceLogService;
+use App\Services\Sharing\ShareAdminService;
+use App\Services\Sharing\ShareClaimAuditService;
+use Carbon\Carbon;
+use Illuminate\Config\Repository;
+use Illuminate\Database\Capsule\Manager;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Foundation\Application;
+use Illuminate\Http\JsonResponse;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Facade;
+use Illuminate\Translation\ArrayLoader;
+use Illuminate\Translation\Translator;
+use Illuminate\Validation\Factory;
+use Illuminate\Validation\ValidationException;
+use PHPUnit\Framework\TestCase;
+
+/** Only an isolated, prefixed SQLite database; never boots project providers or reads .env. */
+class ShareAdminManagementTest extends TestCase
+{
+    private Application $app;
+    private ShareAdminService $admin;
+    private ShareClaimAuditService $claims;
+
+    protected function setUp(): void
+    {
+        Facade::clearResolvedInstances();
+        Carbon::setTestNow(Carbon::parse('2026-10-24 18:00:00', 'Asia/Shanghai'));
+        $this->app = new Application(dirname(__DIR__, 2));
+        $this->app->instance('config', new Repository(['app' => ['locale' => 'zh', 'timezone' => 'Asia/Shanghai']]));
+        Facade::setFacadeApplication($this->app);
+        $db = new Manager($this->app);
+        $db->addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => 'bot_']);
+        $db->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('share_settings', function (Blueprint $t) {
+            $t->id(); $t->integer('enabled')->default(0); $t->integer('benefit_enabled')->default(0);
+            $t->string('share_domain')->default(''); $t->decimal('valid_member_min_deposit', 20, 2)->default(20);
+            $t->decimal('non_seller_threshold', 24, 10)->default(200); $t->decimal('tier_one_rate', 8, 4)->default(4);
+            $t->decimal('tier_two_rate', 8, 4)->default(6); $t->integer('operator_id')->nullable(); $t->string('operator_name')->default(''); $t->timestamps();
+        });
+        $db->schema()->create('users', function (Blueprint $t) {
+            $t->id(); $t->string('member_id')->unique(); $t->string('account'); $t->string('first_name'); $t->timestamps();
+        });
+        $db->schema()->create('wallets', function (Blueprint $t) {
+            $t->id(); $t->string('member_id')->unique(); $t->decimal('available_balance', 24, 10)->default(0); $t->timestamps();
+        });
+        $db->schema()->create('balance_logs', function (Blueprint $t) {
+            $t->id(); $t->string('member_id'); $t->string('change_type'); $t->integer('type')->default(1);
+            foreach (['amount', 'before_balance', 'after_balance'] as $field) $t->decimal($field, 24, 10);
+            $t->integer('related_id')->nullable(); $t->text('remark')->nullable(); $t->timestamps();
+        });
+        $db->schema()->create('operation_audits', function (Blueprint $t) {
+            $t->id(); $t->string('resource_type'); $t->integer('resource_id')->nullable(); $t->string('action');
+            $t->json('changes'); $t->integer('operator_id')->nullable(); $t->string('operator_name'); $t->timestamp('created_at')->useCurrent();
+        });
+        (require dirname(__DIR__, 2) . '/database/migrations/2026_10_24_120000_create_member_sharing_system.php')->up();
+        $request = Request::create('/admin/shareEarning/settings', 'GET');
+        $request->user = (object)['id' => 8, 'username' => 'admin'];
+        $this->app->instance('request', $request);
+        $this->admin = new ShareAdminService(); $this->claims = new ShareClaimAuditService();
+    }
+
+    protected function tearDown(): void
+    {
+        BalanceLogService::$MODEL = BalanceLog::class;
+        Carbon::setTestNow(); DB::disconnect(); Facade::clearResolvedInstances(); parent::tearDown();
+    }
+
+    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);
+    }
+
+    private function initialized(): void
+    {
+        DB::table('share_system_state')->insert(['key' => 'initialized_at', 'value' => '2026-10-24 18:00:00']);
+    }
+
+    private function fails(callable $callback, string $contains): void
+    {
+        try { $callback(); $this->fail('Expected validation rejection'); }
+        catch (ValidationException $e) { $this->assertStringContainsString($contains, $e->validator->errors()->first()); }
+    }
+
+    public function test_explicit_setup_and_initializer_gate_enablement(): void
+    {
+        $this->assertNull($this->admin->settings()['activation_date']);
+        $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']);
+        $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'])), '不可指定');
+        $this->fails(fn () => $this->admin->saveSettings($this->config(['share_domain' => 'https://pc.example/a'])), '源地址');
+        $data = $this->admin->saveSettings($this->config());
+        $this->assertSame('2026-10-25', $data['activation_date']);
+        $this->assertSame('https://pc.example.com', $data['share_domain']);
+        $this->assertSame(0, $data['current_policy']['benefit_enabled']);
+        $this->assertSame(1, $data['pending_policy']['benefit_enabled']);
+        $this->assertArrayNotHasKey('tier_one_rate', $data);
+        $this->assertSame(12, count($data['pending_policy']['rules']['third:1']));
+    }
+
+    public function test_pending_policy_edits_preserve_past_policy_and_activation_date(): void
+    {
+        $this->initialized();
+        $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']));
+        $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'));
+        $before = (array)DB::table('share_policies')->where('id', $pendingId)->first();
+        $this->admin->saveSettings($this->config(['benefit_enabled' => 0]));
+        $this->admin->saveRateRules('third:2', [['min_valid_members' => 0, 'min_performance' => 10, 'rate' => '0.50']]);
+        $this->assertSame($before, (array)DB::table('share_policies')->where('id', $pendingId)->first());
+        $this->assertSame(0, $this->admin->settings()['pending_policy']['benefit_enabled']);
+        $this->admin->saveSettings($this->config());
+        $this->assertSame('2026-10-25', $this->admin->settings()['activation_date']);
+        $this->assertSame(3, DB::table('share_policies')->count());
+    }
+
+    public function test_deposit_precision_and_display_switch_do_not_silently_change_money_configuration(): void
+    {
+        $this->initialized();
+        $this->fails(fn () => $this->admin->saveSettings($this->config(['valid_member_min_deposit' => '20.123'])), 'regex');
+        $this->assertSame(0, DB::table('share_settings')->count());
+        $this->assertSame(1, DB::table('share_policies')->count());
+        $saved = $this->admin->saveSettings($this->config(['enabled' => 0, 'valid_member_min_deposit' => '20.12']));
+        $this->assertSame(0, $saved['enabled']);
+        $this->assertSame(1, $saved['benefit_enabled']);
+        $this->assertSame(1, $saved['pending_policy']['benefit_enabled']);
+        $this->assertSame('20.12', $saved['pending_policy']['valid_member_min_deposit']);
+        $this->assertSame('20.12', $saved['valid_member_min_deposit']);
+        $this->assertSame('20.1200000000', bcadd((string)DB::table('share_policies')->where('effective_date', '2026-10-25')->value('valid_member_min_deposit'), '0', 10));
+    }
+
+    public function test_rules_are_ordered_by_threshold_and_invalid_ladders_are_rejected_without_pending_writes(): void
+    {
+        $this->admin->saveRateRules('third:1', [
+            ['min_valid_members' => 2, 'min_performance' => '100', 'rate' => '0.30'],
+            ['min_valid_members' => 1, 'min_performance' => '100', 'rate' => '0.20'],
+            ['min_valid_members' => 0, 'min_performance' => '10', 'rate' => '0.10'],
+        ]);
+        $rules = $this->admin->rateRules('third:1')['categories'][0]['pending_rules'];
+        $this->assertSame([0, 1, 2], array_column($rules, 'min_valid_members'));
+        $this->assertSame(['10.00', '100.00', '100.00'], array_column($rules, 'min_performance'));
+        $before = (array)DB::table('share_policies')->where('effective_date', '2026-10-25')->first();
+        $this->fails(fn () => $this->admin->saveRateRules('third:1', [
+            ['min_valid_members' => 1, 'min_performance' => '100.0', 'rate' => '0.20'],
+            ['min_valid_members' => 1, 'min_performance' => '100.00', 'rate' => '0.30'],
+        ]), '不可重复');
+        $this->fails(fn () => $this->admin->saveRateRules('third:1', [
+            ['min_valid_members' => 2, 'min_performance' => '100', 'rate' => '0.20'],
+            ['min_valid_members' => 1, 'min_performance' => '200', 'rate' => '0.30'],
+        ]), '人数门槛不可降低');
+        $this->fails(fn () => $this->admin->saveRateRules('third:1', [
+            ['min_valid_members' => 1, 'min_performance' => '100', 'rate' => '0.30'],
+            ['min_valid_members' => 2, 'min_performance' => '200', 'rate' => '0.20'],
+        ]), '返佣比例不可降低');
+        $this->assertSame($before, (array)DB::table('share_policies')->where('effective_date', '2026-10-25')->first());
+        $this->assertSame(1, DB::table('operation_audits')->count());
+    }
+
+    public function test_levels_sum_increments_and_never_downgrade_members(): void
+    {
+        DB::table('share_members')->insert(['user_id' => 7, 'level' => 1, 'bound_at' => '2026-10-24 00:00:00']);
+        DB::table('share_accounts')->insert(['user_id' => 7, 'earned_amount' => 25]);
+        $this->admin->saveLevel(['level' => 2, 'name' => '二级', 'image' => '', 'incremental_commission' => 10]);
+        $this->admin->saveLevel(['level' => 3, 'name' => '三级', 'image' => '/three.png', 'incremental_commission' => 20]);
+        $this->assertSame(2, (int)DB::table('share_members')->value('level'));
+        $levels = $this->admin->levels()['data'];
+        $this->assertSame('30.00', $levels[2]['cumulative_commission']);
+        $this->admin->saveLevel(['id' => $levels[1]['id'], 'level' => 2, 'name' => '二级', 'image' => '', 'incremental_commission' => 100]);
+        $this->assertSame(2, (int)DB::table('share_members')->value('level'));
+        $this->fails(fn () => $this->admin->deleteLevel($levels[1]['id']), '已有会员');
+        $this->admin->deleteLevel($levels[2]['id']);
+        $this->assertCount(2, $this->admin->levels()['data']);
+    }
+
+    private function pending(string $earned = '10.019', string $amount = '10.01'): int
+    {
+        DB::table('users')->insert(['id' => 7, 'member_id' => 'M7', 'account' => 'member7', 'first_name' => '会员']);
+        DB::table('wallets')->insert(['member_id' => 'M7', 'available_balance' => 20]);
+        DB::table('share_accounts')->insert(['user_id' => 7, 'earned_amount' => $earned, 'paid_amount' => 0, 'reserved_amount' => $amount]);
+        return DB::table('share_claims')->insertGetId(['user_id' => 7, 'request_id' => 'req-1', 'active_user_id' => 7,
+            'amount' => $amount, 'status' => 'pending', 'created_at' => '2026-10-24 12:00:00', 'updated_at' => '2026-10-24 12:00:00']);
+    }
+
+    public function test_approval_credits_member_wallet_once_and_retains_fractional_remainder(): void
+    {
+        $id = $this->pending();
+        $approved = $this->claims->audit($id, 'approve', 8, '已核对');
+        $this->assertSame('approved', $approved['status']);
+        $this->assertSame('0.00', $approved['commission_account']['spendable_amount']);
+        $this->assertSame('10.01', $approved['commission_account']['paid_amount']);
+        $this->assertEquals('30.01', DB::table('wallets')->value('available_balance'));
+        $this->assertEquals('0.0090000000', bcsub((string)DB::table('share_accounts')->value('earned_amount'), (string)DB::table('share_accounts')->value('paid_amount'), 10));
+        $this->assertNull(DB::table('share_claims')->value('active_user_id'));
+        $this->claims->audit($id, 'approve', 8, '重复');
+        $this->assertSame(1, DB::table('balance_logs')->count());
+        $this->assertSame(1, DB::table('operation_audits')->count());
+        $this->assertSame('分享佣金领取', DB::table('balance_logs')->value('change_type'));
+        $this->assertSame(8, (int)DB::table('operation_audits')->value('operator_id'));
+        $this->fails(fn () => $this->claims->audit($id, 'reject', 8), '不可更改');
+    }
+
+    public function test_recomputed_insufficiency_blocks_approval_and_rejection_releases_full_reservation(): void
+    {
+        $id = $this->pending('5.00', '10.01');
+        $this->assertFalse($this->claims->detail($id)['can_approve']);
+        $this->fails(fn () => $this->claims->audit($id, 'approve', 8), '重新申请');
+        $this->assertSame('pending', DB::table('share_claims')->value('status'));
+        $this->assertEquals(20, DB::table('wallets')->value('available_balance'));
+        $rejected = $this->claims->audit($id, 'reject', 8, '请重新申请');
+        $this->assertSame('5.00', $rejected['commission_account']['spendable_amount']);
+        $this->assertSame(0, DB::table('balance_logs')->count());
+        $this->claims->audit($id, 'reject', 8);
+        $this->assertSame(1, DB::table('operation_audits')->count());
+    }
+
+    public function test_audit_failure_rolls_back_wallet_log_account_and_claim(): void
+    {
+        $id = $this->pending();
+        DB::statement("CREATE TRIGGER block_operation_audit BEFORE INSERT ON bot_operation_audits BEGIN SELECT RAISE(ABORT, 'audit offline'); END");
+        try { $this->claims->audit($id, 'approve', 8); $this->fail('Expected audit write failure'); }
+        catch (\Illuminate\Database\QueryException $e) { $this->assertStringContainsString('audit offline', $e->getMessage()); }
+        $this->assertEquals(20, DB::table('wallets')->value('available_balance'));
+        $this->assertSame('pending', DB::table('share_claims')->value('status'));
+        $this->assertEquals(0, DB::table('share_accounts')->value('paid_amount'));
+        $this->assertEquals('10.01', DB::table('share_accounts')->value('reserved_amount'));
+        $this->assertSame(0, DB::table('balance_logs')->count());
+    }
+
+    public function test_admin_controller_envelopes_and_filtered_lists(): void
+    {
+        $id = $this->pending();
+        $this->assertSame(0, (new ShareEarning())->settings($this->admin)->getData(true)['code']);
+        $request = Request::create('/admin/shareEarning/claims/audit', 'POST', ['id' => $id, 'action' => 'approve']);
+        $request->user = (object)['id' => 8, 'username' => 'admin'];
+        $this->app->instance('request', $request);
+        $body = (new SharingManagement())->auditClaim($this->claims)->getData(true);
+        $this->assertSame(0, $body['code']); $this->assertSame('approved', $body['data']['status']);
+        $list = $this->claims->list(['status' => 'approved', 'account' => 'member7', 'start_date' => '2026-10-24', 'end_date' => '2026-10-24']);
+        $this->assertSame(1, $list['total']); $this->assertSame('10.01', $list['data'][0]['amount']);
+        $this->assertSame(0, $this->claims->list(['status' => 'pending'])['total']);
+    }
+
+    public function test_database_errors_do_not_expose_sql_in_admin_responses(): void
+    {
+        $this->app->instance('log', new \Psr\Log\NullLogger());
+        DB::statement('DROP TABLE bot_share_levels');
+        $body = (new SharingManagement())->levels($this->admin)->getData(true);
+        $this->assertSame(-3, $body['code']);
+        $this->assertStringContainsString('暂不可用', $body['msg']);
+        $this->assertStringNotContainsString('SQLSTATE', $body['msg']);
+        $this->assertStringNotContainsString('share_levels', $body['msg']);
+    }
+
+    public function test_money_configuration_cannot_lose_subcent_values_during_frontend_roundtrip(): void
+    {
+        $this->initialized();
+        $this->fails(fn () => $this->admin->saveSettings($this->config(['valid_member_min_effective_bet'=>'0.001'])), 'regex');
+        $this->fails(fn () => $this->admin->saveRateRules('third:2', [['min_valid_members'=>0,'min_performance'=>'0.001','rate'=>'0.1']]), 'regex');
+        $this->fails(fn () => $this->admin->saveLevel(['level'=>2,'name'=>'二级','image'=>'','incremental_commission'=>'0.001']), 'regex');
+        $this->assertSame(1, DB::table('share_policies')->count());
+        $this->assertSame(1, DB::table('share_levels')->count());
+    }
+}

+ 3 - 2
tests/Integration/ShareCommissionRecordingTest.php

@@ -373,7 +373,8 @@ class ShareCommissionRecordingTest extends TestCase
         $this->assertSame(':memory:', $data['database']);
         $this->assertSame('bot_', $data['table_prefix']);
         $this->assertSame('0', $data['recharge_rate']);
-        $this->assertEquals(4, $data['tier_one_rate']);
+        $this->assertArrayNotHasKey('tier_one_rate', $data);
+        $this->assertArrayNotHasKey('tier_two_rate', $data);
         $this->assertSame(0, $data['counts']['rebate_logs_all']);
         $this->assertSame(1, $data['counts']['real_invitee_deposit_logs_all']);
         $this->assertSame(1, $data['counts']['linked_real_invitee_deposit_logs_all']);
@@ -720,7 +721,7 @@ class ShareCommissionRecordingTest extends TestCase
         $this->assertSame('100.0000', $rows['PROMO']['deposit_total']);
         $this->assertSame(1, $rows['PROMO']['registered_count']);
         $this->assertSame('2.5000', $rows['PROMO']['commission']);
-        $this->assertSame('4.0000', $rows['PROMO']['current_fee_rate']);
+        $this->assertSame('0.0000', $rows['PROMO']['current_fee_rate']);
         $this->assertSame('missing', $rows['OTHER']['promoter_status']);
         $this->assertSame('30.0000', $rows['OTHER']['deposit_total']);
     }

+ 266 - 0
tests/Integration/SharingBetFactsTest.php

@@ -0,0 +1,266 @@
+<?php
+
+namespace Tests\Integration;
+
+use App\Console\Commands\RecalculateShareRebates;
+use App\Models\Rebate;
+use App\Services\RebateService;
+use App\Services\Sharing\BetFactSynchronizer;
+use Carbon\Carbon;
+use Illuminate\Config\Repository;
+use Illuminate\Database\Capsule\Manager;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Events\Dispatcher;
+use Illuminate\Foundation\Application;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Facade;
+use Illuminate\Support\Facades\Schema;
+use PHPUnit\Framework\TestCase;
+
+class SharingBetFactsTest extends TestCase
+{
+    protected function setUp(): void
+    {
+        parent::setUp();
+        Carbon::setTestNow(Carbon::parse('2026-10-24 12:00:00', 'Asia/Shanghai'));
+        Facade::clearResolvedInstances();
+        $app = new Application(dirname(__DIR__, 2));
+        $app->instance('config', new Repository(['app' => ['timezone' => 'Asia/Shanghai']]));
+        Facade::setFacadeApplication($app);
+        $db = new Manager($app);
+        $db->addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => 'bot_']);
+        $db->setEventDispatcher(new Dispatcher($app)); $db->setAsGlobal(); $db->bootEloquent();
+        $app->instance('db', $db->getDatabaseManager());
+        $app->bind('db.schema', fn () => $db->schema());
+        Schema::create('users', function (Blueprint $t) { $t->id(); $t->string('member_id'); $t->integer('from')->default(1); });
+        DB::table('users')->insert([['id' => 1, 'member_id' => '10001', 'from' => -1], ['id' => 2, 'member_id' => '20002', 'from' => 2]]);
+        Schema::create('lhc_order', function (Blueprint $t) {
+            $t->id(); $t->string('member_id'); $t->string('ordernum'); $t->integer('type'); $t->integer('lottery_status');
+            $t->decimal('amount', 24, 10); $t->decimal('total_amount', 24, 10)->nullable(); $t->bigInteger('created_at'); $t->bigInteger('updated_at');
+        });
+        Schema::create('lhc_order_item', function (Blueprint $t) {
+            $t->id(); $t->string('member_id'); $t->string('ordernum'); $t->integer('lottery_status');
+            $t->decimal('win_amount', 24, 10)->nullable(); $t->decimal('profit_and_loss', 24, 10)->nullable(); $t->bigInteger('updated_at');
+        });
+        Schema::create('sport_game_order', function (Blueprint $t) {
+            $t->id(); $t->string('member_id'); $t->integer('status'); $t->decimal('amount', 24, 10); $t->decimal('win_amount', 24, 10)->default(0); $t->timestamps();
+        });
+        Schema::create('sport_order', function (Blueprint $t) {
+            $t->id(); $t->string('user_id'); $t->integer('status'); $t->integer('pay_status'); $t->integer('return_status')->default(0);
+            $t->integer('settlement_status'); $t->decimal('amount', 24, 10); $t->decimal('win_amount', 24, 10)->default(0);
+            $t->decimal('return_amount', 24, 10)->default(0); $t->bigInteger('create_time'); $t->bigInteger('update_time');
+            $t->bigInteger('settlement_time'); $t->bigInteger('return_operation_time')->nullable();
+        });
+        Schema::create('third_game_orders', function (Blueprint $t) {
+            $t->id(); $t->string('order_key'); $t->unsignedBigInteger('user_id')->nullable(); $t->string('member_id');
+            $t->integer('game_type'); $t->integer('status'); $t->decimal('bet_amount', 24, 10)->nullable(); $t->decimal('settled_amount', 24, 10)->nullable();
+            $t->string('currency'); $t->dateTime('bet_time'); $t->dateTime('last_update_time');
+        });
+        Schema::create('balance_logs', function (Blueprint $t) {
+            $t->id(); $t->string('member_id'); $t->unsignedBigInteger('related_id'); $t->string('change_type'); $t->decimal('amount', 24, 10);
+        });
+        Schema::create('share_bet_facts', function (Blueprint $t) {
+            $t->id(); $t->string('source'); $t->string('source_id'); $t->unsignedBigInteger('user_id'); $t->string('member_id');
+            $t->string('category'); $t->dateTime('bet_at'); $t->date('bet_date'); $t->dateTime('settled_at')->nullable();
+            foreach (['bet_amount', 'net_profit', 'effective_amount'] as $col) $t->decimal($col, 24, 10);
+            $t->boolean('settled'); $t->boolean('voided'); $t->string('currency'); $t->string('source_version'); $t->timestamps(); $t->unique(['source', 'source_id']);
+        });
+        Schema::create('share_system_state', function (Blueprint $t) { $t->string('key')->primary(); $t->text('value'); $t->timestamps(); });
+        DB::table('share_system_state')->insert(['key' => 'relations_lock', 'value' => '1']);
+        Schema::create('rebates', function (Blueprint $t) {
+            $t->id(); $t->string('member_id'); $t->date('date'); $t->decimal('betting_amount', 24, 2);
+            $t->decimal('effective_betting_amount', 24, 2); $t->decimal('profit', 24, 2)->default(0); $t->integer('status'); $t->timestamps();
+        });
+    }
+
+    protected function tearDown(): void
+    {
+        DB::disconnect(); Facade::clearResolvedInstances(); Facade::setFacadeApplication(null); Carbon::setTestNow();
+        parent::tearDown();
+    }
+
+    private function lottery(int $status = 2): void
+    {
+        $time = Carbon::parse('2026-10-24 00:10:00', 'Asia/Shanghai')->timestamp;
+        DB::table('lhc_order')->insert(['id' => 10, 'member_id' => '10001', 'ordernum' => 'current-lottery', 'type' => 5,
+            'lottery_status' => $status, 'amount' => 100, 'total_amount' => 200, 'created_at' => $time, 'updated_at' => $time + 3600]);
+        DB::table('lhc_order_item')->insert([
+            ['id' => 101, 'member_id' => '10001', 'ordernum' => 'current-lottery', 'lottery_status' => 2, 'win_amount' => 150, 'profit_and_loss' => 50, 'updated_at' => $time + 3600],
+            ['id' => 102, 'member_id' => '10001', 'ordernum' => 'current-lottery', 'lottery_status' => 1, 'win_amount' => 0, 'profit_and_loss' => -100, 'updated_at' => $time + 3600],
+        ]);
+    }
+
+    public function test_preview_then_sync_uses_child_bets_and_repeated_sync_is_idempotent(): void
+    {
+        $this->lottery();
+        $sync = new BetFactSynchronizer();
+        $this->assertSame(2, $sync->sync()['created']);
+        $this->assertSame(0, DB::table('share_bet_facts')->count());
+        $this->assertSame(2, $sync->sync(null, null, true)['created']);
+        $this->assertEquals(150, DB::table('share_bet_facts')->sum('effective_amount'));
+        $this->assertSame('2026-10-24', DB::table('share_bet_facts')->first()->bet_date);
+        $this->assertSame(['2026-10-24'], json_decode(DB::table('share_system_state')->where('key', 'facts_dirty_dates')->value('value'), true));
+        $again = $sync->sync(null, null, true);
+        $this->assertSame(0, $again['created']); $this->assertSame(0, $again['updated']);
+        $this->assertSame(2, DB::table('share_bet_facts')->count());
+    }
+
+    public function test_refunded_orders_mark_previous_facts_void_and_preserve_unfinished_facts(): void
+    {
+        $this->lottery();
+        $sync = new BetFactSynchronizer(); $sync->sync(null, null, true);
+        // Failed draw legacy refund is stored with loss status; its positive refund log is authoritative.
+        DB::table('lhc_order')->where('id', 10)->update(['lottery_status' => 1, 'updated_at' => Carbon::now()->timestamp]);
+        DB::table('balance_logs')->insert(['member_id' => '10001', 'related_id' => 10, 'change_type' => '投注退分', 'amount' => 200]);
+        $this->assertSame(2, $sync->sync(null, null, true)['updated']);
+        $this->assertEquals(0, DB::table('share_bet_facts')->sum('effective_amount'));
+        $this->assertSame(2, DB::table('share_bet_facts')->where('voided', 1)->count());
+        DB::table('sport_game_order')->insert(['member_id' => '10001', 'status' => 0, 'amount' => 100, 'created_at' => '2026-10-24 10:00:00']);
+        $sync->sync(null, null, true);
+        $pending = DB::table('share_bet_facts')->where('source', 'sport_game_order')->first();
+        $this->assertEquals(0, $pending->effective_amount); $this->assertEquals(0, $pending->settled);
+    }
+
+    public function test_football_partial_returns_third_net_profit_and_demo_exclusion(): void
+    {
+        $time = Carbon::parse('2026-10-24 01:00:00', 'Asia/Shanghai')->timestamp;
+        DB::table('sport_order')->insert(['user_id' => '10001', 'status' => 1, 'pay_status' => 1, 'settlement_status' => 1,
+            'amount' => 100, 'win_amount' => 0, 'return_amount' => 50, 'create_time' => $time, 'update_time' => $time + 3600, 'settlement_time' => $time + 3600]);
+        foreach ([1 => '10001', 2 => '20002'] as $id => $member) {
+            DB::table('third_game_orders')->insert(['user_id' => $id, 'member_id' => $member, 'order_key' => hash('sha256', (string) $id),
+                'game_type' => 4, 'status' => 1, 'bet_amount' => 100, 'settled_amount' => 80, 'currency' => 'CNY',
+                'bet_time' => '2026-10-24 01:00:00', 'last_update_time' => '2026-10-24 02:00:00']);
+        }
+        $result = (new BetFactSynchronizer())->sync(null, null, true);
+        $this->assertSame([], $result['errors']); $this->assertSame(2, $result['created']);
+        $this->assertEquals(50, DB::table('share_bet_facts')->where('source', 'sport_order')->value('effective_amount'));
+        $this->assertEquals(80, DB::table('share_bet_facts')->where('source', 'third_game_orders')->value('effective_amount'));
+        $this->assertSame(0, DB::table('share_bet_facts')->where('user_id', 2)->count());
+        DB::table('third_game_orders')->where('user_id', 1)->update(['status' => 3, 'game_type' => 0, 'bet_amount' => null,
+            'settled_amount' => null, 'last_update_time' => '2026-10-24 12:00:00']);
+        $sync = (new BetFactSynchronizer())->sync(null, null, true);
+        $this->assertSame([], $sync['errors']); $this->assertSame(1, $sync['updated']);
+        $cancelled = DB::table('share_bet_facts')->where('source', 'third_game_orders')->first();
+        $this->assertEquals(0, $cancelled->effective_amount); $this->assertEquals(1, $cancelled->voided);
+        $this->assertSame('third:4', $cancelled->category);
+    }
+
+    public function test_history_recalculation_is_preview_by_default_and_never_changes_paid_or_ambiguous_dates(): void
+    {
+        $this->lottery();
+        DB::table('rebates')->insert([
+            ['id' => 1, 'member_id' => '10001', 'date' => '2026-10-23', 'betting_amount' => 200, 'effective_betting_amount' => 200, 'status' => 0],
+            ['id' => 2, 'member_id' => '10001', 'date' => '2026-10-24', 'betting_amount' => 200, 'effective_betting_amount' => 200, 'status' => 0],
+            ['id' => 3, 'member_id' => '10001', 'date' => '2026-10-22', 'betting_amount' => 200, 'effective_betting_amount' => 200, 'status' => 1],
+        ]);
+        $command = new RecalculateShareRebates(); $sync = new BetFactSynchronizer();
+        $preview = $command->recalculate($sync);
+        $this->assertSame(1, $preview['changed']); $this->assertSame(1, $preview['skipped']); $this->assertSame(1, $preview['paid_untouched']);
+        $this->assertEquals(200, DB::table('rebates')->where('id', 1)->value('effective_betting_amount'));
+        $written = $command->recalculate($sync, true); $this->assertSame(1, $written['changed']);
+        $this->assertEquals(150, DB::table('rebates')->where('id', 1)->value('effective_betting_amount'));
+        $this->assertEquals(200, DB::table('rebates')->where('id', 2)->value('effective_betting_amount'));
+        $this->assertEquals(200, DB::table('rebates')->where('id', 3)->value('effective_betting_amount'));
+    }
+
+    public function test_live_rebate_uses_betting_date_and_does_not_modify_paid_effective_basis(): void
+    {
+        DB::table('rebates')->insert(['id' => 1, 'member_id' => '10001', 'date' => '2026-10-23',
+            'betting_amount' => 100, 'effective_betting_amount' => 0, 'profit' => 0, 'status' => 0]);
+        $row = Rebate::updateProfit(['member_id' => '10001', 'date' => '2026-10-23', 'profit' => 50]);
+        $this->assertSame(1, $row->id); RebateService::updateEffectiveBettingAmount($row, '50.00');
+        $this->assertEquals(50, DB::table('rebates')->value('effective_betting_amount'));
+        DB::table('rebates')->where('id', 1)->update(['status' => 1]);
+        RebateService::updateEffectiveBettingAmount($row, '100.00');
+        $this->assertEquals(50, DB::table('rebates')->value('effective_betting_amount'));
+    }
+
+    public function test_legacy_injected_bets_are_never_a_source_and_partial_refund_is_flagged_for_review(): void
+    {
+        Schema::create('bets', function (Blueprint $t) { $t->id(); $t->integer('status'); $t->decimal('amount', 24, 10); });
+        DB::table('bets')->insert(['status' => 2, 'amount' => 100000]);
+        $this->lottery();
+        DB::table('balance_logs')->insert(['member_id' => '10001', 'related_id' => 10, 'change_type' => '投注退分', 'amount' => 100]);
+        $result = (new BetFactSynchronizer())->sync(null, null, true);
+        $this->assertSame(1, $result['scanned']); $this->assertSame(1, count($result['errors']));
+        $this->assertStringContainsString('refund requires review', $result['errors'][0]['reason']);
+        $this->assertSame(0, DB::table('share_bet_facts')->count());
+        $this->assertNull(DB::table('share_system_state')->where('key', 'facts_cursor:lhc_order')->value('value'));
+    }
+
+    public function test_incremental_cursor_includes_new_orders_with_old_dates_and_retries_updated_sources(): void
+    {
+        $sync = new BetFactSynchronizer(); $sync->sync(null, null, true);
+        DB::table('sport_game_order')->insert(['id' => 20, 'member_id' => '10001', 'status' => 2, 'amount' => 100,
+            'win_amount' => 150, 'created_at' => '2026-10-01 10:00:00', 'updated_at' => '2026-10-01 11:00:00']);
+        $this->assertSame(1, $sync->sync(null, null, true)['created']);
+        DB::table('sport_game_order')->where('id', 20)->update(['status' => 3, 'updated_at' => '2026-10-24 12:00:00']);
+        $this->assertSame(1, $sync->sync(null, null, true)['updated']);
+        $this->assertEquals(0, DB::table('share_bet_facts')->value('effective_amount'));
+        $this->assertSame(['2026-10-01'], json_decode(DB::table('share_system_state')->where('key', 'facts_dirty_dates')->value('value'), true));
+    }
+
+    public function test_second_child_failure_rolls_back_all_facts_dirty_dates_and_source_cursor(): void
+    {
+        $this->lottery();
+        DB::statement("CREATE TRIGGER reject_second_fact BEFORE INSERT ON bot_share_bet_facts WHEN NEW.source_id='102' BEGIN SELECT RAISE(ABORT,'second child failed'); END");
+        $result = (new BetFactSynchronizer())->sync(null, null, true);
+        $this->assertSame(1, count($result['errors'])); $this->assertSame(0, $result['created']);
+        $this->assertSame(0, DB::table('share_bet_facts')->count());
+        $this->assertNull(DB::table('share_system_state')->where('key', 'facts_dirty_dates')->value('value'));
+        $this->assertNull(DB::table('share_system_state')->where('key', 'facts_cursor:lhc_order')->value('value'));
+        $this->assertSame(0, DB::transactionLevel());
+    }
+
+    public function test_write_reloads_source_after_chunk_read_and_preview_never_creates_the_guard(): void
+    {
+        $this->lottery();
+        DB::table('share_system_state')->where('key', 'relations_lock')->delete();
+        $preview = (new BetFactSynchronizer())->sync();
+        $this->assertSame(2, $preview['created']); $this->assertSame(0, DB::table('share_system_state')->count());
+        DB::table('share_system_state')->insert(['key' => 'relations_lock', 'value' => '1']);
+        $changed = false;
+        DB::listen(function ($event) use (&$changed) {
+            if (!$changed && str_contains($event->sql, 'from "bot_lhc_order" order by "id"')) {
+                $changed = true;
+                DB::table('lhc_order')->where('id', 10)->update(['lottery_status' => 4, 'updated_at' => Carbon::now()->timestamp]);
+            }
+        });
+        $result = (new BetFactSynchronizer())->sync(null, null, true);
+        $this->assertTrue($changed); $this->assertSame([], $result['errors']); $this->assertSame(2, $result['created']);
+        $this->assertEquals(0, DB::table('share_bet_facts')->sum('effective_amount'));
+        $this->assertSame(2, DB::table('share_bet_facts')->where('voided', 1)->count());
+    }
+
+    public function test_untrusted_source_quarantines_only_identifiable_old_facts_and_repair_restores_them(): void
+    {
+        $this->lottery();
+        DB::table('sport_game_order')->insert(['member_id' => '10001', 'status' => 2, 'amount' => 100, 'win_amount' => 180,
+            'created_at' => '2026-10-24 10:00:00', 'updated_at' => '2026-10-24 11:00:00']);
+        $sync = new BetFactSynchronizer(); $sync->sync(null, null, true);
+        $original = DB::table('share_bet_facts')->where('source', 'lhc_order_item')->orderBy('source_id')->get();
+        $cursor = DB::table('share_system_state')->where('key', 'facts_cursor:lhc_order')->value('value');
+        DB::table('share_system_state')->where('key', 'facts_dirty_dates')->update(['value' => '[]']);
+        DB::table('lhc_order')->where('id', 10)->update(['updated_at' => Carbon::now()->timestamp]);
+        DB::table('balance_logs')->insert(['member_id' => '10001', 'related_id' => 10, 'change_type' => '投注退分', 'amount' => 100]);
+        $bad = $sync->sync(null, null, true);
+        $this->assertSame(1, count($bad['errors'])); $this->assertSame(2, $bad['errors'][0]['isolated_facts']);
+        $this->assertSame(2, $bad['updated']);
+        $this->assertEquals(0, DB::table('share_bet_facts')->where('source', 'lhc_order_item')->sum('effective_amount'));
+        $this->assertSame(2, DB::table('share_bet_facts')->where('source', 'lhc_order_item')->where('settled', 0)->where('voided', 0)->count());
+        $this->assertEquals(80, DB::table('share_bet_facts')->where('source', 'sport_game_order')->value('effective_amount'));
+        $quarantined = DB::table('share_bet_facts')->where('source', 'lhc_order_item')->orderBy('source_id')->get();
+        foreach ($original as $i => $old) {
+            $this->assertEquals($old->bet_amount, $quarantined[$i]->bet_amount);
+            $this->assertEquals($old->net_profit, $quarantined[$i]->net_profit);
+            $this->assertNotSame($old->source_version, $quarantined[$i]->source_version);
+        }
+        $this->assertSame($cursor, DB::table('share_system_state')->where('key', 'facts_cursor:lhc_order')->value('value'));
+        $this->assertSame(['2026-10-24'], json_decode(DB::table('share_system_state')->where('key', 'facts_dirty_dates')->value('value'), true));
+        DB::table('balance_logs')->where('change_type', '投注退分')->delete();
+        $fixed = $sync->sync(null, null, true);
+        $this->assertSame([], $fixed['errors']); $this->assertSame(2, $fixed['updated']);
+        $this->assertEquals(150, DB::table('share_bet_facts')->where('source', 'lhc_order_item')->sum('effective_amount'));
+        $this->assertSame(2, DB::table('share_bet_facts')->where('source', 'lhc_order_item')->where('settled', 1)->count());
+    }
+}

+ 270 - 0
tests/Integration/SharingMysqlConcurrencyTest.php

@@ -0,0 +1,270 @@
+<?php
+
+namespace Tests\Integration;
+
+use App\Services\Sharing\ShareSettlementService;
+use Illuminate\Support\Facades\DB;
+use PHPUnit\Framework\TestCase;
+use Tests\Fixtures\SharingMysqlSupport;
+
+require_once dirname(__DIR__) . '/fixtures/sharing_mysql_support.php';
+
+/**
+ * Opt-in, real InnoDB row-lock tests. Never loads either project's .env or connects over TCP.
+ * SHARING_MYSQL_SOCKET must belong to a new --skip-networking server under the guarded tmp prefix.
+ * The runner starts and stops its own server; this suite owns only randomly named test databases.
+ */
+class SharingMysqlConcurrencyTest extends TestCase
+{
+    private ?\PDO $server = null;
+    private string $socket;
+    private string $database;
+    private string $runDir;
+    private array $processes = [];
+
+    protected function setUp(): void
+    {
+        $socket = getenv('SHARING_MYSQL_SOCKET');
+        if (!$socket) $this->markTestSkipped('Set SHARING_MYSQL_SOCKET to a new isolated, socket-only MySQL fixture; no existing database is allowed');
+        if (!in_array('mysql', \PDO::getAvailableDrivers(), true)) $this->markTestSkipped('The isolated PHP runtime has no pdo_mysql driver');
+        $this->socket = $socket;
+        $this->server = SharingMysqlSupport::guardedPdo($socket);
+        $this->database = 'share_concurrency_' . bin2hex(random_bytes(6));
+        $this->server->exec('CREATE DATABASE `' . $this->database . '` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci');
+        $this->runDir = dirname($socket) . '/workers-' . bin2hex(random_bytes(6));
+        mkdir($this->runDir, 0700);
+        SharingMysqlSupport::laravel($socket, $this->database);
+        SharingMysqlSupport::schema();
+        SharingMysqlSupport::seed();
+    }
+
+    protected function tearDown(): void
+    {
+        foreach ($this->processes as $worker) {
+            if (is_resource($worker['process'])) {
+                if (proc_get_status($worker['process'])['running']) proc_terminate($worker['process']);
+                foreach ($worker['pipes'] as $pipe) if (is_resource($pipe)) fclose($pipe);
+                proc_close($worker['process']);
+            }
+        }
+        $this->processes = [];
+        if ($this->server !== null) {
+            DB::disconnect();
+            $this->server->exec('DROP DATABASE IF EXISTS `' . $this->database . '`');
+            foreach (glob($this->runDir . '/*') ?: [] as $file) unlink($file);
+            rmdir($this->runDir);
+            $this->server = null;
+            \Carbon\Carbon::setTestNow();
+            \Illuminate\Support\Facades\Facade::clearResolvedInstances();
+        }
+        parent::tearDown();
+    }
+
+    public function test_same_tp_request_in_two_processes_reserves_once_and_returns_the_same_claim(): void
+    {
+        [$a, $b] = $this->race(['framework' => 'tp', 'request_id' => 'same-request'], ['framework' => 'tp', 'request_id' => 'same-request']);
+        $this->assertTrue($a['ok']); $this->assertTrue($b['ok']);
+        $this->assertSame($a['result']['id'], $b['result']['id']);
+        $this->assertSame('10.01', $a['result']['amount']);
+        $this->assertSame(1, DB::table('share_claims')->count());
+        $this->assertSame('10.0100000000', $this->amount('reserved_amount'));
+        $this->assertSame('20.0000000000', $this->wallet());
+    }
+
+    public function test_different_tp_requests_in_two_processes_leave_only_one_pending_claim(): void
+    {
+        [$a, $b] = $this->race(['framework' => 'tp', 'request_id' => 'request-a'], ['framework' => 'tp', 'request_id' => 'request-b']);
+        $this->assertTrue($a['ok']); $this->assertFalse($b['ok']);
+        $this->assertStringContainsString('已有待审核', $b['message']);
+        $this->assertSame(1, DB::table('share_claims')->where('active_user_id', 1)->count());
+        $this->assertSame('10.0100000000', $this->amount('reserved_amount'));
+        $this->assertSame('0.0000000000', $this->amount('paid_amount'));
+    }
+
+    public function test_concurrent_laravel_approvals_credit_one_wallet_log_and_audit(): void
+    {
+        $claim = $this->single(['framework' => 'tp', 'request_id' => 'approve-once']);
+        $this->assertTrue($claim['ok']);
+        $args = ['framework' => 'laravel', 'action' => 'audit', 'claim_id' => $claim['result']['id']];
+        [$a, $b] = $this->race($args, $args);
+        $this->assertTrue($a['ok']); $this->assertTrue($b['ok']);
+        $this->assertSame('approved', $a['result']['status']);
+        $this->assertSame($a['result']['credited_log_id'], $b['result']['credited_log_id']);
+        $this->assertSame('30.0100000000', $this->wallet());
+        $this->assertSame('10.0100000000', $this->amount('paid_amount'));
+        $this->assertSame('0.0000000000', $this->amount('reserved_amount'));
+        $this->assertSame(1, DB::table('balance_logs')->where('change_type', '分享佣金领取')->count());
+        $this->assertSame(1, DB::table('operation_audits')->where('resource_type', 'share_claim')->count());
+    }
+
+    public function test_correction_holding_account_blocks_approval_and_preserves_the_original_pending_request(): void
+    {
+        $id = $this->settledClaim();
+        DB::table('share_bet_facts')->update(['effective_amount' => '50', 'net_profit' => '-50']);
+        [$corrected, $approval] = $this->race(['framework' => 'laravel', 'action' => 'settle'], ['framework' => 'laravel', 'action' => 'audit', 'claim_id' => $id]);
+        $this->assertTrue($corrected['ok']); $this->assertFalse($approval['ok']);
+        $this->assertStringContainsString('重新申请', $approval['message']);
+        $this->assertSame('5.0000000000', $this->amount('earned_amount'));
+        $this->assertSame('0.0000000000', $this->amount('paid_amount'));
+        $this->assertSame('10.0000000000', $this->amount('reserved_amount'));
+        $this->assertSame('pending', DB::table('share_claims')->where('id', $id)->value('status'));
+        $this->assertSame('20.0000000000', $this->wallet());
+        $this->assertSame(0, DB::table('balance_logs')->where('change_type', '分享佣金领取')->count());
+        $before = DB::table('share_ledger')->count();
+        $this->assertSame(0, (new ShareSettlementService())->settle('2026-10-24')['changed']);
+        $this->assertSame($before, DB::table('share_ledger')->count());
+    }
+
+    public function test_approval_holding_account_credits_once_then_correction_becomes_debt_without_wallet_debit(): void
+    {
+        $id = $this->settledClaim();
+        DB::table('share_bet_facts')->update(['effective_amount' => '50', 'net_profit' => '-50']);
+        [$approval, $correction] = $this->race(['framework' => 'laravel', 'action' => 'audit', 'claim_id' => $id], ['framework' => 'laravel', 'action' => 'settle']);
+        $this->assertTrue($approval['ok']); $this->assertTrue($correction['ok']);
+        $this->assertSame('30.0000000000', $this->wallet());
+        $this->assertSame('10.0000000000', $this->amount('paid_amount'));
+        $this->assertSame('5.0000000000', $this->amount('earned_amount'));
+        $this->assertSame('0.0000000000', $this->amount('reserved_amount'));
+        $this->assertSame('approved', DB::table('share_claims')->where('id', $id)->value('status'));
+        $this->assertSame(1, DB::table('balance_logs')->where('change_type', '分享佣金领取')->count());
+        $this->assertSame(2, DB::table('share_ledger')->count());
+        $this->assertSame(0, (new ShareSettlementService())->settle('2026-10-24')['changed']);
+        $this->assertSame('30.0000000000', $this->wallet());
+    }
+
+    public function test_two_settlement_processes_serialize_one_revision_and_one_ledger_delta(): void
+    {
+        DB::table('share_accounts')->where('user_id', 1)->update(['earned_amount' => 0]);
+        $args = ['framework' => 'laravel', 'action' => 'settle'];
+        [$a, $b] = $this->race($args, $args);
+        $this->assertTrue($a['ok']); $this->assertTrue($b['ok']);
+        $this->assertSame(1, $a['result']['changed']); $this->assertSame(0, $b['result']['changed']);
+        $this->assertSame('10.0000000000', $this->amount('earned_amount'));
+        $this->assertSame(1, DB::table('share_ledger')->count());
+        $this->assertSame(1, DB::table('share_settlements')->count());
+        $this->assertSame(1, (int)DB::table('share_settlements')->value('revision'));
+    }
+
+    public function test_lottery_fact_swap_is_one_publication_and_settlement_waits_for_the_complete_parent_order(): void
+    {
+        DB::table('share_bet_facts')->delete();
+        DB::table('share_accounts')->where('user_id', 1)->update(['earned_amount' => 0]);
+        $schema = DB::connection()->getSchemaBuilder();
+        $schema->create('lhc_order', function ($t) {
+            $t->id(); $t->string('member_id'); $t->string('ordernum'); $t->integer('type'); $t->integer('lottery_status');
+            $t->decimal('amount', 24, 10); $t->decimal('total_amount', 24, 10); $t->bigInteger('created_at'); $t->bigInteger('updated_at');
+        });
+        $schema->create('lhc_order_item', function ($t) {
+            $t->id(); $t->string('member_id'); $t->string('ordernum'); $t->integer('lottery_status');
+            $t->decimal('win_amount', 24, 10); $t->decimal('profit_and_loss', 24, 10); $t->bigInteger('updated_at');
+        });
+        $time = \Carbon\Carbon::parse('2026-10-24 12:00:00', 'Asia/Shanghai')->timestamp;
+        DB::table('lhc_order')->insert(['id' => 1, 'member_id' => 'M2', 'ordernum' => 'atomic-swap', 'type' => 5,
+            'lottery_status' => 2, 'amount' => 100, 'total_amount' => 200, 'created_at' => $time, 'updated_at' => $time]);
+        DB::table('lhc_order_item')->insert([
+            ['id' => 101, 'member_id' => 'M2', 'ordernum' => 'atomic-swap', 'lottery_status' => 2,
+                'win_amount' => 150, 'profit_and_loss' => 50, 'updated_at' => $time],
+            ['id' => 102, 'member_id' => 'M2', 'ordernum' => 'atomic-swap', 'lottery_status' => 1,
+                'win_amount' => 0, 'profit_and_loss' => -100, 'updated_at' => $time],
+        ]);
+        $baseline = (new \App\Services\Sharing\BetFactSynchronizer())->sync('2026-10-24', '2026-10-24', true);
+        $this->assertSame([], $baseline['errors']); $this->assertSame(2, $baseline['created']);
+        $this->assertSame('150.0000000000', bcadd((string)DB::table('share_bet_facts')->sum('effective_amount'), '0', 10));
+        $this->assertSame(1, (new ShareSettlementService())->settle('2026-10-24')['changed']);
+        $this->assertSame('15.0000000000', $this->amount('earned_amount'));
+        DB::table('lhc_order_item')->where('id', 101)->update(['lottery_status' => 1, 'win_amount' => 0, 'profit_and_loss' => -100, 'updated_at' => $time + 1]);
+        DB::table('lhc_order_item')->where('id', 102)->update(['lottery_status' => 2, 'win_amount' => 150, 'profit_and_loss' => 50, 'updated_at' => $time + 1]);
+        [$sync, $settled] = $this->race(['framework' => 'laravel', 'action' => 'sync'], ['framework' => 'laravel', 'action' => 'settle'], function ($held) {
+            // Inside the source-order transaction the first changed child temporarily makes 200.
+            // Another connection must still see the old, committed 150 and settlement must wait.
+            $this->assertSame('200.0000000000', file_get_contents($held['partial']));
+            $this->assertSame('150.0000000000', bcadd((string)DB::table('share_bet_facts')->sum('effective_amount'), '0', 10));
+        });
+        $this->assertTrue($sync['ok']); $this->assertSame([], $sync['result']['errors']); $this->assertSame(2, $sync['result']['updated']);
+        $this->assertTrue($settled['ok']); $this->assertSame(0, $settled['result']['changed']);
+        $this->assertSame('150.0000000000', bcadd((string)DB::table('share_settlements')->value('direct_performance'), '0', 10));
+        $this->assertSame('150.0000000000', bcadd((string)DB::table('share_bet_facts')->sum('effective_amount'), '0', 10));
+        $this->assertSame('15.0000000000', $this->amount('earned_amount'));
+        $this->assertSame(1, DB::table('share_ledger')->count());
+        $this->assertSame(1, (int)DB::table('share_settlements')->value('revision'));
+    }
+
+    private function settledClaim(): int
+    {
+        DB::table('share_accounts')->where('user_id', 1)->update(['earned_amount' => 0]);
+        $this->assertSame(1, (new ShareSettlementService())->settle('2026-10-24')['changed']);
+        $claim = $this->single(['framework' => 'tp', 'request_id' => 'corrected-claim']);
+        $this->assertTrue($claim['ok']); $this->assertSame('10.00', $claim['result']['amount']);
+        return (int)$claim['result']['id'];
+    }
+
+    private function race(array $first, array $second, ?callable $whileHeld = null): array
+    {
+        $a = $this->spawn($first + ['hold' => true]);
+        try {
+            $this->waitFile($a['locked']);
+            if ($whileHeld) $whileHeld($a);
+            $b = $this->spawn($second);
+            $this->waitFile($b['ready']);
+            $deadline = microtime(true) + 5;
+            do {
+                $waiting = (int)$this->server->query('SELECT COUNT(*) FROM performance_schema.data_lock_waits')->fetchColumn();
+                if ($waiting > 0) break;
+                usleep(10000);
+            } while (microtime(true) < $deadline);
+            $this->assertGreaterThan(0, $waiting, 'The second real database transaction must contend on an InnoDB row lock');
+        } finally {
+            touch($a['release']);
+        }
+        return [$this->collect($a), $this->collect($b)];
+    }
+
+    private function single(array $args): array { return $this->collect($this->spawn($args)); }
+
+    private function spawn(array $args): array
+    {
+        $token = bin2hex(random_bytes(6));
+        $params = $args + ['socket' => $this->socket, 'database' => $this->database,
+            'tp_root' => getenv('SHARING_TP_ROOT') ?: dirname(__DIR__, 3) . '/melbet_sport-api',
+            'laravel_autoload' => getenv('AGENT_TEST_AUTOLOAD') ?: dirname(__DIR__, 2) . '/vendor/autoload.php',
+            'ready' => $this->runDir . '/' . $token . '.ready', 'locked' => $this->runDir . '/' . $token . '.locked',
+            'partial' => $this->runDir . '/' . $token . '.partial',
+            'release' => $this->runDir . '/' . $token . '.release'];
+        $process = proc_open([PHP_BINARY, '-d', 'xdebug.mode=off', '-d', 'error_reporting=8191',
+            dirname(__DIR__) . '/fixtures/sharing_mysql_worker.php', json_encode($params, JSON_THROW_ON_ERROR)],
+            [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes);
+        if (!is_resource($process)) throw new \RuntimeException('Cannot start an isolated worker process');
+        fclose($pipes[0]); unset($pipes[0]);
+        foreach ($pipes as $pipe) stream_set_blocking($pipe, false);
+        $worker = $params + ['process' => $process, 'pipes' => $pipes, 'token' => $token];
+        $this->processes[$token] = $worker;
+        return $worker;
+    }
+
+    private function collect(array $worker): array
+    {
+        $deadline = microtime(true) + 20;
+        while (proc_get_status($worker['process'])['running']) {
+            if (microtime(true) > $deadline) throw new \RuntimeException('A fixture worker did not finish');
+            usleep(10000);
+        }
+        $stdout = stream_get_contents($worker['pipes'][1]); $stderr = stream_get_contents($worker['pipes'][2]);
+        foreach ($worker['pipes'] as $pipe) fclose($pipe);
+        proc_close($worker['process']); unset($this->processes[$worker['token']]);
+        $decoded = json_decode($stdout, true);
+        if (!is_array($decoded)) throw new \RuntimeException('Worker produced no JSON result: ' . $stdout . $stderr);
+        return $decoded;
+    }
+
+    private function waitFile(string $path): void
+    {
+        $deadline = microtime(true) + 15;
+        while (!is_file($path)) {
+            if (microtime(true) > $deadline) throw new \RuntimeException('Worker did not reach expected synchronization point: ' . basename($path));
+            usleep(10000);
+        }
+    }
+
+    private function amount(string $field): string { return bcadd((string)DB::table('share_accounts')->where('user_id', 1)->value($field), '0', 10); }
+    private function wallet(): string { return bcadd((string)DB::table('wallets')->where('member_id', 'M1')->value('available_balance'), '0', 10); }
+}

+ 276 - 0
tests/Integration/SharingSettlementTest.php

@@ -0,0 +1,276 @@
+<?php
+
+namespace Tests\Integration;
+
+use App\Services\Sharing\ShareDefaults;
+use App\Services\Sharing\ShareRelations;
+use App\Services\Sharing\ShareSettlementService;
+use Carbon\Carbon;
+use Illuminate\Config\Repository;
+use Illuminate\Database\Capsule\Manager;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Foundation\Application;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Facade;
+use PHPUnit\Framework\TestCase;
+
+class SharingSettlementTest extends TestCase
+{
+    private ShareSettlementService $service;
+    private ShareRelations $relations;
+
+    protected function setUp(): void
+    {
+        Facade::clearResolvedInstances();
+        Carbon::setTestNow(Carbon::parse('2026-10-25 12:00:00','Asia/Shanghai'));
+        $app=new Application(dirname(__DIR__,2));
+        $app->instance('config',new Repository(['app'=>['timezone'=>'Asia/Shanghai'],'third_game'=>['currency'=>'CNY']]));
+        Facade::setFacadeApplication($app);
+        $db=new Manager($app); $db->addConnection(['driver'=>'sqlite','database'=>':memory:','prefix'=>'bot_']);
+        $db->setAsGlobal(); $db->bootEloquent(); $app->instance('db',$db->getDatabaseManager()); $app->bind('db.schema',fn()=>$db->schema());
+        $db->schema()->create('users',function(Blueprint $t){
+            $t->id(); $t->string('member_id'); $t->string('user_code'); $t->string('agent_user_code')->nullable(); $t->integer('from')->default(1); $t->timestamps();
+            $t->string('user_id')->nullable(); $t->string('first_name')->nullable(); $t->string('username')->nullable(); $t->string('language')->nullable();
+        });
+        $db->schema()->create('share_settings',function(Blueprint $t){
+            $t->id(); $t->boolean('enabled')->default(1); $t->boolean('benefit_enabled')->default(0); $t->string('share_domain')->default('https://pc.example');
+            $t->decimal('valid_member_min_deposit',24,10)->default(20); $t->timestamps();
+        });
+        $db->schema()->create('balance_logs',function(Blueprint $t){
+            $t->id(); $t->string('member_id'); $t->integer('type')->default(1); $t->string('change_type'); $t->decimal('amount',24,10); $t->timestamps();
+        });
+        $db->schema()->create('wallets',function(Blueprint $t){$t->id();$t->string('member_id');$t->decimal('available_balance',24,10)->default(50);$t->decimal('frozen_balance',24,10)->default(0);});
+        (require dirname(__DIR__,2).'/database/migrations/2026_10_24_120000_create_member_sharing_system.php')->up();
+        foreach ([[1,'A',null],[2,'B','A'],[3,'C','B'],[4,'D','C'],[5,'E','A'],[6,'X',null],[7,'DEMO',null]] as [$id,$code,$parent]) {
+            DB::table('users')->insert(['id'=>$id,'member_id'=>'M'.$id,'user_code'=>$code,'agent_user_code'=>$parent,'from'=>$id===7?2:1,'created_at'=>'2026-10-01 00:00:00']);
+            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_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())]);
+        $this->relations=new ShareRelations(); $this->relations->initialize(true); $this->service=new ShareSettlementService();
+    }
+
+    protected function tearDown(): void
+    {
+        Carbon::setTestNow(); DB::disconnect(); Facade::clearResolvedInstances(); parent::tearDown();
+    }
+
+    private function fact(int $user,string $amount,string $date='2026-10-24',array $extra=[]): int
+    {
+        return DB::table('share_bet_facts')->insertGetId($extra+['source'=>'fixture','source_id'=>uniqid('',true),'user_id'=>$user,'member_id'=>'M'.$user,
+            'category'=>'third:2','bet_at'=>$date.' 12:00:00','bet_date'=>$date,'settled_at'=>'2026-10-25 10:00:00',
+            'bet_amount'=>$amount,'net_profit'=>'-'.$amount,'effective_amount'=>$amount,'settled'=>1,'voided'=>0,'currency'=>'CNY','source_version'=>str_repeat('a',64)]);
+    }
+
+    private function amount(int $user,string $field='earned_amount'): string { return bcadd((string)DB::table('share_accounts')->where('user_id',$user)->value($field),'0',10); }
+
+    public function test_nine_categories_have_the_exact_twelve_tiers_and_both_qualifiers_are_required(): void
+    {
+        $this->assertCount(9,ShareDefaults::rules());
+        $rules=ShareDefaults::rules()['third:2']; $this->assertCount(12,$rules);
+        $this->assertSame('0.25',ShareSettlementService::rate($rules,1,'1000000'));
+        $this->assertSame('0',ShareSettlementService::rate($rules,0,'99.99'));
+        $this->assertSame('0.10',ShareSettlementService::rate($rules,0,'100'));
+        $this->assertSame('0.70',ShareSettlementService::rate($rules,5,'1700000'));
+    }
+
+    public function test_receiver_own_rate_controls_unlimited_indirect_bonus_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('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('40.0000000000',$this->amount(2));
+        $entries=DB::table('share_ledger')->count();
+        $this->assertSame(0,$this->service->settle('2026-10-24')['changed']);
+        $this->assertSame($entries,DB::table('share_ledger')->count());
+        $this->assertSame($before,DB::table('wallets')->get()->toJson());
+    }
+
+    public function test_infinite_depth_is_flat_not_repeatedly_discounted(): 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(3,DB::table('share_tree')->where('ancestor_user_id',1)->where('descendant_user_id',4)->value('depth'));
+    }
+
+    public function test_qualification_excludes_performance_and_later_deposits_do_not_requalify_previous_days(): void
+    {
+        $this->fact(2,'200'); $this->fact(3,'20000');
+        DB::table('balance_logs')->where('member_id','M3')->update(['created_at'=>'2026-10-25 00:01:00']);
+        $projection=$this->service->projection('2026-10-24');
+        $this->assertArrayNotHasKey(2,$projection['rows']);
+        $this->assertSame('0',$projection['rows'][1]['third:2']['indirect_performance']);
+        $this->assertSame(1,$projection['rows'][1]['third:2']['valid_direct_members']);
+        $this->fact(2,'10','2026-10-25'); $this->fact(3,'20000','2026-10-25');
+        $this->assertArrayHasKey(2,$this->service->projection('2026-10-25')['rows']);
+    }
+
+    public function test_all_game_cumulative_qualification_but_category_tiers_stay_independent(): void
+    {
+        $this->fact(2,'50'); $this->fact(2,'60','2026-10-23',['category'=>'self:sport']);
+        $this->fact(5,'20000','2026-10-24',['category'=>'self:lottery']);
+        $rows=$this->service->projection('2026-10-24')['rows'][1];
+        $this->assertSame('50.0000000000',$rows['third:2']['direct_performance']);
+        $this->assertSame('0',$rows['third:2']['rate']);
+        $this->assertSame('0.20',$rows['self:lottery']['rate']);
+        $this->assertSame(2,$rows['self:lottery']['valid_direct_members']);
+    }
+
+    public function test_late_result_reconciles_original_day_and_later_cumulative_qualifications(): void
+    {
+        $this->fact(2,'200'); $this->fact(3,'99');
+        $this->service->settle('2026-10-24'); $this->assertSame('0.2000000000',$this->amount(1));
+        $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('[]',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']);
+        $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.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($wallet,DB::table('wallets')->get()->toJson());
+    }
+
+    public function test_policy_snapshot_is_used_for_replay_and_preview_is_read_only(): void
+    {
+        $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)]);
+        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']);
+        $this->assertSame($before,DB::table('share_accounts')->get()->toJson());
+        $this->service->settle('2026-10-24'); $this->assertSame('0.3000000000',$this->amount(1));
+    }
+
+    public function test_grades_use_incremental_thresholds_and_never_downgrade_on_correction(): void
+    {
+        DB::table('share_levels')->insert([['level'=>2,'name'=>'二级','incremental_commission'=>100],['level'=>3,'name'=>'三级','incremental_commission'=>300]]);
+        $fact=$this->fact(2,'50000'); $this->service->settle('2026-10-24');
+        $this->assertSame(2,DB::table('share_members')->where('user_id',1)->value('level'));
+        DB::table('share_bet_facts')->where('id',$fact)->update(['effective_amount'=>160000]);
+        $this->service->settle('2026-10-24');
+        $this->assertSame('400.0000000000',$this->amount(1));
+        $this->assertSame(3,DB::table('share_members')->where('user_id',1)->value('level'));
+        DB::table('share_bet_facts')->where('id',$fact)->update(['effective_amount'=>200]); $this->service->settle('2026-10-24');
+        $this->assertSame(3,DB::table('share_members')->where('user_id',1)->value('level'));
+    }
+
+    public function test_later_first_binding_keeps_subtree_but_does_not_give_old_bets_to_new_ancestor(): void
+    {
+        $this->fact(2,'200'); $this->fact(3,'20000');
+        $this->relations->bind(1,6,'A');
+        $this->assertSame(3,DB::table('share_tree')->where('ancestor_user_id',6)->where('descendant_user_id',3)->value('depth'));
+        $this->assertArrayNotHasKey(6,$this->service->projection('2026-10-24')['rows']);
+        $this->expectException(\RuntimeException::class); $this->relations->bind(6,3,'X');
+    }
+
+    public function test_invalid_legacy_codes_and_cycles_are_isolated_without_guessing_or_mutating_users(): void
+    {
+        foreach ([[8,'P','Q'],[9,'Q','P'],[10,'R','MISSING'],[11,'DUP',null],[12,'DUP',null],[13,'CHILD','DUP']] as [$id,$code,$parent]) {
+            DB::table('users')->insert(['id'=>$id,'member_id'=>'M'.$id,'user_code'=>$code,'agent_user_code'=>$parent,'created_at'=>'2026-10-01 00:00:00']);
+        }
+        $users=DB::table('users')->get()->toJson();
+        $preview=$this->relations->initialize(); $this->assertSame(6,$preview['created']);
+        $this->assertSame(7,DB::table('share_members')->count());
+        $this->relations->initialize(true);
+        $this->assertSame('cycle',DB::table('share_members')->where('user_id',8)->value('binding_status'));
+        $this->assertNull(DB::table('share_members')->where('user_id',9)->value('parent_user_id'));
+        $this->assertSame('missing',DB::table('share_members')->where('user_id',10)->value('binding_status'));
+        $this->assertSame('ambiguous',DB::table('share_members')->where('user_id',13)->value('binding_status'));
+        $this->assertSame($users,DB::table('users')->get()->toJson());
+        $this->assertSame(0,$this->relations->initialize(true)['created']);
+    }
+
+    public function test_fault_rolls_back_every_ledger_account_and_period_write(): void
+    {
+        $this->fact(2,'200');
+        DB::statement("CREATE TRIGGER reject_share_ledger BEFORE INSERT ON bot_share_ledger BEGIN SELECT RAISE(ABORT,'injected failure'); END");
+        try { $this->service->settle('2026-10-24'); $this->fail('ledger failure expected'); } catch (\Illuminate\Database\QueryException $e) { $this->assertStringContainsString('injected failure',$e->getMessage()); }
+        $this->assertSame('0.0000000000',$this->amount(1)); $this->assertSame(0,DB::table('share_settlements')->count());
+        $this->assertSame(0,DB::table('share_ledger')->count()); $this->assertSame(0,DB::transactionLevel());
+        DB::statement('DROP TRIGGER reject_share_ledger');
+        $this->service->settle('2026-10-24'); $this->assertSame('0.2000000000',$this->amount(1));
+    }
+
+    public function test_today_never_becomes_claimable_and_foreign_currency_is_not_combined(): void
+    {
+        $this->fact(2,'200'); $this->fact(3,'20000','2026-10-24',['currency'=>'USD']);
+        $this->assertSame('0',$this->service->projection('2026-10-24')['rows'][1]['third:2']['indirect_performance']);
+        $this->expectException(\InvalidArgumentException::class); $this->service->settle('2026-10-25');
+    }
+
+    public function test_open_day_stays_queued_and_a_missed_midnight_run_is_automatically_recovered(): void
+    {
+        Carbon::setTestNow(Carbon::parse('2026-10-24 12:00:00','Asia/Shanghai'));
+        $this->fact(2,'200');
+        DB::table('share_system_state')->insert(['key'=>'facts_dirty_dates','value'=>'["2026-10-24"]']);
+        $this->assertSame(0,$this->service->reconcile()['dates']);
+        $this->assertSame(['2026-10-24'],json_decode(DB::table('share_system_state')->where('key','facts_dirty_dates')->value('value'),true));
+        Carbon::setTestNow(Carbon::parse('2026-10-26 12:00:00','Asia/Shanghai'));
+        $this->service->settle('2026-10-25');
+        $this->assertSame(1,$this->service->reconcile()['dates']);
+        $this->assertSame('0.2000000000',$this->amount(1));
+    }
+
+    public function test_real_members_without_an_invitation_code_receive_one_once_without_rebinding(): void
+    {
+        DB::table('users')->insert(['id'=>8,'member_id'=>'M8','user_code'=>'','agent_user_code'=>null,'from'=>-1,'created_at'=>'2026-10-01 00:00:00']);
+        $this->assertSame(1,$this->relations->initialize()['invitation_codes_assigned']);
+        $this->assertSame('',DB::table('users')->where('id',8)->value('user_code'));
+        $this->relations->initialize(true);
+        $code=DB::table('users')->where('id',8)->value('user_code');
+        $this->assertNotSame('',$code); $this->assertNotSame('0',$code);
+        $this->assertSame($code,DB::table('share_members')->where('user_id',8)->value('invitation_code'));
+        $this->assertNull(DB::table('share_members')->where('user_id',8)->value('parent_user_id'));
+        $this->relations->initialize(true);
+        $this->assertSame($code,DB::table('users')->where('id',8)->value('user_code'));
+    }
+
+    public function test_entry_visibility_does_not_control_monetary_policy_and_pre_activation_bets_never_pay(): void
+    {
+        $this->fact(2,'200');
+        DB::table('share_policies')->where('effective_date','2026-10-24')->update(['enabled'=>0]);
+        $this->service->settle('2026-10-24');
+        $this->assertSame('0.2000000000',$this->amount(1));
+        $this->assertSame([],$this->service->projection('2026-10-23')['rows']);
+        $this->assertSame(0,$this->service->settle('2026-10-23')['changed']);
+    }
+
+    public function test_new_telegram_member_after_initialization_gets_a_root_and_account_without_reinitializing_others(): void
+    {
+        if (!function_exists('removeZero')) require_once dirname(__DIR__,2).'/app/Helpers/helpers.php';
+        DB::table('wallets')->insert(['member_id'=>'M99']);
+        $user=\App\Services\PublicService::index('M99','telegram-new','新会员');
+        $id=(int)$user->id;
+        $this->assertSame(-1,(int)$user->from);
+        $this->assertSame('root',DB::table('share_members')->where('user_id',$id)->value('binding_status'));
+        $this->assertSame((string)$user->user_code,DB::table('share_members')->where('user_id',$id)->value('invitation_code'));
+        $this->assertSame(0,DB::table('share_tree')->where('ancestor_user_id',$id)->where('descendant_user_id',$id)->value('depth'));
+        $this->assertSame('0.0000000000',$this->amount($id));
+        $this->assertSame(8,DB::table('share_members')->count());
+    }
+}

+ 53 - 0
tests/Unit/SharingEffectiveBetTest.php

@@ -0,0 +1,53 @@
+<?php
+
+namespace Tests\Unit;
+
+use App\Services\Sharing\EffectiveBet;
+use App\Services\BetService;
+use PHPUnit\Framework\TestCase;
+
+class SharingEffectiveBetTest extends TestCase
+{
+    /** @dataProvider outcomes */
+    public function test_calculates_per_bet_net_exposure(string $stake, string $profit, bool $settled, bool $voided, string $expected): void
+    {
+        $this->assertSame($expected, EffectiveBet::amount($stake, $profit, $settled, $voided));
+    }
+
+    public function outcomes(): array
+    {
+        return [
+            ['100', '80', true, false, '80.0000000000'],
+            ['100', '150', true, false, '100.0000000000'],
+            ['100', '-100', true, false, '100.0000000000'],
+            ['100', '-50', true, false, '50.0000000000'],
+            ['100', '0', true, false, '0.0000000000'],
+            ['100', '80', false, false, '0.0000000000'],
+            ['100', '-100', true, true, '0.0000000000'],
+            ['100.0000000001', '0.1234567891', true, false, '0.1234567891'],
+        ];
+    }
+
+    public function test_original_betting_day_is_converted_to_new_york_across_midnight(): void
+    {
+        $this->assertSame('2026-10-23', EffectiveBet::newYorkDate('2026-10-24 00:10:00'));
+        $this->assertSame('2026-10-23', EffectiveBet::newYorkDate(1792771800));
+    }
+
+    public function test_missing_source_date_is_rejected_instead_of_becoming_today(): void
+    {
+        $this->expectException(\InvalidArgumentException::class);
+        EffectiveBet::newYorkDate(null);
+    }
+
+    public function test_invalid_calendar_date_is_rejected(): void
+    {
+        $this->expectException(\InvalidArgumentException::class);
+        EffectiveBet::newYorkDate('2026-02-31 10:00:00');
+    }
+
+    public function test_pc28_pending_filter_keeps_zero_so_retries_do_not_select_settled_bets(): void
+    {
+        $this->assertContains(['lottery_status', '=', 0], BetService::getWhere(['lottery_status' => 0]));
+    }
+}

+ 42 - 0
tests/Unit/SharingFootballSettlementTest.php

@@ -0,0 +1,42 @@
+<?php
+
+namespace Tests\Unit;
+
+use PHPUnit\Framework\TestCase;
+
+class SharingFootballSettlementTest extends TestCase
+{
+    protected function setUp(): void
+    {
+        parent::setUp();
+        require_once dirname(__DIR__, 3) . '/melbet_sport-api/app/common/service/FootballSettlementService.php';
+    }
+
+    /** @dataProvider footballOutcomes */
+    public function test_actual_wallet_return_includes_unlost_principal(string $selection, string $expectedProfit, string $expectedPayout): void
+    {
+        $detail = ['odds' => [['values' => [['value' => $selection, 'odd' => '1.80']]]]];
+        $result = \app\common\service\FootballSettlementService::totalHomeOrAway('100.00', 2, $detail);
+        $result['amount'] = '100.00';
+        $this->assertEquals($expectedProfit, $result['profit_and_loss']);
+        $this->assertSame($expectedPayout, \app\common\service\FootballSettlementService::payoutAmount($result));
+    }
+
+    public function footballOutcomes(): array
+    {
+        return [
+            ['Over 1.5', '80', '180.00'],
+            ['Over 1.75', '40', '140.00'],
+            ['Over 2', '0', '100.00'],
+            ['Over 2.25', '-50', '50.00'],
+            ['Over 2.5', '-100', '0.00'],
+        ];
+    }
+
+    public function test_generic_draw_result_exposes_the_principal_refund(): void
+    {
+        $result = \app\common\service\FootballSettlementService::getResult(2, '100.00', '1.80');
+        $this->assertSame('100.00', $result['return_amount']);
+        $this->assertSame('100.00', \app\common\service\FootballSettlementService::payoutAmount($result + ['amount' => '100.00']));
+    }
+}

+ 30 - 0
tests/Unit/SharingThirdGameOrderPrecisionTest.php

@@ -0,0 +1,30 @@
+<?php
+
+namespace Tests\Unit;
+
+use App\Services\ThirdGameBalanceService;
+use PHPUnit\Framework\TestCase;
+
+class SharingThirdGameOrderPrecisionTest extends TestCase
+{
+    public function test_provider_decimal_strings_reach_order_persistence_without_float_conversion(): void
+    {
+        $method = new \ReflectionMethod(ThirdGameBalanceService::class, 'formatOrder');
+        $method->setAccessible(true);
+        $order = $method->invoke(new ThirdGameBalanceService(), [
+            'betAmount' => '99999999999999.1234567891', 'validAmount' => '0.0000000001', 'settledAmount' => '-0.1234567891',
+        ]);
+        $this->assertSame('99999999999999.1234567891', $order['bet_amount']);
+        $this->assertSame('0.0000000001', $order['valid_amount']);
+        $this->assertSame('-0.1234567891', $order['settled_amount']);
+    }
+
+    public function test_numeric_json_values_keep_their_original_type_and_invalid_strings_fail_closed(): void
+    {
+        $method = new \ReflectionMethod(ThirdGameBalanceService::class, 'formatOrder');
+        $method->setAccessible(true);
+        $order = $method->invoke(new ThirdGameBalanceService(), ['betAmount' => 100, 'validAmount' => 12.5, 'settledAmount' => '1e8']);
+        $this->assertSame(100, $order['bet_amount']); $this->assertSame(12.5, $order['valid_amount']);
+        $this->assertNull($order['settled_amount']);
+    }
+}

+ 119 - 0
tests/fixtures/sharing_mysql_support.php

@@ -0,0 +1,119 @@
+<?php
+
+namespace Tests\Fixtures;
+
+/** Test-only bootstraps. Only the unique temporary, socket-only server is ever accepted. */
+final class SharingMysqlSupport
+{
+    public static function guardedPdo(string $socket, ?string $database = null): \PDO
+    {
+        if (!preg_match('~^/private/tmp/bot28-sharing-mysql\.[A-Za-z0-9]{6}/mysql\.sock$~D', $socket)) {
+            throw new \RuntimeException('Refusing any socket outside a unique sharing MySQL test directory');
+        }
+        if ($database !== null && !preg_match('/^share_concurrency_[a-f0-9]{12}$/D', $database)) {
+            throw new \RuntimeException('Refusing a non-test database');
+        }
+        $pdo = new \PDO('mysql:unix_socket=' . $socket . ';charset=utf8mb4' . ($database ? ';dbname=' . $database : ''), 'root', '', [
+            \PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION, \PDO::ATTR_TIMEOUT => 3,
+        ]);
+        $server = $pdo->query('SELECT @@datadir AS data_dir, @@socket AS socket_path, @@skip_networking AS network_disabled')->fetch(\PDO::FETCH_ASSOC);
+        if (realpath(rtrim($server['data_dir'], '/')) !== realpath(dirname($socket) . '/data')
+            || $server['socket_path'] !== $socket || (int)$server['network_disabled'] !== 1) {
+            throw new \RuntimeException('Refusing a server whose datadir, socket or skip-networking does not match the fixture');
+        }
+        return $pdo;
+    }
+
+    public static function laravel(string $socket, string $database): \Illuminate\Foundation\Application
+    {
+        self::guardedPdo($socket, $database);
+        \Illuminate\Support\Facades\Facade::clearResolvedInstances();
+        $app = new \Illuminate\Foundation\Application(dirname(__DIR__, 2));
+        $app->instance('config', new \Illuminate\Config\Repository(['app' => ['locale' => 'zh', 'timezone' => 'Asia/Shanghai'], 'third_game' => ['currency' => 'CNY']]));
+        $app->instance('events', new \Illuminate\Events\Dispatcher($app));
+        \Illuminate\Support\Facades\Facade::setFacadeApplication($app);
+        $db = new \Illuminate\Database\Capsule\Manager($app);
+        $db->addConnection(['driver' => 'mysql', 'unix_socket' => $socket, 'database' => $database,
+            'username' => 'root', 'password' => '', 'prefix' => 'bot_', 'charset' => 'utf8mb4', 'collation' => 'utf8mb4_unicode_ci', 'strict' => true]);
+        $db->setEventDispatcher($app['events']);
+        $db->setAsGlobal(); $db->bootEloquent();
+        $app->instance('db', $db->getDatabaseManager());
+        $app->bind('db.schema', fn () => $db->schema());
+        $translator = new \Illuminate\Translation\Translator(new \Illuminate\Translation\ArrayLoader(), 'zh');
+        $app->instance('translator', $translator);
+        $app->instance('validator', new \Illuminate\Validation\Factory($translator, $app));
+        $request = \Illuminate\Http\Request::create('/isolated-sharing-mysql-fixture');
+        $request->user = (object)['id' => 8, 'username' => 'fixture-admin'];
+        $app->instance('request', $request);
+        \Carbon\Carbon::setTestNow(\Carbon\Carbon::parse('2026-10-25 12:00:00', 'Asia/Shanghai'));
+        return $app;
+    }
+
+    public static function schema(): void
+    {
+        $schema = \Illuminate\Support\Facades\DB::connection()->getSchemaBuilder();
+        $schema->create('users', function ($t) {
+            $t->id(); $t->string('user_id'); $t->string('member_id')->unique(); $t->string('account'); $t->string('first_name');
+            $t->string('user_code'); $t->string('agent_user_code')->nullable(); $t->integer('from')->default(1);
+            $t->dateTime('delete_time')->nullable(); $t->timestamps();
+        });
+        $schema->create('share_settings', function ($t) {
+            $t->id(); $t->boolean('enabled')->default(0); $t->boolean('benefit_enabled')->default(0); $t->string('share_domain')->default('');
+            $t->decimal('valid_member_min_deposit', 20, 2)->default(20); $t->decimal('non_seller_threshold', 20, 2)->default(200);
+            $t->decimal('tier_one_rate', 8, 4)->default(4); $t->decimal('tier_two_rate', 8, 4)->default(6);
+            $t->unsignedBigInteger('operator_id')->nullable(); $t->string('operator_name')->default(''); $t->timestamps();
+        });
+        $schema->create('wallets', function ($t) {
+            $t->id(); $t->string('member_id')->unique(); $t->decimal('available_balance', 24, 10)->default(20); $t->timestamps();
+        });
+        $schema->create('balance_logs', function ($t) {
+            $t->id(); $t->string('member_id'); $t->integer('type')->default(1); $t->string('change_type');
+            foreach (['amount', 'before_balance', 'after_balance'] as $field) $t->decimal($field, 24, 10);
+            $t->unsignedBigInteger('related_id')->nullable(); $t->text('remark')->nullable(); $t->timestamps();
+        });
+        $schema->create('operation_audits', function ($t) {
+            $t->id(); $t->string('resource_type'); $t->unsignedBigInteger('resource_id')->nullable(); $t->string('action'); $t->json('changes');
+            $t->unsignedBigInteger('operator_id')->nullable(); $t->string('operator_name'); $t->timestamp('created_at')->useCurrent();
+        });
+        (require dirname(__DIR__, 2) . '/database/migrations/2026_10_24_120000_create_member_sharing_system.php')->up();
+    }
+
+    public static function seed(): void
+    {
+        $db = '\\Illuminate\\Support\\Facades\\DB';
+        $old = '2026-10-01 00:00:00';
+        foreach ([1, 2] as $id) {
+            $db::table('users')->insert(['id' => $id, 'user_id' => (string)(100 + $id), 'member_id' => 'M' . $id,
+                'account' => 'member' . $id, 'first_name' => 'fixture' . $id, 'user_code' => 'CODE' . $id,
+                'agent_user_code' => $id === 2 ? 'CODE1' : null, 'from' => 1, 'created_at' => $old, 'updated_at' => $old]);
+            $db::table('share_members')->insert(['user_id' => $id, 'parent_user_id' => $id === 2 ? 1 : null,
+                'invitation_code' => 'CODE' . $id, 'binding_status' => $id === 2 ? 'matched' : 'root', 'level' => 1,
+                'bound_at' => $old, 'created_at' => $old, 'updated_at' => $old]);
+            $db::table('share_tree')->insert(['ancestor_user_id' => $id, 'descendant_user_id' => $id, 'depth' => 0, 'bound_at' => $old]);
+        }
+        $db::table('share_tree')->insert(['ancestor_user_id' => 1, 'descendant_user_id' => 2, 'depth' => 1, 'bound_at' => $old]);
+        $db::table('wallets')->insert(['member_id' => 'M1', 'available_balance' => '20', 'created_at' => $old, 'updated_at' => $old]);
+        $db::table('share_accounts')->insert(['user_id' => 1, 'earned_amount' => '10.019', 'paid_amount' => 0, 'reserved_amount' => 0,
+            '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]);
+        $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)]);
+        $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',
+            'source_version' => str_repeat('a', 64), 'created_at' => $old, 'updated_at' => $old]);
+    }
+
+    public static function hold(array $params): void
+    {
+        file_put_contents($params['locked'], 'locked');
+        $deadline = microtime(true) + 20;
+        while (!is_file($params['release'])) {
+            if (microtime(true) > $deadline) throw new \RuntimeException('Timed out waiting for the test to release its row lock');
+            usleep(10000);
+        }
+    }
+}

+ 62 - 0
tests/fixtures/sharing_mysql_worker.php

@@ -0,0 +1,62 @@
+<?php
+
+// No application bootstrap, .env, provider or pre-existing database is loaded by this worker.
+error_reporting(E_ALL & ~E_DEPRECATED);
+require __DIR__ . '/sharing_mysql_support.php';
+use Tests\Fixtures\SharingMysqlSupport;
+
+$params = json_decode($argv[1], true, 512, JSON_THROW_ON_ERROR);
+try {
+    SharingMysqlSupport::guardedPdo($params['socket'], $params['database']);
+    if ($params['framework'] === 'tp') {
+        $tpRoot = $params['tp_root'];
+        require $tpRoot . '/vendor/autoload.php';
+        foreach (['ShareSupport', 'ShareService'] as $class) require $tpRoot . '/app/common/service/sharing/' . $class . '.php';
+        $app = new think\App($tpRoot . '/');
+        think\Container::setInstance($app);
+        $db = new think\DbManager();
+        $db->setConfig(['default' => 'isolated', 'connections' => ['isolated' => ['type' => 'mysql', 'socket' => $params['socket'],
+            'database' => $params['database'], 'username' => 'root', 'password' => '', 'prefix' => '', 'charset' => 'utf8mb4',
+            'fields_strict' => true, 'debug' => false]]]);
+        $app->instance('think\DbManager', $db);
+        $app->instance('log', new Psr\Log\NullLogger());
+        $call = fn () => (new app\common\service\sharing\ShareService(1))->claim(['request_id' => $params['request_id']]);
+        file_put_contents($params['ready'], 'ready');
+        $result = !empty($params['hold']) ? think\facade\Db::transaction(function () use ($params, $call) {
+            think\facade\Db::table('bot_share_accounts')->where('user_id', 1)->lock(true)->find();
+            SharingMysqlSupport::hold($params);
+            return $call();
+        }) : $call();
+    } else {
+        $loader = require $params['laravel_autoload'];
+        $loader->setPsr4('App\\', dirname(__DIR__, 2) . '/app');
+        SharingMysqlSupport::laravel($params['socket'], $params['database']);
+        if (!empty($params['hold'])) {
+            $held = false;
+            Illuminate\Support\Facades\DB::listen(function ($event) use ($params, &$held) {
+                $sync = $params['action'] === 'sync';
+                $targetQuery = $sync
+                    ? str_starts_with(strtolower($event->sql), 'update `bot_share_bet_facts`')
+                    : (str_contains($event->sql, 'share_accounts') && str_contains(strtolower($event->sql), 'for update'));
+                if (!$held && $targetQuery) {
+                    $held = true;
+                    if ($sync) {
+                        $partial = Illuminate\Support\Facades\DB::table('share_bet_facts')->where('source', 'lhc_order_item')->sum('effective_amount');
+                        file_put_contents($params['partial'], bcadd((string)$partial, '0', 10));
+                    }
+                    SharingMysqlSupport::hold($params);
+                }
+            });
+        }
+        file_put_contents($params['ready'], 'ready');
+        $result = match ($params['action']) {
+            'audit' => (new App\Services\Sharing\ShareClaimAuditService())->audit((int)$params['claim_id'], 'approve', 8, 'mysql concurrency fixture'),
+            'sync' => (new App\Services\Sharing\BetFactSynchronizer())->sync('2026-10-24', '2026-10-24', true),
+            default => (new App\Services\Sharing\ShareSettlementService())->settle('2026-10-24'),
+        };
+    }
+    echo json_encode(['ok' => true, 'result' => $result], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
+} catch (Throwable $e) {
+    echo json_encode(['ok' => false, 'exception' => get_class($e), 'message' => $e->getMessage()], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
+    exit(1);
+}