Pārlūkot izejas kodu

Merge branch 'master' into dev

* master: (52 commits)
  保留两个字段
  位置
  链接
  删除接口
  保存提示错误
  activity_name
  副标题不要
  websocket
  活动修改
  不允许新增
  跑马灯
  第三方用户ID
  去掉
  修改文案
  去掉时间必填
  报表重写
  接口
  活动、报表
  筛选
  筛选
  ...

# Conflicts:
#	app/Http/Controllers/api/Settle.php
doge 8 stundas atpakaļ
vecāks
revīzija
c98624acd3
100 mainītis faili ar 6187 papildinājumiem un 377 dzēšanām
  1. 7 2
      README.md
  2. 19 0
      app/Console/Commands/AutoGrantRewards.php
  3. 113 0
      app/Console/Commands/BackfillShareCommissions.php
  4. 17 0
      app/Console/Commands/DispatchSiteMessages.php
  5. 17 0
      app/Console/Commands/InitializeSharing.php
  6. 83 0
      app/Console/Commands/RecalculateShareRebates.php
  7. 34 0
      app/Console/Commands/SettleSharing.php
  8. 31 0
      app/Console/Commands/SyncPromotions.php
  9. 18 0
      app/Console/Commands/SyncReporting.php
  10. 19 0
      app/Console/Commands/SyncShareFacts.php
  11. 7 0
      app/Console/Kernel.php
  12. 2 0
      app/Http/Controllers/Controller.php
  13. 42 6
      app/Http/Controllers/admin/ActivityReward.php
  14. 2 2
      app/Http/Controllers/admin/ActivityUser.php
  15. 1 0
      app/Http/Controllers/admin/Agent.php
  16. 51 0
      app/Http/Controllers/admin/Announcement.php
  17. 97 25
      app/Http/Controllers/admin/Banner.php
  18. 42 0
      app/Http/Controllers/admin/Maintenance.php
  19. 11 5
      app/Http/Controllers/admin/PaymentOrder.php
  20. 32 0
      app/Http/Controllers/admin/Promotion.php
  21. 31 0
      app/Http/Controllers/admin/Reporting.php
  22. 39 0
      app/Http/Controllers/admin/RewardGrantSettings.php
  23. 10 40
      app/Http/Controllers/admin/ShareEarning.php
  24. 72 0
      app/Http/Controllers/admin/SharingManagement.php
  25. 25 0
      app/Http/Controllers/admin/SiteConfiguration.php
  26. 29 0
      app/Http/Controllers/admin/SiteMessage.php
  27. 1 0
      app/Http/Controllers/admin/User.php
  28. 5 4
      app/Http/Controllers/admin/Wallet.php
  29. 1 0
      app/Http/Controllers/agent/AuthController.php
  30. 15 0
      app/Http/Controllers/agent/MessageController.php
  31. 4 2
      app/Http/Controllers/agent/ProfileController.php
  32. 28 12
      app/Http/Controllers/api/ActivityReward.php
  33. 1 1
      app/Http/Controllers/api/Settle.php
  34. 3 1
      app/Http/Controllers/api/TelegramWebHook.php
  35. 2 2
      app/Http/Middleware/CheckButtonPermission.php
  36. 13 5
      app/Http/Middleware/CheckToken.php
  37. 41 0
      app/Http/Middleware/MemberMaintenance.php
  38. 30 9
      app/Jobs/FiveSecondTaskJob.php
  39. 47 1
      app/Models/ActivityReward.php
  40. 10 0
      app/Models/ActivityRewardRecipient.php
  41. 1 1
      app/Models/Agent/Agent.php
  42. 3 1
      app/Models/Banner.php
  43. 9 1
      app/Models/BaseModel.php
  44. 20 0
      app/Models/PromotionActivity.php
  45. 4 3
      app/Models/Rebate.php
  46. 3 4
      app/Models/ShareSetting.php
  47. 10 0
      app/Models/User.php
  48. 25 2
      app/Models/UserSession.php
  49. 2 2
      app/Models/YuebaoItem.php
  50. 32 0
      app/Services/ActivityBudgetService.php
  51. 43 0
      app/Services/ActivityEligibilityService.php
  52. 73 0
      app/Services/ActivityQuotaService.php
  53. 121 7
      app/Services/ActivityRewardService.php
  54. 44 23
      app/Services/ActivityUserService.php
  55. 2 1
      app/Services/Agent/AgentMemberService.php
  56. 1 1
      app/Services/Agent/AgentReportService.php
  57. 2 2
      app/Services/Agent/AgentService.php
  58. 278 0
      app/Services/AnnouncementService.php
  59. 54 0
      app/Services/AutomaticRewardGrantService.php
  60. 2 33
      app/Services/BackflowService.php
  61. 81 43
      app/Services/BalanceLogService.php
  62. 69 0
      app/Services/BannerVisibilityService.php
  63. 10 5
      app/Services/BetService.php
  64. 74 25
      app/Services/IssueService.php
  65. 61 0
      app/Services/LocalizedAuditService.php
  66. 89 30
      app/Services/LotteryImageService.php
  67. 157 0
      app/Services/MaintenanceService.php
  68. 7 5
      app/Services/OperationAuditService.php
  69. 3 0
      app/Services/PaymentOrderService.php
  70. 141 0
      app/Services/PromotionAdminService.php
  71. 156 0
      app/Services/PromotionFacts.php
  72. 481 0
      app/Services/PromotionService.php
  73. 8 1
      app/Services/PublicService.php
  74. 6 21
      app/Services/RebateService.php
  75. 18 8
      app/Services/RechargeService.php
  76. 22 0
      app/Services/Reporting/ReportActivity.php
  77. 137 0
      app/Services/Reporting/ReportAdmin.php
  78. 64 0
      app/Services/Reporting/ReportContext.php
  79. 406 0
      app/Services/Reporting/ReportReader.php
  80. 210 0
      app/Services/Reporting/ReportSync.php
  81. 88 0
      app/Services/RewardGrantService.php
  82. 66 0
      app/Services/RewardGrantSettingsService.php
  83. 88 0
      app/Services/ShareCommissionDiagnostics.php
  84. 126 0
      app/Services/ShareCommissionRecorder.php
  85. 38 25
      app/Services/ShareEarningReportService.php
  86. 325 0
      app/Services/Sharing/BetFactSynchronizer.php
  87. 67 0
      app/Services/Sharing/EffectiveBet.php
  88. 267 0
      app/Services/Sharing/ShareAdminService.php
  89. 128 0
      app/Services/Sharing/ShareClaimAuditService.php
  90. 26 0
      app/Services/Sharing/ShareDefaults.php
  91. 50 0
      app/Services/Sharing/ShareMemberQualification.php
  92. 152 0
      app/Services/Sharing/ShareRelations.php
  93. 329 0
      app/Services/Sharing/ShareSettlementService.php
  94. 87 0
      app/Services/SiteConfigurationService.php
  95. 58 0
      app/Services/SiteInboxService.php
  96. 237 0
      app/Services/SiteMessageService.php
  97. 30 0
      app/Services/SiteRealtimeService.php
  98. 24 7
      app/Services/ThirdGameBalanceService.php
  99. 17 9
      app/Services/ThirdGameOrderService.php
  100. 3 0
      app/Services/UserService.php

+ 7 - 2
README.md

@@ -155,7 +155,13 @@ MAIL_EXP=600  # 邮件验证码有效期(秒)
     - php artisan queue:table
     - php artisan queue:table
     - php artisan migrate
     - php artisan migrate
     
     
-    (1) 队列进程:php artisan queue:work --queue   #【此进程启动或重启后,都需手动执行一次命令:php artisan five_task】
+    (1) 队列进程:php artisan queue:work --queue=default --sleep=1 --timeout=85
+
+    队列连接需使用 database 或 redis,常驻 queue:work 需要 PHP CLI 的 pcntl 可用,并由宝塔/Supervisor 自动重启。
+    当前任务最大超时为 85 秒,必须小于队列连接的 retry_after(默认 90 秒)。
+    首次启动或确认循环任务已丢失时,执行一次 php artisan five_task;正常重启队列进程不需要再次执行。
+    不要同时定时访问 /admin/fake 并启动 five_task,两者都会生成虚拟投注。
+    耗时诊断与 queue:listen 超时处理见 [PC28队列与投注通知排查](docs/后端/PC28队列与投注通知排查.md)。
 
 
 
 
 ## 六、执行脚本(添加计划任务)
 ## 六、执行脚本(添加计划任务)
@@ -210,4 +216,3 @@ MAIL_EXP=600  # 邮件验证码有效期(秒)
     cd 开奖图片目录 && rm -rf ./*
     cd 开奖图片目录 && rm -rf ./*
 
 
 
 
-

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

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

+ 113 - 0
app/Console/Commands/BackfillShareCommissions.php

@@ -0,0 +1,113 @@
+<?php
+
+namespace App\Console\Commands;
+
+use App\Models\BalanceLog;
+use App\Services\ShareCommissionDiagnostics;
+use App\Services\ShareCommissionRecorder;
+use Carbon\Carbon;
+use Illuminate\Console\Command;
+use Illuminate\Support\Facades\DB;
+
+class BackfillShareCommissions extends Command
+{
+    protected $signature = 'share:backfill-commissions
+        {--start-date= : 返现入账起始日 Y-m-d,默认结束日前30天}
+        {--end-date= : 返现入账结束日 Y-m-d,默认今天}
+        {--chunk=500 : 每批读取条数,1至2000}
+        {--all : 查询全部历史,不能与日期选项同时使用}
+        {--diagnose : 只读诊断数据来源与各级筛选,不登记或发奖}
+        {--write : 实际登记佣金明细;不传时只预览}';
+
+    protected $description = '从已有充值返现流水补记分享佣金明细,不修改钱包或再次发奖';
+
+    public function handle(ShareCommissionRecorder $recorder): int
+    {
+        try {
+            $all = (bool)$this->option('all');
+            $diagnose = (bool)$this->option('diagnose');
+            $write = (bool)$this->option('write');
+            if ($diagnose && $write) throw new \InvalidArgumentException('--diagnose 为只读诊断,不能与 --write 同时使用');
+            if ($all && ($this->option('start-date') !== null || $this->option('end-date') !== null)) {
+                throw new \InvalidArgumentException('--all 不能与日期选项同时使用');
+            }
+            $range = null;
+            if (!$all) {
+                $end = $this->parseDate((string)($this->option('end-date') ?: date('Y-m-d')));
+                $start = $this->parseDate((string)($this->option('start-date') ?: $end->copy()->subDays(30)->toDateString()));
+                if ($start->gt($end) || $start->diffInDays($end) > 366) {
+                    throw new \InvalidArgumentException('日期范围须先后有效,单次不超过366天');
+                }
+                $range = [$start->toDateString() . ' 00:00:00', $end->toDateString() . ' 23:59:59'];
+            }
+            $chunk = filter_var($this->option('chunk'), FILTER_VALIDATE_INT);
+            if ($chunk === false || $chunk < 1 || $chunk > 2000) {
+                throw new \InvalidArgumentException('chunk 必须是1至2000的整数');
+            }
+            $label = $all ? '全部历史(无日期过滤)' : $start->toDateString() . ' 至 ' . $end->toDateString();
+            if ($diagnose) return $this->diagnose($range, $label);
+            $counts = ['scanned' => 0, 'created' => 0, 'pending' => 0, 'existing' => 0, 'skipped' => 0, 'incomplete_metadata' => 0];
+            $reasons = [];
+            $this->info(($write ? '写入登记' : '只读预览') . ':' . $label);
+            $query = BalanceLog::where('change_type', '充值返现')->where('type', 1)->where('amount', '>', 0);
+            if ($range !== null) $query->whereBetween('created_at', $range);
+            $query->chunkById($chunk, function ($rows) use ($recorder, $write, &$counts, &$reasons) {
+                    foreach ($rows as $row) {
+                        $result = $write ? $recorder->record($row) : $recorder->preview($row);
+                        $counts['scanned']++;
+                        $counts[$result['status']]++;
+                        if (!empty($result['incomplete_metadata'])) $counts['incomplete_metadata']++;
+                        if ($result['status'] === 'skipped') {
+                            $reason = $result['reason'];
+                            $reasons[$reason] = ($reasons[$reason] ?? 0) + 1;
+                        }
+                    }
+                });
+            $this->table(['项目', '条数'], array_map(fn ($key, $count) => [$key, $count], array_keys($counts), array_values($counts)));
+            foreach ($reasons as $reason => $count) $this->line("跳过 {$reason}: {$count}");
+            if (!$counts['scanned']) {
+                $this->warn('没有扫描到符合条件的返现流水;省略日期仍默认最近31天。可运行 --diagnose --all 定位上游数据。');
+            }
+            if (!$write) $this->info('预览未写入数据;确认后使用相同日期并加 --write 登记,钱包不会再次入账。');
+            return self::SUCCESS;
+        } catch (\Throwable $e) {
+            $this->error($e->getMessage());
+            return self::FAILURE;
+        }
+    }
+
+    private function diagnose(?array $range, string $label): int
+    {
+        $this->info('只读诊断:' . $label);
+        $this->line('database: ' . DB::connection()->getDatabaseName());
+        $this->line('table_prefix: ' . DB::getTablePrefix());
+        $data = (new ShareCommissionDiagnostics())->inspect($range);
+        foreach (['recharge_rate', 'share_enabled', 'valid_member_min_deposit', 'valid_member_min_effective_bet', 'first_rebate_at', 'last_rebate_at'] as $key) {
+            $this->line($key . ': ' . ($data[$key] ?? '(无)'));
+        }
+        $this->table(['检查项', '条数'], array_map(fn ($key, $count) => [$key, $count], array_keys($data['counts']), array_values($data['counts'])));
+        $counts = $data['counts'];
+        if (!$counts['rebate_logs_all']) $this->warn('全部历史没有“充值返现”流水,回填没有来源;需检查实际返现是否发生。');
+        elseif (!$counts['positive_type1_rebate_logs_all']) $this->warn('存在返现流水,但没有 type=1 且金额大于0的可登记流水。');
+        elseif (!$counts['selected_rebate_logs']) $this->warn('全历史有可登记返现,但当前日期范围没有;按原入账日期查询或使用 --all。');
+        if ($data['recharge_rate'] === null || !is_numeric($data['recharge_rate']) || (float)$data['recharge_rate'] <= 0) {
+            $this->warn('当前 recharge_rate 缺失、无效或为0,现有邀请充值返现路径不会产生正数返现。');
+        }
+        if ($counts['invited_members_all'] && !$counts['real_invited_members_all']) $this->warn('有推荐注册,但没有 from=1 的真实被邀请会员。');
+        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('上游数量按当前关系统计,仅供排查,不代表历史应付佣金;本诊断未写入记录或发钱。');
+        return self::SUCCESS;
+    }
+
+    private function parseDate(string $value): Carbon
+    {
+        $date = Carbon::createFromFormat('!Y-m-d', $value, config('app.timezone', 'Asia/Shanghai'));
+        if (!$date || $date->format('Y-m-d') !== $value) {
+            throw new \InvalidArgumentException('日期必须为有效的 Y-m-d');
+        }
+        return $date;
+    }
+}

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

@@ -0,0 +1,17 @@
+<?php
+
+namespace App\Console\Commands;
+
+use App\Services\SiteMessageService;
+use Illuminate\Console\Command;
+
+class DispatchSiteMessages extends Command
+{
+    protected $signature = 'messages:dispatch';
+    protected $description = 'Deliver due internal messages to snapshotted recipients using their saved languages';
+    public function handle(SiteMessageService $service): int
+    {
+        $result = $service->dispatchDue(); $this->line(json_encode($result, JSON_UNESCAPED_UNICODE));
+        return $result['failed'] > 0 ? self::FAILURE : 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;
+    }
+}

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

@@ -0,0 +1,34 @@
+<?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,默认北京时间昨日;allow-today时默认当天} {--preview : 只读预览,不登记应付佣金} {--reconcile : 处理迟到结算和改判} {--allow-today : 允许调试结算北京时间当天,不允许未来日期} {--diagnose : 只读说明生效配置、投注明细及各项排除原因,不结算}';
+    protected $description='每日结算新分享应付佣金,不直接入会员钱包';
+    public function handle(ShareSettlementService $service): int
+    {
+        try {
+            if ($this->option('reconcile') && $this->option('preview')) throw new \InvalidArgumentException('reconcile不支持preview,请使用日期预览');
+            $allowToday=(bool)$this->option('allow-today');
+            if ($this->option('reconcile') && $allowToday) throw new \InvalidArgumentException('allow-today不能与reconcile同时使用');
+            if ($this->option('reconcile') && $this->option('diagnose')) throw new \InvalidArgumentException('diagnose不能与reconcile同时使用');
+            $defaultDate=Carbon::now(ShareDefaults::TIMEZONE);
+            if (!$allowToday) $defaultDate->subDay();
+            if ($this->option('diagnose')) {
+                $result=$service->diagnose($this->argument('date') ?: $defaultDate->toDateString());
+                $this->line(json_encode($result,JSON_UNESCAPED_UNICODE|JSON_PRETTY_PRINT|JSON_THROW_ON_ERROR));
+                return self::SUCCESS;
+            }
+            $result=$this->option('reconcile') ? $service->reconcile() : $service->settle(
+                $this->argument('date') ?: $defaultDate->toDateString(), !(bool)$this->option('preview'), $allowToday
+            );
+            $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; }
+    }
+}

+ 31 - 0
app/Console/Commands/SyncPromotions.php

@@ -0,0 +1,31 @@
+<?php
+
+namespace App\Console\Commands;
+
+use App\Services\PromotionService;
+use Illuminate\Console\Command;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+use Illuminate\Support\Facades\Log;
+
+class SyncPromotions extends Command
+{
+    protected $signature = 'promotions:sync';
+    protected $description = '补记转盘任务次数并解冻满足流水的新活动奖金';
+    public function handle(PromotionService $service): int
+    {
+        if (!Schema::hasTable('promotion_accounts')) return self::SUCCESS;
+        $errors = 0;
+        DB::table('promotion_accounts as p')->join('promotion_activities as a', 'a.id', '=', 'p.activity_id')->where('a.type', 9)->where('a.status', 1)
+            ->where('a.start_time', '<=', now()->timestamp)->where('a.end_time', '>=', now()->timestamp)->select('p.*')->orderBy('p.id')
+            ->chunkById(100, function ($rows) use ($service, &$errors) {
+                foreach ($rows as $row) try { $service->progress($row->activity_id, $row->user_id); }
+                catch (\Throwable $e) { ++$errors; Log::error('promotion_sync_failed', ['account_id' => $row->id, 'exception' => $e]); }
+            }, 'p.id', 'id');
+        foreach (DB::table('promotion_rewards')->where('status', 'locked')->distinct()->pluck('user_id') as $userId) {
+            try { $service->unlock((int)$userId); } catch (\Throwable $e) { ++$errors; Log::error('promotion_unlock_failed', ['user_id' => $userId, 'exception' => $e]); }
+        }
+        $this->info('活动任务和奖金同步完成,失败数:' . $errors);
+        return $errors ? self::FAILURE : self::SUCCESS;
+    }
+}

+ 18 - 0
app/Console/Commands/SyncReporting.php

@@ -0,0 +1,18 @@
+<?php
+
+namespace App\Console\Commands;
+
+use App\Services\Reporting\ReportSync;
+use Illuminate\Console\Command;
+
+class SyncReporting extends Command
+{
+    protected $signature = 'reporting:sync {--full : 分批重新核对全部历史来源,不修改业务订单或钱包}';
+    protected $description = '同步后台统一报表事实(默认增量,幂等,可重试)';
+    public function handle(ReportSync $service): int
+    {
+        $result = $service->sync((bool) $this->option('full'));
+        $this->line(json_encode($result, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR));
+        return $result['errors'] > 0 ? self::FAILURE : self::SUCCESS;
+    }
+}

+ 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;
+    }
+}

+ 7 - 0
app/Console/Kernel.php

@@ -26,12 +26,19 @@ class Kernel extends ConsoleKernel
         //      ->onOneServer();       // 如果在多服务器环境
         //      ->onOneServer();       // 如果在多服务器环境
 
 
         $schedule->command('sport')->dailyAt('23:59:00');
         $schedule->command('sport')->dailyAt('23:59:00');
+        $schedule->command('rewards:auto-grant')->everyMinute()->withoutOverlapping(60);
+        $schedule->command('messages:dispatch')->everyMinute()->withoutOverlapping(10);
+        $schedule->command('reporting:sync')->everyMinute()->withoutOverlapping(30);
+        $schedule->command('promotions:sync')->everyMinute()->withoutOverlapping(30);
         $schedule->command('third-game:sync-orders')
         $schedule->command('third-game:sync-orders')
             ->everyMinute()
             ->everyMinute()
             ->withoutOverlapping(5);
             ->withoutOverlapping(5);
         $schedule->command('agent:settle')
         $schedule->command('agent:settle')
             ->dailyAt('04:10')
             ->dailyAt('04:10')
             ->withoutOverlapping(180);
             ->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);
     }
     }
 
 
     /**
     /**

+ 2 - 0
app/Http/Controllers/Controller.php

@@ -90,6 +90,8 @@ class Controller extends BaseController
     public function __construct()
     public function __construct()
     {
     {
         $lang = request()->input('lang', 'zh');
         $lang = request()->input('lang', 'zh');
+        // Invalid list-filter input must reach the action validator, not crash locale setup first.
+        if (!is_string($lang) || $lang === '') $lang = 'zh';
         App::setLocale($lang);
         App::setLocale($lang);
         $this->lang = $lang;
         $this->lang = $lang;
     }
     }

+ 42 - 6
app/Http/Controllers/admin/ActivityReward.php

@@ -10,9 +10,16 @@ use Illuminate\Support\Facades\DB;
 use Illuminate\Validation\ValidationException;
 use Illuminate\Validation\ValidationException;
 use Exception;
 use Exception;
 use App\Models\ActivityReward as ActivityRewardModel;
 use App\Models\ActivityReward as ActivityRewardModel;
+use App\Support\ActivityCatalog;
+use Illuminate\Validation\Rule;
 
 
 class ActivityReward extends Controller
 class ActivityReward extends Controller
 {
 {
+    public function options(): JsonResponse
+    {
+        return $this->success(ActivityCatalog::options());
+    }
+
     public function index(): JsonResponse
     public function index(): JsonResponse
     {
     {
         try {
         try {
@@ -21,13 +28,39 @@ class ActivityReward extends Controller
                 'limit' => ['required', 'integer', 'min:1'],
                 'limit' => ['required', 'integer', 'min:1'],
                 'title' => ['nullable', 'string'],
                 'title' => ['nullable', 'string'],
                 'type' => ['nullable', 'string'],
                 'type' => ['nullable', 'string'],
+                'platform' => ['nullable', 'string', 'in:pc,h5'],
+                'status' => ['nullable', 'integer', 'in:0,1'],
+                'lang' => ['nullable', 'string', 'max:10'],
+                'game_category' => ['nullable', 'string', Rule::in(array_keys(ActivityCatalog::gameTypes()))],
             ]);
             ]);
             $page = request()->input('page', 1);
             $page = request()->input('page', 1);
             $limit = request()->input('limit', 10);
             $limit = request()->input('limit', 10);
-            $where = ActivityRewardService::getWhere($params);
+            $where = ActivityRewardService::getWhere(array_intersect_key($params, ['title' => true, 'type' => true]));
             $query = ActivityRewardModel::where($where);
             $query = ActivityRewardModel::where($where);
+            if (isset($params['status']) && $params['status'] !== '') {
+                $query->where('status', (int)$params['status']);
+            }
+            if (isset($params['lang']) && $params['lang'] !== '') {
+                $query->where('lang', $params['lang']);
+            }
+            if (isset($params['game_category']) && $params['game_category'] !== '') {
+                $query->where('game_category', $params['game_category']);
+            }
+            if (isset($params['platform']) && $params['platform'] !== '') {
+                $platform = strtoupper($params['platform']);
+                $query->where(function ($platformQuery) use ($platform) {
+                    $platformQuery->where('type', 2)
+                        ->orWhereNull('publish_platforms')
+                        ->orWhereJsonLength('publish_platforms', 0);
+                    if ($platformQuery->getModel()->getConnection()->getDriverName() === 'sqlite') {
+                        $platformQuery->orWhereRaw('EXISTS (SELECT 1 FROM json_each(publish_platforms) WHERE json_each.value = ?)', [$platform]);
+                    } else {
+                        $platformQuery->orWhereJsonContains('publish_platforms', $platform);
+                    }
+                });
+            }
             $count = $query->count();
             $count = $query->count();
-            $list = $query->orderByDesc('id')
+            $list = $query->orderByDesc('sort')->orderByDesc('id')
                 ->forPage($page, $limit)->get()->toArray();
                 ->forPage($page, $limit)->get()->toArray();
             foreach ($list as &$item) {
             foreach ($list as &$item) {
                 if (!empty($item['params'])) {
                 if (!empty($item['params'])) {
@@ -56,14 +89,17 @@ class ActivityReward extends Controller
                 'end_time' => ['required', 'date', 'after_or_equal:start_time'],
                 'end_time' => ['required', 'date', 'after_or_equal:start_time'],
                 'detail_image' => ['required'],
                 'detail_image' => ['required'],
                 'pc_image' => ['required'],
                 'pc_image' => ['required'],
-                'detail_banner' => ['required'],
-                'pc_banner' => ['required'],
                 'status' => ['required', 'integer', 'min:0', 'max:1'],
                 'status' => ['required', 'integer', 'min:0', 'max:1'],
-                'type' => ['required'],
+                'type' => ['required', 'integer', 'min:1', 'max:6'],
                 'lang' => ['required', 'string'],
                 'lang' => ['required', 'string'],
                 'content' => ['nullable', 'string'],
                 'content' => ['nullable', 'string'],
                 'params' => ['nullable', 'array'],
                 'params' => ['nullable', 'array'],
+                'game_category' => ['nullable', 'string', Rule::in(array_keys(ActivityCatalog::gameTypes()))],
             ]);
             ]);
+            $params = array_merge($params, array_intersect_key(request()->all(), ActivityRewardService::configurationDefaults()));
+            $params = array_merge($params, array_intersect_key(request()->all(), ['click_type' => true, 'link' => true, 'click_position' => true]));
+            // Wrong-mode inputs are rejected rather than silently disabling a limit.
+            $params = array_merge($params, array_intersect_key(request()->all(), ['budget' => true, 'budget_used' => true]));
             $params['params'] = empty($params['params']) ? null : json_encode($params['params']);
             $params['params'] = empty($params['params']) ? null : json_encode($params['params']);
             ActivityRewardService::submit($params);
             ActivityRewardService::submit($params);
             DB::commit();
             DB::commit();
@@ -95,4 +131,4 @@ class ActivityReward extends Controller
         }
         }
         return $this->success([], '删除成功');
         return $this->success([], '删除成功');
     }
     }
-}
+}

+ 2 - 2
app/Http/Controllers/admin/ActivityUser.php

@@ -44,7 +44,7 @@ class ActivityUser extends Controller
         try {
         try {
             $params = request()->validate([
             $params = request()->validate([
                 'id' => ['required', 'integer', 'min:1'],
                 'id' => ['required', 'integer', 'min:1'],
-                'amount' => ['required', 'numeric', 'min:0.01'],
+                'amount' => ['required', 'numeric', 'min:0.01', 'regex:/^\d{1,12}(\.\d{1,2})?$/'],
                 'betting_amount' => ['required', 'integer', 'min:0', 'max:9999999'],
                 'betting_amount' => ['required', 'integer', 'min:0', 'max:9999999'],
             ]);
             ]);
             ActivityUserService::gift($params['id'], $params['amount'], $params['betting_amount']);
             ActivityUserService::gift($params['id'], $params['amount'], $params['betting_amount']);
@@ -92,4 +92,4 @@ class ActivityUser extends Controller
         }
         }
         return $this->success($result);
         return $this->success($result);
     }
     }
-}
+}

+ 1 - 0
app/Http/Controllers/admin/Agent.php

@@ -332,6 +332,7 @@ class Agent extends AgentApiController
     private function rules(bool $update = false): array
     private function rules(bool $update = false): array
     {
     {
         return [
         return [
+            'language' => ['sometimes', 'required', 'in:zh,en,vi'],
             'username' => $update
             'username' => $update
                 ? ['sometimes', 'string', 'min:4', 'max:64', 'alpha_dash', Rule::unique('agents', 'username')->ignore((int) request()->input('id'))]
                 ? ['sometimes', 'string', 'min:4', 'max:64', 'alpha_dash', Rule::unique('agents', 'username')->ignore((int) request()->input('id'))]
                 : ['required', 'string', 'min:4', 'max:64', 'alpha_dash', 'unique:agents,username'],
                 : ['required', 'string', 'min:4', 'max:64', 'alpha_dash', 'unique:agents,username'],

+ 51 - 0
app/Http/Controllers/admin/Announcement.php

@@ -0,0 +1,51 @@
+<?php
+
+namespace App\Http\Controllers\admin;
+
+use App\Constants\HttpStatus;
+use App\Http\Controllers\Controller;
+use App\Services\AnnouncementService;
+use App\Support\AnnouncementFailure;
+use Illuminate\Support\Facades\Log;
+use Illuminate\Validation\ValidationException;
+use Throwable;
+
+class Announcement extends Controller
+{
+    public function index(AnnouncementService $service) { return $this->run(fn () => $service->listing(request()->all())); }
+    public function options(AnnouncementService $service) { return $this->run(fn () => $service->options()); }
+    public function detail(AnnouncementService $service)
+    {
+        return $this->run(function () use ($service) {
+            $params = request()->validate(['id' => ['required', 'integer', 'min:1']]);
+            return $service->detail((int) $params['id']);
+        });
+    }
+    public function submit(AnnouncementService $service) { return $this->run(fn () => $service->save(request()->all())); }
+    public function status(AnnouncementService $service)
+    {
+        return $this->run(function () use ($service) {
+            $params = request()->validate(['id' => ['required', 'integer', 'min:1'], 'status' => ['required', 'integer', 'in:0,1']]);
+            return $service->status((int) $params['id'], (int) $params['status']);
+        });
+    }
+    public function logs(AnnouncementService $service) { return $this->run(fn () => $service->logs(request()->all())); }
+
+    public function delete(AnnouncementService $service)
+    {
+        return $this->run(function () use ($service) {
+            $params = request()->validate(['id' => ['required', 'integer', 'min:1']]);
+            return $service->delete((int) $params['id']);
+        });
+    }
+
+    private function run(callable $callback)
+    {
+        try { return $this->success($callback()); }
+        catch (ValidationException $e) { return $this->error(HttpStatus::CUSTOM_ERROR, $e->validator->errors()->first()); }
+        catch (Throwable $e) {
+            Log::error('announcement_admin_failed', ['exception' => $e]);
+            return $this->error(HttpStatus::CUSTOM_ERROR, AnnouncementFailure::message($e));
+        }
+    }
+}

+ 97 - 25
app/Http/Controllers/admin/Banner.php

@@ -9,12 +9,13 @@ use Illuminate\Support\Facades\DB;
 use Illuminate\Validation\ValidationException;
 use Illuminate\Validation\ValidationException;
 use Exception;
 use Exception;
 use App\Models\Banner as BannerModel;
 use App\Models\Banner as BannerModel;
+use App\Services\BannerVisibilityService;
 
 
 class Banner extends Controller
 class Banner extends Controller
 {
 {
 
 
     /**
     /**
-     * @api {get} /admin/banner 轮播图列表
+     * @api {get} /admin/banner/index 轮播图列表
      * @apiGroup 轮播图管理
      * @apiGroup 轮播图管理
      * @apiUse result
      * @apiUse result
      * @apiUse header
      * @apiUse header
@@ -27,20 +28,45 @@ class Banner extends Controller
     function index()
     function index()
     {
     {
         try {
         try {
+            $params = request()->validate([
+                'page' => ['sometimes', 'integer', 'min:1'],
+                'limit' => ['sometimes', 'integer', 'min:1', 'max:200'],
+                'type' => ['nullable', 'in:pc,app'],
+                'platform' => ['nullable', 'in:pc,h5'],
+                'status' => ['nullable', 'integer', 'in:0,1'],
+                'lang' => ['nullable', 'string', 'max:10'],
+            ]);
             $page = request()->input('page', 1);
             $page = request()->input('page', 1);
             $limit = request()->input('limit', 15);
             $limit = request()->input('limit', 15);
-            $type = request()->input('type', '');
-            
-            $query = new BannerModel();
+            $type = $params['type'] ?? '';
+            if (isset($params['platform']) && $params['platform'] !== '') {
+                // The existing mobile banner enum is "app"; H5 filtering preserves that storage contract.
+                $platformType = $params['platform'] === 'pc' ? 'pc' : 'app';
+                if ($type !== '' && $type !== $platformType) {
+                    throw ValidationException::withMessages(['platform' => '平台与原类型筛选不一致']);
+                }
+                $type = $platformType;
+            }
 
 
-            if (!empty($type)) {
+            $query = BannerModel::query();
+
+            if ($type !== '') {
                 $query->where('type', $type);
                 $query->where('type', $type);
             }
             }
-            $count = $query->count();
-            $list = $query
-                 ->forPage($page, $limit)
-                ->orderByDesc("sort")
-                ->get()->toArray();
+            if (isset($params['lang']) && $params['lang'] !== '') {
+                $query->where('lang', $params['lang']);
+            }
+            $query->orderByDesc('sort')->orderByDesc('id');
+            if (isset($params['status']) && $params['status'] !== '') {
+                // Stored manual status is not the linked activity's actual status. Filter before pagination.
+                $rows = BannerVisibilityService::decorate($query->get());
+                $rows = array_values(array_filter($rows, static fn ($row) => $row['status'] === (int) $params['status']));
+                $count = count($rows);
+                $list = array_slice($rows, ((int) $page - 1) * (int) $limit, (int) $limit);
+            } else {
+                $count = $query->count();
+                $list = BannerVisibilityService::decorate($query->forPage($page, $limit)->get());
+            }
         } catch (ValidationException $e) {
         } catch (ValidationException $e) {
             return $this->error(HttpStatus::VALIDATION_FAILED, $e->validator->errors()->first());
             return $this->error(HttpStatus::VALIDATION_FAILED, $e->validator->errors()->first());
         } catch (Exception $e) {
         } catch (Exception $e) {
@@ -63,27 +89,46 @@ class Banner extends Controller
         DB::beginTransaction();
         DB::beginTransaction();
         try {
         try {
             $params = request()->validate([
             $params = request()->validate([
-                'id' => ['nullable'],
+                'id' => ['nullable', 'integer', 'min:1'],
                 'title' => ['required'],
                 'title' => ['required'],
                 'image' => ['required'],
                 'image' => ['required'],
-                'link' => ['nullable'],
-                'type' => ['required'],
-                'sort' => ['nullable'],
+                'link' => ['nullable', function ($attribute, $value, $fail) {
+                    if ((!is_string($value) && !is_int($value)) || strlen((string) $value) > 191) {
+                        $fail('跳转ID或链接格式错误');
+                    }
+                }],
+                'type' => ['required', 'in:pc,app'],
+                'sort' => ['nullable', 'integer'],
                 'lang' => ['required'],
                 'lang' => ['required'],
+                'status' => ['sometimes', 'integer', 'in:0,1'],
+                'manual_status' => ['nullable', 'integer', 'in:0,1'],
             ]);
             ]);
             $id = $params['id'] ?? null;
             $id = $params['id'] ?? null;
+            $banner = $id === null ? new BannerModel() : BannerModel::where('id', $id)->lockForUpdate()->first();
+            if (!$banner) throw new Exception('数据不存在', HttpStatus::CUSTOM_ERROR);
 
 
-            BannerModel::updateOrCreate(
-                ['id' => $id],
-                [
-                    'title' => $params['title'],
-                    'image' => Util::replacePartInUrl($params['image']),
-                    'link' => $params['link'],
-                    'type' => $params['type'],
-                    'sort' => $params['sort'],
-                    'lang' => $params['lang'],
-                ],
-            );
+            $link = array_key_exists('link', $params) ? $params['link'] : $banner->link;
+            $activityId = BannerVisibilityService::activityId($link);
+            $previousActivityId = BannerVisibilityService::activityId($banner->link);
+            if ($activityId !== null && (!$banner->exists || $activityId !== $previousActivityId)) {
+                if (!DB::table('activity_rewards')->where('id', $activityId)->exists()) {
+                    throw ValidationException::withMessages(['link' => '绑定的活动不存在']);
+                }
+            }
+            $manualStatus = $banner->status ?? 1;
+            if ($activityId === null) {
+                // Both the row button and the editor write status; a stale compatibility alias must not override it.
+                $manualStatus = $params['status'] ?? $params['manual_status'] ?? $manualStatus;
+            }
+            $banner->fill([
+                'title' => $params['title'],
+                'image' => Util::replacePartInUrl($params['image']),
+                'link' => $activityId ?? ($link === null ? null : (string) $link),
+                'type' => $params['type'],
+                'sort' => array_key_exists('sort', $params) ? ($params['sort'] ?? 0) : ($banner->sort ?? 0),
+                'lang' => $params['lang'],
+                'status' => $manualStatus,
+            ])->save();
             DB::commit();
             DB::commit();
         } catch (ValidationException $e) {
         } catch (ValidationException $e) {
             DB::rollBack();
             DB::rollBack();
@@ -95,6 +140,33 @@ class Banner extends Controller
         return $this->success();
         return $this->success();
     }
     }
 
 
+    /** Change manual publication without resubmitting the banner form. */
+    public function status()
+    {
+        try {
+            $params = request()->validate([
+                'id' => ['required', 'integer', 'min:1'],
+                'status' => ['required_without:manual_status', 'integer', 'in:0,1'],
+                'manual_status' => ['required_without:status', 'integer', 'in:0,1'],
+            ]);
+            DB::transaction(function () use ($params) {
+                $banner = BannerModel::where('id', $params['id'])->lockForUpdate()->first();
+                if (!$banner) throw new Exception('数据不存在', HttpStatus::CUSTOM_ERROR);
+                if (BannerVisibilityService::activityId($banner->link) !== null) {
+                    throw new Exception('关联活动的 Banner 由活动状态自动控制,不能手动上下架', HttpStatus::CUSTOM_ERROR);
+                }
+                $banner->status = $params['status'] ?? $params['manual_status'];
+                $banner->save();
+            });
+        } catch (ValidationException $e) {
+            return $this->error(HttpStatus::VALIDATION_FAILED, $e->validator->errors()->first());
+        } catch (Exception $e) {
+            return $this->error(HttpStatus::CUSTOM_ERROR, $e->getMessage());
+        }
+
+        return $this->success();
+    }
+
 
 
     /**
     /**
      * @api {post} /admin/banner/delete 删除
      * @api {post} /admin/banner/delete 删除

+ 42 - 0
app/Http/Controllers/admin/Maintenance.php

@@ -0,0 +1,42 @@
+<?php
+
+namespace App\Http\Controllers\admin;
+
+use App\Constants\HttpStatus;
+use App\Http\Controllers\Controller;
+use App\Services\MaintenanceService;
+use Illuminate\Support\Facades\Log;
+use Illuminate\Validation\ValidationException;
+use Throwable;
+
+class Maintenance extends Controller
+{
+    public function index(MaintenanceService $service) { return $this->run(fn () => $service->listing(request()->all())); }
+    public function options(MaintenanceService $service) { return $this->run(fn () => $service->options()); }
+    public function detail(MaintenanceService $service)
+    {
+        return $this->run(function () use ($service) {
+            $params = request()->validate(['id' => ['required', 'integer', 'min:1']]);
+            return $service->detail((int) $params['id']);
+        });
+    }
+    public function submit(MaintenanceService $service) { return $this->run(fn () => $service->save(request()->all())); }
+    public function status(MaintenanceService $service)
+    {
+        return $this->run(function () use ($service) {
+            $params = request()->validate(['id' => ['required', 'integer', 'min:1'], 'enabled' => ['required', 'integer', 'in:0,1']]);
+            return $service->status((int) $params['id'], (int) $params['enabled']);
+        });
+    }
+    public function logs(MaintenanceService $service) { return $this->run(fn () => $service->logs(request()->all())); }
+
+    private function run(callable $callback)
+    {
+        try { return $this->success($callback()); }
+        catch (ValidationException $e) { return $this->error(HttpStatus::CUSTOM_ERROR, $e->validator->errors()->first()); }
+        catch (Throwable $e) {
+            Log::error('maintenance_admin_failed', ['exception' => $e]);
+            return $this->error(HttpStatus::CUSTOM_ERROR, '维护设置服务暂不可用,请稍后重试');
+        }
+    }
+}

+ 11 - 5
app/Http/Controllers/admin/PaymentOrder.php

@@ -18,7 +18,7 @@ use App\Services\BalanceLogService;
 class PaymentOrder extends Controller
 class PaymentOrder extends Controller
 {
 {
 
 
-    //统计后台待处理订单总数(在线平台人数见 GET /admin/online/platformCount)
+    //统计后台待处理订单及领取审核数(在线平台人数见 GET /admin/online/platformCount)
     public function unProcessed()
     public function unProcessed()
     {
     {
         //USDT充值订单
         //USDT充值订单
@@ -34,6 +34,8 @@ class PaymentOrder extends Controller
         $data['rgWithdraw'] = (int)PaymentOrderModel::where('status', 0)->where('type', 4)->where('created_at', '>', date('Y-m-d H:i:s', strtotime('-1 day')))->count();
         $data['rgWithdraw'] = (int)PaymentOrderModel::where('status', 0)->where('type', 4)->where('created_at', '>', date('Y-m-d H:i:s', strtotime('-1 day')))->count();
         //人民币提现订单
         //人民币提现订单
         $data['rmbWithdraw'] = (int)PaymentOrderModel::where('status', 0)->where('type', 2)->where('created_at', '>', date('Y-m-d H:i:s', strtotime('-1 day')))->count();
         $data['rmbWithdraw'] = (int)PaymentOrderModel::where('status', 0)->where('type', 2)->where('created_at', '>', date('Y-m-d H:i:s', strtotime('-1 day')))->count();
+        // 分享赚钱领取审核:所有未审核申请持续提示,不限制申请日期。
+        $data['shareClaim'] = (int)DB::table('share_claims')->where('status', 'pending')->count();
         return $this->success($data);
         return $this->success($data);
     }
     }
 
 
@@ -79,7 +81,9 @@ class PaymentOrder extends Controller
                 'remark' => ['nullable', 'string'],
                 'remark' => ['nullable', 'string'],
             ]);
             ]);
             $remark = $params['remark'] ?? '';
             $remark = $params['remark'] ?? '';
-            $info = PaymentOrderModel::find($params['id']);
+            $info = PaymentOrderModel::where('id', $params['id'])
+                ->where('type', PaymentOrderService::TYPE_SELF_PAY)
+                ->lockForUpdate()->first();
             if (!$info) throw new Exception('数据不存在', HttpStatus::CUSTOM_ERROR);
             if (!$info) throw new Exception('数据不存在', HttpStatus::CUSTOM_ERROR);
             if ($params['status'] == 2 && $info->status != 5) throw new Exception('用户未提交充值凭证', HttpStatus::CUSTOM_ERROR);
             if ($params['status'] == 2 && $info->status != 5) throw new Exception('用户未提交充值凭证', HttpStatus::CUSTOM_ERROR);
             if ($params['status'] == 3 && !in_array($info->status, [0,1,4,5])) throw new Exception('订单已完成,不可驳回', HttpStatus::CUSTOM_ERROR);
             if ($params['status'] == 3 && !in_array($info->status, [0,1,4,5])) throw new Exception('订单已完成,不可驳回', HttpStatus::CUSTOM_ERROR);
@@ -100,13 +104,15 @@ class PaymentOrder extends Controller
 
 
                 $memberId = $info->member_id;
                 $memberId = $info->member_id;
                 $amount = $info->amount;
                 $amount = $info->amount;
-                $changeType = '人工充值';
-                $wallet = Wallet::where('member_id', $memberId)->first();
+                // 前台提交并审核到账的人工充值属于真实充值;后台直接加款仍使用“人工充值”。
+                $changeType = '充值';
+                $wallet = Wallet::where('member_id', $memberId)->lockForUpdate()->first();
                 if (!$wallet) throw new Exception('用户不存在', HttpStatus::CUSTOM_ERROR);
                 if (!$wallet) throw new Exception('用户不存在', HttpStatus::CUSTOM_ERROR);
                 $availableBalance = bcadd($wallet->available_balance, $amount, 10);
                 $availableBalance = bcadd($wallet->available_balance, $amount, 10);
-                BalanceLogService::addLog($memberId, $amount, $wallet->available_balance, $availableBalance, $changeType, null, $remark);
+                $beforeBalance = (string)$wallet->available_balance;
                 $wallet->available_balance = $availableBalance;
                 $wallet->available_balance = $availableBalance;
                 $wallet->save();
                 $wallet->save();
+                BalanceLogService::addLog($memberId, $amount, $beforeBalance, $availableBalance, $changeType, $info->id, $remark);
             }
             }
             
             
             DB::commit();
             DB::commit();

+ 32 - 0
app/Http/Controllers/admin/Promotion.php

@@ -0,0 +1,32 @@
+<?php
+
+namespace App\Http\Controllers\admin;
+
+use App\Constants\HttpStatus;
+use App\Http\Controllers\Controller;
+use App\Services\PromotionAdminService;
+use App\Support\PromotionAmountFormatter;
+use Illuminate\Support\Facades\Log;
+use Illuminate\Validation\ValidationException;
+
+class Promotion extends Controller
+{
+    private function run(callable $callback)
+    {
+        try { return $this->success(PromotionAmountFormatter::response($callback())); }
+        catch (ValidationException $e) { return $this->error(HttpStatus::CUSTOM_ERROR, $e->validator->errors()->first()); }
+        catch (\Throwable $e) { Log::error('promotion_admin_failed', ['exception' => $e]); return $this->error(HttpStatus::CUSTOM_ERROR, '独立活动服务暂不可用'); }
+    }
+    public function index(PromotionAdminService $s) { return $this->run(fn () => $s->listing(request()->all())); }
+    public function options(PromotionAdminService $s) { return $this->run(fn () => $s->options()); }
+    public function submit(PromotionAdminService $s) { return $this->run(fn () => $s->save(request()->all())); }
+    public function records(PromotionAdminService $s) { return $this->run(fn () => $s->records(request()->all())); }
+    public function detail(PromotionAdminService $s)
+    {
+        return $this->run(function () use ($s) { $p = request()->validate(['id' => ['required', 'integer', 'min:1']]); return $s->detail((int)$p['id']); });
+    }
+    public function status(PromotionAdminService $s)
+    {
+        return $this->run(function () use ($s) { $p = request()->validate(['id' => ['required', 'integer', 'min:1'], 'status' => ['required', 'integer', 'in:0,1']]); return $s->status((int)$p['id'], (int)$p['status']); });
+    }
+}

+ 31 - 0
app/Http/Controllers/admin/Reporting.php

@@ -0,0 +1,31 @@
+<?php
+
+namespace App\Http\Controllers\admin;
+
+use App\Constants\HttpStatus;
+use App\Http\Controllers\Controller;
+use App\Services\Reporting\ReportAdmin;
+use App\Services\Reporting\ReportReader;
+use Illuminate\Support\Facades\Log;
+use Illuminate\Validation\ValidationException;
+
+class Reporting extends Controller
+{
+    public function options(ReportAdmin $service) { return $this->run(fn () => $service->options()); }
+    public function leaderboard(ReportReader $service) { return $this->run(fn () => $service->details(request()->all(), true)); }
+    public function funds(ReportReader $service) { return $this->run(fn () => $service->funds(request()->all())); }
+    public function summary(ReportReader $service) { return $this->run(fn () => $service->summary(request()->all())); }
+    public function details(ReportReader $service) { return $this->run(fn () => $service->details(request()->all())); }
+    public function orders(ReportReader $service) { return $this->run(fn () => $service->orders(request()->all())); }
+    public function events(ReportReader $service) { return $this->run(fn () => $service->events(request()->all())); }
+    public function settings(ReportAdmin $service) { return $this->run(fn () => $service->settings(request()->isMethod('POST') ? request()->all() : null)); }
+    public function payments(ReportAdmin $service) { return $this->run(fn () => request()->isMethod('POST') ? $service->reconcile(request()->all()) : $service->reconciliations(request()->all())); }
+    public function orderTime(ReportAdmin $service) { return $this->run(fn () => $service->reconcileOrder(request()->all())); }
+    public function quality(ReportAdmin $service) { return $this->run(fn () => $service->quality(request()->all())); }
+    private function run(callable $callback)
+    {
+        try { return $this->success($callback()); }
+        catch (ValidationException $e) { return $this->error(HttpStatus::CUSTOM_ERROR, $e->validator->errors()->first()); }
+        catch (\Throwable $e) { Log::error('reporting_request_failed', ['exception' => $e]); return $this->error(HttpStatus::CUSTOM_ERROR, '报表暂不可用,请稍后重试'); }
+    }
+}

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

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

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

@@ -5,13 +5,15 @@ namespace App\Http\Controllers\admin;
 use App\Constants\HttpStatus;
 use App\Constants\HttpStatus;
 use App\Http\Controllers\Controller;
 use App\Http\Controllers\Controller;
 use App\Models\ShareBlacklist;
 use App\Models\ShareBlacklist;
-use App\Models\ShareSetting;
+use App\Services\Sharing\ShareAdminService;
 use App\Models\User;
 use App\Models\User;
 use App\Services\OperationAuditService;
 use App\Services\OperationAuditService;
 use App\Services\ShareEarningReportService;
 use App\Services\ShareEarningReportService;
 use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\DB;
 use Illuminate\Validation\Rule;
 use Illuminate\Validation\Rule;
 use Illuminate\Validation\ValidationException;
 use Illuminate\Validation\ValidationException;
+use Illuminate\Database\QueryException;
+use Illuminate\Support\Facades\Log;
 use Throwable;
 use Throwable;
 
 
 class ShareEarning extends Controller
 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()
     public function blacklists()
@@ -205,6 +172,9 @@ class ShareEarning extends Controller
             return $this->success($callback());
             return $this->success($callback());
         } catch (ValidationException $e) {
         } catch (ValidationException $e) {
             return $this->error(HttpStatus::CUSTOM_ERROR, $e->validator->errors()->first());
             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) {
         } catch (Throwable $e) {
             return $this->error(HttpStatus::CUSTOM_ERROR, $e->getMessage());
             return $this->error(HttpStatus::CUSTOM_ERROR, $e->getMessage());
         }
         }

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

@@ -0,0 +1,72 @@
+<?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 claimOptions(ShareClaimAuditService $service) { return $this->run(fn () => $service->options()); }
+    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, '分享管理服务暂不可用,请稍后重试');
+        }
+    }
+}

+ 25 - 0
app/Http/Controllers/admin/SiteConfiguration.php

@@ -0,0 +1,25 @@
+<?php
+
+namespace App\Http\Controllers\admin;
+
+use App\Constants\HttpStatus;
+use App\Http\Controllers\Controller;
+use App\Services\SiteConfigurationService;
+use Illuminate\Support\Facades\Log;
+use Illuminate\Validation\ValidationException;
+
+class SiteConfiguration extends Controller
+{
+    public function show(SiteConfigurationService $service) { return $this->run(fn () => $service->get()); }
+    public function save(SiteConfigurationService $service) { return $this->run(fn () => $service->save(request()->all())); }
+
+    private function run(callable $callback)
+    {
+        try { return $this->success($callback())->header('Cache-Control', 'no-store'); }
+        catch (ValidationException $e) { return $this->error(HttpStatus::CUSTOM_ERROR, $e->validator->errors()->first()); }
+        catch (\Throwable $e) {
+            Log::error('site_configuration_admin_failed', ['exception' => $e]);
+            return $this->error(HttpStatus::CUSTOM_ERROR, '站点配置暂不可用,请稍后重试');
+        }
+    }
+}

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

@@ -0,0 +1,29 @@
+<?php
+
+namespace App\Http\Controllers\admin;
+
+use App\Http\Controllers\Controller;
+use App\Services\SiteMessageService;
+use Illuminate\Auth\Access\AuthorizationException;
+use Illuminate\Support\Facades\Log;
+use Illuminate\Validation\ValidationException;
+
+class SiteMessage extends Controller
+{
+    public function index(SiteMessageService $service) { return $this->run(fn () => $service->listing(request()->all())); }
+    public function options(SiteMessageService $service) { return $this->run(fn () => $service->options()); }
+    public function users(SiteMessageService $service) { return $this->run(fn () => $service->recipients('user', request()->all())); }
+    public function agents(SiteMessageService $service) { return $this->run(fn () => $service->recipients('agent', request()->all())); }
+    public function send(SiteMessageService $service) { return $this->run(fn () => $service->send(request()->all())); }
+    public function detail(SiteMessageService $service) { return $this->run(fn () => $service->detail($this->id())); }
+    public function delete(SiteMessageService $service) { return $this->run(fn () => $service->retract($this->id())); }
+    public function logs(SiteMessageService $service) { return $this->run(fn () => $service->logs(request()->all())); }
+    private function id(): int { return (int) request()->validate(['id' => ['required', 'integer', 'min:1']])['id']; }
+    private function run(callable $callback)
+    {
+        try { return $this->success($callback()); }
+        catch (ValidationException $e) { return $this->error(-3, $e->validator->errors()->first()); }
+        catch (AuthorizationException $e) { return $this->error(-1, $e->getMessage()); }
+        catch (\Throwable $e) { Log::error('site_message_admin_failed', ['exception' => $e]); return $this->error(-3, '信息服务暂不可用,请稍后重试'); }
+    }
+}

+ 1 - 0
app/Http/Controllers/admin/User.php

@@ -107,6 +107,7 @@ class User extends Controller
                 'page' => ['nullable', 'integer', 'min:1'],
                 'page' => ['nullable', 'integer', 'min:1'],
                 'limit' => ['nullable', 'integer', 'min:1'],
                 'limit' => ['nullable', 'integer', 'min:1'],
                 'member_id' => ['nullable', 'string', 'min:1'],
                 'member_id' => ['nullable', 'string', 'min:1'],
+                'account' => ['nullable', 'string', 'min:1'],
                 'like_first_name' => ['nullable', 'string', 'min:1'],
                 'like_first_name' => ['nullable', 'string', 'min:1'],
                 'username' => ['nullable', 'string', 'min:1'],
                 'username' => ['nullable', 'string', 'min:1'],
                 'register_ip' => ['nullable', 'string', 'min:1'],
                 'register_ip' => ['nullable', 'string', 'min:1'],

+ 5 - 4
app/Http/Controllers/admin/Wallet.php

@@ -351,7 +351,7 @@ class Wallet extends Controller
             $remark = request()->input('remark', '');
             $remark = request()->input('remark', '');
             $recharge = Recharge::where('id', $id)
             $recharge = Recharge::where('id', $id)
                 ->where('type', 2)
                 ->where('type', 2)
-                ->where('status', 0)->first();
+                ->where('status', 0)->lockForUpdate()->first();
             if (!$recharge) throw new Exception("数据不存在", HttpStatus::CUSTOM_ERROR);
             if (!$recharge) throw new Exception("数据不存在", HttpStatus::CUSTOM_ERROR);
             $amount = floatval($recharge->amount);
             $amount = floatval($recharge->amount);
             if ($status == 2) {
             if ($status == 2) {
@@ -370,13 +370,14 @@ class Wallet extends Controller
                 $recharge->exchange_rate = $rate;
                 $recharge->exchange_rate = $rate;
                 $recharge->save();
                 $recharge->save();
                 $wallet = WalletModel::where('member_id', $recharge->member_id)
                 $wallet = WalletModel::where('member_id', $recharge->member_id)
-                    ->first();
+                    ->lockForUpdate()->first();
                 $rate_amount = bcmul($amount, $rate, 10); // 汇率转换后分数
                 $rate_amount = bcmul($amount, $rate, 10); // 汇率转换后分数
                 $available_balance = bcadd($wallet->available_balance, $rate_amount, 10);
                 $available_balance = bcadd($wallet->available_balance, $rate_amount, 10);
 
 
-                BalanceLogService::addLog($recharge->member_id, $rate_amount, $wallet->available_balance, $available_balance, "充值", $recharge->id, "用户充值后台审核到账");
+                $beforeBalance = (string)$wallet->available_balance;
                 $wallet->available_balance = $available_balance;
                 $wallet->available_balance = $available_balance;
                 $wallet->save();
                 $wallet->save();
+                BalanceLogService::addLog($recharge->member_id, $rate_amount, $beforeBalance, $available_balance, "充值", $recharge->id, "用户充值后台审核到账");
 
 
                 $text = "充值结果通知\n";
                 $text = "充值结果通知\n";
                 $text .= "充值数量:{$amount} USDT\n";
                 $text .= "充值数量:{$amount} USDT\n";
@@ -532,4 +533,4 @@ class Wallet extends Controller
         $result = RechargeService::syncUsdtRechargeRecords($memberId);
         $result = RechargeService::syncUsdtRechargeRecords($memberId);
         var_dump($result);
         var_dump($result);
     }
     }
-}
+}

+ 1 - 0
app/Http/Controllers/agent/AuthController.php

@@ -21,6 +21,7 @@ class AuthController extends AgentApiController
                 'id' => (int) $agent->id,
                 'id' => (int) $agent->id,
                 'username' => $agent->username,
                 'username' => $agent->username,
                 'real_name' => $agent->real_name,
                 'real_name' => $agent->real_name,
+                'language' => $agent->language ?? 'zh',
                 'invitation_code' => $agent->invitation_code,
                 'invitation_code' => $agent->invitation_code,
                 'level' => (int) $agent->level,
                 'level' => (int) $agent->level,
                 'level_text' => $agent->level_text,
                 'level_text' => $agent->level_text,

+ 15 - 0
app/Http/Controllers/agent/MessageController.php

@@ -0,0 +1,15 @@
+<?php
+
+namespace App\Http\Controllers\agent;
+
+use App\Http\Controllers\AgentApiController;
+use App\Services\SiteInboxService;
+
+class MessageController extends AgentApiController
+{
+    public function index(SiteInboxService $service) { return $this->execute(fn () => $service->listing('agent', (int) $this->currentAgent()->id, request()->all())); }
+    public function detail(SiteInboxService $service) { return $this->execute(fn () => $service->detail('agent', (int) $this->currentAgent()->id, $this->id())); }
+    public function unread(SiteInboxService $service) { return $this->execute(fn () => $service->unread('agent', (int) $this->currentAgent()->id)); }
+    public function read(SiteInboxService $service) { return $this->execute(fn () => $service->read('agent', (int) $this->currentAgent()->id, $this->id())); }
+    private function id(): int { return (int) request()->validate(['id' => ['required', 'integer', 'min:1']])['id']; }
+}

+ 4 - 2
app/Http/Controllers/agent/ProfileController.php

@@ -21,6 +21,7 @@ class ProfileController extends AgentApiController
                 'id' => (int) $agent->id,
                 'id' => (int) $agent->id,
                 'username' => $agent->username,
                 'username' => $agent->username,
                 'real_name' => $agent->real_name,
                 'real_name' => $agent->real_name,
+                'language' => $agent->language ?? 'zh',
                 'parent_id' => $agent->parent_id,
                 'parent_id' => $agent->parent_id,
                 'parent_username' => $agent->parent?->username,
                 'parent_username' => $agent->parent?->username,
                 'level' => (int) $agent->level,
                 'level' => (int) $agent->level,
@@ -45,9 +46,10 @@ class ProfileController extends AgentApiController
     public function update(Request $request)
     public function update(Request $request)
     {
     {
         return $this->execute(function () use ($request) {
         return $this->execute(function () use ($request) {
-            $data = $request->validate(['real_name' => ['nullable', 'string', 'max:128']]);
+            $data = $request->validate(['real_name' => ['nullable', 'string', 'max:128'], 'language' => ['sometimes', 'required', 'in:zh,en,vi']]);
             $agent = $this->currentAgent();
             $agent = $this->currentAgent();
-            $agent->real_name = trim((string) ($data['real_name'] ?? ''));
+            if (array_key_exists('real_name', $data)) $agent->real_name = trim((string) ($data['real_name'] ?? ''));
+            if (array_key_exists('language', $data)) $agent->language = $data['language'];
             $agent->save();
             $agent->save();
             return [];
             return [];
         });
         });

+ 28 - 12
app/Http/Controllers/api/ActivityReward.php

@@ -9,6 +9,8 @@ use App\Models\ActivityUser;
 use App\Models\Config;
 use App\Models\Config;
 use App\Models\Recharge;
 use App\Models\Recharge;
 use App\Services\ActivityRewardService;
 use App\Services\ActivityRewardService;
+use App\Services\ActivityEligibilityService;
+use App\Services\ActivityQuotaService;
 use App\Services\ActivityUserService;
 use App\Services\ActivityUserService;
 use App\Services\ConfigService;
 use App\Services\ConfigService;
 use App\Services\PhoneCodeService;
 use App\Services\PhoneCodeService;
@@ -95,13 +97,24 @@ class ActivityReward extends BaseController
             request()->validate([
             request()->validate([
                 'member_id' => ['required', 'integer', 'exists:users,member_id'],
                 'member_id' => ['required', 'integer', 'exists:users,member_id'],
                 'activity_id' => ['required', 'integer'],
                 'activity_id' => ['required', 'integer'],
+                'platform' => ['nullable', 'string', 'in:PC,H5,APP'],
             ]);
             ]);
             $memberId = request()->input('member_id');
             $memberId = request()->input('member_id');
+            $activityId = request()->input('activity_id');
+            $activity = ActivityRewardService::findOne(['status' => ActivityRewardModel::STATUS_UP, 'id' => $activityId, 'type' => 1]);
+            if (!$activity) throw new Exception('活动不存在', HttpStatus::CUSTOM_ERROR);
+            if (!ActivityEligibilityService::isPublishedOn($activity, request()->input('platform'))) {
+                throw new Exception('此平台未发布该活动', HttpStatus::CUSTOM_ERROR);
+            }
+            $eligibility = ActivityEligibilityService::evaluate($activity, ActivityEligibilityService::memberContext($memberId));
+            if (!$eligibility['is_eligible']) throw new Exception($eligibility['ineligible_reason'], HttpStatus::CUSTOM_ERROR);
+            if (!ActivityQuotaService::canAward($activity, $memberId)) {
+                throw new Exception($activity->quota_enabled ? '活动获奖份数已用完' : '活动预算已用完', HttpStatus::CUSTOM_ERROR);
+            }
             $user = UserService::findOne(['member_id' => $memberId]);
             $user = UserService::findOne(['member_id' => $memberId]);
             if (empty($user->phone)) throw new Exception('请先绑定手机号', HttpStatus::NOT_BIND_PHONE);
             if (empty($user->phone)) throw new Exception('请先绑定手机号', HttpStatus::NOT_BIND_PHONE);
 
 
 
 
-            $activityId = request()->input('activity_id');
             if (ActivityUser::where('member_id', $memberId)->where('activity_id', $activityId)->exists()) {
             if (ActivityUser::where('member_id', $memberId)->where('activity_id', $activityId)->exists()) {
                 throw new Exception('已参与过此活动,每个活动仅可参加一次', HttpStatus::CUSTOM_ERROR);
                 throw new Exception('已参与过此活动,每个活动仅可参加一次', HttpStatus::CUSTOM_ERROR);
             }
             }
@@ -126,13 +139,6 @@ class ActivityReward extends BaseController
             }
             }
 
 
             $time = time();
             $time = time();
-            $activity = ActivityRewardService::findOne([
-//                'start_time' => ['<=', $time],
-//                'end_time' => ['>=', $time],
-                'status' => ActivityRewardModel::STATUS_UP,
-                'id' => $activityId
-            ]);
-            if (!$activity) throw new Exception('活动不存在', HttpStatus::CUSTOM_ERROR);
             if (strtotime($activity->start_time) > $time) throw new Exception("活动未开始$activity->start_time", HttpStatus::CUSTOM_ERROR);
             if (strtotime($activity->start_time) > $time) throw new Exception("活动未开始$activity->start_time", HttpStatus::CUSTOM_ERROR);
             if (strtotime($activity->end_time) < $time) throw new Exception('活动已结束', HttpStatus::CUSTOM_ERROR);
             if (strtotime($activity->end_time) < $time) throw new Exception('活动已结束', HttpStatus::CUSTOM_ERROR);
 
 
@@ -162,8 +168,10 @@ class ActivityReward extends BaseController
     public function index(): JsonResponse
     public function index(): JsonResponse
     {
     {
         try {
         try {
-            request()->validate(['member_id' => ['nullable', 'string']]);
+            request()->validate(['member_id' => ['nullable', 'string'], 'platform' => ['nullable', 'string', 'in:PC,H5,APP']]);
             $memberId = request()->input('member_id');
             $memberId = request()->input('member_id');
+            $context = ActivityEligibilityService::memberContext($memberId);
+            $platform = request()->input('platform');
             $time = time();
             $time = time();
             $query = ActivityRewardModel::where(ActivityRewardService::getWhere([
             $query = ActivityRewardModel::where(ActivityRewardService::getWhere([
 //                'start_time' => ['<=', $time],
 //                'start_time' => ['<=', $time],
@@ -176,8 +184,16 @@ class ActivityReward extends BaseController
                     $query1->where('member_id', $memberId);
                     $query1->where('member_id', $memberId);
                 }]);
                 }]);
             }
             }
-            $list = $query->orderByDesc('id')->get();
-            $list->transform(function ($activity) use ($memberId) {
+            $list = $query->orderByDesc('sort')->orderByDesc('id')->get()
+                ->filter(function ($activity) use ($context, $platform) {
+                    if (!ActivityEligibilityService::isPublishedOn($activity, $platform)) return false;
+                    $eligibility = ActivityEligibilityService::evaluate($activity, $context);
+                    return $eligibility['is_eligible'] || $activity->show_ineligible;
+                })->values();
+            $list = $list->map(function ($activity) use ($memberId, $context) {
+                $eligibility = ActivityEligibilityService::evaluate($activity, $context);
+                $activity->is_eligible = $eligibility['is_eligible'];
+                $activity->ineligible_reason = $eligibility['ineligible_reason'];
                 if (!empty($memberId)) {
                 if (!empty($memberId)) {
                     $activity->is_claimed = $activity->activityUser->isNotEmpty();
                     $activity->is_claimed = $activity->activityUser->isNotEmpty();
                 } else {
                 } else {
@@ -193,4 +209,4 @@ class ActivityReward extends BaseController
         }
         }
         return $this->success($list);
         return $this->success($list);
     }
     }
-}
+}

+ 1 - 1
app/Http/Controllers/api/Settle.php

@@ -94,7 +94,7 @@ class Settle extends Controller
             $balance_after_bet = bcsub($balance, $amount, 2);
             $balance_after_bet = bcsub($balance, $amount, 2);
             BalanceLogService::addLog(
             BalanceLogService::addLog(
                 $member_id,
                 $member_id,
-                $amount,
+                -$amount,
                 $balance,
                 $balance,
                 $balance_after_bet,
                 $balance_after_bet,
                 $betType,
                 $betType,

+ 3 - 1
app/Http/Controllers/api/TelegramWebHook.php

@@ -62,6 +62,7 @@ class TelegramWebHook extends BaseController
                     list($chatId, $firstName, $username) = PublicService::getChatInfo($message, $from);
                     list($chatId, $firstName, $username) = PublicService::getChatInfo($message, $from);
                     //用户注册和初始化用户钱包
                     //用户注册和初始化用户钱包
                     $user = PublicService::index($chatId, $username, $firstName);
                     $user = PublicService::index($chatId, $username, $firstName);
+                    \App\Services\Reporting\ReportActivity::telegram($user);
                     App::setLocale($user->language);
                     App::setLocale($user->language);
                     PublicService::init($telegram, $data, $chatId, $firstName, $messageId);
                     PublicService::init($telegram, $data, $chatId, $firstName, $messageId);
                     WalletService::init($telegram, $data, $chatId, $firstName, $messageId, $callbackId);
                     WalletService::init($telegram, $data, $chatId, $firstName, $messageId, $callbackId);
@@ -157,6 +158,7 @@ class TelegramWebHook extends BaseController
             $username = "";
             $username = "";
             if (isset($message['chat']['username'])) $username = $message['chat']['username'];
             if (isset($message['chat']['username'])) $username = $message['chat']['username'];
             $user = PublicService::index($chatId, $username, $message['chat']['first_name']);
             $user = PublicService::index($chatId, $username, $message['chat']['first_name']);
+            \App\Services\Reporting\ReportActivity::telegram($user);
             App::setLocale($user->language);
             App::setLocale($user->language);
             if ($message['chat']['type'] === 'private') {
             if ($message['chat']['type'] === 'private') {
                 // 校验开始菜单事件
                 // 校验开始菜单事件
@@ -259,4 +261,4 @@ class TelegramWebHook extends BaseController
 
 
 
 
     }
     }
-}
+}

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

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

+ 13 - 5
app/Http/Middleware/CheckToken.php

@@ -18,7 +18,7 @@ class CheckToken
      */
      */
     public function handle(Request $request, Closure $next)
     public function handle(Request $request, Closure $next)
     {
     {
-        // 1. 从请求头获取 token(header 名称:token / Token 都支持)
+        // 1. 前台沿用 Authorization 请求头中的原始 Token
         $token = $request->header('authorization');
         $token = $request->header('authorization');
 
 
         // 2. 如果请求头没有 token,直接返回未授权
         // 2. 如果请求头没有 token,直接返回未授权
@@ -30,12 +30,13 @@ class CheckToken
         }
         }
 
 
         // 3. 查询 user_session:token 存在 + 未过期
         // 3. 查询 user_session:token 存在 + 未过期
+        $time = now()->getTimestamp();
         $session = UserSession::where('token', $token)
         $session = UserSession::where('token', $token)
-            ->where('expire_time', '>', now()) // 过期时间 > 当前时间 = 未过期
+            ->where('expire_time', '>', $time)
             ->first();
             ->first();
 
 
         // 4. 会话不存在或已过期
         // 4. 会话不存在或已过期
-        if (!$session) {
+        if (!$session || UserSession::validUntil($session->getAttributes()) <= $time) {
             return response()->json([
             return response()->json([
                 'code' => 401,
                 'code' => 401,
                 'msg' => lang('token无效或已过期')
                 'msg' => lang('token无效或已过期')
@@ -46,12 +47,19 @@ class CheckToken
         $user = User::where('user_id',$session->user_id)->first();
         $user = User::where('user_id',$session->user_id)->first();
 
 
         // 6. 用户不存在
         // 6. 用户不存在
-        if (!$user) {
+        if (!$user || !empty($user->getAttributes()['delete_time'])) {
             return response()->json([
             return response()->json([
                 'code' => 401,
                 'code' => 401,
                 'msg' => lang('用户不存在')
                 'msg' => lang('用户不存在')
             ], 401);
             ], 401);
         }
         }
+        if ((int)($user->getAttributes()['is_banned'] ?? 0) === 1) {
+            return response()->json(['code' => 401, 'msg' => lang('账号已禁用')], 401);
+        }
+
+        if (!UserSession::renewActiveToken((string)$token, $time, (int)$session->id)) {
+            return response()->json(['code' => 401, 'msg' => lang('token无效或已过期')], 401);
+        }
 
 
         // 7. 把用户信息赋值给 $request->user
         // 7. 把用户信息赋值给 $request->user
         $request->user = $user;
         $request->user = $user;
@@ -59,4 +67,4 @@ class CheckToken
         // 8. 放行请求,进入控制器
         // 8. 放行请求,进入控制器
         return $next($request);
         return $next($request);
     }
     }
-}
+}

+ 41 - 0
app/Http/Middleware/MemberMaintenance.php

@@ -0,0 +1,41 @@
+<?php
+
+namespace App\Http\Middleware;
+
+use App\Models\User;
+use App\Models\UserSession;
+use App\Services\MaintenanceService;
+use App\Support\MaintenancePolicy as Policy;
+use Closure;
+use Illuminate\Http\Request;
+
+/** Assigned only to member routes, never the entire Laravel API group. */
+class MemberMaintenance
+{
+    public function handle(Request $request, Closure $next)
+    {
+        if ($request->isMethod('OPTIONS')) return $next($request);
+        $now = now()->getTimestamp();
+        $platform = Policy::platform($request->header('Platform', $request->input('platform')));
+        $language = Policy::language($request->header('lang', $request->input('lang'))) ?? 'vi';
+        $service = new MaintenanceService();
+        $rows = $service->runtimeRows($language);
+        $row = Policy::select($rows, $platform, null, $now);
+        if ($row === null) return $next($request);
+        // Read-only session validation: blocked requests must not renew login activity.
+        $token = $request->header('Authorization');
+        if (is_string($token) && $token !== '') {
+            $session = UserSession::where('token', $token)->where('expire_time', '>', $now)->first();
+            if ($session && UserSession::validUntil($session->getAttributes()) > $now) {
+                $user = User::where('user_id', $session->user_id)->first();
+                $saved = $user && empty($user->delete_time) && !(int) ($user->is_banned ?? 0) ? Policy::language($user->language) : null;
+                if ($saved && $saved !== $language) {
+                    $language = $saved; $row = Policy::select($service->runtimeRows($language), $platform, null, $now);
+                }
+            }
+        }
+        // The rule may have been switched off between reads.
+        if ($row === null) return $next($request);
+        return response()->json(Policy::failure($row, $platform, $language, $now) + ['timestamp' => $now])->header('Cache-Control', 'no-store');
+    }
+}

+ 30 - 9
app/Jobs/FiveSecondTaskJob.php

@@ -8,7 +8,6 @@ use App\Services\LogService;
 use App\Services\PcIssueService;
 use App\Services\PcIssueService;
 use Exception;
 use Exception;
 use Illuminate\Bus\Queueable;
 use Illuminate\Bus\Queueable;
-use Illuminate\Contracts\Queue\ShouldBeUnique;
 use Illuminate\Contracts\Queue\ShouldQueue;
 use Illuminate\Contracts\Queue\ShouldQueue;
 use Illuminate\Foundation\Bus\Dispatchable;
 use Illuminate\Foundation\Bus\Dispatchable;
 use Illuminate\Queue\InteractsWithQueue;
 use Illuminate\Queue\InteractsWithQueue;
@@ -26,9 +25,9 @@ class FiveSecondTaskJob implements ShouldQueue
     public $tries = 3;
     public $tries = 3;
 
 
     /**
     /**
-     * 任务超时时间(秒)
+     * 任务超时时间(秒),需小于队列的 retry_after。
      */
      */
-    public $timeout = 30;
+    public $timeout = 85;
 
 
 
 
     /**
     /**
@@ -49,21 +48,20 @@ class FiveSecondTaskJob implements ShouldQueue
     public function handle()
     public function handle()
     {
     {
         try {
         try {
-//            Log::error('🚀 开始执行15秒任务: ' . now());
             //(极速28)自定义开奖
             //(极速28)自定义开奖
-            PcIssueService::index();
+            $this->runTimedStep('极速28开奖处理', fn () => PcIssueService::index());
 
 
             // 提前60秒秒提醒封盘倒数
             // 提前60秒秒提醒封盘倒数
-            IssueService::syncCountdownIssue();
+            $this->runTimedStep('加拿大28封盘倒计时', fn () => IssueService::syncCountdownIssue());
 
 
             // 停止下注  //封盘
             // 停止下注  //封盘
-            IssueService::syncCloseIssue();
+            $this->runTimedStep('加拿大28封盘', fn () => IssueService::syncCloseIssue());
 
 
             // 获取最新的期号
             // 获取最新的期号
-            IssueService::getLatestIssue();
+            $this->runTimedStep('加拿大28开奖同步', fn () => IssueService::getLatestIssue());
 
 
             //随机虚拟投注
             //随机虚拟投注
-           BetService::randomVirtualBetting(3);
+            $this->runTimedStep('虚拟投注', fn () => BetService::randomVirtualBetting(3));
 
 
             // 重要:使用类名而不是 self(),避免递归
             // 重要:使用类名而不是 self(),避免递归
             FiveSecondTaskJob::dispatch()->delay(now()->addSeconds(15));
             FiveSecondTaskJob::dispatch()->delay(now()->addSeconds(15));
@@ -81,6 +79,29 @@ class FiveSecondTaskJob implements ShouldQueue
         }
         }
     }
     }
 
 
+    private function runTimedStep(string $step, callable $callback): void
+    {
+        $startedAt = microtime(true);
+        $completed = false;
+        $context = [
+            'step' => $step,
+            'job_id' => $this->job?->getJobId(),
+            'attempt' => $this->attempts(),
+            'process_id' => getmypid(),
+        ];
+        Log::channel('issue')->info('PC28任务步骤开始', $context);
+
+        try {
+            $callback();
+            $completed = true;
+        } finally {
+            Log::channel('issue')->info('PC28任务步骤结束', $context + [
+                'elapsed_ms' => round((microtime(true) - $startedAt) * 1000, 1),
+                'status' => $completed ? 'completed' : 'failed',
+            ]);
+        }
+    }
+
     // 可选:失败处理
     // 可选:失败处理
     public function failed(\Throwable $exception)
     public function failed(\Throwable $exception)
     {
     {

+ 47 - 1
app/Models/ActivityReward.php

@@ -4,6 +4,7 @@ namespace App\Models;
 
 
 use App\Constants\Util;
 use App\Constants\Util;
 use Illuminate\Database\Eloquent\Relations\HasMany;
 use Illuminate\Database\Eloquent\Relations\HasMany;
+use App\Support\ActivityCatalog;
 
 
 /**
 /**
  * @property string $title
  * @property string $title
@@ -20,13 +21,58 @@ class ActivityReward extends BaseModel
     const STATUS_DOWN = 0;
     const STATUS_DOWN = 0;
 
 
     protected $table = 'activity_rewards';
     protected $table = 'activity_rewards';
-    protected $fillable = ['title', 'sub_title', 'detail_image', 'start_time', 'end_time', 'status','type','lang','content', 'params','pc_image','detail_banner', 'pc_banner'];
+    protected $hidden = ['created_at', 'updated_at', 'detail_banner', 'pc_banner'];
+    protected $fillable = ['title', 'sub_title', 'detail_image', 'start_time', 'end_time', 'status','type','lang','content', 'params','pc_image','detail_banner', 'pc_banner',
+        'publish_platforms', 'budget', 'reward_quota', 'sort', 'register_time_limited', 'register_start_time', 'register_end_time',
+        'register_platforms', 'deposit_limited', 'deposit_min', 'deposit_max', 'show_ineligible', 'game_category', 'click_type', 'link', 'click_position'];
+
+    protected $appends = ['type_name', 'game_category_name', 'click_type', 'link', 'click_position', 'can_jump'];
+
+    protected function getClickTypeAttribute($value): string
+    {
+        $value = $value ?? ($this->attributes['click_type'] ?? 'none');
+        return in_array($value, ['none', 'external', 'internal'], true) ? $value : 'none';
+    }
+
+    protected function getLinkAttribute($value): ?string
+    {
+        return in_array($this->click_type, ['external', 'internal'], true) ? ($value ?? ($this->attributes['link'] ?? null)) : null;
+    }
+
+    protected function getCanJumpAttribute(): bool
+    {
+        return in_array($this->click_type, ['external', 'internal'], true) && !empty($this->link) && $this->click_position !== null;
+    }
+
+    protected function getClickPositionAttribute($value): ?string
+    {
+        $value = $value ?? ($this->attributes['click_position'] ?? null);
+        return $this->click_type !== 'none' && in_array($value, ['cover', 'detail'], true) ? $value : null;
+    }
+
+    protected $casts = [
+        'publish_platforms' => 'array', 'register_platforms' => 'array',
+        'register_time_limited' => 'boolean', 'deposit_limited' => 'boolean',
+        'show_ineligible' => 'boolean', 'reward_quota' => 'integer', 'rewarded_count' => 'integer',
+        'quota_enabled' => 'boolean', 'budget' => 'decimal:2', 'budget_used' => 'decimal:2',
+        'deposit_min' => 'decimal:2', 'deposit_max' => 'decimal:2', 'sort' => 'integer',
+    ];
 
 
     public function activityUser(): HasMany
     public function activityUser(): HasMany
     {
     {
         return $this->hasMany(ActivityUser::class, 'activity_id', 'id');
         return $this->hasMany(ActivityUser::class, 'activity_id', 'id');
     }
     }
 
 
+    protected function getTypeNameAttribute(): ?string
+    {
+        return ActivityCatalog::activityTypes()[(int) ($this->attributes['type'] ?? 0)] ?? null;
+    }
+
+    protected function getGameCategoryNameAttribute(): ?string
+    {
+        return ActivityCatalog::gameTypes()[$this->attributes['game_category'] ?? ''] ?? null;
+    }
+
     protected function getDetailImageAttribute($value)
     protected function getDetailImageAttribute($value)
     {
     {
         return Util::ensureUrl($value);
         return Util::ensureUrl($value);

+ 10 - 0
app/Models/ActivityRewardRecipient.php

@@ -0,0 +1,10 @@
+<?php
+
+namespace App\Models;
+
+class ActivityRewardRecipient extends BaseModel
+{
+    protected $table = 'activity_reward_recipients';
+    protected $fillable = ['activity_id', 'member_id', 'created_at'];
+    public $timestamps = false;
+}

+ 1 - 1
app/Models/Agent/Agent.php

@@ -16,7 +16,7 @@ class Agent extends BaseModel
     protected $table = 'agents';
     protected $table = 'agents';
     protected $fillable = [
     protected $fillable = [
         'parent_id', 'path', 'level', 'username', 'password', 'withdraw_password',
         'parent_id', 'path', 'level', 'username', 'password', 'withdraw_password',
-        'real_name', 'invitation_code', 'balance', 'frozen_balance',
+        'real_name', 'language', 'invitation_code', 'balance', 'frozen_balance',
         'deposit_fee_rate', 'withdraw_fee_rate',
         'deposit_fee_rate', 'withdraw_fee_rate',
         'profit_commission_rate', 'status', 'last_login_at', 'last_login_ip', 'created_by',
         'profit_commission_rate', 'status', 'last_login_at', 'last_login_ip', 'created_by',
         'flow_commission_profile_id', 'profit_commission_profile_id',
         'flow_commission_profile_id', 'profit_commission_profile_id',

+ 3 - 1
app/Models/Banner.php

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

+ 9 - 1
app/Models/BaseModel.php

@@ -24,6 +24,14 @@ class BaseModel extends Authenticatable
     protected $hidden = ['created_at', 'updated_at'];
     protected $hidden = ['created_at', 'updated_at'];
     protected $fillable = [];
     protected $fillable = [];
 
 
+    protected static function booted()
+    {
+        static::saved(function ($model) {
+            try { \App\Services\Reporting\ReportContext::captureModel($model); }
+            catch (\Throwable $e) { \Illuminate\Support\Facades\Log::error('reporting_context_failed', ['source' => $model->getTable(), 'id' => $model->getKey(), 'error' => get_class($e)]); }
+        });
+    }
+
     protected function getCreatedAtAttribute($value): string
     protected function getCreatedAtAttribute($value): string
     {
     {
         return Carbon::parse($value)->setTimezone('Asia/Shanghai')->format('Y-m-d H:i:s');
         return Carbon::parse($value)->setTimezone('Asia/Shanghai')->format('Y-m-d H:i:s');
@@ -33,4 +41,4 @@ class BaseModel extends Authenticatable
     {
     {
         return Carbon::parse($value)->setTimezone('Asia/Shanghai')->format('Y-m-d H:i:s');
         return Carbon::parse($value)->setTimezone('Asia/Shanghai')->format('Y-m-d H:i:s');
     }
     }
-}
+}

+ 20 - 0
app/Models/PromotionActivity.php

@@ -0,0 +1,20 @@
+<?php
+
+namespace App\Models;
+
+use App\Support\PromotionRules;
+
+/** Independent campaigns. They never appear in activity_rewards or its legacy catalog. */
+class PromotionActivity extends BaseModel
+{
+    protected $table = 'promotion_activities';
+    protected $guarded = ['id', 'rewarded_count', 'budget_used'];
+    protected $fillable = ['title', 'translations', 'type', 'status', 'start_time', 'end_time', 'lang', 'sort', 'params', 'content', 'image',
+        'publish_platforms', 'register_time_limited', 'register_start_time', 'register_end_time', 'register_platforms',
+        'deposit_limited', 'deposit_min', 'deposit_max', 'show_ineligible', 'reward_quota', 'budget'];
+    protected $casts = ['translations' => 'array', 'publish_platforms' => 'array', 'register_platforms' => 'array', 'register_time_limited' => 'boolean',
+        'deposit_limited' => 'boolean', 'show_ineligible' => 'boolean', 'quota_enabled' => 'boolean', 'budget' => 'decimal:2', 'budget_used' => 'decimal:2',
+        'reward_quota' => 'integer', 'rewarded_count' => 'integer', 'deposit_min' => 'decimal:2', 'deposit_max' => 'decimal:2'];
+    protected $appends = ['type_name'];
+    protected function getTypeNameAttribute(): ?string { return PromotionRules::types()[(int)($this->attributes['type'] ?? 0)] ?? null; }
+}

+ 4 - 3
app/Models/Rebate.php

@@ -36,7 +36,8 @@ class Rebate extends BaseModel
 
 
     static function updateProfit($data)
     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'])
         $rebate = static::where('date', $data['date'])
             ->where('member_id', $data['member_id'])->first();
             ->where('member_id', $data['member_id'])->first();
         if ($rebate) {
         if ($rebate) {
@@ -49,7 +50,7 @@ class Rebate extends BaseModel
 
 
     static function addOrUpdate($data)
     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['rebate_ratio'] = Config::where('field', 'rebate')->first()->val;
         $data['huishui_restriction'] = Config::where('field', 'huishui_restriction')->first()->val;
         $data['huishui_restriction'] = Config::where('field', 'huishui_restriction')->first()->val;
         $data['huishui_percentage'] = Config::where('field', 'huishui_percentage')->first()->val;
         $data['huishui_percentage'] = Config::where('field', 'huishui_percentage')->first()->val;
@@ -106,4 +107,4 @@ class Rebate extends BaseModel
         }
         }
         return $value;
         return $value;
     }
     }
-}
+}

+ 3 - 4
app/Models/ShareSetting.php

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

+ 10 - 0
app/Models/User.php

@@ -28,9 +28,19 @@ class User extends BaseModel
     'game_id', 'username', 'secret_key', 'secret_pass', 'language','user_code', 'agent_user_code', 'agent_id', 'level','recharge_channel_group_id'];
     'game_id', 'username', 'secret_key', 'secret_pass', 'language','user_code', 'agent_user_code', 'agent_id', 'level','recharge_channel_group_id'];
     protected $attributes = [
     protected $attributes = [
         'language' => 'zh',
         'language' => 'zh',
+        'recharge_channel_group_id' => 1,
     ];
     ];
     protected $hidden = ['updated_at'];
     protected $hidden = ['updated_at'];
 
 
+    protected static function booted()
+    {
+        static::creating(static function (self $user) {
+            if (empty($user->recharge_channel_group_id)) {
+                $user->recharge_channel_group_id = 1;
+            }
+        });
+    }
+
     
     
     // public function newQuery($excludeDeleted = true): Builder
     // public function newQuery($excludeDeleted = true): Builder
     // {
     // {

+ 25 - 2
app/Models/UserSession.php

@@ -6,6 +6,8 @@ namespace App\Models;
 
 
 class UserSession extends BaseModel
 class UserSession extends BaseModel
 {
 {
+    public const IDLE_SECONDS = 86400;
+    public const LAST_ACTIVITY_SQL = 'CASE WHEN update_time > 0 THEN update_time ELSE create_time END';
     protected $table = 'user_session';
     protected $table = 'user_session';
     protected $fillable = ['user_id', 'token', 'expire_time'];
     protected $fillable = ['user_id', 'token', 'expire_time'];
     
     
@@ -18,11 +20,11 @@ class UserSession extends BaseModel
     
     
     public static function setToken($userId)
     public static function setToken($userId)
     {
     {
-        $time = time();
+        $time = now()->getTimestamp();
         $userSession = UserSession::where('user_id', $userId)->first();
         $userSession = UserSession::where('user_id', $userId)->first();
 
 
         //获取token延长过期的时间
         //获取token延长过期的时间
-        $expireTime = $time + 3600 * 24 * 7;
+        $expireTime = $time + self::IDLE_SECONDS;
         $token = create_token($userId);
         $token = create_token($userId);
         //token处理
         //token处理
         if ($userSession) {
         if ($userSession) {
@@ -42,5 +44,26 @@ class UserSession extends BaseModel
         }
         }
         return $token;
         return $token;
     }
     }
+
+    public static function validUntil(array $session): int
+    {
+        $lastActivity = (int)($session['update_time'] ?? 0);
+        if ($lastActivity <= 0) $lastActivity = (int)($session['create_time'] ?? 0);
+        return $lastActivity > 0 ? min((int)($session['expire_time'] ?? 0), $lastActivity + self::IDLE_SECONDS) : 0;
+    }
+
+    /** Renew only the same still-active token; never move the activity clock backwards. */
+    public static function renewActiveToken(string $token, int $time, ?int $sessionId = null): ?self
+    {
+        if ($token === '') return null;
+        $sessionId = $sessionId ?? static::where('token', $token)->value('id');
+        if (!$sessionId) return null;
+        static::where('id', $sessionId)->where('token', $token)->where('expire_time', '>', $time)
+            ->whereRaw(self::LAST_ACTIVITY_SQL . ' > (? + 0)', [$time - self::IDLE_SECONDS])
+            ->whereRaw(self::LAST_ACTIVITY_SQL . ' < (? + 0)', [$time])
+            ->update(['expire_time' => $time + self::IDLE_SECONDS, 'update_time' => $time]);
+        $session = static::where('id', $sessionId)->where('token', $token)->where('expire_time', '>', $time)->first();
+        return $session && self::validUntil($session->getAttributes()) > $time ? $session : null;
+    }
 }
 }
 
 

+ 2 - 2
app/Models/YuebaoItem.php

@@ -4,11 +4,11 @@ namespace App\Models;
 class YuebaoItem extends BaseModel
 class YuebaoItem extends BaseModel
 {
 {
     protected $table = 'yuebao_item';
     protected $table = 'yuebao_item';
-    protected $fillable = ['user_id','principal','surplus_principal','daily_interest_rate','status'];
+    protected $fillable = ['user_id','principal','surplus_principal','daily_interest_rate','status', 'activity_id'];
     protected $hidden = [];
     protected $hidden = [];
     
     
     public $timestamps = true;
     public $timestamps = true;
     protected $dateFormat = 'U'; // U 代表 UNIX 时间戳(int)
     protected $dateFormat = 'U'; // U 代表 UNIX 时间戳(int)
 
 
  
  
-}
+}

+ 32 - 0
app/Services/ActivityBudgetService.php

@@ -0,0 +1,32 @@
+<?php
+
+namespace App\Services;
+
+use App\Models\ActivityReward;
+use Illuminate\Support\Facades\DB;
+
+/** Preserve the monetary budget behavior for activities created before quotas. */
+class ActivityBudgetService
+{
+    public static function award(ActivityReward $activity, $amount, callable $grant): bool
+    {
+        if (!is_numeric($amount) || !preg_match('/^\d{1,12}(\.\d{1,2})?$/', (string)$amount)
+            || bccomp((string)$amount, '0', 2) <= 0) {
+            return false;
+        }
+        return DB::transaction(function () use ($activity, $amount, $grant) {
+            $locked = ActivityReward::where('id', $activity->id)->lockForUpdate()->first();
+            if (!$locked) return false;
+            if ((int)$locked->type === 2) {
+                $grant();
+                return true;
+            }
+            $used = bcadd((string)($locked->budget_used ?? '0'), (string)$amount, 2);
+            if ($locked->budget !== null && bccomp($used, (string)$locked->budget, 2) > 0) return false;
+            $grant();
+            $locked->budget_used = $used;
+            $locked->save();
+            return true;
+        });
+    }
+}

+ 43 - 0
app/Services/ActivityEligibilityService.php

@@ -0,0 +1,43 @@
+<?php
+
+namespace App\Services;
+
+use App\Models\ActivityReward;
+use App\Models\BalanceLog;
+use App\Models\User;
+use App\Models\UserLogin;
+use App\Support\ActivityEligibility;
+
+class ActivityEligibilityService
+{
+    public static function memberContext($memberId): ?array
+    {
+        if ($memberId === null || $memberId === '') {
+            return null;
+        }
+        $user = User::where('member_id', $memberId)->first();
+        if (!$user) {
+            return null;
+        }
+        $platform = UserLogin::where('user_id', $memberId)
+            ->where('status', UserLogin::STATUS_SUCCESS)->orderBy('id')->value('platform');
+        return [
+            'registered_at' => $user->getRawOriginal('created_at'),
+            'register_platform' => ActivityEligibility::normalizePlatform($platform),
+            // Same categories as getTotalRecharge(), retaining the database decimal precision.
+            'deposit_total' => (string)BalanceLog::where('member_id', $memberId)->where('type', 1)
+                ->whereIn('change_type', ['充值', '人工充值', '三方充值'])
+                ->selectRaw('COALESCE(SUM(amount), 0) AS total')->value('total'),
+        ];
+    }
+
+    public static function evaluate(ActivityReward|\App\Models\PromotionActivity $activity, ?array $member): array
+    {
+        return ActivityEligibility::evaluate($activity->toArray(), $member);
+    }
+
+    public static function isPublishedOn(ActivityReward|\App\Models\PromotionActivity $activity, ?string $platform): bool
+    {
+        return ActivityEligibility::isPublishedOn($activity->toArray(), $platform);
+    }
+}

+ 73 - 0
app/Services/ActivityQuotaService.php

@@ -0,0 +1,73 @@
+<?php
+
+namespace App\Services;
+
+use App\Models\ActivityReward;
+use App\Models\ActivityRewardRecipient;
+use Illuminate\Support\Facades\DB;
+
+class ActivityQuotaService
+{
+    /** A preliminary check; award() always checks again under the activity lock. */
+    public static function canAward(ActivityReward $activity, $memberId): bool
+    {
+        if ((int)$activity->type === 2) return true;
+        if (!$activity->quota_enabled) {
+            return $activity->budget === null || bccomp((string)($activity->budget_used ?? '0'), (string)$activity->budget, 2) < 0;
+        }
+        $member = static::memberKey($memberId);
+        if ($member === null) return false;
+        if (static::hasReceived($activity->id, $member)) return true;
+        return $activity->reward_quota === null || (int)$activity->rewarded_count < (int)$activity->reward_quota;
+    }
+
+    /** Count a member only on their first successful positive reward for this activity. */
+    public static function award(ActivityReward $activity, $memberId, $amount, callable $grant): bool
+    {
+        $member = static::memberKey($memberId);
+        if ($member === null || !is_numeric($amount)
+            || !preg_match('/^\d{1,12}(\.\d{1,2})?$/', (string)$amount)
+            || bccomp((string)$amount, '0', 2) <= 0) {
+            return false;
+        }
+        return DB::transaction(function () use ($activity, $member, $amount, $grant) {
+            $locked = ActivityReward::where('id', $activity->id)->lockForUpdate()->first();
+            if (!$locked) return false;
+            if ((int)$locked->type === 2) {
+                $grant();
+                return true;
+            }
+            if (!$locked->quota_enabled) return ActivityBudgetService::award($locked, $amount, $grant);
+            // Match the locked counter with a current read, even inside an older caller snapshot.
+            $received = static::hasReceived($locked->id, $member, true);
+            if (!$received && $locked->reward_quota !== null
+                && (int)$locked->rewarded_count >= (int)$locked->reward_quota) {
+                return false;
+            }
+            $grant();
+            if (!$received) {
+                ActivityRewardRecipient::create([
+                    'activity_id' => $locked->id,
+                    'member_id' => $member,
+                    'created_at' => date('Y-m-d H:i:s'),
+                ]);
+                $locked->rewarded_count = (int)$locked->rewarded_count + 1;
+                $locked->save();
+            }
+            return true;
+        });
+    }
+
+    private static function hasReceived($activityId, string $memberId, bool $currentRead = false): bool
+    {
+        $query = ActivityRewardRecipient::where('activity_id', $activityId)->where('member_id', $memberId);
+        return $currentRead ? $query->lockForUpdate()->first() !== null : $query->exists();
+    }
+
+    private static function memberKey($memberId): ?string
+    {
+        if (!is_int($memberId) && !is_string($memberId)) return null;
+        $member = trim((string)$memberId);
+        return $member === '' || $member === '0' || strlen($member) > 64 ? null : $member;
+    }
+}

+ 121 - 7
app/Services/ActivityRewardService.php

@@ -9,6 +9,11 @@ use App\Models\Config;
 use App\Models\Role;
 use App\Models\Role;
 use App\Models\UserSession;
 use App\Models\UserSession;
 use App\Models\User;
 use App\Models\User;
+use Illuminate\Support\Facades\Validator;
+use Illuminate\Validation\Rule;
+use App\Support\ActivityCatalog;
+use App\Support\AnnouncementCatalog;
+use Illuminate\Validation\ValidationException;
 use Exception;
 use Exception;
 
 
 class ActivityRewardService extends BaseService
 class ActivityRewardService extends BaseService
@@ -16,6 +21,68 @@ class ActivityRewardService extends BaseService
 
 
     public static string $MODEL = ActivityReward::class;
     public static string $MODEL = ActivityReward::class;
 
 
+    public static function configurationDefaults(): array
+    {
+        return [
+            'publish_platforms' => [], 'budget' => null, 'reward_quota' => null, 'sort' => 0,
+            'register_time_limited' => false, 'register_start_time' => null, 'register_end_time' => null,
+            'register_platforms' => [], 'deposit_limited' => false, 'deposit_min' => null,
+            'deposit_max' => null, 'show_ineligible' => true,
+        ];
+    }
+
+    private static function configuration(array $params, ?ActivityReward $existing): array
+    {
+        $defaults = static::configurationDefaults();
+        if ((int)($params['type'] ?? $existing?->type) === 2) {
+            return $defaults;
+        }
+        $quotaMode = !$existing || $existing->quota_enabled;
+        Validator::make($params, [$quotaMode ? 'budget' : 'reward_quota' => ['prohibited']])->validate();
+        $previous = $existing ? $existing->only(array_keys($defaults)) : [];
+        foreach (['publish_platforms', 'register_platforms'] as $field) {
+            if (($previous[$field] ?? null) === null) $previous[$field] = [];
+        }
+        $config = array_replace($defaults, $previous, array_intersect_key($params, $defaults));
+        if ($quotaMode) $config['budget'] = null;
+        else $config['reward_quota'] = null;
+        if (empty($config['register_time_limited'])) {
+            $config['register_start_time'] = $config['register_end_time'] = null;
+        }
+        if (empty($config['deposit_limited'])) {
+            $config['deposit_min'] = $config['deposit_max'] = null;
+        }
+        $money = ['nullable', 'numeric', 'min:0', 'regex:/^\d{1,12}(\.\d{1,2})?$/'];
+        Validator::make($config, [
+            'publish_platforms' => ['present', 'array'],
+            'publish_platforms.*' => ['required', 'string', 'distinct', Rule::in(['PC', 'H5', 'APP'])],
+            'reward_quota' => ['nullable', 'integer', 'min:0', 'max:2147483647'],
+            'budget' => $money,
+            'sort' => ['required', 'integer', 'min:0', 'max:4294967295'],
+            'register_time_limited' => ['required', 'boolean'],
+            'register_start_time' => ['nullable', 'required_if:register_time_limited,1', 'date'],
+            'register_end_time' => ['nullable', 'required_if:register_time_limited,1', 'date', 'after_or_equal:register_start_time'],
+            'register_platforms' => ['present', 'array'],
+            'register_platforms.*' => ['required', 'string', 'distinct', Rule::in(['PC', 'H5', 'APP'])],
+            'deposit_limited' => ['required', 'boolean'],
+            'deposit_min' => array_merge($money, ['required_if:deposit_limited,1']),
+            'deposit_max' => array_merge($money, ['required_if:deposit_limited,1', 'gte:deposit_min']),
+            'show_ineligible' => ['required', 'boolean'],
+        ])->validate();
+        foreach (['budget', 'deposit_min', 'deposit_max'] as $field) {
+            if ($config[$field] !== null) {
+                $config[$field] = bcadd((string)$config[$field], '0', 2);
+            }
+        }
+        if ($config['reward_quota'] !== null) $config['reward_quota'] = (int)$config['reward_quota'];
+        foreach (['register_start_time', 'register_end_time'] as $field) {
+            if ($config[$field] !== null) {
+                $config[$field] = date('Y-m-d H:i:s', strtotime($config[$field]));
+            }
+        }
+        return $config;
+    }
+
     /**
     /**
      * @description: 查询单条数据
      * @description: 查询单条数据
      * @param array $search
      * @param array $search
@@ -26,6 +93,20 @@ class ActivityRewardService extends BaseService
         return static::$MODEL::where(static::getWhere($search))->first();
         return static::$MODEL::where(static::getWhere($search))->first();
     }
     }
 
 
+    public static function activeForMember(int $type, $memberId): ?ActivityReward
+    {
+        $context = ActivityEligibilityService::memberContext($memberId);
+        $activities = ActivityReward::where('lang', 'zh')->where('type', $type)->where('status', 1)
+            ->where('start_time', '<=', time())->where('end_time', '>=', time())
+            ->orderByDesc('sort')->orderByDesc('id')->get();
+        foreach ($activities as $activity) {
+            if (!ActivityEligibilityService::evaluate($activity, $context)['is_eligible']) continue;
+            if (!ActivityQuotaService::canAward($activity, $memberId)) continue;
+            return $activity;
+        }
+        return null;
+    }
+
     public static function getWhere(array $search = []): array
     public static function getWhere(array $search = []): array
     {
     {
         $where = [];
         $where = [];
@@ -87,6 +168,17 @@ class ActivityRewardService extends BaseService
      */
      */
     public static function submit(array $params = []): bool
     public static function submit(array $params = []): bool
     {
     {
+        unset($params['detail_banner'], $params['pc_banner']);
+        $info = !empty($params['id']) ? static::findOne(['id' => $params['id']]) : null;
+        if (!empty($params['id']) && !$info) throw new Exception('操作失败', HttpStatus::CUSTOM_ERROR);
+        if (array_key_exists('game_category', $params)) {
+            Validator::make($params, [
+                'game_category' => ['nullable', 'string', Rule::in(array_keys(ActivityCatalog::gameTypes()))],
+            ])->validate();
+            if ($params['game_category'] === '') $params['game_category'] = null;
+        }
+        $params = array_merge($params, static::configuration($params, $info), static::clickBehavior($params, $info));
+        unset($params['rewarded_count'], $params['quota_enabled'], $params['budget_used']); // Server-owned fields.
         if (isset($params['start_time']))
         if (isset($params['start_time']))
             $params['start_time'] = strtotime($params['start_time']);
             $params['start_time'] = strtotime($params['start_time']);
         if (isset($params['end_time']))
         if (isset($params['end_time']))
@@ -95,22 +187,44 @@ class ActivityRewardService extends BaseService
             $params['detail_image'] = Util::replacePartInUrl($params['detail_image']);
             $params['detail_image'] = Util::replacePartInUrl($params['detail_image']);
         if (isset($params['pc_image']))
         if (isset($params['pc_image']))
             $params['pc_image'] = Util::replacePartInUrl($params['pc_image']);
             $params['pc_image'] = Util::replacePartInUrl($params['pc_image']);
-        if (isset($params['detail_banner']))
-            $params['detail_banner'] = Util::replacePartInUrl($params['detail_banner']);
-        if (isset($params['pc_banner']))
-            $params['pc_banner'] = Util::replacePartInUrl($params['pc_banner']);
 
 
         if (!empty($params['id'])) {
         if (!empty($params['id'])) {
-            $info = static::findOne(['id' => $params['id']]);
-            if (!$info) throw new Exception("操作失败", HttpStatus::CUSTOM_ERROR);
             $info->update($params);
             $info->update($params);
         } else {
         } else {
-            static::$MODEL::create($params);
+            $info = new ActivityReward($params);
+            // Legacy schemas require these unused columns; frontend inputs are never persisted.
+            $info->detail_banner = '';
+            $info->pc_banner = '';
+            $info->quota_enabled = true; // Existing rows stay false; clients cannot change the mode.
+            $info->save();
         }
         }
         return true;
         return true;
     }
     }
 
 
 
 
+    private static function clickBehavior(array $params, ?ActivityReward $existing): array
+    {
+        if (!array_key_exists('click_type', $params) && !array_key_exists('link', $params) && !array_key_exists('click_position', $params)) return [];
+        $type = array_key_exists('click_type', $params) ? $params['click_type'] : ($existing?->click_type ?? 'none');
+        Validator::make(['click_type' => $type], ['click_type' => ['required', 'string', Rule::in(array_keys(AnnouncementCatalog::CLICKS))]],
+            ['click_type.in' => '点击方式仅支持无链接、外站链接、站内路径'])->validate();
+        if ($type === 'none') return ['click_type' => 'none', 'link' => null, 'click_position' => null];
+        $position = array_key_exists('click_position', $params) ? $params['click_position'] : $existing?->click_position;
+        Validator::make(['click_position' => $position], ['click_position' => ['required', 'string', Rule::in(['cover', 'detail'])]],
+            ['click_position.required' => '请选择链接触发位置', 'click_position.in' => '链接触发位置仅支持活动列表封面或活动详情按钮'])->validate();
+        $link = array_key_exists('link', $params) ? $params['link'] : $existing?->link;
+        Validator::make(['link' => $link], ['link' => ['required', 'string', 'max:2048']], ['link.required' => '请填写跳转链接'])->validate();
+        $valid = !preg_match('/[\x00-\x20\x7f\\\\]/', $link);
+        if ($type === 'internal') $valid = $valid && str_starts_with($link, '/') && !str_starts_with($link, '//');
+        else {
+            $parts = parse_url($link);
+            $valid = $valid && filter_var($link, FILTER_VALIDATE_URL) && $parts
+                && in_array(strtolower($parts['scheme'] ?? ''), ['http', 'https'], true) && !isset($parts['user']) && !isset($parts['pass']);
+        }
+        if (!$valid) throw ValidationException::withMessages(['link' => '请填写有效的外站链接或站内相对路径']);
+        return ['click_type' => $type, 'link' => $link, 'click_position' => $position];
+    }
+
     public static function promotionalActivity($chatId): array
     public static function promotionalActivity($chatId): array
     {
     {
         $keyboard = [];
         $keyboard = [];

+ 44 - 23
app/Services/ActivityUserService.php

@@ -9,6 +9,7 @@ use App\Models\ActivityReward;
 use App\Models\ActivityUser;
 use App\Models\ActivityUser;
 use App\Models\Wallet as WalletModel;
 use App\Models\Wallet as WalletModel;
 use Exception;
 use Exception;
+use Illuminate\Support\Facades\DB;
 use Illuminate\Database\Eloquent\Builder;
 use Illuminate\Database\Eloquent\Builder;
 
 
 class ActivityUserService extends BaseService
 class ActivityUserService extends BaseService
@@ -99,27 +100,47 @@ class ActivityUserService extends BaseService
      */
      */
     public static function gift($id, $amount, $bettingAmount): void
     public static function gift($id, $amount, $bettingAmount): void
     {
     {
-        $activityUser = static::findOne(['id' => $id]);
-        if (!$activityUser) throw new Exception('活动不存在', HttpStatus::CUSTOM_ERROR);
-        if ($activityUser->status == static::$MODEL::STATUS_IN_PROGRESS) throw new Exception('充值失败:活动进行中', HttpStatus::CUSTOM_ERROR);
-        if ($activityUser->status == static::$MODEL::STATUS_COMPLETE) throw new Exception("充值失败:活动已完成", HttpStatus::CUSTOM_ERROR);
-        if ($activityUser->status != static::$MODEL::STATUS_APPLY) throw new Exception('充值失败:状态异常', HttpStatus::CUSTOM_ERROR);
-        $activityUser->status = static::$MODEL::STATUS_IN_PROGRESS;
-        $activityUser->betting_amount = $bettingAmount;
-        $activityUser->gift_amount = $amount;
-        $activityUser->effective_betting_amount = 0;
-        $activityUser->save();
-        $memberId = $activityUser->member_id;
-        $res = WalletService::updateBalance($memberId, $amount);
-        BalanceLogService::addLog($memberId, $amount, $res['before_balance'], $res['after_balance'], "优惠活动", $activityUser->id, "$activityUser->title; 赠送:$activityUser->gift_amount;  打码量:$activityUser->betting_amount");
-
-        $availableBalance = floatval($res['after_balance']);
-        // 去除多余0后,再用 sprintf 补足两位
-        $availableBalance = sprintf('%.2f', $availableBalance);
-        $text = "余额变动通知\n";
-        $text .= "变动金额:" . ($amount > 0 ? '+' : '') . "{$amount}\n";
-        $text .= "总余额为:{$availableBalance}\n";
-        $text .= "说明:优惠活动赠送";
-        TopUpService::notifyTransferSuccess($memberId, $text);
+        $notification = DB::transaction(function () use ($id, $amount, $bettingAmount) {
+            $application = ActivityUser::where('id', $id)->first();
+            if (!$application) throw new Exception('活动不存在', HttpStatus::CUSTOM_ERROR);
+            // Wallet first, matching deposit and settlement transactions.
+            $wallet = WalletModel::where('member_id', $application->member_id)->lockForUpdate()->first();
+            if (!$wallet) throw new Exception('账户钱包不存在', HttpStatus::CUSTOM_ERROR);
+            $activityUser = ActivityUser::where('id', $id)->lockForUpdate()->first();
+            if (!$activityUser) throw new Exception('活动不存在', HttpStatus::CUSTOM_ERROR);
+            if ($activityUser->status == static::$MODEL::STATUS_IN_PROGRESS) throw new Exception('充值失败:活动进行中', HttpStatus::CUSTOM_ERROR);
+            if ($activityUser->status == static::$MODEL::STATUS_COMPLETE) throw new Exception("充值失败:活动已完成", HttpStatus::CUSTOM_ERROR);
+            if ($activityUser->status != static::$MODEL::STATUS_APPLY) throw new Exception('充值失败:状态异常', HttpStatus::CUSTOM_ERROR);
+            $activity = ActivityReward::where('id', $activityUser->activity_id)->first();
+            if (!$activity) throw new Exception('活动不存在', HttpStatus::CUSTOM_ERROR);
+            $eligibility = ActivityEligibilityService::evaluate($activity, ActivityEligibilityService::memberContext($activityUser->member_id));
+            if (!$eligibility['is_eligible']) throw new Exception($eligibility['ineligible_reason'], HttpStatus::CUSTOM_ERROR);
+            $res = null;
+            $granted = ActivityQuotaService::award($activity, $activityUser->member_id, $amount, function () use ($activityUser, $wallet, $amount, $bettingAmount, &$res) {
+                $activityUser->status = static::$MODEL::STATUS_IN_PROGRESS;
+                $activityUser->betting_amount = $bettingAmount;
+                $activityUser->gift_amount = $amount;
+                $activityUser->effective_betting_amount = 0;
+                $activityUser->save();
+                $memberId = $activityUser->member_id;
+                $before = (string)$wallet->available_balance;
+                $wallet->available_balance = bcadd($before, (string)$amount, 2);
+                $wallet->save();
+                $res = ['before_balance' => $before, 'after_balance' => $wallet->available_balance];
+                BalanceLogService::addLog($memberId, $amount, $res['before_balance'], $res['after_balance'], "优惠活动", $activityUser->id, "$activityUser->title; 赠送:$activityUser->gift_amount;  打码量:$activityUser->betting_amount");
+            });
+            if (!$granted) throw new Exception($activity->quota_enabled
+                ? '活动获奖份数已用完,已停止新用户发奖' : '活动预算不足,已停止发奖', HttpStatus::CUSTOM_ERROR);
+
+            $availableBalance = floatval($res['after_balance']);
+            // 去除多余0后,再用 sprintf 补足两位
+            $availableBalance = sprintf('%.2f', $availableBalance);
+            $text = "余额变动通知\n";
+            $text .= "变动金额:" . ($amount > 0 ? '+' : '') . "{$amount}\n";
+            $text .= "总余额为:{$availableBalance}\n";
+            $text .= "说明:优惠活动赠送";
+            return ['member_id' => $activityUser->member_id, 'text' => $text];
+        });
+        TopUpService::notifyTransferSuccess($notification['member_id'], $notification['text']);
     }
     }
-}
+}

+ 2 - 1
app/Services/Agent/AgentMemberService.php

@@ -77,7 +77,8 @@ class AgentMemberService
             if (Schema::hasTable('payment_orders')) {
             if (Schema::hasTable('payment_orders')) {
                 $orders = DB::table('payment_orders')->where('member_id', $memberId)
                 $orders = DB::table('payment_orders')->where('member_id', $memberId)
                     ->where('status', PaymentOrderService::STATUS_SUCCESS)->whereBetween('created_at', [$start, $end]);
                     ->where('status', PaymentOrderService::STATUS_SUCCESS)->whereBetween('created_at', [$start, $end]);
-                $member->recharge_count += (clone $orders)->where('type', PaymentOrderService::TYPE_PAY)->count();
+                $member->recharge_count += (clone $orders)
+                    ->whereIn('type', [PaymentOrderService::TYPE_PAY, PaymentOrderService::TYPE_SELF_PAY])->count();
                 $member->withdraw_count += (clone $orders)
                 $member->withdraw_count += (clone $orders)
                     ->whereIn('type', [PaymentOrderService::TYPE_PAYOUT, PaymentOrderService::TYPE_SELF_PAYOUT])->count();
                     ->whereIn('type', [PaymentOrderService::TYPE_PAYOUT, PaymentOrderService::TYPE_SELF_PAYOUT])->count();
             }
             }

+ 1 - 1
app/Services/Agent/AgentReportService.php

@@ -79,7 +79,7 @@ class AgentReportService
             $depositAmount = bcadd((string) $depositAmount, (string) DB::table('payment_orders')
             $depositAmount = bcadd((string) $depositAmount, (string) DB::table('payment_orders')
                 ->whereIn('member_id', clone $memberIds)
                 ->whereIn('member_id', clone $memberIds)
                 ->where('status', PaymentOrderService::STATUS_SUCCESS)
                 ->where('status', PaymentOrderService::STATUS_SUCCESS)
-                ->where('type', PaymentOrderService::TYPE_PAY)
+                ->whereIn('type', [PaymentOrderService::TYPE_PAY, PaymentOrderService::TYPE_SELF_PAY])
                 ->whereBetween('created_at', [$start, $end])->sum('amount'), 4);
                 ->whereBetween('created_at', [$start, $end])->sum('amount'), 4);
             $withdrawAmount = bcadd((string) $withdrawAmount, (string) DB::table('payment_orders')
             $withdrawAmount = bcadd((string) $withdrawAmount, (string) DB::table('payment_orders')
                 ->whereIn('member_id', clone $memberIds)
                 ->whereIn('member_id', clone $memberIds)

+ 2 - 2
app/Services/Agent/AgentService.php

@@ -71,7 +71,7 @@ class AgentService
                 'profit_commission_profile_id' => $profitProfileId,
                 'profit_commission_profile_id' => $profitProfileId,
                 'status' => (int) ($data['status'] ?? Agent::STATUS_ENABLED),
                 'status' => (int) ($data['status'] ?? Agent::STATUS_ENABLED),
                 'created_by' => $createdBy,
                 'created_by' => $createdBy,
-            ]);
+            ] + array_intersect_key($data, ['language' => true]));
             $agent->path = ($parent ? rtrim((string) $parent->path, '/') . '/' : '/') . $agent->id . '/';
             $agent->path = ($parent ? rtrim((string) $parent->path, '/') . '/' : '/') . $agent->id . '/';
             $agent->save();
             $agent->save();
             $this->flowAssignments->assign($agent, $flowProfile, now()->toDateString(), $createdBy);
             $this->flowAssignments->assign($agent, $flowProfile, now()->toDateString(), $createdBy);
@@ -116,7 +116,7 @@ class AgentService
             if (array_key_exists('real_name', $data)) {
             if (array_key_exists('real_name', $data)) {
                 $agent->real_name = trim((string) ($data['real_name'] ?? ''));
                 $agent->real_name = trim((string) ($data['real_name'] ?? ''));
             }
             }
-            foreach (['username', 'status', 'deposit_fee_rate', 'withdraw_fee_rate', 'flow_commission_profile_id', 'profit_commission_profile_id'] as $field) {
+            foreach (['username', 'language', 'status', 'deposit_fee_rate', 'withdraw_fee_rate', 'flow_commission_profile_id', 'profit_commission_profile_id'] as $field) {
                 if (array_key_exists($field, $data) && $data[$field] !== null) {
                 if (array_key_exists($field, $data) && $data[$field] !== null) {
                     $agent->{$field} = $data[$field];
                     $agent->{$field} = $data[$field];
                 }
                 }

+ 278 - 0
app/Services/AnnouncementService.php

@@ -0,0 +1,278 @@
+<?php
+
+namespace App\Services;
+
+use App\Constants\Util;
+use App\Support\AnnouncementCatalog as Catalog;
+use App\Support\LocalizedContent;
+use Carbon\Carbon;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Validator;
+use Illuminate\Validation\Rule;
+use Illuminate\Validation\ValidationException;
+
+class AnnouncementService
+{
+    private const FIELDS = ['category', 'publish_platforms', 'display_frequency',
+        'publish_at', 'end_at', 'status', 'click_type', 'link'];
+
+    public function options(): array
+    {
+        return [
+            'categories' => Catalog::options(Catalog::CATEGORIES),
+            'languages' => Catalog::options(LocalizedContent::LANGUAGES),
+            'platforms' => Catalog::options(Catalog::PLATFORMS),
+            'display_frequencies' => Catalog::options(Catalog::FREQUENCIES),
+            'click_types' => Catalog::options(Catalog::CLICKS),
+            'statuses' => [['value' => 0, 'label' => '关闭'], ['value' => 1, 'label' => '开启']],
+            'timezone' => Catalog::TIMEZONE,
+            'defaults' => ['category' => 'content', 'publish_platforms' => array_keys(Catalog::PLATFORMS),
+                'display_frequency' => 'daily_first_login', 'publish_immediately' => true, 'no_end' => true,
+                'status' => 1, 'click_type' => 'none'],
+        ];
+    }
+
+    public function listing(array $input): array
+    {
+        $params = Validator::make($input, $this->pageRules() + $this->dateRangeRules() + [
+            'category' => ['nullable', Rule::in(array_keys(Catalog::CATEGORIES))],
+            'platform' => ['nullable', Rule::in(array_keys(Catalog::PLATFORMS))],
+            'status' => ['nullable', 'integer', Rule::in([0, 1])],
+            'title' => ['nullable', 'string', 'max:200'],
+            'language' => ['nullable', Rule::in(array_keys(LocalizedContent::LANGUAGES))],
+        ])->validate();
+        $query = DB::table('announcements');
+        foreach (['category', 'status'] as $key) {
+            if (isset($params[$key]) && $params[$key] !== '') $query->where($key, $params[$key]);
+        }
+        if (!empty($params['language']) || (isset($params['title']) && $params['title'] !== '')) {
+            $query->whereExists(function ($q) use ($params) {
+                $q->selectRaw('1')->from('announcement_translations as t')->whereColumn('t.announcement_id', 'announcements.id');
+                if (!empty($params['language'])) $q->where('t.language', $params['language']);
+                if (isset($params['title']) && $params['title'] !== '') $q->where('t.title', 'like', '%' . $params['title'] . '%');
+            });
+        }
+        if (!empty($params['platform'])) {
+            if (DB::connection()->getDriverName() === 'sqlite') {
+                $query->whereRaw('EXISTS (SELECT 1 FROM json_each(publish_platforms) WHERE json_each.value = ?)', [$params['platform']]);
+            } else $query->whereJsonContains('publish_platforms', $params['platform']);
+        }
+        $this->dateRange($query, 'publish_at', $params);
+        $total = (clone $query)->count();
+        $rows = $query->orderByDesc('publish_at')->orderByDesc('id')
+            ->forPage((int) ($params['page'] ?? 1), (int) ($params['limit'] ?? 20))->get();
+        $translations = [];
+        if ($rows->isNotEmpty()) foreach (DB::table('announcement_translations')->whereIn('announcement_id', $rows->pluck('id')->all())->get(['announcement_id', 'language', 'title']) as $text) {
+            $translations[$text->announcement_id][$text->language] = ['title' => $text->title];
+        }
+        return ['total' => $total, 'data' => $rows->map(fn ($row) => $this->dto((array) $row + ['translations' => $translations[$row->id] ?? []], false))->all()];
+    }
+
+    public function detail(int $id): array
+    {
+        $row = DB::table('announcements')->where('id', $id)->first();
+        if (!$row) $this->invalid('id', '公告不存在');
+        return $this->dto((array) $row);
+    }
+
+    public function save(array $input): array
+    {
+        foreach (['sender_id', 'sender_name', 'sender_display', 'title', 'content'] as $field) {
+            if (array_key_exists($field, $input)) $this->invalid($field, '发送者不可指定;标题与内容请使用translations提交');
+        }
+        $params = Validator::make($input, [
+            'id' => ['nullable', 'integer', 'min:1'],
+            'category' => ['sometimes', 'required', Rule::in(array_keys(Catalog::CATEGORIES))],
+            'translations' => ['sometimes', 'required', 'array'],
+            'publish_platforms' => ['sometimes', 'required', 'array', 'min:1', 'max:3'],
+            'publish_platforms.*' => ['required', 'string', 'distinct', Rule::in(array_keys(Catalog::PLATFORMS))],
+            'display_frequency' => ['sometimes', 'required', Rule::in(array_keys(Catalog::FREQUENCIES))],
+            'publish_immediately' => ['sometimes', 'boolean'],
+            'publish_at' => ['nullable', 'date_format:Y-m-d H:i:s'],
+            'no_end' => ['sometimes', 'boolean'],
+            'end_at' => ['nullable', 'date_format:Y-m-d H:i:s'],
+            'status' => ['sometimes', 'integer', Rule::in([0, 1])],
+            'click_type' => ['sometimes', 'required', Rule::in(array_keys(Catalog::CLICKS))],
+            'link' => ['nullable', 'string', 'max:2048'],
+        ], ['click_type.in' => '点击方式仅支持无链接、外站链接、站内路径'])->validate();
+
+        return DB::transaction(function () use ($params) {
+            $now = $this->now();
+            $before = [];
+            $id = $params['id'] ?? null;
+            if ($id !== null) {
+                $found = DB::table('announcements')->where('id', $id)->lockForUpdate()->first();
+                if (!$found) $this->invalid('id', '公告不存在');
+                $before = $this->snapshot((int) $id);
+            }
+            $row = $before ?: ['category' => 'content', 'publish_platforms' => array_keys(Catalog::PLATFORMS),
+                'display_frequency' => 'daily_first_login', 'publish_at' => $now, 'end_at' => null,
+                'status' => 1, 'click_type' => 'none', 'link' => null];
+            foreach (self::FIELDS as $field) if (array_key_exists($field, $params)) $row[$field] = $params[$field];
+            if (array_key_exists('translations', $params)) $row['translations'] = LocalizedContent::parse($params['translations'], 200, 20000);
+            if (empty($row['translations'])) $this->invalid('translations', '至少填写一个语言的标题和正文');
+            if ($before && $row['category'] !== $before['category'] && !array_key_exists('translations', $params)) {
+                $this->invalid('translations', '切换公告分类时请重新填写多语言内容');
+            }
+            $row['publish_platforms'] = array_values(array_intersect(array_keys(Catalog::PLATFORMS), $row['publish_platforms']));
+            if (array_key_exists('publish_immediately', $params) && $params['publish_immediately']) {
+                $row['publish_at'] = $before && $before['publish_at'] <= $now ? $before['publish_at'] : $now;
+            } elseif (($params['publish_immediately'] ?? null) === false || ($params['publish_immediately'] ?? null) === 0 || ($params['publish_immediately'] ?? null) === '0') {
+                if (!$row['publish_at'] || (!$before && empty($params['publish_at']))) $this->invalid('publish_at', '定时发布必须填写发布时间');
+            }
+            if (!$row['publish_at']) $this->invalid('publish_at', '请填写发布时间');
+            if (array_key_exists('no_end', $params)) {
+                if ($params['no_end']) $row['end_at'] = null;
+                elseif (!$row['end_at']) $this->invalid('end_at', '请填写结束时间');
+            }
+            if ($row['end_at'] !== null && $row['end_at'] <= $row['publish_at']) $this->invalid('end_at', '结束时间必须晚于发布时间');
+            if ($row['category'] === 'image') {
+                foreach ($row['translations'] as $language => &$block) {
+                    if (mb_strlen($block['content']) > 2048 || !$this->imageAddress($block['content'])) $this->invalid('translations.' . $language . '.content', '图片公告请使用上传成功的jpg/jpeg/png/webp图片地址');
+                    $block['content'] = Util::replacePartInUrl($block['content']);
+                }
+                unset($block);
+            }
+            if (in_array($row['click_type'], ['external', 'internal'], true)) {
+                if (!$this->validLink((string) $row['link'], $row['click_type'])) $this->invalid('link', '请填写有效的外站链接或站内相对路径');
+            } else { $row['link'] = null; }
+            $row['status'] = (int) $row['status'];
+            $actor = OperationAuditService::actor();
+            if (!$actor['operator_id'] || $actor['operator_name'] === '') $this->invalid('sender_id', '请使用后台管理员身份');
+            $values = array_intersect_key($row, array_flip(self::FIELDS));
+            $values['publish_platforms'] = json_encode($values['publish_platforms'], JSON_THROW_ON_ERROR);
+            if (!$before) {
+                $id = DB::table('announcements')->insertGetId($values + ['sender_id' => $actor['operator_id'],
+                    'sender_name' => $actor['operator_name'], 'created_at' => $now, 'updated_at' => $now]);
+            } else {
+                $changed = false;
+                foreach (array_merge(self::FIELDS, ['translations']) as $field) if ($row[$field] !== $before[$field]) { $changed = true; break; }
+                if (!$changed) return $this->detail((int) $id);
+                DB::table('announcements')->where('id', $id)->update($values + ['updated_at' => $now]);
+            }
+            DB::table('announcement_translations')->where('announcement_id', $id)->delete();
+            foreach ($row['translations'] as $language => $block) DB::table('announcement_translations')->insert($block + ['announcement_id' => $id, 'language' => $language]);
+            $after = $this->snapshot((int) $id);
+            OperationAuditService::record('announcement', (int) $id, $before ? 'update' : 'create', $before, $after, $now);
+            SiteRealtimeService::changed('announcement', (int) $id, $before ? 'update' : 'create',
+                $row['status'] ? ['start' => $row['publish_at'], 'end' => $row['end_at']] : [],
+                ($before && Catalog::state($before, $now) === 'active') || Catalog::state($after, $now) === 'active');
+            return $this->dto($after);
+        });
+    }
+
+    public function status(int $id, int $status): array
+    {
+        Validator::make(compact('id', 'status'), ['id' => ['required', 'integer', 'min:1'], 'status' => ['required', Rule::in([0, 1])]])->validate();
+        return DB::transaction(function () use ($id, $status) {
+            $found = DB::table('announcements')->where('id', $id)->lockForUpdate()->first();
+            if (!$found) $this->invalid('id', '公告不存在');
+            $before = $this->snapshot((int) $id);
+            if ($before['status'] === $status) return $this->dto($before);
+            $now = $this->now();
+            DB::table('announcements')->where('id', $id)->update(['status' => $status, 'updated_at' => $now]);
+            $after = $this->snapshot($id);
+            OperationAuditService::record('announcement', $id, 'status', $before, $after, $now);
+            SiteRealtimeService::changed('announcement', $id, 'status',
+                $status ? ['start' => $after['publish_at'], 'end' => $after['end_at']] : [],
+                Catalog::state($before, $now) === 'active' || Catalog::state($after, $now) === 'active');
+            return $this->dto($after);
+        });
+    }
+
+    public function delete(int $id): array
+    {
+        Validator::make(compact('id'), ['id' => ['required', 'integer', 'min:1']])->validate();
+        return DB::transaction(function () use ($id) {
+            $row = DB::table('announcements')->where('id', $id)->lockForUpdate()->first();
+            if (!$row) $this->invalid('id', '公告不存在');
+            $before = $this->snapshot($id); $now = $this->now();
+            DB::table('announcement_translations')->where('announcement_id', $id)->delete();
+            DB::table('announcements')->where('id', $id)->delete();
+            OperationAuditService::record('announcement', $id, 'delete', $before, [], $now);
+            SiteRealtimeService::changed('announcement', $id, 'delete', [], false);
+            return ['id' => $id, 'deleted' => true];
+        });
+    }
+
+    public function logs(array $input): array
+    {
+        if (isset($input['announcement_id'])) $input['resource_id'] = $input['announcement_id'];
+        return LocalizedAuditService::listing('announcement', $input);
+    }
+
+    private function translations(int $id): array
+    {
+        $rows = DB::table('announcement_translations')->where('announcement_id', $id)->get()->keyBy('language');
+        $result = [];
+        foreach (LocalizedContent::LANGUAGES as $language => $_) if (isset($rows[$language])) $result[$language] = ['title' => $rows[$language]->title, 'content' => $rows[$language]->content];
+        return $result;
+    }
+
+    private function snapshot(int $id): array
+    {
+        $row = $this->normalize((array) DB::table('announcements')->where('id', $id)->first());
+        $row['translations'] = $this->translations($id);
+        return $row;
+    }
+
+    private function dto(array $row, bool $full = true): array
+    {
+        $row = $this->normalize($row);
+        $translations = $row['translations'] ?? $this->translations((int) $row['id']);
+        unset($row['translations']);
+        $row['languages'] = array_keys($translations);
+        $row['titles'] = (object) array_map(fn ($b) => $b['title'], $translations);
+        $now = $this->now();
+        $row['category_name'] = Catalog::CATEGORIES[$row['category']];
+        $row['platform_label'] = count($row['publish_platforms']) === 3 ? '全部' : implode('、', array_map(fn ($p) => Catalog::PLATFORMS[$p], $row['publish_platforms']));
+        $row['display_frequency_name'] = Catalog::FREQUENCIES[$row['display_frequency']];
+        $row['sender_display'] = '批核者:' . $row['sender_name'];
+        $row['publication_state'] = Catalog::state($row, $now);
+        $row['publication_state_name'] = Catalog::STATES[$row['publication_state']];
+        $row['effective_status'] = $row['publication_state'] === 'active' ? 1 : 0;
+        $row['publish_immediately'] = $row['publish_at'] <= $now;
+        $row['no_end'] = $row['end_at'] === null;
+        if ($full) {
+            if ($row['category'] === 'image') foreach ($translations as &$block) $block['content'] = Util::ensureUrl($block['content']);
+            unset($block); $row['translations'] = $translations;
+        }
+        return $row;
+    }
+
+    private function normalize(array $row): array
+    {
+        if (isset($row['publish_platforms']) && is_string($row['publish_platforms'])) $row['publish_platforms'] = json_decode($row['publish_platforms'], true, 512, JSON_THROW_ON_ERROR);
+        foreach (['id', 'sender_id', 'status'] as $field) if (isset($row[$field])) $row[$field] = (int) $row[$field];
+        unset($row['activity_id']);
+        if (($row['click_type'] ?? 'none') === 'activity') { $row['click_type'] = 'none'; $row['link'] = null; }
+        return $row;
+    }
+
+    private function imageAddress(string $value): bool
+    {
+        if (!$this->validLink($value, str_starts_with($value, '/') ? 'internal' : 'external')) return false;
+        return (bool) preg_match('/\.(?:jpe?g|png|webp)$/i', (string) parse_url($value, PHP_URL_PATH));
+    }
+
+    private function validLink(string $value, string $type): bool
+    {
+        if ($value === '' || preg_match('/[\x00-\x20\x7f\\\\]/', $value)) return false;
+        if ($type === 'internal') return str_starts_with($value, '/') && !str_starts_with($value, '//');
+        $parts = parse_url($value);
+        return filter_var($value, FILTER_VALIDATE_URL) && $parts && in_array(strtolower($parts['scheme'] ?? ''), ['http', 'https'], true)
+            && !isset($parts['user']) && !isset($parts['pass']);
+    }
+
+    private function pageRules(): array { return ['page' => ['nullable', 'integer', 'min:1'], 'limit' => ['nullable', 'integer', 'min:1', 'max:200']]; }
+    private function dateRangeRules(): array { return [
+        'start_date' => ['nullable', 'required_with:end_date', 'date_format:Y-m-d'],
+        'end_date' => ['nullable', 'required_with:start_date', 'date_format:Y-m-d', 'after_or_equal:start_date'],
+    ]; }
+    private function dateRange($query, string $field, array $params): void
+    {
+        if (!empty($params['start_date'])) $query->where($field, '>=', $params['start_date'] . ' 00:00:00')->where($field, '<=', $params['end_date'] . ' 23:59:59');
+    }
+    private function now(): string { return Carbon::now(Catalog::TIMEZONE)->format('Y-m-d H:i:s'); }
+    private function invalid(string $key, string $message): void { throw ValidationException::withMessages([$key => $message]); }
+}

+ 54 - 0
app/Services/AutomaticRewardGrantService.php

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

+ 2 - 33
app/Services/BackflowService.php

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

+ 81 - 43
app/Services/BalanceLogService.php

@@ -11,6 +11,7 @@ use App\Models\Wallet;
 use App\Models\Level;
 use App\Models\Level;
 use App\Models\RechargeChannelGroup;
 use App\Models\RechargeChannelGroup;
 use App\Models\UserLogin;
 use App\Models\UserLogin;
+use Illuminate\Support\Facades\DB;
 
 
 // 余额额变动记录
 // 余额额变动记录
 class BalanceLogService extends BaseService
 class BalanceLogService extends BaseService
@@ -37,7 +38,7 @@ class BalanceLogService extends BaseService
         'SG飞艇投注','SG飞艇退款','SG飞艇中奖',
         'SG飞艇投注','SG飞艇退款','SG飞艇中奖',
         'SG快3投注','SG快3退款','SG快3中奖',
         'SG快3投注','SG快3退款','SG快3中奖',
         //'足球游戏投注','足球游戏退款','足球游戏中奖',
         //'足球游戏投注','足球游戏退款','足球游戏中奖',
-        '资产转移', '比比返', '返水', '回水', '笔笔返', '投注退分','充值返现','即充即送','老用户回归','余额宝转入','转出至余额宝','余额宝利息','流水解冻'
+        '资产转移', '比比返', '返水', '回水', '笔笔返', '投注退分','充值返现','即充即送','老用户回归','余额宝转入','转出至余额宝','余额宝利息','流水解冻','分享佣金领取'
     ];
     ];
 
 
     public static function init($telegram, $data, $chatId, $firstName, $messageId, $callbackId): void
     public static function init($telegram, $data, $chatId, $firstName, $messageId, $callbackId): void
@@ -159,6 +160,18 @@ class BalanceLogService extends BaseService
      * @return {*}
      * @return {*}
      */
      */
     public static function addLog($memberId, $amount, $before_balance, $after_balance, $change_type, $related_id, $remark, $room_id = null)
     public static function addLog($memberId, $amount, $before_balance, $after_balance, $change_type, $related_id, $remark, $room_id = null)
+    {
+        if (in_array($change_type, ['充值', '三方充值'], true)
+            && DB::connection()->getSchemaBuilder()->hasTable('promotion_first_deposits')) {
+            return DB::transaction(function () use ($memberId, $amount, $before_balance, $after_balance, $change_type, $related_id, $remark, $room_id) {
+                Wallet::where('member_id', $memberId)->lockForUpdate()->first();
+                return self::writeLog($memberId, $amount, $before_balance, $after_balance, $change_type, $related_id, $remark, $room_id);
+            }, 3);
+        }
+        return self::writeLog($memberId, $amount, $before_balance, $after_balance, $change_type, $related_id, $remark, $room_id);
+    }
+
+    private static function writeLog($memberId, $amount, $before_balance, $after_balance, $change_type, $related_id, $remark, $room_id = null)
     {
     {
         //回水相关
         //回水相关
         if (in_array($change_type, static::$computeBackFlowChangeTypes)) {
         if (in_array($change_type, static::$computeBackFlowChangeTypes)) {
@@ -199,8 +212,23 @@ class BalanceLogService extends BaseService
                     if (!empty($agent_user_code)) {
                     if (!empty($agent_user_code)) {
                         $agent_member_id = User::where('user_code', $agent_user_code)->value('member_id');
                         $agent_member_id = User::where('user_code', $agent_user_code)->value('member_id');
                         if (!empty($agent_member_id)) {
                         if (!empty($agent_member_id)) {
-                            $balanceData = WalletService::updateBalance($agent_member_id, $add_amount);
-                            BalanceLogService::addLog($agent_member_id, $add_amount, $balanceData['before_balance'], $balanceData['after_balance'], '充值返现', $related_id, '被邀请人:'.$memberId."充值金额为:{$remark_amount}");
+                            DB::transaction(function () use ($agent_member_id, $add_amount, $related_id, $memberId, $remark_amount, $user, $amount, $rate) {
+                                $wallet = Wallet::where('member_id', $agent_member_id)->lockForUpdate()->first();
+                                if (!$wallet) throw new \RuntimeException('推广人钱包不存在');
+                                $before = (string)$wallet->available_balance;
+                                $wallet->available_balance = bcadd($before, (string)$add_amount, 10);
+                                $wallet->save();
+                                $rebate = BalanceLogService::addLog($agent_member_id, $add_amount, $before, $wallet->available_balance,
+                                    '充值返现', $related_id, '被邀请人:'.$memberId."充值金额为:{$remark_amount}");
+                                $record = (new ShareCommissionRecorder())->record($rebate, [
+                                    'invitee_user_id' => $user->id,
+                                    'base_amount' => $amount,
+                                    'rate' => $rate,
+                                ]);
+                                if ($record['status'] === 'skipped') {
+                                    throw new \RuntimeException('返现佣金明细登记失败:' . $record['reason']);
+                                }
+                            });
                         }
                         }
                     }
                     }
                 }
                 }
@@ -208,51 +236,57 @@ class BalanceLogService extends BaseService
                 //用户活动
                 //用户活动
                 $activity_type = RechargeChannelGroup::getActivityType($user->recharge_channel_group_id);
                 $activity_type = RechargeChannelGroup::getActivityType($user->recharge_channel_group_id);
                 if ($activity_type) {
                 if ($activity_type) {
-                    $walletInfo = Wallet::where('member_id', $memberId)->first();
-                    if (in_array('recharge', $activity_type)) {
-                        //即充即送-返彩活动
-                        $bonusAmount = self::calculateRechargeBonus($amount,$memberId, $related_id);
-                        if ($bonusAmount > 0) {
-                            static::$MODEL::create([
-                                'type' => 2,
-                                'member_id' => $memberId,
-                                'amount' => $bonusAmount,
-                                'before_balance' => $walletInfo->frozen_balance,
-                                'after_balance' => bcadd($walletInfo->frozen_balance, $bonusAmount, 2),
-                                'change_type' => '即充即送',
-                                'frozen_status' => 1,
-                                'related_id' => $related_id,
-                                'remark' => '充值金额为:'.$remark_amount,
-                            ]);
-                            $walletInfo->frozen_balance = bcadd($walletInfo->frozen_balance, $bonusAmount, 2);
+                    // USDT confirmation has no outer transaction. Keep every reward write
+                    // and its recipient slot pending until the frozen balance is saved.
+                    DB::transaction(function () use ($activity_type, $memberId, $amount, $related_id, $level, $remark_amount) {
+                        $walletInfo = Wallet::where('member_id', $memberId)->lockForUpdate()->first();
+                        if (!$walletInfo) throw new \RuntimeException('账户钱包不存在');
+                        if (in_array('recharge', $activity_type)) {
+                            //即充即送-返彩活动
+                            $bonusAmount = self::calculateRechargeBonus($amount,$memberId, $related_id);
+                            if ($bonusAmount > 0) {
+                                static::$MODEL::create([
+                                    'type' => 2,
+                                    'member_id' => $memberId,
+                                    'amount' => $bonusAmount,
+                                    'before_balance' => $walletInfo->frozen_balance,
+                                    'after_balance' => bcadd($walletInfo->frozen_balance, $bonusAmount, 2),
+                                    'change_type' => '即充即送',
+                                    'frozen_status' => 1,
+                                    'related_id' => $related_id,
+                                    'remark' => '充值金额为:'.$remark_amount,
+                                ]);
+                                $walletInfo->frozen_balance = bcadd($walletInfo->frozen_balance, $bonusAmount, 2);
+                            }
                         }
                         }
-                    }
 
 
-                    if (in_array('old_user', $activity_type)) {
-                        //老用户回归-返彩活动
-                        $bonusAmount = self::calculateUserReturnRechargeBonus($amount,$memberId, $level, $related_id);
+                        if (in_array('old_user', $activity_type)) {
+                            //老用户回归-返彩活动
+                            $bonusAmount = self::calculateUserReturnRechargeBonus($amount,$memberId, $level, $related_id);
                         
                         
-                        if ($bonusAmount > 0) {
-                            static::$MODEL::create([
-                                'type' => 2,
-                                'member_id' => $memberId,
-                                'amount' => $bonusAmount,
-                                'before_balance' => $walletInfo->frozen_balance,
-                                'after_balance' => bcadd($walletInfo->frozen_balance, $bonusAmount, 2),
-                                'change_type' => '老用户回归',
-                                'frozen_status' => 1,
-                                'related_id' => $related_id,
-                                'remark' => '充值金额为:'.$remark_amount,
-                            ]);
-                            $walletInfo->frozen_balance = bcadd($walletInfo->frozen_balance, $bonusAmount, 2);
+                            if ($bonusAmount > 0) {
+                                static::$MODEL::create([
+                                    'type' => 2,
+                                    'member_id' => $memberId,
+                                    'amount' => $bonusAmount,
+                                    'before_balance' => $walletInfo->frozen_balance,
+                                    'after_balance' => bcadd($walletInfo->frozen_balance, $bonusAmount, 2),
+                                    'change_type' => '老用户回归',
+                                    'frozen_status' => 1,
+                                    'related_id' => $related_id,
+                                    'remark' => '充值金额为:'.$remark_amount,
+                                ]);
+                                $walletInfo->frozen_balance = bcadd($walletInfo->frozen_balance, $bonusAmount, 2);
+                            }
                         }
                         }
-                    }
                     
                     
-                    $walletInfo->save();
+                        $walletInfo->save();
+                    });
                 }
                 }
             }
             }
         }
         }
         
         
+        if (in_array($change_type, ['充值', '三方充值'], true)) (new PromotionService())->onDeposit($result->refresh());
         return $result;
         return $result;
     }
     }
 
 
@@ -391,7 +425,7 @@ class BalanceLogService extends BaseService
      */
      */
     public static function calculateRechargeBonus($amount,$memberId, $related_id = null)
     public static function calculateRechargeBonus($amount,$memberId, $related_id = null)
     {
     {
-        $activity = ActivityReward::where('lang','zh')->where('type',3)->where('status',1)->where('start_time', '<', time())->where('end_time', '>', time())->first();
+        $activity = ActivityRewardService::activeForMember(3, $memberId);
         if (!$activity) {
         if (!$activity) {
             return false;
             return false;
         }
         }
@@ -426,7 +460,9 @@ class BalanceLogService extends BaseService
         $need_flow = !empty($params['reward_turnover_ratio']) ? bcmul($bonusAmount, $params['reward_turnover_ratio'], 2) : $bonusAmount;
         $need_flow = !empty($params['reward_turnover_ratio']) ? bcmul($bonusAmount, $params['reward_turnover_ratio'], 2) : $bonusAmount;
         
         
         //返彩流水
         //返彩流水
-        WalletBonus::addData($memberId, 1, $bonusAmount, $activity->id, $related_id, $need_flow);
+        if (!ActivityQuotaService::award($activity, $memberId, $bonusAmount, function () use ($memberId, $bonusAmount, $activity, $related_id, $need_flow) {
+            WalletBonus::addData($memberId, 1, $bonusAmount, $activity->id, $related_id, $need_flow);
+        })) return false;
 
 
         return $bonusAmount;
         return $bonusAmount;
     }
     }
@@ -437,7 +473,7 @@ class BalanceLogService extends BaseService
      */
      */
     public static function calculateUserReturnRechargeBonus($amount,$memberId, $level, $related_id = null)
     public static function calculateUserReturnRechargeBonus($amount,$memberId, $level, $related_id = null)
     {
     {
-        $activity = ActivityReward::where('lang','zh')->where('type',4)->where('status',1)->where('start_time', '<', time())->where('end_time', '>', time())->first();
+        $activity = ActivityRewardService::activeForMember(4, $memberId);
         if (!$activity) {
         if (!$activity) {
             return false;
             return false;
         }
         }
@@ -473,7 +509,9 @@ class BalanceLogService extends BaseService
         }
         }
         
         
         //返彩流水
         //返彩流水
-        WalletBonus::addData($memberId, 3, $bonusAmount, $activity->id, $related_id, $need_flow);
+        if (!ActivityQuotaService::award($activity, $memberId, $bonusAmount, function () use ($memberId, $bonusAmount, $activity, $related_id, $need_flow) {
+            WalletBonus::addData($memberId, 3, $bonusAmount, $activity->id, $related_id, $need_flow);
+        })) return false;
 
 
         return $bonusAmount;
         return $bonusAmount;
     }
     }

+ 69 - 0
app/Services/BannerVisibilityService.php

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

+ 10 - 5
app/Services/BetService.php

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

+ 74 - 25
app/Services/IssueService.php

@@ -14,6 +14,7 @@ use Illuminate\Support\Collection;
 use Illuminate\Support\Facades\Cache;
 use Illuminate\Support\Facades\Cache;
 use Illuminate\Support\Facades\Http;
 use Illuminate\Support\Facades\Http;
 use Illuminate\Support\Facades\Log;
 use Illuminate\Support\Facades\Log;
+use GuzzleHttp\TransferStats;
 
 
 use App\Services\GameplayRuleService;
 use App\Services\GameplayRuleService;
 use App\Constants\GameplayRuleEnum;
 use App\Constants\GameplayRuleEnum;
@@ -897,34 +898,82 @@ class IssueService extends BaseService
     private static function fetchPlayNowKenoResult()
     private static function fetchPlayNowKenoResult()
     {
     {
         $url = self::getPlayNowConfig('keno_url', self::PLAYNOW_KENO_URL);
         $url = self::getPlayNowConfig('keno_url', self::PLAYNOW_KENO_URL);
-        $response = Http::timeout(25)
-            ->connectTimeout(10)
-            ->withHeaders([
-                'Accept' => 'application/json, text/plain, */*',
-                'User-Agent' => 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36',
-            ])
-            ->withOptions([
-                'proxy' => self::getPlayNowProxyUrl(),
-                'curl' => [
-                    CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
-                ],
-            ])
-            ->get($url);
-
-        Log::channel('issue')->info('PlayNow接口响应', [
-            'status' => $response->status(),
-            'url' => $url,
-        ] + self::getPlayNowProxyLogContext());
+        $startedAt = microtime(true);
+        $networkTimings = [];
+        $response = null;
 
 
-        if (!$response->successful()) {
-            Log::channel('issue')->info('PlayNow接口请求失败', [
+        try {
+            $response = Http::timeout(25)
+                ->connectTimeout(10)
+                ->withHeaders([
+                    'Accept' => 'application/json, text/plain, */*',
+                    'User-Agent' => 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36',
+                ])
+                ->withOptions([
+                    'proxy' => self::getPlayNowProxyUrl(),
+                    'curl' => [
+                        CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1,
+                    ],
+                ])
+                ->withMiddleware(self::playNowTimingMiddleware($networkTimings))
+                ->get($url);
+
+            Log::channel('issue')->info('PlayNow接口响应', [
                 'status' => $response->status(),
                 'status' => $response->status(),
-                'body' => substr($response->body(), 0, 500),
-            ]);
-            return null;
+                'url' => $url,
+            ] + self::getPlayNowProxyLogContext());
+
+            if (!$response->successful()) {
+                Log::channel('issue')->info('PlayNow接口请求失败', [
+                    'status' => $response->status(),
+                    'body' => substr($response->body(), 0, 500),
+                ]);
+                return null;
+            }
+
+            return $response->json();
+        } finally {
+            // cURL 时间为自请求开始的累计值;通过代理时包含代理连接的耗时。
+            Log::channel('issue')->info('PlayNow请求耗时', [
+                'target_host' => parse_url($url, PHP_URL_HOST),
+                'elapsed_ms' => round((microtime(true) - $startedAt) * 1000, 1),
+                'http_status' => $response?->status(),
+                'network_timings_ms' => $networkTimings,
+            ] + self::getPlayNowProxyLogContext());
         }
         }
+    }
 
 
-        return $response->json();
+    private static function playNowTimingMiddleware(array &$networkTimings): callable
+    {
+        return function (callable $handler) use (&$networkTimings) {
+            return function ($request, array $options) use ($handler, &$networkTimings) {
+                // Laravel 会设置自己的 on_stats;在最终请求选项中保留并追加记录。
+                $previousOnStats = $options['on_stats'] ?? null;
+                $options['on_stats'] = function (TransferStats $stats) use ($previousOnStats, &$networkTimings) {
+                    if (is_callable($previousOnStats)) {
+                        $previousOnStats($stats);
+                    }
+                    $handlerStats = $stats->getHandlerStats();
+                    foreach ([
+                        'namelookup_time' => 'dns_ms',
+                        'connect_time' => 'connect_ms',
+                        'appconnect_time' => 'tls_ms',
+                        'starttransfer_time' => 'first_byte_ms',
+                        'total_time' => 'total_ms',
+                    ] as $source => $target) {
+                        if (isset($handlerStats[$source])) {
+                            $networkTimings[$target] = round($handlerStats[$source] * 1000, 1);
+                        }
+                    }
+                    $errorCode = $stats->getHandlerErrorData();
+                    if (is_int($errorCode)) {
+                        $networkTimings['curl_error_code'] = $errorCode;
+                    }
+                };
+
+                return $handler($request, $options);
+            };
+        };
     }
     }
 
 
     private static function convertPlayNowDrawDateToBeijing($drawDate)
     private static function convertPlayNowDrawDateToBeijing($drawDate)
@@ -1446,4 +1495,4 @@ class IssueService extends BaseService
         self::sendMessage($chatId, '', [], url($recordImage));
         self::sendMessage($chatId, '', [], url($recordImage));
         // dispatch(new SendTelegramMessageJob('', [], url($recordImage)));
         // dispatch(new SendTelegramMessageJob('', [], url($recordImage)));
     }
     }
-}
+}

+ 61 - 0
app/Services/LocalizedAuditService.php

@@ -0,0 +1,61 @@
+<?php
+
+namespace App\Services;
+
+use App\Support\LocalizedContent;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Validator;
+use Illuminate\Validation\Rule;
+
+class LocalizedAuditService
+{
+    public static function listing(string $type, array $input, ?callable $scope = null): array
+    {
+        $params = Validator::make($input, [
+            'page' => ['nullable', 'integer', 'min:1'], 'limit' => ['nullable', 'integer', 'min:1', 'max:200'],
+            'language' => ['nullable', Rule::in(array_keys(LocalizedContent::LANGUAGES))],
+            'operator_name' => ['nullable', 'string', 'max:100'], 'resource_id' => ['nullable', 'integer', 'min:1'],
+            'start_date' => ['nullable', 'required_with:end_date', 'date_format:Y-m-d'],
+            'end_date' => ['nullable', 'required_with:start_date', 'date_format:Y-m-d', 'after_or_equal:start_date'],
+        ])->validate();
+        $query = DB::table('operation_audits as a')->where('a.resource_type', $type);
+        if ($scope) $scope($query);
+        if (isset($params['operator_name']) && $params['operator_name'] !== '') $query->where('a.operator_name', 'like', '%' . $params['operator_name'] . '%');
+        if (!empty($params['resource_id'])) $query->where('a.resource_id', $params['resource_id']);
+        if (!empty($params['start_date'])) $query->where('a.created_at', '>=', $params['start_date'] . ' 00:00:00')->where('a.created_at', '<=', $params['end_date'] . ' 23:59:59');
+        if (!empty($params['language'])) {
+            $language = $params['language'];
+            $query->where(function ($q) use ($language) {
+                $q->whereNotNull('a.changes->before->translations->' . $language)->orWhereNotNull('a.changes->after->translations->' . $language);
+            });
+        }
+        $total = (clone $query)->count();
+        $rows = $query->select('a.*')->orderByDesc('a.created_at')->orderByDesc('a.id')
+            ->forPage((int) ($params['page'] ?? 1), (int) ($params['limit'] ?? 20))->get();
+        $items = [];
+        foreach ($rows as $row) {
+            $change = json_decode($row->changes, true, 512, JSON_THROW_ON_ERROR);
+            $before = $change['before'] ?? []; $after = $change['after'] ?? [];
+            $translations = array_replace($before['translations'] ?? [], $after['translations'] ?? []);
+            $titles = array_map(fn ($block) => $block['title'], $translations);
+            $old = []; $new = [];
+            foreach (array_unique(array_merge(array_keys($before), array_keys($after))) as $field) {
+                if (in_array($field, ['id', 'created_at', 'updated_at', 'request_hash', 'request_id'], true)) continue;
+                $a = $before[$field] ?? null; $b = $after[$field] ?? null;
+                if ($field === 'translations' && !empty($params['language'])) {
+                    $a = isset($a[$params['language']]) ? [$params['language'] => $a[$params['language']]] : null;
+                    $b = isset($b[$params['language']]) ? [$params['language'] => $b[$params['language']]] : null;
+                }
+                if ($a !== $b) { $old[$field] = $a; $new[$field] = $b; }
+            }
+            $recipientType = $after['recipient_type'] ?? $before['recipient_type'] ?? 'all_users';
+            $items[] = ['id' => (int) $row->id, 'resource_id' => (int) $row->resource_id,
+                'created_at' => $row->created_at, 'operator_name' => $row->operator_name, 'action' => $row->action,
+                'languages' => array_keys($translations), 'titles' => (object) $titles,
+                'recipient_type' => $recipientType,
+                'recipient_label' => $type === 'announcement' ? '所有人' : (SiteMessageService::TARGETS[$recipientType] ?? ''),
+                'old_values' => (object) $old, 'new_values' => (object) $new];
+        }
+        return ['total' => $total, 'data' => $items];
+    }
+}

+ 89 - 30
app/Services/LotteryImageService.php

@@ -4,9 +4,11 @@ namespace App\Services;
 
 
 use App\Constants\Util;
 use App\Constants\Util;
 use App\Models\Config;
 use App\Models\Config;
-use App\Models\Message;
 use Illuminate\Support\Facades\App;
 use Illuminate\Support\Facades\App;
+use Illuminate\Support\Facades\Log;
 use Illuminate\Support\Facades\Storage;
 use Illuminate\Support\Facades\Storage;
+use Symfony\Component\Process\Exception\ProcessTimedOutException;
+use Symfony\Component\Process\Process;
 
 
 class LotteryImageService
 class LotteryImageService
 {
 {
@@ -18,41 +20,98 @@ class LotteryImageService
      */
      */
     public function generate(array $records): string
     public function generate(array $records): string
     {
     {
-        // ========= 1. 生成HTML内容 =========
-        $lang = App::getLocale();
-        $group_language = Config::where('field', 'group_language')->first()->val;
-        App::setLocale($group_language);
-        $html = $this->buildHtml($records);
-        App::setLocale($lang);
-        // ========= 2. 保存HTML到临时文件 =========
-        $htmlPath = storage_path("app/lottery_temp.html");
-//        $htmlPath = base_path() . "/public/static/html/lottery_temp.html";
-//        $htmlPath = base_path() . "/public/static/html/lottery_temp_{$group_language}.html";
-        file_put_contents($htmlPath, $html);
-
-        // ========= 3. 输出图片路径 =========
-        $fileName = 'lottery_' . time() . '.png';
+        $html = $this->buildLocalizedHtml($records);
+        $fileName = 'lottery_' . bin2hex(random_bytes(16)) . '.png';
         $outputPath = storage_path('app/public/lottery/' . $fileName);
         $outputPath = storage_path('app/public/lottery/' . $fileName);
-        if (!is_dir(dirname($outputPath))) {
-            mkdir(dirname($outputPath), 0755, true);
-        }
+        $htmlPath = null;
+        $process = null;
+        $completed = false;
+        $timeout = max(1, (float) config('services.lottery_image.timeout', 20));
+        $startedAt = microtime(true);
+
+        try {
+            if (!is_dir(dirname($outputPath)) && !mkdir(dirname($outputPath), 0755, true) && !is_dir(dirname($outputPath))) {
+                throw new \RuntimeException('无法创建开奖图片目录');
+            }
+
+            $htmlPath = tempnam(storage_path('app'), 'lottery_');
+            if ($htmlPath === false) {
+                throw new \RuntimeException('无法创建开奖图片临时HTML');
+            }
+            $temporaryHtmlPath = $htmlPath . '.html';
+            if (!rename($htmlPath, $temporaryHtmlPath)) {
+                throw new \RuntimeException('无法创建开奖图片临时HTML文件');
+            }
+            $htmlPath = $temporaryHtmlPath;
+            if (file_put_contents($htmlPath, $html) === false) {
+                throw new \RuntimeException('无法保存开奖图片临时HTML');
+            }
+
+            $process = new Process([
+                (string) config('services.lottery_image.binary', 'wkhtmltoimage'),
+                '--quality',
+                '100',
+                $htmlPath,
+                $outputPath,
+            ]);
+            $process->setTimeout($timeout);
+            $process->run();
+
+            if (!$process->isSuccessful() || !is_file($outputPath) || filesize($outputPath) === 0) {
+                throw new \RuntimeException('渲染进程未生成有效图片');
+            }
 
 
-        // ========= 4. 调用 wkhtmltoimage =========
-        // --quality 100 提高图片质量,可选
-        $cmd = sprintf(
-            'wkhtmltoimage --quality 100 %s %s',
-            escapeshellarg($htmlPath),
-            escapeshellarg($outputPath)
-        );
+            $url = Util::replacePartInUrl(Storage::url('lottery/' . $fileName));
+            Log::channel('issue')->info('开奖记录图片生成成功', $this->renderLogContext($process, $startedAt, $timeout));
+            $completed = true;
 
 
-        exec($cmd, $output, $code);
+            return $url;
+        } catch (\Throwable $exception) {
+            $timedOut = $exception instanceof ProcessTimedOutException;
+            Log::channel('issue')->error('开奖记录图片生成失败', $this->renderLogContext($process, $startedAt, $timeout) + [
+                'reason' => $timedOut ? 'timeout' : 'render_failed',
+                'error_type' => get_class($exception),
+            ]);
 
 
-        if ($code !== 0 || !file_exists($outputPath)) {
-            throw new \Exception('图片生成失败,请检查是否已安装 wkhtmltoimage 并可在命令行执行');
+            $message = $timedOut
+                ? '图片生成超时:渲染超过 ' . $timeout . ' 秒'
+                : '图片生成失败,请检查 wkhtmltoimage、存储目录权限和开奖记录图片日志';
+            throw new \RuntimeException($message, 0, $exception);
+        } finally {
+            if ($htmlPath && is_file($htmlPath)) {
+                unlink($htmlPath);
+            }
+            if (!$completed && is_file($outputPath)) {
+                unlink($outputPath);
+            }
+        }
+    }
+
+    protected function buildLocalizedHtml(array $records): string
+    {
+        $lang = App::getLocale();
+        $group_language = Config::where('field', 'group_language')->first()->val;
+        try {
+            App::setLocale($group_language);
+            return $this->buildHtml($records);
+        } finally {
+            App::setLocale($lang);
+        }
+    }
+
+    private function renderLogContext(?Process $process, float $startedAt, float $timeout): array
+    {
+        $output = '';
+        if ($process && $process->isStarted()) {
+            $output = trim($process->getErrorOutput() . "\n" . $process->getOutput());
         }
         }
 
 
-        // ========= 5. 返回访问URL =========
-        return Util::replacePartInUrl(Storage::url('lottery/' . $fileName));
+        return [
+            'duration_seconds' => round(microtime(true) - $startedAt, 3),
+            'timeout_seconds' => $timeout,
+            'exit_code' => $process ? $process->getExitCode() : null,
+            'renderer_output' => substr($output, 0, 2000),
+        ];
     }
     }
 
 
     /**
     /**

+ 157 - 0
app/Services/MaintenanceService.php

@@ -0,0 +1,157 @@
+<?php
+
+namespace App\Services;
+
+use App\Support\MaintenancePolicy as Policy;
+use Carbon\Carbon;
+use DateTimeImmutable;
+use DateTimeZone;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Validator;
+use Illuminate\Validation\Rule;
+use Illuminate\Validation\ValidationException;
+
+class MaintenanceService
+{
+    public function options(): array
+    {
+        $options = static fn ($map) => array_map(static fn ($value, $label) => compact('value', 'label'), array_keys($map), array_values($map));
+        return ['scopes' => $options(Policy::scopes()), 'categories' => $options(Policy::CATEGORIES),
+            'platforms' => $options(['PC' => 'PC', 'H5' => 'H5', 'APP' => 'APP']), 'languages' => $options(Policy::LANGUAGES),
+            'states' => $options(Policy::STATES), 'timezones' => DateTimeZone::listIdentifiers(),
+            'defaults' => ['enabled' => 0, 'platforms' => ['PC', 'H5', 'APP'], 'timezone' => 'Asia/Shanghai', 'ends_at' => null],
+            'business_codes' => ['site' => Policy::SITE_CODE, 'game' => Policy::GAME_CODE]];
+    }
+
+    public function listing(array $input): array
+    {
+        $params = Validator::make($input, [
+            'page' => ['nullable', 'integer', 'min:1', 'max:1000000'], 'limit' => ['nullable', 'integer', 'min:1', 'max:100'],
+            'scope_code' => ['nullable', Rule::in(array_keys(Policy::scopes()))], 'platform' => ['nullable', Rule::in(['PC', 'H5', 'APP'])],
+            'enabled' => ['nullable', 'integer', 'in:0,1'], 'state' => ['nullable', Rule::in(array_keys(Policy::STATES))],
+            'language' => ['nullable', Rule::in(array_keys(Policy::LANGUAGES))],
+        ])->validate();
+        $texts = [];
+        foreach (DB::table('maintenance_translations')->get() as $text) $texts[$text->maintenance_id][$text->language] = ['content' => $text->content];
+        $rows = [];
+        foreach (DB::table('maintenance_settings')->orderBy('id')->get() as $record) {
+            $row = $this->dto((array) $record, $texts[$record->id] ?? []);
+            if (!empty($params['scope_code']) && $row['scope_code'] !== $params['scope_code']) continue;
+            if (!empty($params['platform']) && !in_array($params['platform'], $row['platforms'], true)) continue;
+            if (isset($params['enabled']) && $row['enabled'] !== (int) $params['enabled']) continue;
+            if (!empty($params['state']) && $row['state'] !== $params['state']) continue;
+            if (!empty($params['language']) && !isset($texts[$record->id][$params['language']])) continue;
+            $rows[] = $row;
+        }
+        return ['total' => count($rows), 'data' => array_slice($rows, ((int) ($params['page'] ?? 1) - 1) * (int) ($params['limit'] ?? 20), (int) ($params['limit'] ?? 20))];
+    }
+
+    public function detail(int $id): array
+    {
+        $row = DB::table('maintenance_settings')->where('id', $id)->first();
+        if (!$row) $this->invalid('id', '维护设置不存在');
+        return $this->dto((array) $row, $this->texts($id));
+    }
+
+    public function save(array $input): array
+    {
+        $params = Validator::make($input, [
+            'id' => ['required', 'integer', 'min:1'], 'scope_code' => ['prohibited'],
+            'platforms' => ['sometimes', 'required', 'array', 'min:1', 'max:3'],
+            'platforms.*' => ['required', 'distinct', Rule::in(['PC', 'H5', 'APP'])],
+            'enabled' => ['sometimes', 'required', 'integer', 'in:0,1'], 'start_now' => ['sometimes', 'boolean'],
+            'starts_at' => ['sometimes', 'required', 'date_format:Y-m-d H:i:s'], 'ends_at' => ['nullable', 'date_format:Y-m-d H:i:s'],
+            'timezone' => ['sometimes', 'required', 'string', Rule::in(DateTimeZone::listIdentifiers())],
+            'translations' => ['sometimes', 'array'],
+        ])->validate();
+        return DB::transaction(function () use ($params) {
+            $id = (int) $params['id'];
+            $found = DB::table('maintenance_settings')->where('id', $id)->lockForUpdate()->first();
+            if (!$found) $this->invalid('id', '维护设置不存在');
+            $before = $this->snapshot($id); $row = (array) $found;
+            foreach (['enabled', 'timezone', 'platforms'] as $field) if (array_key_exists($field, $params)) $row[$field] = $params[$field];
+            $row['platforms'] = array_values(array_intersect(['PC', 'H5', 'APP'], Policy::platforms($row)));
+            if (!empty($params['start_now'])) $row['starts_at'] = $this->now()->format('Y-m-d H:i:s');
+            elseif (isset($params['starts_at'])) $row['starts_at'] = $this->utc($params['starts_at'], $row['timezone'], 'starts_at');
+            if (array_key_exists('ends_at', $params)) $row['ends_at'] = $params['ends_at'] === null ? null : $this->utc($params['ends_at'], $row['timezone'], 'ends_at');
+            if ($row['ends_at'] !== null && $row['ends_at'] <= $row['starts_at']) $this->invalid('ends_at', '结束时间必须晚于开始时间');
+            $texts = $before['translations'];
+            foreach ($texts as &$text) unset($text['title']); unset($text);
+            if (array_key_exists('translations', $params)) {
+                if (array_diff(array_keys($params['translations']), array_keys(Policy::LANGUAGES))) $this->invalid('translations', '仅支持zh/en/vi');
+                $texts = [];
+                foreach ($params['translations'] as $language => $text) {
+                    if (!is_array($text)) $this->invalid('translations', '语言内容必须为对象');
+                    $value = Validator::make($text, ['content' => ['nullable', 'string', 'max:5000']])->validate();
+                    if (trim((string) ($value['content'] ?? '')) !== '') $texts[$language] = ['content' => $value['content']];
+                }
+            }
+            $values = array_intersect_key($row, array_flip(['enabled', 'timezone', 'platforms', 'starts_at', 'ends_at']));
+            $values['enabled'] = (int) $values['enabled']; $values['platforms'] = json_encode($values['platforms'], JSON_THROW_ON_ERROR);
+            $values['updated_at'] = $this->now()->format('Y-m-d H:i:s');
+            DB::table('maintenance_settings')->where('id', $id)->update($values);
+            if (array_key_exists('translations', $params)) {
+                DB::table('maintenance_translations')->where('maintenance_id', $id)->delete();
+                foreach ($texts as $language => $text) DB::table('maintenance_translations')->insert(['maintenance_id' => $id, 'language' => $language, 'content' => $text['content']]);
+            }
+            $after = $this->snapshot($id);
+            if ($before !== $after) {
+                OperationAuditService::record('maintenance', $id, 'update', $before, $after, $this->now()->setTimezone('Asia/Shanghai')->format('Y-m-d H:i:s'));
+                $local = static fn ($at) => $at === null ? null : Carbon::parse($at, 'UTC')->setTimezone('Asia/Shanghai')->format('Y-m-d H:i:s');
+                SiteRealtimeService::changed('maintenance', $id, 'update',
+                    $after['enabled'] ? ['start' => $local($after['starts_at']), 'end' => $local($after['ends_at'])] : []);
+            }
+            return $this->detail($id);
+        });
+    }
+
+    public function status(int $id, int $enabled): array { return $this->save(compact('id', 'enabled')); }
+
+    public function logs(array $input): array { return LocalizedAuditService::listing('maintenance', $input); }
+
+    public function runtimeRows(string $language): array
+    {
+        return DB::table('maintenance_settings as m')->leftJoin('maintenance_translations as t', function ($join) use ($language) {
+            $join->on('t.maintenance_id', '=', 'm.id')->where('t.language', $language);
+        })->where('m.enabled', 1)->select('m.*', 't.content')->get()->map(fn ($row) => (array) $row)->all();
+    }
+
+    private function dto(array $row, array $texts): array
+    {
+        $public = Policy::publicRule($row, 'zh'); unset($public['language'], $public['content']);
+        return ['id' => (int) $row['id'], 'scope_code' => $row['scope_code'], 'scope_name' => Policy::scopes()[$row['scope_code']],
+            'enabled' => (int) $row['enabled'], 'state' => Policy::state($row, $this->now()->getTimestamp()),
+            'translations' => (object) $texts] + $public;
+    }
+
+    private function texts(int $id): array
+    {
+        $texts = [];
+        foreach (DB::table('maintenance_translations')->where('maintenance_id', $id)->orderBy('language')->get() as $text) $texts[$text->language] = ['content' => $text->content];
+        return $texts;
+    }
+
+    private function snapshot(int $id): array
+    {
+        $row = (array) DB::table('maintenance_settings')->where('id', $id)->first();
+        unset($row['created_at'], $row['updated_at']); $row['enabled'] = (int) $row['enabled']; $row['platforms'] = Policy::platforms($row);
+        $row['translations'] = $this->texts($id);
+        foreach ($row['translations'] as &$text) $text['title'] = Policy::scopes()[$row['scope_code']];
+        return $row;
+    }
+
+    private function utc(string $value, string $timezone, string $field): string
+    {
+        $date = DateTimeImmutable::createFromFormat('!Y-m-d H:i:s', $value, new DateTimeZone($timezone));
+        if (!$date || $date->format('Y-m-d H:i:s') !== $value) $this->invalid($field, '该时区中不存在此时间');
+        // Repeated local wall times during DST transitions require a different, unambiguous time.
+        $utc = $date->getTimestamp();
+        foreach ([-7200, -3600, -1800, 1800, 3600, 7200] as $offset) {
+            if ($date->setTimestamp($utc + $offset)->format('Y-m-d H:i:s') === $value) $this->invalid($field, '该时区中此时间存在夏令时歧义,请选择其他时间');
+        }
+        return $date->setTimezone(new DateTimeZone('UTC'))->format('Y-m-d H:i:s');
+    }
+
+    private function now(): Carbon { return Carbon::now('UTC'); }
+    private function invalid(string $field, string $message): void { throw ValidationException::withMessages([$field => $message]); }
+}

+ 7 - 5
app/Services/OperationAuditService.php

@@ -9,10 +9,10 @@ class OperationAuditService
 {
 {
     private const SENSITIVE_KEYS = ['secret', 'key', 'password', 'token', 'private'];
     private const SENSITIVE_KEYS = ['secret', 'key', 'password', 'token', 'private'];
 
 
-    public static function record(string $resourceType, ?int $resourceId, string $action, $before, $after): void
+    public static function record(string $resourceType, ?int $resourceId, string $action, $before, $after, ?string $createdAt = null, ?array $actor = null): void
     {
     {
         $admin = request()->user ?? null;
         $admin = request()->user ?? null;
-        OperationAudit::query()->create([
+        $attributes = [
             'resource_type' => $resourceType,
             'resource_type' => $resourceType,
             'resource_id' => $resourceId,
             'resource_id' => $resourceId,
             'action' => $action,
             'action' => $action,
@@ -20,9 +20,11 @@ class OperationAuditService
                 'before' => self::sanitize(self::toArray($before)),
                 'before' => self::sanitize(self::toArray($before)),
                 'after' => self::sanitize(self::toArray($after)),
                 'after' => self::sanitize(self::toArray($after)),
             ],
             ],
-            'operator_id' => $admin->id ?? null,
-            'operator_name' => (string)($admin->username ?? ''),
-        ]);
+            'operator_id' => $actor !== null ? ($actor['operator_id'] ?? null) : ($admin->id ?? null),
+            'operator_name' => $actor !== null ? (string) ($actor['operator_name'] ?? '') : (string)($admin->username ?? ''),
+        ];
+        if ($createdAt !== null) $attributes['created_at'] = $createdAt;
+        OperationAudit::query()->create($attributes);
     }
     }
 
 
     public static function actor(): array
     public static function actor(): array

+ 3 - 0
app/Services/PaymentOrderService.php

@@ -682,6 +682,9 @@ class PaymentOrderService extends BaseService
 
 
                 BalanceLogService::addLog($order->member_id, $payAmount, $balance, $availableBalance, '三方充值', $order->id, '');
                 BalanceLogService::addLog($order->member_id, $payAmount, $balance, $availableBalance, '三方充值', $order->id, '');
 
 
+                // Deposit hooks can grant an immediately available first-deposit reward.
+                $wallet->refresh();
+
                 $rate = ConfigService::getVal('recharges_bibi_return');
                 $rate = ConfigService::getVal('recharges_bibi_return');
                 $bonusAmount = $payAmount * $rate;
                 $bonusAmount = $payAmount * $rate;
                 if ($rate > 0 && $bonusAmount > 0) {
                 if ($rate > 0 && $bonusAmount > 0) {

+ 141 - 0
app/Services/PromotionAdminService.php

@@ -0,0 +1,141 @@
+<?php
+
+namespace App\Services;
+
+use App\Models\PromotionActivity;
+use App\Support\PromotionRules;
+use App\Support\PromotionTranslations;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+use Illuminate\Support\Facades\Validator;
+use Illuminate\Validation\ValidationException;
+
+class PromotionAdminService
+{
+    public function options(): array
+    {
+        $types = []; foreach (PromotionRules::types() as $value => $label) $types[] = compact('value', 'label');
+        return ['types' => $types, 'languages' => [['value' => 'zh', 'label' => '中文'], ['value' => 'en', 'label' => '英文'], ['value' => 'vi', 'label' => '越南文']],
+            'create_types' => [['value' => PromotionRules::FIRST_DEPOSIT, 'label' => PromotionRules::types()[PromotionRules::FIRST_DEPOSIT]]],
+            'defaults' => [7 => PromotionRules::defaults(7), 8 => PromotionRules::defaults(8), 9 => PromotionRules::defaults(9)],
+            'prizes' => PromotionRules::PRIZES, 'tasks' => PromotionRules::TASKS];
+    }
+
+    public function listing(array $input): array
+    {
+        $input = Validator::make($input, ['page' => ['sometimes', 'integer', 'min:1'], 'limit' => ['sometimes', 'integer', 'between:1,100'],
+            'type' => ['sometimes', 'integer', 'in:7,8,9'], 'status' => ['sometimes', 'integer', 'in:0,1'], 'title' => ['sometimes', 'string', 'max:140']])->validate();
+        $q = PromotionActivity::query();
+        foreach (['type', 'status'] as $key) if (isset($input[$key])) $q->where($key, $input[$key]);
+        if (!empty($input['title'])) $q->where('title', 'like', '%' . $input['title'] . '%');
+        return ['total' => $q->count(), 'data' => $q->orderByDesc('sort')->orderByDesc('id')->forPage($input['page'] ?? 1, $input['limit'] ?? 20)->get()->map(fn ($r) => $this->format($r))->all()];
+    }
+
+    public function detail(int $id): array
+    {
+        $row = PromotionActivity::find($id);
+        if (!$row) throw ValidationException::withMessages(['id' => '独立活动不存在']);
+        return $this->format($row);
+    }
+
+    private function format(PromotionActivity $row): array
+    {
+        $data = array_replace($row->toArray(), ['translations' => PromotionTranslations::all($row->toArray()), 'params' => json_decode($row->params, true),
+            'start_time' => date('Y-m-d H:i:s', (int)$row->start_time), 'end_time' => date('Y-m-d H:i:s', (int)$row->end_time),
+            'first_published_at' => DB::table('promotion_campaigns')->where('activity_id', $row->id)->value('first_published_at')]);
+        unset($data['sub_title']);
+        if (in_array((int) $row->type, [PromotionRules::LOSS, PromotionRules::WHEEL], true)) unset($data['image']);
+        return $data;
+    }
+
+    public function save(array $input): array
+    {
+        unset($input['sub_title']);
+        if (isset($input['translations']) && is_array($input['translations'])) {
+            foreach ($input['translations'] as &$text) if (is_array($text)) unset($text['sub_title']);
+            unset($text);
+        }
+        if (in_array($input['type'] ?? null, [7, 9, '7', '9'], true)) unset($input['image']);
+        $money = ['nullable', 'numeric', 'min:0', 'regex:/^\d{1,12}(\.\d{1,2})?$/'];
+        $textRules = ['translations' => ['required', 'array:zh,en,vi']];
+        $messages = ['translations.required' => '请完整填写中文、英文、越南文的标题和正文'];
+        foreach (['zh' => '中文', 'en' => '英文', 'vi' => '越南文'] as $lang => $label) {
+            $textRules['translations.' . $lang] = ['required', 'array:title,content'];
+            $messages['translations.' . $lang . '.required'] = '请完整填写' . $label . '文本';
+            foreach (['title' => ['标题', 140], 'content' => ['正文', 100000]] as $field => [$name, $max]) {
+                $key = 'translations.' . $lang . '.' . $field;
+                $textRules[$key] = ['required', 'string', 'max:' . $max];
+                $messages[$key . '.required'] = $label . $name . '不能为空';
+            }
+        }
+        $data = Validator::make($input, $textRules + ['id' => ['sometimes', 'integer', 'min:1'],
+            'type' => ['required', 'integer', 'in:7,8,9'], 'status' => ['required', 'integer', 'in:0,1'],
+            'start_time' => ['required', 'date'], 'end_time' => ['required', 'date', 'after:start_time'],
+            'sort' => ['sometimes', 'integer', 'between:0,4294967295'], 'params' => ['sometimes', 'array'],
+            'image' => ['sometimes', 'nullable', 'string', 'max:2048'], 'publish_platforms' => ['sometimes', 'array'], 'publish_platforms.*' => ['string', 'distinct', 'in:PC,H5,APP'],
+            'register_time_limited' => ['sometimes', 'boolean'], 'register_start_time' => ['nullable', 'required_if:register_time_limited,1', 'date'],
+            'register_end_time' => ['nullable', 'required_if:register_time_limited,1', 'date', 'after_or_equal:register_start_time'],
+            'register_platforms' => ['sometimes', 'array'], 'register_platforms.*' => ['string', 'distinct', 'in:PC,H5,APP'], 'deposit_limited' => ['sometimes', 'boolean'],
+            'deposit_min' => [...$money, 'required_if:deposit_limited,1'], 'deposit_max' => [...$money, 'required_if:deposit_limited,1', 'gte:deposit_min'],
+            'show_ineligible' => ['sometimes', 'boolean'], 'reward_quota' => ['sometimes', 'nullable', 'integer', 'between:0,2147483647'], 'budget' => ['sometimes', ...$money]], $messages)->validate();
+        if (!isset($data['id']) && in_array((int) $data['type'], [PromotionRules::LOSS, PromotionRules::WHEEL], true)) {
+            throw ValidationException::withMessages(['id' => '净亏和转盘活动不支持新增,请编辑现有活动']);
+        }
+        return DB::transaction(function () use ($data) {
+            $row = isset($data['id']) ? PromotionActivity::where('id', $data['id'])->lockForUpdate()->first() : new PromotionActivity();
+            if (!$row) throw ValidationException::withMessages(['id' => '独立活动不存在']);
+            if ($row->exists && (int)$row->type !== (int)$data['type']) throw ValidationException::withMessages(['type' => '创建后不能变更活动类型']);
+            $before = $row->exists ? $row->toArray() : [];
+            $params = array_replace($row->params ? json_decode($row->params, true) : [], $data['params'] ?? []);
+            $values = $data; unset($values['id']);
+            $texts = $this->completeTexts($data['translations']);
+            $values = array_replace($values, $texts['zh'], ['translations' => $texts, 'lang' => 'zh']);
+            $values['params'] = json_encode(PromotionRules::normalize((int)$data['type'], $params));
+            $values['start_time'] = strtotime($data['start_time']); $values['end_time'] = strtotime($data['end_time']);
+            if (!$row->exists) $values += ['lang' => 'zh', 'sort' => 0, 'publish_platforms' => [], 'register_platforms' => []];
+            $row->fill($values); $row->save(); PromotionService::publish($row);
+            if (Schema::hasTable('operation_audits')) OperationAuditService::record('promotion', $row->id, $before ? 'update' : 'create', $before, $row->toArray());
+            return $this->format($row);
+        }, 3);
+    }
+
+    private function completeTexts(array $texts): array
+    {
+        foreach (['zh' => '中文', 'en' => '英文', 'vi' => '越南文'] as $lang => $label) {
+            foreach (['title' => '标题', 'content' => '正文'] as $field => $name) {
+                $value = html_entity_decode(strip_tags($texts[$lang][$field]), ENT_QUOTES | ENT_HTML5, 'UTF-8');
+                if (preg_replace('/[\s\p{Z}\x{200B}\x{FEFF}]+/u', '', $value) === '') {
+                    throw ValidationException::withMessages(['translations.' . $lang . '.' . $field => $label . $name . '不能为空']);
+                }
+                if ($field !== 'content') $texts[$lang][$field] = trim($texts[$lang][$field]);
+            }
+        }
+        return $texts;
+    }
+
+    public function status(int $id, int $status): array
+    {
+        return DB::transaction(function () use ($id, $status) {
+            $row = PromotionActivity::where('id', $id)->lockForUpdate()->first();
+            if (!$row) throw ValidationException::withMessages(['id' => '独立活动不存在']);
+            if ($status === 1) $this->completeTexts(PromotionTranslations::all($row->toArray()));
+            $before = $row->toArray(); $row->status = $status; $row->save(); PromotionService::publish($row);
+            if (Schema::hasTable('operation_audits')) OperationAuditService::record('promotion', $id, 'status', $before, $row->toArray());
+            return $this->format($row);
+        }, 3);
+    }
+
+    public function records(array $input): array
+    {
+        $input = Validator::make($input, ['activity_id' => ['required', 'integer', 'min:1'], 'user_id' => ['sometimes', 'integer', 'min:1'],
+            'kind' => ['required', 'in:accounts,rounds,tasks,spins,rewards'], 'page' => ['sometimes', 'integer', 'min:1'], 'limit' => ['sometimes', 'integer', 'between:1,100']])->validate();
+        $q = DB::table('promotion_accounts as a')->where('a.activity_id', $input['activity_id']);
+        if (isset($input['user_id'])) $q->where('a.user_id', $input['user_id']);
+        if ($input['kind'] === 'accounts') $q->select('a.*')->orderByDesc('a.id');
+        else {
+            $table = match ($input['kind']) { 'rounds' => 'promotion_rounds', 'tasks' => 'promotion_task_grants', 'spins' => 'promotion_spins', 'rewards' => 'promotion_rewards' };
+            $q->join($table . ' as r', 'r.account_id', '=', 'a.id')->select(['r.*', 'a.user_id', 'a.activity_id'])->orderByDesc('r.id');
+        }
+        return ['total' => $q->count(), 'data' => $q->forPage($input['page'] ?? 1, $input['limit'] ?? 20)->get()];
+    }
+}

+ 156 - 0
app/Services/PromotionFacts.php

@@ -0,0 +1,156 @@
+<?php
+
+namespace App\Services;
+
+use App\Services\Sharing\EffectiveBet;
+use Carbon\Carbon;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+use Illuminate\Validation\ValidationException;
+
+/** Promotion eligibility is independent of commission accrual and never uses raw stake as effective stake. */
+class PromotionFacts
+{
+    public static function now(): string { return now('Asia/Shanghai')->format('Y-m-d H:i:s'); }
+
+    public function policy(): array
+    {
+        $row = DB::table('share_policies')->where('effective_date', '<=', substr(self::now(), 0, 10))->orderByDesc('effective_date')->first();
+        if (!$row || $row->valid_member_min_deposit === null || $row->valid_member_min_effective_bet === null) {
+            throw ValidationException::withMessages(['policy' => '请先配置已生效的有效会员充值和有效投注门槛']);
+        }
+        return ['id' => $row->id, 'effective_date' => $row->effective_date,
+            'min_deposit' => (string)$row->valid_member_min_deposit, 'min_effective_bet' => (string)$row->valid_member_min_effective_bet];
+    }
+
+    public function children(int $userId): array
+    {
+        return DB::table('share_members as m')->join('users as u', 'u.id', '=', 'm.user_id')
+            ->where('m.parent_user_id', $userId)->where('u.from', '<>', 2)->where('m.bound_at', '<=', self::now())
+            ->get(['u.id', 'u.member_id', 'u.created_at', 'm.bound_at'])->map(fn ($r) => (array)$r)->all();
+    }
+
+    public function deposits(array $members, string $from, ?string $until = null, int $cursor = 0): array
+    {
+        $totals = [];
+        if (!$members) return $totals;
+        $q = DB::table('balance_logs')->whereIn('member_id', $members)->where('type', 1)->where('amount', '>', 0)
+            ->whereIn('change_type', ['充值', '三方充值'])->where('created_at', '>=', $from)->where('created_at', '<=', self::now())->where('id', '>', $cursor);
+        if ($until !== null) $q->where('created_at', '<', $until);
+        foreach ($q->orderBy('id')->cursor() as $row) {
+            $day = substr($row->created_at, 0, 10);
+            $totals[$day] = bcadd($totals[$day] ?? '0', (string)$row->amount, 10);
+        }
+        return $totals;
+    }
+
+    public function cursors(): array
+    {
+        $result = [];
+        foreach (['share_bet_facts', 'lhc_order_item', 'sport_order', 'sport_game_order', 'third_game_orders', 'bets', 'jisu_game_order'] as $table) {
+            if (Schema::hasTable($table)) $result[$table] = (int)DB::table($table)->max('id');
+        }
+        return $result;
+    }
+
+    public function betting(array $ids, string $from, ?string $until = null, array $cursors = []): array
+    {
+        $totals = ['effective' => [], 'profit' => '0'];
+        if (!$ids) return $totals;
+        $currency = (string)config('third_game.currency', 'CNY');
+        $q = DB::table('share_bet_facts')->whereIn('user_id', $ids)->where('bet_at', '>=', $from)
+            ->where('bet_at', '<=', self::now())->where('currency', $currency)->where('settled', 1)->where('voided', 0);
+        if ($until !== null) $q->where('bet_at', '<', $until);
+        foreach ($q->cursor() as $row) {
+            if (!$this->afterCursor($row, $cursors)) continue;
+            $this->addBet($totals, $row->bet_at, (string)$row->bet_amount, (string)$row->net_profit);
+        }
+        // The legacy Telegram games are not imported into sharing facts. Read only their own sources.
+        $members = DB::table('users')->whereIn('id', $ids)->pluck('member_id')->all();
+        foreach (['bets', 'jisu_game_order'] as $table) {
+            if (!Schema::hasTable($table)) continue;
+            $q = DB::table($table)->whereIn('member_id', $members)->where('created_at', '>=', $from)->where('created_at', '<=', self::now())
+                ->where('id', '>', $cursors[$table] ?? 0)->whereIn('status', $table === 'bets' ? [2] : [1, 2]);
+            if ($until !== null) $q->where('created_at', '<', $until);
+            foreach ($q->cursor() as $row) {
+                $profit = bcsub((string)($table === 'bets' ? $row->profit : $row->win_amount), (string)$row->amount, 10);
+                $this->addBet($totals, $row->created_at, (string)$row->amount, $profit);
+            }
+        }
+        return $totals;
+    }
+
+    private function afterCursor(object $fact, array $cursors): bool
+    {
+        if (!$cursors) return true;
+        if ($fact->source === 'third_game_orders' && isset($cursors['third_game_orders'])) {
+            $id = DB::table('third_game_orders')->where('order_key', $fact->source_id)->value('id');
+            return $id !== null && (int)$id > $cursors['third_game_orders'];
+        }
+        if (isset($cursors[$fact->source])) return ctype_digit((string)$fact->source_id) && (int)$fact->source_id > $cursors[$fact->source];
+        return (int)$fact->id > ($cursors['share_bet_facts'] ?? 0);
+    }
+
+    private function addBet(array &$totals, string $at, string $stake, string $profit): void
+    {
+        $day = substr($at, 0, 10);
+        $totals['effective'][$day] = bcadd($totals['effective'][$day] ?? '0', EffectiveBet::amount($stake, $profit), 10);
+        $totals['profit'] = bcadd($totals['profit'], $profit, 10);
+    }
+
+    public function qualified(array $child, array $policy, ?string $before = null): bool
+    {
+        $deposits = $this->deposits([(string)$child['member_id']], '1970-01-01 00:00:00', $before);
+        $bet = $this->betting([(int)$child['id']], '1970-01-01 00:00:00', $before);
+        return bccomp($this->total($deposits), $policy['min_deposit'], 10) >= 0
+            && bccomp($this->total($bet['effective']), $policy['min_effective_bet'], 10) >= 0;
+    }
+
+    public function total(array $amounts): string
+    {
+        $total = '0'; foreach ($amounts as $amount) $total = bcadd($total, (string)$amount, 10); return $total;
+    }
+
+    public function logins(string $memberId, string $from): array
+    {
+        $days = [];
+        foreach (DB::table('user_login')->where('user_id', $memberId)->where('status', 1)->where('created_at', '>=', $from)
+            ->where('created_at', '<=', self::now())->cursor() as $row) $days[substr($row->created_at, 0, 10)] = true;
+        $userId = DB::table('users')->where('member_id', $memberId)->value('id');
+        foreach (['promotion_presence', 'reporting_activity'] as $table) {
+            if (!Schema::hasTable($table)) continue;
+            foreach (DB::table($table)->where('user_id', $userId)->where('observed_at', '>=', $from)->where('observed_at', '<=', self::now())->cursor() as $row) $days[substr($row->observed_at, 0, 10)] = true;
+        }
+        return $days;
+    }
+
+    public function online(int $userId, string $from): array
+    {
+        $last = []; $intervals = []; $observations = [];
+        foreach (['reporting_activity', 'promotion_presence'] as $table) {
+            if (!Schema::hasTable($table)) continue;
+            foreach (DB::table($table)->where('user_id', $userId)->where('observed_at', '>=', $from)->where('observed_at', '<=', self::now())->cursor() as $row) $observations[] = $row;
+        }
+        usort($observations, fn ($a, $b) => strcmp($a->observed_at, $b->observed_at));
+        foreach ($observations as $row) {
+            $at = Carbon::parse($row->observed_at, 'Asia/Shanghai')->timestamp;
+            $key = $row->session_hash;
+            if (isset($last[$key]) && $at > $last[$key] && $at - $last[$key] <= 300) {
+                $start = $last[$key];
+                while ($start < $at) {
+                    $day = Carbon::createFromTimestamp($start, 'Asia/Shanghai')->toDateString();
+                    $end = min($at, Carbon::parse($day, 'Asia/Shanghai')->addDay()->timestamp);
+                    $intervals[$day][] = [$start, $end]; $start = $end;
+                }
+            }
+            $last[$key] = $at;
+        }
+        $result = [];
+        foreach ($intervals as $day => $list) {
+            usort($list, fn ($a, $b) => $a[0] <=> $b[0]); $end = 0; $seconds = 0;
+            foreach ($list as [$a, $b]) { if ($b > max($a, $end)) $seconds += $b - max($a, $end); $end = max($end, $b); }
+            $result[$day] = $seconds;
+        }
+        return $result;
+    }
+}

+ 481 - 0
app/Services/PromotionService.php

@@ -0,0 +1,481 @@
+<?php
+
+namespace App\Services;
+
+use App\Models\PromotionActivity;
+use App\Models\BalanceLog;
+use App\Models\User;
+use App\Models\Wallet;
+use App\Support\PromotionRules as Rules;
+use App\Support\PromotionTranslations;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+use Illuminate\Validation\ValidationException;
+
+/** Backend jobs and deposit awards. Member HTTP APIs are owned by melbet_sport-api. */
+class PromotionService
+{
+    private PromotionFacts $facts;
+    public function __construct(?PromotionFacts $facts = null) { $this->facts = $facts ?? new PromotionFacts(); }
+
+    public static function publish(PromotionActivity $activity): void
+    {
+        if (!Rules::supports((int)$activity->type)) return;
+        $now = PromotionFacts::now();
+        DB::table('promotion_campaigns')->insertOrIgnore(['activity_id' => $activity->id, 'first_published_at' => null, 'created_at' => $now, 'updated_at' => $now]);
+        if ((int)$activity->status === 1) DB::table('promotion_campaigns')->where('activity_id', $activity->id)->whereNull('first_published_at')
+            ->update(['first_published_at' => $now, 'updated_at' => $now]);
+    }
+
+    private function invalid(string $message): void { throw ValidationException::withMessages(['promotion' => $message]); }
+    private function json($value): array { return is_array($value) ? $value : (json_decode((string)$value, true) ?: []); }
+    private function rules(PromotionActivity $activity): array { return Rules::normalize((int)$activity->type, $this->json($activity->params)); }
+    private function money(int $cents): string { return bcdiv((string)$cents, '100', 2); }
+
+    public function heartbeat(int $userId, string $token, string $platform): array
+    {
+        $this->member($userId);
+        $now = PromotionFacts::now(); $bucket = substr($now, 0, 16) . ':00';
+        DB::table('promotion_presence')->insertOrIgnore(['user_id' => $userId, 'session_hash' => hash('sha256', $token . ':' . $platform), 'observed_at' => $bucket]);
+        return ['recorded' => true, 'server_time' => $now, 'interval_seconds' => 60];
+    }
+
+    public function listing(int $userId, string $platform, string $lang = 'zh', ?int $type = null): array
+    {
+        $member = $this->member($userId); $context = ActivityEligibilityService::memberContext($member->member_id);
+        $q = PromotionActivity::where('status', 1)->where('start_time', '<=', now()->timestamp)->where('end_time', '>=', now()->timestamp);
+        if ($type !== null) $q->where('type', $type);
+        $data = [];
+        foreach ($q->orderByDesc('sort')->orderByDesc('id')->get() as $activity) {
+            $text = PromotionTranslations::select($activity->toArray(), $lang);
+            if (!$text) continue;
+            if (!ActivityEligibilityService::isPublishedOn($activity, $platform)) continue;
+            $eligibility = ActivityEligibilityService::evaluate($activity, $context);
+            if (!$eligibility['is_eligible'] && !$activity->show_ineligible) continue;
+            $item = ['activity_id' => $activity->id, 'type' => (int)$activity->type, 'type_name' => $activity->type_name,
+                'start_time' => (int)$activity->start_time, 'end_time' => (int)$activity->end_time, 'rules' => $this->rules($activity)] + $text + $eligibility;
+            if ((int) $activity->type === Rules::FIRST_DEPOSIT) $item['image'] = $activity->image;
+            $data[] = $item;
+        }
+        return ['total' => count($data), 'data' => $data];
+    }
+
+    private function activity(int $id, bool $lock = false): PromotionActivity
+    {
+        $query = PromotionActivity::where('id', $id);
+        $row = ($lock ? $query->lockForUpdate() : $query)->first();
+        if (!$row || !Rules::supports((int)$row->type)) $this->invalid('活动不存在');
+        return $row;
+    }
+
+    private function member(int $id): User
+    {
+        $member = User::find($id);
+        if (!$member || (int)$member->from === 2 || !empty($member->getAttributes()['delete_time']) || (int)($member->getAttributes()['is_banned'] ?? 0) === 1) $this->invalid('会员不可参与活动');
+        return $member;
+    }
+
+    private function open(PromotionActivity $activity, User $member, ?string $platform = null): string
+    {
+        $now = now('Asia/Shanghai')->timestamp;
+        if ((int)$activity->status !== 1 || (int)$activity->getRawOriginal('start_time') > $now || (int)$activity->getRawOriginal('end_time') < $now) $this->invalid('活动未上架或不在活动时间内');
+        $eligibility = ActivityEligibilityService::evaluate($activity, ActivityEligibilityService::memberContext($member->member_id));
+        if (!$eligibility['is_eligible']) $this->invalid($eligibility['ineligible_reason']);
+        if ($platform !== null && !ActivityEligibilityService::isPublishedOn($activity, $platform)) $this->invalid('此客户端不可参与该活动');
+        $published = DB::table('promotion_campaigns')->where('activity_id', $activity->id)->value('first_published_at');
+        if (!$published) $this->invalid('活动尚未正式开启');
+        return $published;
+    }
+
+    private function mutate(int $activityId, int $userId, callable $callback): array
+    {
+        return DB::transaction(function () use ($activityId, $userId, $callback) {
+            $member = $this->member($userId);
+            $wallet = Wallet::where('member_id', $member->member_id)->lockForUpdate()->first();
+            if (!$wallet) $this->invalid('会员钱包不存在');
+            $activity = $this->activity($activityId, true);
+            return $callback($activity, $member, $wallet);
+        }, 3);
+    }
+
+    private function account(PromotionActivity $activity, User $member): object
+    {
+        $now = PromotionFacts::now();
+        DB::table('promotion_accounts')->insertOrIgnore(['activity_id' => $activity->id, 'user_id' => $member->id,
+            'round_no' => 1, 'spins' => 0, 'created_at' => $now, 'updated_at' => $now]);
+        return DB::table('promotion_accounts')->where('activity_id', $activity->id)->where('user_id', $member->id)->lockForUpdate()->first();
+    }
+
+    private function newRound(object $account, string $start, array $completed = [], bool $reset = false): object
+    {
+        $now = PromotionFacts::now();
+        DB::table('promotion_rounds')->insert(['account_id' => $account->id, 'round_no' => $account->round_no,
+            'coins_cents' => 0, 'status' => 'active', 'started_at' => $start,
+            'deposit_cursor' => $reset ? (int)DB::table('balance_logs')->max('id') : 0,
+            'bet_cursors' => json_encode($reset ? $this->facts->cursors() : []), 'completed_tasks' => json_encode($completed), 'created_at' => $now, 'updated_at' => $now]);
+        return $this->round($account);
+    }
+
+    private function round(object $account): ?object
+    {
+        return DB::table('promotion_rounds')->where('account_id', $account->id)->where('round_no', $account->round_no)->lockForUpdate()->first();
+    }
+
+    public function detail(int $activityId, int $userId, ?string $platform = null, ?string $lang = null): array
+    {
+        $activity = $this->activity($activityId); $member = $this->member($userId);
+        $this->open($activity, $member, $platform);
+        $account = DB::table('promotion_accounts')->where('activity_id', $activityId)->where('user_id', $userId)->first();
+        $round = $account ? DB::table('promotion_rounds')->where('account_id', $account->id)->where('round_no', $account->round_no)->first() : null;
+        return $this->state($activity, $member, $account, $round, $lang);
+    }
+
+    public function progress(int $activityId, int $userId, ?string $platform = null, ?string $lang = null): array
+    {
+        return $this->mutate($activityId, $userId, function ($activity, $member) use ($platform, $lang) {
+            $start = $this->open($activity, $member, $platform); $account = $this->account($activity, $member); $round = null;
+            if ((int)$activity->type === Rules::WHEEL) {
+                $round = $this->round($account) ?? $this->newRound($account, $start);
+                if ($round->status === 'active') $this->refreshWheel($activity, $member, $account, $round);
+            }
+            return $this->state($activity, $member, $account, $round, $lang);
+        });
+    }
+
+    public function apply(int $activityId, int $userId, ?string $platform = null, ?string $lang = null): array
+    {
+        return $this->mutate($activityId, $userId, function ($activity, $member) use ($platform, $lang) {
+            $start = $this->open($activity, $member, $platform);
+            if ((int)$activity->type !== Rules::LOSS) $this->invalid('只有净亏邀请活动需要申请');
+            $account = $this->account($activity, $member);
+            if (!$account->applied_at) {
+                $rules = $this->rules($activity); $policy = $this->facts->policy();
+                $profit = $this->facts->betting([$member->id], $start)['profit'];
+                $loss = bccomp($profit, '0', 10) < 0 ? bcsub('0', $profit, 10) : '0';
+                if (bccomp($loss, $rules['loss_amount'], 10) < 0) $this->invalid('尚未达到净亏申请门槛');
+                $account->applied_at = PromotionFacts::now();
+                $account->application_snapshot = json_encode(['loss' => $loss, 'from' => $start, 'policy' => $policy, 'rules' => $rules,
+                    'invite_user_cursor' => (int)DB::table('users')->max('id')]);
+                DB::table('promotion_accounts')->where('id', $account->id)->update(['applied_at' => $account->applied_at,
+                    'application_snapshot' => $account->application_snapshot, 'updated_at' => PromotionFacts::now()]);
+            }
+            return $this->state($activity, $member, $account, null, $lang);
+        });
+    }
+
+    private function lossInvitees(User $member, object $account): array
+    {
+        if (!$account->applied_at) return [];
+        $policy = $this->facts->policy(); $qualified = []; $snapshot = $this->json($account->application_snapshot);
+        foreach ($this->facts->children($member->id) as $child) {
+            if ((int)$child['id'] > ($snapshot['invite_user_cursor'] ?? 0) && $child['created_at'] >= $account->applied_at && $child['bound_at'] >= $account->applied_at && $this->facts->qualified($child, $policy)) $qualified[] = (int)$child['id'];
+        }
+        return $qualified;
+    }
+
+    private function grantTask(object $account, object $round, string $task, string $event, array $evidence): void
+    {
+        $inserted = DB::table('promotion_task_grants')->insertOrIgnore(['account_id' => $account->id, 'round_no' => $round->round_no,
+            'task' => $task, 'event_key' => $event, 'evidence' => json_encode($evidence), 'created_at' => PromotionFacts::now(), 'updated_at' => PromotionFacts::now()]);
+        if ($inserted) { ++$account->spins; DB::table('promotion_accounts')->where('id', $account->id)->update(['spins' => $account->spins, 'updated_at' => PromotionFacts::now()]); }
+    }
+
+    private function refreshWheel(PromotionActivity $activity, User $member, object $account, object $round): void
+    {
+        $rules = $this->rules($activity); $policy = $this->facts->policy();
+        $completed = $this->json($round->completed_tasks);
+        $campaignStart = DB::table('promotion_campaigns')->where('activity_id', $activity->id)->value('first_published_at');
+        // Daily tasks are campaign-wide. They survive round reset, but never grant twice on one day.
+        foreach ($this->facts->logins($member->member_id, $campaignStart) as $day => $_) {
+            $this->grantTask($account, $round, 'login', $day, ['day' => $day]); $completed['login'] = true;
+        }
+        foreach ($this->facts->online($member->id, $campaignStart) as $day => $seconds) if ($seconds >= $rules['online_seconds']) {
+            $this->grantTask($account, $round, 'online', $day, ['day' => $day, 'seconds' => $seconds]); $completed['online'] = true;
+        }
+        $children = $this->facts->children($member->id);
+        foreach ($children as $child) {
+            if ($this->facts->qualified($child, $policy) && !$this->facts->qualified($child, $policy, $round->started_at)) {
+                $this->grantTask($account, $round, 'invite', (string)$child['id'], ['user_id' => $child['id'], 'policy' => $policy]);
+            }
+        }
+        // Only a grant belonging to this round can satisfy its resettable invitation task.
+        $completed['invite'] = false; $childrenById = array_column($children, null, 'id');
+        foreach (DB::table('promotion_task_grants')->where('account_id', $account->id)->where('round_no', $round->round_no)->where('task', 'invite')->get() as $grant) {
+            $evidence = $this->json($grant->evidence); $child = $childrenById[$evidence['user_id']] ?? null;
+            if ($child && $this->facts->qualified($child, $evidence['policy'])) $completed['invite'] = true;
+        }
+        $cursors = $this->json($round->bet_cursors);
+        $teamDeposits = $teamBets = [];
+        foreach ($children as $child) {
+            $from = max($round->started_at, $child['bound_at']);
+            foreach ($this->facts->deposits([$child['member_id']], $from, null, $round->deposit_cursor) as $day => $amount) $teamDeposits[$day] = bcadd($teamDeposits[$day] ?? '0', $amount, 10);
+            foreach ($this->facts->betting([$child['id']], $from, null, $cursors)['effective'] as $day => $amount) $teamBets[$day] = bcadd($teamBets[$day] ?? '0', $amount, 10);
+        }
+        $metrics = [
+            'deposit' => $this->facts->deposits([$member->member_id], $round->started_at, null, $round->deposit_cursor),
+            'team_deposit' => $teamDeposits,
+            'bet' => $this->facts->betting([$member->id], $round->started_at, null, $cursors)['effective'],
+            'team_bet' => $teamBets,
+        ];
+        foreach ($metrics as $task => $days) {
+            // Recompute qualification after corrections; daily amounts never carry across midnight.
+            $completed[$task] = false;
+            foreach ($days as $day => $amount) if (bccomp($amount, $rules[$task . '_amount'], 10) >= 0) {
+                $this->grantTask($account, $round, $task, $round->round_no . ':' . $day, ['day' => $day, 'amount' => $amount, 'target' => $rules[$task . '_amount']]); $completed[$task] = true;
+            }
+            foreach (DB::table('promotion_task_grants')->where('account_id', $account->id)->where('round_no', $round->round_no)->where('task', $task)->get() as $grant) {
+                $proof = $this->json($grant->evidence);
+                if (bccomp($days[$proof['day']] ?? '0', $proof['target'] ?? $rules[$task . '_amount'], 10) >= 0) $completed[$task] = true;
+            }
+        }
+        $round->completed_tasks = json_encode($completed);
+        DB::table('promotion_rounds')->where('id', $round->id)->update(['completed_tasks' => $round->completed_tasks, 'updated_at' => PromotionFacts::now()]);
+    }
+
+    private function reserve(PromotionActivity $activity, User $member, object $account, int $roundNo, string $amount, array $rules): object
+    {
+        $this->capacity($activity, $member, $amount);
+        $received = DB::table('promotion_rewards')->where('activity_id', $activity->id)->where('user_id', $member->id)->exists();
+        DB::table('promotion_rewards')->insert(['activity_id' => $activity->id, 'user_id' => $member->id, 'account_id' => $account->id,
+            'round_no' => $roundNo, 'amount' => $amount, 'required_flow' => bcmul($amount, $rules['turnover_ratio'], 2),
+            'status' => 'reserved', 'rule_snapshot' => json_encode($rules), 'created_at' => PromotionFacts::now(), 'updated_at' => PromotionFacts::now()]);
+        if (!$received) ++$activity->rewarded_count;
+        $activity->budget_used = bcadd((string)$activity->budget_used, $amount, 2); $activity->save();
+        return DB::table('promotion_rewards')->where('account_id', $account->id)->where('round_no', $roundNo)->first();
+    }
+
+    private function capacity(PromotionActivity $activity, User $member, string $amount): void
+    {
+        $received = DB::table('promotion_rewards')->where('activity_id', $activity->id)->where('user_id', $member->id)->exists();
+        if (!$received && $activity->reward_quota !== null && (int)$activity->rewarded_count >= (int)$activity->reward_quota) $this->invalid('活动获奖份数已用完');
+        if ($activity->budget !== null && bccomp(bcadd((string)$activity->budget_used, $amount, 2), (string)$activity->budget, 2) > 0) $this->invalid('活动奖励额度不足');
+    }
+
+    public function spin(int $activityId, int $userId, string $requestKey, ?string $platform = null): array
+    {
+        return $this->mutate($activityId, $userId, function ($activity, $member) use ($requestKey, $platform) {
+            $account = $this->account($activity, $member);
+            $previous = DB::table('promotion_spins')->where('account_id', $account->id)->where('request_key', $requestKey)->first();
+            if ($previous) return ['spin' => $this->spinRow($previous), 'spins' => $account->spins, 'replayed' => true];
+            $start = $this->open($activity, $member, $platform);
+            if ((int)$activity->type !== Rules::WHEEL) $this->invalid('该活动不是转盘活动');
+            $round = $this->round($account) ?? $this->newRound($account, $start);
+            if ($round->status !== 'active') $this->invalid('本轮已达到666,请先领取');
+            $this->refreshWheel($activity, $member, $account, $round);
+            if ($account->spins < 1) $this->invalid('抽奖次数不足');
+            $rules = $this->rules($activity); $flags = $this->json($round->completed_tasks);
+            $complete = count(array_filter(array_intersect_key($flags, array_flip(Rules::TASKS)))) === count(Rules::TASKS);
+            // Capacity is checked before random sampling; a winning outcome is reserved immediately.
+            if ($complete && bccomp($rules['completion_probability'], '0', 2) > 0) $this->capacity($activity, $member, '666.00');
+            $draw = Rules::draw((int)$round->coins_cents, $complete, $rules['completion_probability']);
+            if ($draw['reached']) $this->reserve($activity, $member, $account, $round->round_no, '666.00', $rules);
+            $coins = $round->coins_cents + $draw['amount_cents']; $now = PromotionFacts::now();
+            $id = DB::table('promotion_spins')->insertGetId(['account_id' => $account->id, 'round_no' => $round->round_no, 'request_key' => $requestKey,
+                'prize_index' => $draw['prize_index'], 'amount_cents' => $draw['amount_cents'], 'coins_cents' => $coins,
+                'conditions_complete' => $complete, 'probability' => $complete ? $rules['completion_probability'] : '0.00', 'created_at' => $now, 'updated_at' => $now]);
+            DB::table('promotion_rounds')->where('id', $round->id)->update(['coins_cents' => $coins, 'status' => $draw['reached'] ? 'reached' : 'active', 'updated_at' => $now]);
+            --$account->spins;
+            DB::table('promotion_accounts')->where('id', $account->id)->update(['spins' => $account->spins, 'updated_at' => $now]);
+            return ['spin' => $this->spinRow(DB::table('promotion_spins')->where('id', $id)->first()), 'spins' => $account->spins, 'replayed' => false];
+        });
+    }
+
+    private function pay(object $reward, User $member, Wallet $wallet): object
+    {
+        if ($reward->status !== 'reserved') return $reward;
+        $frozen = bccomp((string)$reward->required_flow, '0', 2) > 0;
+        $field = $frozen ? 'frozen_balance' : 'available_balance';
+        $before = (string)$wallet->$field; $wallet->$field = bcadd($before, (string)$reward->amount, 10); $wallet->save();
+        $log = BalanceLog::create(['member_id' => $member->member_id, 'type' => $frozen ? 2 : 1, 'change_type' => '优惠活动',
+            'amount' => $reward->amount, 'before_balance' => $before, 'after_balance' => $wallet->$field, 'related_id' => $reward->id,
+            // Frozen promotion rewards are intentionally unlocked by this service, never by the legacy 1x list.
+            'frozen_status' => $frozen ? 1 : null, 'remark' => '新活动奖励; activity=' . $reward->activity_id . '; round=' . $reward->round_no]);
+        $now = PromotionFacts::now();
+        DB::table('promotion_rewards')->where('id', $reward->id)->update(['status' => $frozen ? 'locked' : 'paid', 'credited_log_id' => $log->id,
+            'flow_snapshot' => json_encode(['from' => $now, 'cursors' => $this->facts->cursors()]), 'updated_at' => $now]);
+        return DB::table('promotion_rewards')->where('id', $reward->id)->first();
+    }
+
+    public function claim(int $activityId, int $userId, int $roundNo, ?string $platform = null): array
+    {
+        return $this->mutate($activityId, $userId, function ($activity, $member, $wallet) use ($roundNo, $platform) {
+            $account = $this->account($activity, $member);
+            $reward = DB::table('promotion_rewards')->where('account_id', $account->id)->where('round_no', $roundNo)->lockForUpdate()->first();
+            if ($reward && $reward->status !== 'reserved') return ['reward' => (array)$reward, 'round_no' => $account->round_no, 'spins' => $account->spins, 'replayed' => true];
+            if ((int)$activity->type === Rules::LOSS) {
+                $this->open($activity, $member, $platform);
+                if ($roundNo !== 1 || !$account->applied_at) $this->invalid('请先申请活动');
+                $snapshot = $this->json($account->application_snapshot); $rules = $snapshot['rules'];
+                $qualified = $this->lossInvitees($member, $account);
+                if (count($qualified) < $rules['invite_count']) $this->invalid('有效邀请人数不足');
+                $reward ??= $this->reserve($activity, $member, $account, 1, $rules['reward_amount'], $rules + ['qualified_invitees' => $qualified]);
+            } elseif ((int)$activity->type === Rules::WHEEL) {
+                // A reserved, earned reward remains payable after the campaign is unpublished.
+                $round = $this->round($account);
+                if (!$reward || !$round || $round->round_no !== $roundNo || $round->status !== 'reached') $this->invalid('本轮尚未达到666');
+            } else $this->invalid('首充奖励由充值成功后自动发放');
+            $reward = $this->pay($reward, $member, $wallet);
+            if ((int)$activity->type === Rules::WHEEL) {
+                $flags = array_intersect_key($this->json($round->completed_tasks), array_flip(Rules::DAILY));
+                DB::table('promotion_rounds')->where('id', $round->id)->update(['status' => 'claimed', 'claimed_at' => PromotionFacts::now(), 'updated_at' => PromotionFacts::now()]);
+                ++$account->round_no;
+                DB::table('promotion_accounts')->where('id', $account->id)->update(['round_no' => $account->round_no, 'updated_at' => PromotionFacts::now()]);
+                $this->newRound($account, PromotionFacts::now(), $flags, true);
+            }
+            return ['reward' => (array)$reward, 'round_no' => $account->round_no, 'spins' => $account->spins, 'replayed' => false];
+        });
+    }
+
+    public function onDeposit(BalanceLog $deposit): void
+    {
+        if (!DB::connection()->getSchemaBuilder()->hasTable('promotion_first_deposits') || (int)$deposit->type !== 1 || !in_array($deposit->change_type, ['充值', '三方充值'], true) || bccomp((string)$deposit->amount, '0', 10) <= 0) return;
+        $member = User::where('member_id', $deposit->member_id)->first();
+        if (!$member || (int)$member->from === 2) return;
+        DB::transaction(function () use ($member, $deposit) {
+            $wallet = Wallet::where('member_id', $member->member_id)->lockForUpdate()->first();
+            if (!$wallet) $this->invalid('会员钱包不存在');
+            $first = DB::table('balance_logs')->where('member_id', $member->member_id)->where('type', 1)->where('amount', '>', 0)
+                ->whereIn('change_type', ['充值', '三方充值'])->orderBy('created_at')->orderBy('id')->value('id');
+            if ((int)$first !== (int)$deposit->id) return;
+            $inserted = DB::table('promotion_first_deposits')->insertOrIgnore(['user_id' => $member->id, 'deposit_log_id' => $deposit->id,
+                'created_at' => PromotionFacts::now(), 'updated_at' => PromotionFacts::now()]);
+            if (!$inserted) return;
+            $activities = PromotionActivity::where('type', Rules::FIRST_DEPOSIT)->where('status', 1)->where('start_time', '<=', now()->timestamp)
+                ->where('end_time', '>=', now()->timestamp)->orderByDesc('sort')->orderByDesc('id')->get();
+            foreach ($activities as $candidate) {
+                $activity = $this->activity($candidate->id, true);
+                if (!PromotionTranslations::select($activity->toArray(), $member->language ?: 'zh')) continue;
+                if ((int)$activity->status !== 1 || (int)$activity->type !== Rules::FIRST_DEPOSIT) continue;
+                $published = DB::table('promotion_campaigns')->where('activity_id', $activity->id)->value('first_published_at');
+                $creditedAt = (string)$deposit->getRawOriginal('created_at');
+                if (!$published || $creditedAt < $published || strtotime($creditedAt) < (int)$activity->start_time || strtotime($creditedAt) > (int)$activity->end_time) continue;
+                if (!ActivityEligibilityService::evaluate($activity, ActivityEligibilityService::memberContext($member->member_id))['is_eligible']) continue;
+                $rules = $this->rules($activity);
+                if (bccomp((string)$deposit->amount, $rules['min_deposit'], 10) < 0) continue;
+                try { $this->capacity($activity, $member, $rules['reward_amount']); } catch (ValidationException $e) { continue; }
+                $account = $this->account($activity, $member);
+                $reward = $this->reserve($activity, $member, $account, 1, $rules['reward_amount'], $rules + ['deposit_log_id' => $deposit->id]);
+                $this->pay($reward, $member, $wallet); break;
+            }
+        }, 3);
+    }
+
+    private function state(PromotionActivity $activity, User $member, ?object $account, ?object $round, ?string $lang = null): array
+    {
+        $rules = $this->rules($activity); $type = (int)$activity->type;
+        $text = PromotionTranslations::select($activity->toArray(), $lang ?? ($member->language ?: 'zh'));
+        if (!$text) $this->invalid('活动未配置所选语言');
+        $result = ['activity_id' => $activity->id, 'type' => $type, 'rules' => $rules, 'account_id' => $account->id ?? null,
+            'round_no' => $account->round_no ?? 1, 'spins' => $account->spins ?? 0, 'server_time' => PromotionFacts::now()] + $text;
+        if ($type === Rules::WHEEL) {
+            $flags = $round ? $this->json($round->completed_tasks) : [];
+            $result += ['coins' => $this->money($round->coins_cents ?? 0), 'remaining' => $this->money(66600 - ($round->coins_cents ?? 0)),
+                'status' => $round->status ?? 'not_started', 'completed_tasks' => $flags,
+                'conditions_complete' => count(array_filter(array_intersect_key($flags, array_flip(Rules::TASKS)))) === 7,
+                'prizes' => Rules::PRIZES, 'valid_member_policy' => $this->facts->policy(),
+                'ordinary_pool_rule' => '固定奖项仅在累计后小于666时参与普通奖池;普通奖池可用奖项等概率,浮动奖项按分等概率取值。'];
+            $today = substr(PromotionFacts::now(), 0, 10);
+            $result['today_granted_tasks'] = $account ? DB::table('promotion_task_grants')->where('account_id', $account->id)
+                ->where(function ($q) use ($today, $account) { $q->where('event_key', $today)->orWhere('event_key', $account->round_no . ':' . $today); })->pluck('task')->all() : [];
+            $result['tasks'] = $this->wheelToday($activity, $member, $account, $round, $flags, $result['today_granted_tasks']);
+        } elseif ($type === Rules::LOSS) {
+            $start = DB::table('promotion_campaigns')->where('activity_id', $activity->id)->value('first_published_at');
+            $profit = $this->facts->betting([$member->id], $start)['profit'];
+            $result += ['loss' => bccomp($profit, '0', 10) < 0 ? bcsub('0', $profit, 2) : '0.00', 'applied_at' => $account->applied_at ?? null,
+                'qualified_invitees' => $account ? count($this->lossInvitees($member, $account)) : 0, 'valid_member_policy' => $this->facts->policy()];
+        } else {
+            $result['has_deposited'] = DB::table('balance_logs')->where('member_id', $member->member_id)->where('type', 1)->where('amount', '>', 0)->whereIn('change_type', ['充值', '三方充值'])->exists();
+        }
+        $result['rewards'] = $account ? DB::table('promotion_rewards')->where('account_id', $account->id)->orderByDesc('round_no')->limit(10)->get()->map(fn ($r) => (array)$r)->all() : [];
+        if ($type === Rules::LOSS) {
+            if ($account?->applied_at) $result['rules'] = $this->json($account->application_snapshot)['rules'];
+            $result['status'] = $result['rewards'] ? 'claimed' : (!$account?->applied_at ? 'not_applied' : ($result['qualified_invitees'] >= $result['rules']['invite_count'] ? 'ready' : 'collecting'));
+        }
+        return $result;
+    }
+
+    private function wheelToday(PromotionActivity $activity, User $member, ?object $account, ?object $round, array $flags, array $granted): array
+    {
+        $rules = $this->rules($activity); $today = substr(PromotionFacts::now(), 0, 10);
+        $published = DB::table('promotion_campaigns')->where('activity_id', $activity->id)->value('first_published_at');
+        $dailyFrom = max($published, $today . ' 00:00:00'); $from = max($round->started_at ?? $published, $dailyFrom);
+        $cursors = $round ? $this->json($round->bet_cursors) : []; $cursor = (int)($round->deposit_cursor ?? 0);
+        $online = $this->facts->online($member->id, $dailyFrom); $logins = $this->facts->logins($member->member_id, $dailyFrom);
+        $values = ['online' => $online[$today] ?? 0, 'login' => isset($logins[$today]) ? 1 : 0,
+            'invite' => $account ? DB::table('promotion_task_grants')->where('account_id', $account->id)->where('round_no', $account->round_no)->where('task', 'invite')->count() : 0,
+            'deposit' => $this->facts->total($this->facts->deposits([$member->member_id], $from, null, $cursor)),
+            'team_deposit' => '0', 'bet' => $this->facts->total($this->facts->betting([$member->id], $from, null, $cursors)['effective']), 'team_bet' => '0'];
+        foreach ($this->facts->children($member->id) as $child) {
+            $childFrom = max($from, $child['bound_at']);
+            $values['team_deposit'] = bcadd($values['team_deposit'], $this->facts->total($this->facts->deposits([$child['member_id']], $childFrom, null, $cursor)), 10);
+            $values['team_bet'] = bcadd($values['team_bet'], $this->facts->total($this->facts->betting([$child['id']], $childFrom, null, $cursors)['effective']), 10);
+        }
+        $tasks = [];
+        foreach (Rules::TASKS as $key) {
+            $target = match ($key) { 'online' => $rules['online_seconds'], 'login', 'invite' => 1, default => $rules[$key . '_amount'] };
+            $tasks[] = ['key' => $key, 'round_completed' => (bool)($flags[$key] ?? false), 'value' => $values[$key], 'target' => $target,
+                'today_rewarded' => in_array($key, $granted, true), 'reward_spins' => 1,
+                'remaining_seconds' => $key === 'online' ? max(0, $target - $values[$key]) : null];
+        }
+        return $tasks;
+    }
+
+    private function spinRow(object $row): array
+    {
+        return ['id' => $row->id, 'round_no' => $row->round_no, 'prize_index' => $row->prize_index, 'prize' => Rules::PRIZES[$row->prize_index],
+            'amount' => $this->money($row->amount_cents), 'coins' => $this->money($row->coins_cents), 'reached' => (int)$row->coins_cents === 66600,
+            'conditions_complete' => (bool)$row->conditions_complete, 'probability' => (string)$row->probability, 'created_at' => $row->created_at];
+    }
+
+    public function records(int $activityId, int $userId, int $page = 1, int $limit = 20): array
+    {
+        $this->activity($activityId); $this->member($userId);
+        $account = DB::table('promotion_accounts')->where('activity_id', $activityId)->where('user_id', $userId)->first();
+        if (!$account) return ['total' => 0, 'data' => []];
+        $q = DB::table('promotion_spins')->where('account_id', $account->id);
+        return ['total' => $q->count(), 'data' => $q->orderByDesc('id')->forPage($page, $limit)->get()->map(fn ($r) => $this->spinRow($r))->all()];
+    }
+
+    public function broadcasts(int $activityId): array
+    {
+        $activity = $this->activity($activityId);
+        if ((int)$activity->type !== Rules::WHEEL) $this->invalid('该活动不是转盘活动');
+        $q = DB::table('promotion_spins as s')->join('promotion_accounts as a', 'a.id', '=', 's.account_id')->join('users as u', 'u.id', '=', 'a.user_id')
+            ->where('a.activity_id', $activityId);
+        $map = fn ($r) => ['account' => substr((string)$r->member_id, 0, strlen((string)$r->member_id) >= 10 ? 4 : 1) . '****' . substr((string)$r->member_id, -1),
+            'amount' => $this->money($r->amount_cents), 'created_at' => $r->created_at];
+        return ['ordinary' => (clone $q)->orderByDesc('s.id')->limit(50)->get(['u.member_id', 's.amount_cents', 's.created_at'])->map($map)->all(),
+            'reached_666' => $q->where('s.coins_cents', 66600)->orderByDesc('s.id')->limit(50)->get(['u.member_id', 's.amount_cents', 's.created_at'])->map(fn ($r) => array_replace($map($r), ['amount' => '666.00']))->all()];
+    }
+
+    public function rewards(int $activityId, int $userId, int $page = 1, int $limit = 20): array
+    {
+        $this->activity($activityId); $this->member($userId);
+        $q = DB::table('promotion_rewards')->where('activity_id', $activityId)->where('user_id', $userId);
+        return ['total' => $q->count(), 'data' => $q->orderByDesc('id')->forPage($page, $limit)->get()];
+    }
+
+    public function unlock(int $userId): int
+    {
+        return DB::transaction(function () use ($userId) {
+            $member = $this->member($userId); $wallet = Wallet::where('member_id', $member->member_id)->lockForUpdate()->first();
+            if (!$wallet) return 0;
+            $count = 0;
+            foreach (DB::table('promotion_rewards')->where('user_id', $userId)->where('status', 'locked')->orderBy('id')->lockForUpdate()->get() as $reward) {
+                $snapshot = $this->json($reward->flow_snapshot);
+                $effective = $this->facts->total($this->facts->betting([$userId], $snapshot['from'], null, $snapshot['cursors'])['effective']);
+                if (bccomp($effective, (string)$reward->required_flow, 10) < 0) continue;
+                if (bccomp((string)$wallet->frozen_balance, (string)$reward->amount, 10) < 0) $this->invalid('冻结奖励余额不足');
+                $beforeFrozen = (string)$wallet->frozen_balance; $before = (string)$wallet->available_balance;
+                $wallet->frozen_balance = bcsub($beforeFrozen, (string)$reward->amount, 10); $wallet->available_balance = bcadd($before, (string)$reward->amount, 10); $wallet->save();
+                BalanceLog::create(['member_id' => $member->member_id, 'type' => 2, 'change_type' => '流水解冻', 'amount' => bcsub('0', (string)$reward->amount, 2),
+                    'before_balance' => $beforeFrozen, 'after_balance' => $wallet->frozen_balance, 'related_id' => $reward->id, 'remark' => '新活动奖励解冻']);
+                $log = BalanceLog::create(['member_id' => $member->member_id, 'type' => 1, 'change_type' => '流水解冻', 'amount' => $reward->amount,
+                    'before_balance' => $before, 'after_balance' => $wallet->available_balance, 'related_id' => $reward->id, 'remark' => '新活动奖励解冻']);
+                DB::table('balance_logs')->where('id', $reward->credited_log_id)->update(['frozen_status' => 2]);
+                DB::table('promotion_rewards')->where('id', $reward->id)->update(['status' => 'paid', 'unlocked_log_id' => $log->id, 'unlocked_at' => PromotionFacts::now(), 'updated_at' => PromotionFacts::now()]); ++$count;
+            }
+            return $count;
+        }, 3);
+    }
+}

+ 8 - 1
app/Services/PublicService.php

@@ -6,6 +6,8 @@ use App\Constants\HttpStatus;
 use App\Models\User;
 use App\Models\User;
 use Exception;
 use Exception;
 use Illuminate\Support\Facades\App;
 use Illuminate\Support\Facades\App;
+use Illuminate\Support\Facades\Schema;
+use App\Services\Sharing\ShareRelations;
 
 
 class PublicService extends BaseService
 class PublicService extends BaseService
 {
 {
@@ -48,6 +50,7 @@ class PublicService extends BaseService
     {
     {
 
 
         $user = User::where('member_id', $chatId)->first();
         $user = User::where('member_id', $chatId)->first();
+        $newMember = !$user;
         if (!$user) {
         if (!$user) {
             $user = new User();
             $user = new User();
             $user->from = -1;
             $user->from = -1;
@@ -63,6 +66,10 @@ class PublicService extends BaseService
 
 
         //给每个用户生成一个专属的钱包
         //给每个用户生成一个专属的钱包
         WalletService::getUserWallet($chatId);
         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;
         return $user;
     }
     }
@@ -72,4 +79,4 @@ class PublicService extends BaseService
         // TODO: Implement getWhere() method.
         // TODO: Implement getWhere() method.
         return [];
         return [];
     }
     }
-}
+}

+ 6 - 21
app/Services/RebateService.php

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

+ 18 - 8
app/Services/RechargeService.php

@@ -485,17 +485,12 @@ class RechargeService extends BaseService
 
 
                 if ($data['confirmations'] >= TronHelper::CONFIRMED_NUMBER) {
                 if ($data['confirmations'] >= TronHelper::CONFIRMED_NUMBER) {
                     $data['status'] = static::$MODEL::STATUS_SUCCESS;
                     $data['status'] = static::$MODEL::STATUS_SUCCESS;
-                    $where = self::getWhere(['txid' => $txid, 'status' => static::$MODEL::STATUS_STAY]);
-                    $recharge = static::$MODEL::where($where)->update($data);
-
-                    $amount = floatval($result['amount']);
-                    $rate_amount = bcmul($amount, $rate, 10); // 汇率转换后分数
+                    $amount = (string)$result['amount'];
+                    $rate_amount = bcmul($amount, (string)$rate, 10);
+                    $recharge = self::creditConfirmedRecharge($info, $data, $rate_amount);
 
 
                     // 更新成功,变动可用余额
                     // 更新成功,变动可用余额
                     if ($recharge) {
                     if ($recharge) {
-                        $balanceData = WalletService::updateBalance($info->member_id, $rate_amount);
-                        BalanceLogService::addLog($info->member_id, $rate_amount, $balanceData['before_balance'], $balanceData['after_balance'], '充值', $info->id, '');
-
                         CollectService::createCollect($result['to_address'], $info->coin, $info->net);
                         CollectService::createCollect($result['to_address'], $info->coin, $info->net);
 
 
                         $text = "充值结果通知\n";
                         $text = "充值结果通知\n";
@@ -514,6 +509,21 @@ class RechargeService extends BaseService
 
 
     }
     }
 
 
+    /** Called only after the chain transfer has been verified. Deposit, log and first-deposit award commit together. */
+    public static function creditConfirmedRecharge(Recharge $info, array $confirmed, string $amount): bool
+    {
+        return DB::transaction(function () use ($info, $confirmed, $amount) {
+            $wallet = \App\Models\Wallet::where('member_id', $info->member_id)->lockForUpdate()->first();
+            if (!$wallet) throw new \RuntimeException('充值钱包不存在');
+            $updated = static::$MODEL::where('id', $info->id)->where('status', Recharge::STATUS_STAY)->update($confirmed);
+            if (!$updated) return false;
+            $before = (string)$wallet->available_balance;
+            $wallet->available_balance = bcadd($before, $amount, 10); $wallet->save();
+            BalanceLogService::addLog($info->member_id, $amount, $before, $wallet->available_balance, '充值', $info->id, '');
+            return true;
+        }, 3);
+    }
+
     private static function ignoreInvalidAutoRecharge(Recharge $info, string $reason, array $context = []): void
     private static function ignoreInvalidAutoRecharge(Recharge $info, string $reason, array $context = []): void
     {
     {
         if (intval($info->type) !== static::$MODEL::TYPE_AUTO || intval($info->status) !== static::$MODEL::STATUS_STAY) {
         if (intval($info->type) !== static::$MODEL::TYPE_AUTO || intval($info->status) !== static::$MODEL::STATUS_STAY) {

+ 22 - 0
app/Services/Reporting/ReportActivity.php

@@ -0,0 +1,22 @@
+<?php
+
+namespace App\Services\Reporting;
+
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Log;
+use Illuminate\Support\Facades\Schema;
+
+class ReportActivity
+{
+    public static function telegram($user): void
+    {
+        if (request()->path() !== 'api/onMessage' || (int) ($user->from ?? 0) === 2) return;
+        try {
+            if (!Schema::hasTable('reporting_activity')) return;
+            $now = now('Asia/Shanghai')->format('Y-m-d H:i:00');
+            $parent = Schema::hasTable('share_members') ? DB::table('share_members')->where('user_id', $user->id)->where('bound_at', '<=', $now)->value('parent_user_id') : null;
+            DB::table('reporting_activity')->insertOrIgnore(['user_id' => $user->id, 'agent_id' => $user->agent_id, 'inviter_id' => $parent,
+                'client_platform' => 'unknown', 'session_hash' => hash('sha256', 'telegram:' . $user->id), 'observed_at' => $now]);
+        } catch (\Throwable $e) { Log::error('reporting_telegram_activity_failed', ['exception' => get_class($e)]); }
+    }
+}

+ 137 - 0
app/Services/Reporting/ReportAdmin.php

@@ -0,0 +1,137 @@
+<?php
+
+namespace App\Services\Reporting;
+
+use App\Services\OperationAuditService;
+use App\Services\Sharing\ShareMemberQualification;
+use App\Support\MaintenancePolicy;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Validator;
+use Illuminate\Validation\Rule;
+use Illuminate\Validation\ValidationException;
+
+class ReportAdmin
+{
+    public function options(): array
+    {
+        return ['report_types' => ['business' => '营业报表', 'promotion' => '推广报表', 'sharing' => '分享赚钱'],
+            'categories' => ['all' => '所有订单'] + MaintenancePolicy::CATEGORIES,
+            'platforms' => ['all', 'H5', 'PC', 'APP', 'unknown'], 'settlement_statuses' => ['all', 'pending', 'settled'],
+            'ranking_metrics' => ['deposit_amount', 'bet_amount', 'payout_amount', 'withdraw_amount', 'activity_minutes'],
+            'timezone' => 'Asia/Shanghai', 'currency' => (new ReportSync())->currency(), 'max_days' => 366,
+            'rules' => ['profit' => '已结算游戏毛利-实发优惠-代理佣金-分享佣金-实际平台手续费',
+                'net_cash_flow' => '成功收款-实际对外打款-实际平台手续费', 'valid_member' => '范围内期间非撤销投注会员,按查询结束日已生效的分享赚钱规则,累计成功存款及累计已结算非撤销同币种有效投注均大于或等于对应门槛;累计包含查询开始日前记录,不受装置及游戏分类筛选影响',
+                'online_rate' => '期间活跃会员/范围内期末真实会员', 'public_costs' => '公共资金和成本只在所有订单展示,未知值为null']];
+    }
+
+    public function settings(?array $input = null): array
+    {
+        if ($input !== null) {
+            throw ValidationException::withMessages(['valid_deposit_min' => '报表有效会员条件统一使用分享赚钱配置,请通过 /admin/shareEarning/settings/save 修改;报表设置仅供只读兼容']);
+        }
+        $settings = (array) DB::table('reporting_settings')->where('id', 1)->first();
+        $policy = (new ShareMemberQualification())->read(now('Asia/Shanghai')->toDateString(), false)['policy_meta'];
+        return array_replace($settings, ['valid_deposit_min' => $policy['min_deposit'], 'valid_effective_bet_min' => $policy['min_effective_bet'],
+            'valid_member_policy' => $policy]);
+    }
+
+    public function reconciliations(array $input): array
+    {
+        $p = Validator::make($input, ['page' => ['integer', 'min:1', 'max:1000000'], 'limit' => ['integer', 'min:1', 'max:100'],
+            'status' => ['nullable', Rule::in(['all', 'pending', 'verified'])], 'kind' => ['nullable', Rule::in(['deposit', 'withdraw', 'agent_withdraw'])]])->validate();
+        $query = DB::table('reporting_events as e')->leftJoin('reporting_reconciliations as r', 'r.event_id', '=', 'e.id')->whereIn('e.kind', ['deposit', 'withdraw', 'agent_withdraw']);
+        if (($p['status'] ?? 'all') === 'pending') $query->whereNull('r.id');
+        if (($p['status'] ?? 'all') === 'verified') $query->whereNotNull('r.id');
+        if (!empty($p['kind'])) $query->where('e.kind', $p['kind']);
+        $total = (clone $query)->count();
+        return ['total' => $total, 'data' => $query->select('e.*', 'r.actual_amount', 'r.external_fee', 'r.occurred_at as verified_at', 'r.evidence')
+            ->orderByDesc('e.id')->forPage((int) ($p['page'] ?? 1), (int) ($p['limit'] ?? 20))->get()->map(fn ($r) => (array) $r)->all()];
+    }
+
+    public function reconcile(array $input): array
+    {
+        $rules = ['event_id' => ['required', 'integer', 'min:1'], 'actual_amount' => ['required', 'regex:/^\d{1,14}(?:\.\d{1,10})?$/D'],
+            'external_fee' => ['required', 'regex:/^\d{1,14}(?:\.\d{1,10})?$/D'], 'occurred_at' => ['required', 'date_format:Y-m-d H:i:s', 'before_or_equal:' . now('Asia/Shanghai')->toDateTimeString()],
+            'currency' => ['required', Rule::in([(new ReportSync())->currency()])], 'evidence' => ['required', 'string', 'max:1000']] + $this->attributionRules();
+        $data = Validator::make($input, $rules)->validate();
+        if (trim($data['evidence']) === '') throw ValidationException::withMessages(['evidence' => '请填写凭据或核对依据']);
+        return DB::transaction(function () use ($data) {
+            $event = DB::table('reporting_events')->where('id', $data['event_id'])->lockForUpdate()->first();
+            if (!$event || !in_array($event->kind, ['deposit', 'withdraw', 'agent_withdraw'], true)) throw ValidationException::withMessages(['event_id' => '只能核对真实收付款记录']);
+            $before = (array) DB::table('reporting_reconciliations')->where('event_id', $event->id)->first();
+            $values = array_diff_key($data, array_flip(['agent_id', 'inviter_id', 'client_platform'])) + ['operator_id' => (int) (request()->user->id ?? 0), 'updated_at' => now()->toDateTimeString()];
+            if (!$before) $values['created_at'] = now()->toDateTimeString();
+            DB::table('reporting_reconciliations')->updateOrInsert(['event_id' => $event->id], $values);
+            $attribution = $this->verifyAttribution($event->source, $event->source_id, (array) $event, $data, $event->candidate_at);
+            if ($attribution) DB::table('reporting_events')->where('id', $event->id)->update($attribution);
+            $after = (array) DB::table('reporting_reconciliations')->where('event_id', $event->id)->first();
+            OperationAuditService::record('reporting_payment', (int) $event->id, $before ? 'update' : 'create', $before + ['event_attribution' => (array) $event], $after + ['event_attribution' => $attribution]);
+            return $after;
+        });
+    }
+
+    public function reconcileOrder(array $input): array
+    {
+        $data = Validator::make($input, ['id' => ['required', 'integer', 'min:1'],
+            'settled_at' => ['sometimes', 'required', 'date_format:Y-m-d H:i:s', 'before_or_equal:' . now('Asia/Shanghai')->toDateTimeString()],
+            'evidence' => ['required', 'string', 'max:1000']] + $this->attributionRules())->validate();
+        if (trim($data['evidence']) === '') throw ValidationException::withMessages(['evidence' => '请填写核对依据']);
+        return DB::transaction(function () use ($data) {
+            $order = DB::table('reporting_orders')->where('id', $data['id'])->lockForUpdate()->first();
+            if (!$order || (isset($data['settled_at']) && ((!$order->settled && !$order->voided) || $data['settled_at'] < $order->bet_at))) {
+                throw ValidationException::withMessages(['id' => '已结算或撤销订单才能补录有效的结算时间']);
+            }
+            $before = (array) $order;
+            $attribution = $this->verifyAttribution($order->parent_source, $order->parent_id, (array) $order, $data, $order->bet_at);
+            if (!isset($data['settled_at']) && !$attribution) throw ValidationException::withMessages(['id' => '请提供核对时间或归属信息']);
+            if ($attribution) DB::table('reporting_orders')->where('parent_source', $order->parent_source)->where('parent_id', $order->parent_id)->update($attribution);
+            if (isset($data['settled_at'])) {
+                $context = DB::table('reporting_contexts')->where('source', $order->parent_source)->where('source_id', $order->parent_id)->first();
+                if (!$context) DB::table('reporting_contexts')->insert(['source' => $order->parent_source, 'source_id' => $order->parent_id,
+                    'user_id' => $order->user_id, 'agent_id' => $order->agent_id, 'inviter_id' => $order->inviter_id,
+                    'client_platform' => $order->client_platform, 'attribution_quality' => $order->attribution_quality,
+                    'settlement_verified' => true, 'occurred_at' => $order->bet_at, 'succeeded_at' => $data['settled_at']]);
+                else DB::table('reporting_contexts')->where('id', $context->id)->update(['succeeded_at' => $data['settled_at'], 'settlement_verified' => true]);
+                DB::table('reporting_orders')->where('parent_source', $order->parent_source)->where('parent_id', $order->parent_id)
+                    ->where(function ($q) { $q->where('settled', 1)->orWhere('voided', 1); })
+                    ->update(['settled_at' => $data['settled_at'], 'time_quality' => 'confirmed', 'updated_at' => now()]);
+            }
+            OperationAuditService::record('reporting_order_time', (int) $order->id, 'update', $before, $data);
+            return (array) DB::table('reporting_orders')->where('id', $order->id)->first();
+        });
+    }
+
+    public function quality(array $input): array
+    {
+        $p = Validator::make($input, ['page' => ['integer', 'min:1', 'max:1000000'], 'limit' => ['integer', 'min:1', 'max:100']])->validate();
+        return ['total' => DB::table('reporting_errors')->count(), 'data' => DB::table('reporting_errors')->orderByDesc('id')
+            ->forPage((int) ($p['page'] ?? 1), (int) ($p['limit'] ?? 20))->get()->map(fn ($r) => (array) $r)->all(),
+            'sources' => DB::table('reporting_sync_state')->orderBy('source')->get()->map(fn ($r) => ['source' => $r->source, 'state' => json_decode($r->state, true), 'updated_at' => $r->updated_at])->all()];
+    }
+
+    private function attributionRules(): array
+    {
+        return ['agent_id' => ['sometimes', 'nullable', 'integer', 'exists:agents,id'], 'inviter_id' => ['sometimes', 'nullable', 'integer', 'exists:users,id'],
+            'client_platform' => ['sometimes', 'required', Rule::in(['H5', 'PC', 'APP', 'unknown'])]];
+    }
+
+    private function verifyAttribution(string $source, string $sourceId, array $row, array $data, string $at): array
+    {
+        $values = array_intersect_key($data, array_flip(['agent_id', 'inviter_id', 'client_platform']));
+        if (!$values) return [];
+        if (empty($row['user_id']) && (array_key_exists('agent_id', $values) || array_key_exists('inviter_id', $values))) {
+            throw ValidationException::withMessages(['agent_id' => '不能改写代理账户实际收付款接收人']);
+        }
+        if (isset($values['inviter_id']) && (int) $values['inviter_id'] === (int) $row['user_id']) {
+            throw ValidationException::withMessages(['inviter_id' => '不能将会员自己设为邀请人']);
+        }
+        $context = (array) DB::table('reporting_contexts')->where('source', $source)->where('source_id', $sourceId)->lockForUpdate()->first();
+        if (!$context) $context = ['source' => $source, 'source_id' => $sourceId, 'user_id' => $row['user_id'],
+            'agent_id' => $row['agent_id'], 'inviter_id' => $row['inviter_id'], 'client_platform' => $row['client_platform'], 'occurred_at' => $at, 'succeeded_at' => null];
+        $context = array_replace($context, $values);
+        $context['attribution_quality'] = array_key_exists('agent_id', $values) ? 'verified' : ($context['attribution_quality'] ?? $row['attribution_quality']);
+        unset($context['id']);
+        DB::table('reporting_contexts')->updateOrInsert(['source' => $source, 'source_id' => $sourceId], $context);
+        return $values + ['attribution_quality' => $context['attribution_quality']];
+    }
+}

+ 64 - 0
app/Services/Reporting/ReportContext.php

@@ -0,0 +1,64 @@
+<?php
+
+namespace App\Services\Reporting;
+
+use Carbon\Carbon;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+
+/** Captures attribution at creation, never retrospectively guesses from the latest login. */
+class ReportContext
+{
+    public const SOURCES = ['bets', 'lhc_order', 'jisu_game_order', 'sport_game_order', 'sport_order', 'recharges', 'withdraws', 'payment_orders', 'balance_logs', 'agent_withdrawals', 'share_claims'];
+
+    public static function captureModel($model): void
+    {
+        $source = $model->getTable();
+        if (!in_array($source, self::SOURCES, true) || !Schema::hasTable('reporting_contexts')) return;
+        $row = $model->getAttributes(); $id = (string) $model->getKey();
+        if ($id === '') return;
+        $now = now('Asia/Shanghai')->format('Y-m-d H:i:s');
+        $identity = self::identity($row, $now, $source);
+        $platform = 'unknown';
+        if ($model->wasRecentlyCreated && !app()->runningInConsole() && (str_starts_with(request()->path(), 'api/') || str_starts_with(request()->path(), 'agent/')) && request()->path() !== 'api/onMessage') {
+            $value = request()->header('Platform', request()->input('platform'));
+            $platform = is_string($value) && in_array(strtoupper($value), ['PC', 'H5', 'APP'], true) ? strtoupper($value) : 'unknown';
+        }
+        DB::table('reporting_contexts')->insertOrIgnore(['source' => $source, 'source_id' => $id,
+            'user_id' => $identity['user_id'], 'agent_id' => $identity['agent_id'], 'inviter_id' => $identity['inviter_id'],
+            'client_platform' => $platform, 'attribution_quality' => $model->wasRecentlyCreated ? 'event_snapshot' : 'historical_current_agent', 'occurred_at' => $now, 'succeeded_at' => null]);
+        $success = ($source === 'withdraws' && (int) ($row['status'] ?? -1) === 1)
+            || ($source === 'payment_orders' && (int) ($row['status'] ?? -1) === 2)
+            || ($source === 'agent_withdrawals' && ($row['status'] ?? '') === 'paid')
+            || ($source === 'lhc_order' && in_array((int) ($row['lottery_status'] ?? 0), [1, 2, 3, 4], true))
+            || (in_array($source, ['sport_game_order', 'jisu_game_order'], true) && in_array((int) ($row['status'] ?? 0), [1, 2, 3], true))
+            || ($source === 'bets' && in_array((int) ($row['status'] ?? 0), [2, 3], true))
+            || ($source === 'sport_order' && ((int) ($row['settlement_status'] ?? 0) === 1 || (int) ($row['status'] ?? 0) === 2));
+        if ($success && ($model->wasRecentlyCreated || $model->wasChanged(['status', 'lottery_status', 'settlement_status']))) {
+            DB::table('reporting_contexts')->where('source', $source)->where('source_id', $id)->whereNull('succeeded_at')->update(['succeeded_at' => $now]);
+        }
+    }
+
+    public static function identity(array $row, string $at, string $source = ''): array
+    {
+        $context = isset($row['id']) ? DB::table('reporting_contexts')->where('source', $source)->where('source_id', (string) $row['id'])->first() : null;
+        if ($context) return ['user_id' => $context->user_id === null ? null : (int) $context->user_id,
+            'agent_id' => $context->agent_id === null ? null : (int) $context->agent_id, 'inviter_id' => $context->inviter_id === null ? null : (int) $context->inviter_id,
+            'client_platform' => $context->client_platform, 'attribution_quality' => $context->attribution_quality];
+        $member = $row['member_id'] ?? ($source === 'sport_order' ? ($row['user_id'] ?? '') : '');
+        $query = DB::table('users');
+        if (!empty($row['report_user_id'])) $query->where('id', $row['report_user_id']);
+        elseif ($member !== '') $query->where('member_id', (string) $member);
+        else return ['user_id' => null, 'agent_id' => $source === 'agent_withdrawals' ? ($row['agent_id'] ?? null) : null,
+            'inviter_id' => null, 'client_platform' => 'unknown', 'attribution_quality' => 'unknown'];
+        $users = $query->limit(2)->get();
+        if ($users->count() !== 1 || (int) ($users[0]->from ?? 0) === 2) throw new \RuntimeException('真实会员身份不明确');
+        $user = (array) $users[0]; $inviter = null;
+        if (Schema::hasTable('share_members')) {
+            $relation = DB::table('share_members')->where('user_id', $user['id'])->where('bound_at', '<=', $at)->first();
+            $inviter = $relation && $relation->parent_user_id !== null ? (int) $relation->parent_user_id : null;
+        }
+        return ['user_id' => (int) $user['id'], 'agent_id' => isset($user['agent_id']) ? (int) $user['agent_id'] : null,
+            'inviter_id' => $inviter, 'client_platform' => 'unknown', 'attribution_quality' => 'historical_current_agent'];
+    }
+}

+ 406 - 0
app/Services/Reporting/ReportReader.php

@@ -0,0 +1,406 @@
+<?php
+
+namespace App\Services\Reporting;
+
+use App\Services\Sharing\EffectiveBet as Money;
+use App\Services\Sharing\ShareMemberQualification;
+use App\Support\MaintenancePolicy;
+use Carbon\Carbon;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+use Illuminate\Support\Facades\Validator;
+use Illuminate\Validation\Rule;
+use Illuminate\Validation\ValidationException;
+
+class ReportReader
+{
+    private const AMOUNTS = ['deposit_amount', 'withdraw_amount', 'member_withdraw_amount', 'agent_withdraw_amount', 'deposit_fee', 'withdraw_fee',
+        'customer_fee_income', 'configured_fee', 'bonus_amount', 'agent_commission', 'share_commission', 'agent_accrual', 'share_accrual', 'manual_credit', 'manual_debit',
+        'bet_amount', 'settled_bet_amount', 'payout_amount', 'refund_amount', 'game_gross_profit'];
+    private const CASH = ['deposit_amount', 'withdraw_amount', 'member_withdraw_amount', 'agent_withdraw_amount', 'deposit_fee', 'withdraw_fee', 'customer_fee_income', 'configured_fee', 'bonus_amount', 'manual_credit', 'manual_debit'];
+
+    public function params(array $input): array
+    {
+        $p = Validator::make($input, ['start_date' => ['nullable', 'date_format:Y-m-d'], 'end_date' => ['nullable', 'date_format:Y-m-d'],
+            'report_type' => ['nullable', Rule::in(['business', 'promotion', 'sharing'])], 'platform' => ['nullable', Rule::in(['all', 'H5', 'PC', 'APP', 'unknown'])],
+            'category' => ['nullable', Rule::in(array_merge(['all'], array_keys(MaintenancePolicy::CATEGORIES)))],
+            'settlement_status' => ['nullable', Rule::in(['all', 'pending', 'settled'])], 'member_account' => ['nullable', 'string', 'max:100'],
+            'agent_account' => ['nullable', 'string', 'max:100'], 'inviter_account' => ['nullable', 'string', 'max:100'],
+            'group_id' => ['nullable', 'string', 'max:40'], 'page' => ['nullable', 'integer', 'min:1', 'max:1000000'], 'limit' => ['nullable', 'integer', 'min:1', 'max:100'],
+            'metric' => ['nullable', Rule::in(['deposit_amount', 'bet_amount', 'payout_amount', 'withdraw_amount', 'activity_minutes'])],
+            'kind' => ['nullable', Rule::in(['deposit', 'withdraw', 'agent_withdraw', 'bonus', 'agent_commission', 'share_commission', 'agent_accrual', 'share_accrual', 'manual_credit', 'manual_debit'])]])->validate();
+        $p = array_filter($p, fn ($v) => $v !== null && $v !== '');
+        $allTime = !isset($p['start_date']) && !isset($p['end_date']);
+        $p += ['report_type' => 'business', 'platform' => 'all', 'category' => 'all', 'settlement_status' => 'all',
+            'start_date' => now('Asia/Shanghai')->toDateString(), 'end_date' => now('Asia/Shanghai')->toDateString(), 'page' => 1, 'limit' => 20, 'metric' => 'deposit_amount'];
+        $p['all_time'] = (int) $allTime;
+        if ($allTime) $p['start_date'] = $this->firstReportDate($p['end_date']);
+        $start = Carbon::parse($p['start_date'], 'Asia/Shanghai'); $end = Carbon::parse($p['end_date'], 'Asia/Shanghai')->addDay();
+        if ($end->lte($start) || (!$allTime && $start->diffInDays($end) > 366) || $p['end_date'] > now('Asia/Shanghai')->toDateString()) throw ValidationException::withMessages(['end_date' => '日期范围必须有效、不超过366天且不能查询未来日期']);
+        $p['start'] = $start->toDateTimeString(); $p['end'] = $end->toDateTimeString(); return $p;
+    }
+
+    private function firstReportDate(string $today): string
+    {
+        $dates = [$today]; $grammar = DB::connection()->getQueryGrammar();
+        $effectiveAt = 'COALESCE(' . $grammar->wrap('r.occurred_at') . ', ' . $grammar->wrap('e.occurred_at') . ')';
+        $earliest = [DB::table('reporting_orders')->min('bet_at'), DB::table('reporting_orders')->min('settled_at'),
+            DB::table('reporting_events as e')->leftJoin('reporting_reconciliations as r', 'r.event_id', '=', 'e.id')
+                ->min(DB::raw($effectiveAt)),
+            DB::table('reporting_activity')->min('observed_at'),
+            DB::table('users')->where(function ($q) { $q->whereNull('from')->orWhere('from', '<>', 2); })->min('created_at')];
+        foreach ($earliest as $at) if ($at !== null) $dates[] = substr((string) $at, 0, 10);
+        return min($dates);
+    }
+
+    public function summary(array $input): array
+    {
+        $p = $this->params($input); $result = $this->compute($p, 'summary');
+        return $result['meta'] + ['summary' => $result['groups']['all'], 'categories' => array_values(array_filter($result['groups'], fn ($k) => $k !== 'all', ARRAY_FILTER_USE_KEY))];
+    }
+
+    public function details(array $input, bool $ranking = false): array
+    {
+        $p = $this->params($input); if ($ranking) $p['report_type'] = 'business';
+        $result = $this->compute($p, 'details'); $rows = array_values($result['groups']);
+        if ($ranking) {
+            $metric = $p['metric']; if ($metric === 'activity_minutes') $rows = array_values(array_filter($rows, fn ($row) => $row[$metric] !== null));
+            usort($rows, fn ($a, $b) => bccomp((string) ($b[$metric] ?? 0), (string) ($a[$metric] ?? 0), 10) ?: strcmp($a['group_id'], $b['group_id']));
+            foreach ($rows as $i => &$row) $row['rank'] = $i + 1; unset($row);
+        } else usort($rows, fn ($a, $b) => bccomp((string) $b['bet_amount'], (string) $a['bet_amount'], 10) ?: strcmp($a['group_id'], $b['group_id']));
+        $total = count($rows); $rows = array_slice($rows, ((int) $p['page'] - 1) * (int) $p['limit'], (int) $p['limit']);
+        return $result['meta'] + ['total' => $total, 'data' => $rows, 'unallocated' => $result['unallocated'] ?? null];
+    }
+
+    public function funds(array $input): array
+    {
+        $p = $this->params($input + ['report_type' => 'business']); $p['report_type'] = 'business'; $p['category'] = 'all'; $p['settlement_status'] = 'all';
+        $result = $this->compute($p, 'daily'); $all = $result['groups']['all']; unset($result['groups']['all']);
+        $days = array_values($result['groups']); usort($days, fn ($a, $b) => strcmp($b['group_id'], $a['group_id']));
+        $keep = array_flip(array_merge(['group_id', 'label', 'net_cash_flow', 'company_profit', 'known_net_cash_flow', 'known_company_profit', 'complete', 'quality'], self::AMOUNTS));
+        $all = array_intersect_key($all, $keep);
+        $days = array_map(fn ($row) => ['date' => $row['group_id']] + array_intersect_key($row, $keep), $days);
+        return $result['meta'] + ['summary' => $all, 'total' => (int) Carbon::parse($p['start'])->diffInDays(Carbon::parse($p['end'])), 'data' => $days];
+    }
+
+    public function orders(array $input): array
+    {
+        $p = $this->params($input); [$users, $agents] = $this->people($p); $query = $this->orderQuery($p);
+        $rows = []; $total = 0;
+        foreach ($query->orderByDesc('bet_at')->orderByDesc('id')->cursor() as $record) {
+            $row = (array) $record;
+            if (!isset($users[$row['user_id']]) || !$this->scope($row, $p, $users, $agents)) continue;
+            if ($p['category'] !== 'all' && $row['category'] !== $p['category']) continue;
+            if (isset($p['group_id']) && $this->group($row, $p) !== $p['group_id']) continue;
+            ++$total;
+            if ($total > ((int) $p['page'] - 1) * (int) $p['limit'] && count($rows) < (int) $p['limit']) $rows[] = $row + ['member_account' => $this->account($users[$row['user_id']])];
+        }
+        return ['total' => $total, 'data' => $rows, 'timezone' => 'Asia/Shanghai', 'filters' => array_diff_key($p, ['start' => 1, 'end' => 1])];
+    }
+
+    public function events(array $input): array
+    {
+        $p = $this->params($input); [$users, $agents] = $this->people($p); $rows = []; $total = 0;
+        $query = $this->eventQuery($p); if (isset($p['kind'])) $query->where('e.kind', $p['kind']);
+        foreach ($query->orderByDesc('e.id')->cursor() as $record) {
+            $row = (array) $record;
+            if (!$this->scope($row, $p, $users, $agents)) continue;
+            if (isset($p['group_id']) && $this->group($row, $p) !== $p['group_id']) continue;
+            if ($p['category'] !== 'all' && $row['category'] !== $p['category']) continue;
+            ++$total;
+            if ($total > ((int) $p['page'] - 1) * (int) $p['limit'] && count($rows) < (int) $p['limit']) $rows[] = $row;
+        }
+        return ['total' => $total, 'data' => $rows, 'timezone' => 'Asia/Shanghai', 'filters' => array_diff_key($p, ['start' => 1, 'end' => 1])];
+    }
+
+    private function compute(array $p, string $mode): array
+    {
+        [$users, $agents] = $this->people($p); $settings = (new ReportAdmin())->settings();
+        $qualification = (new ShareMemberQualification())->read($p['end_date'], $mode !== 'daily'); $policy = $qualification['policy_meta'];
+        $groups = []; $unallocated = null; $now = now('Asia/Shanghai')->toDateTimeString();
+        if ($mode === 'summary') foreach (['all' => '所有订单'] + MaintenancePolicy::CATEGORIES as $key => $label) $groups[$key] = $this->empty($key, $label);
+        if ($mode === 'daily') {
+            $groups['all'] = $this->empty('all', '总计');
+            $offset = ((int) $p['page'] - 1) * (int) $p['limit'];
+            if ($offset < Carbon::parse($p['start'])->diffInDays(Carbon::parse($p['end']))) {
+                $day = Carbon::parse($p['end'])->subDay()->subDays($offset);
+                for ($i = 0; $i < (int) $p['limit'] && $day->toDateTimeString() >= $p['start']; ++$i, $day->subDay()) {
+                    $groups[$day->toDateString()] = $this->empty($day->toDateString(), $day->toDateString());
+                }
+            }
+        }
+        $globalMissingTime = 0;
+        foreach ($this->orderQuery($p)->orderBy('id')->cursor() as $record) {
+            $row = (array) $record;
+            if (!isset($users[$row['user_id']]) || !$this->scope($row, $p, $users, $agents)) continue;
+            if ($p['category'] !== 'all' && $row['category'] !== $p['category']) continue;
+            if (isset($p['group_id']) && $this->group($row, $p) !== $p['group_id']) continue;
+            $keys = $mode === 'summary' ? ['all', $row['category']] : ($mode === 'details' ? [$this->group($row, $p)] : ['all']);
+            if ($mode === 'daily') {
+                if ($this->within($row['bet_at'], $p) && isset($groups[substr($row['bet_at'], 0, 10)])) $keys[] = substr($row['bet_at'], 0, 10);
+                if ($this->within($row['settled_at'], $p) && isset($groups[substr($row['settled_at'], 0, 10)])) $keys[] = substr($row['settled_at'], 0, 10);
+            }
+            foreach (array_unique($keys) as $key) {
+                if ($key === null) continue;
+                if (!isset($groups[$key])) $groups[$key] = $this->empty($key, $this->label($key, $p, $users, $agents));
+                $window = $mode === 'daily' && $key !== 'all' ? ['start' => $key . ' 00:00:00', 'end' => Carbon::parse($key)->addDay()->toDateTimeString()] + $p : $p;
+                $this->addOrder($groups[$key], $row, $window);
+                if ($p['report_type'] === 'promotion' && $row['attribution_quality'] === 'historical_current_agent') ++$groups[$key]['quality']['unverified_attribution'];
+            }
+        }
+        $events = $this->eventQuery($p);
+        foreach ($events->orderBy('e.id')->cursor() as $record) {
+            $row = (array) $record;
+            if (!$this->scope($row, $p, $users, $agents)) continue;
+            if (isset($p['group_id']) && $this->group($row, $p) !== $p['group_id']) continue;
+            if ($row['reconciliation_id'] !== null) { $row['amount'] = $row['verified_amount']; $row['occurred_at'] = $row['verified_at']; }
+            $keys = $mode === 'summary' ? ['all'] : ($mode === 'details' ? [$this->group($row, $p)] : ['all']);
+            if ($mode === 'summary' && $row['category'] !== null) $keys[] = $row['category'];
+            if ($mode === 'daily' && $row['occurred_at'] !== null && isset($groups[substr($row['occurred_at'], 0, 10)])) $keys[] = substr($row['occurred_at'], 0, 10);
+            if ($mode === 'daily' && $row['occurred_at'] === null) ++$globalMissingTime;
+            foreach (array_unique($keys) as $key) {
+                if ($key === null) $key = 'unallocated';
+                if (!isset($groups[$key])) $groups[$key] = $this->empty($key, $this->label($key, $p, $users, $agents));
+                $this->addEvent($groups[$key], $row);
+                if ($p['report_type'] === 'promotion' && $row['user_id'] !== null && $row['attribution_quality'] === 'historical_current_agent') ++$groups[$key]['quality']['unverified_attribution'];
+            }
+        }
+        $activity = $mode === 'daily' ? ['members' => [], 'groups' => []] : $this->activity($p, $users, $agents);
+        foreach ($activity['groups'] as $key => $members) {
+            if ($mode === 'details' && !isset($groups[$key])) $groups[$key] = $this->empty($key, $this->label($key, $p, $users, $agents));
+        }
+        $stateRows = DB::table('reporting_sync_state')->get(); $hasErrors = DB::table('reporting_errors')->where(function ($q) use ($p) { $q->whereNull('candidate_at')->orWhere('candidate_at', '<', $p['end']); })->count();
+        $run = $stateRows->firstWhere('source', '__run__'); $ready = $run && (json_decode($run->state, true)['complete'] ?? false);
+        $syncAt = $run->updated_at ?? null;
+        $scopePopulation = []; $groupPopulation = [];
+        if ($mode !== 'daily') foreach ($users as $uid => $u) {
+            $person = ['user_id' => $u['id'], 'agent_id' => $u['agent_id'] ?? null, 'inviter_id' => $u['inviter_id'] ?? null, 'kind' => 'activity', 'client_platform' => $p['platform']];
+            if (!$this->scope($person, $p, $users, $agents)) continue;
+            if (isset($p['group_id']) && $this->group($person, $p) !== $p['group_id']) continue;
+            $scopePopulation[$uid] = $u;
+            $group = $this->group($person, $p); if ($group !== null) $groupPopulation[$group][$uid] = $u;
+        }
+        foreach ($groups as $key => &$value) {
+            $population = $mode === 'details' ? ($groupPopulation[$key] ?? []) : $scopePopulation;
+            if ($mode === 'details') {
+                $person = $users[$key] ?? [];
+                $value['member_account'] = $p['report_type'] === 'promotion' ? null : ($person ? $this->account($person) : null);
+                $value['nickname'] = $p['report_type'] === 'promotion' ? null : ($person['first_name'] ?? '');
+                $value['agent_account'] = $p['report_type'] === 'promotion' ? ($agents[$key]['username'] ?? null) : ($agents[$person['agent_id'] ?? 0]['username'] ?? null);
+                $parent = $person['inviter_id'] ?? null; $value['inviter_account'] = isset($users[$parent]) ? $this->account($users[$parent]) : null;
+            }
+            $value['member_count'] = count($population); $value['valid_member_count'] = $policy['available'] ? 0 : null; $value['active_member_count'] = 0; $seconds = 0;
+            if ($policy['available']) foreach ($value['_bettors'] as $uid => $_) if (isset($qualification['qualified'][$uid])) ++$value['valid_member_count'];
+            $active = $mode === 'details' ? ($activity['groups'][$key] ?? []) : $activity['members'];
+            $value['active_member_count'] = count($active);
+            foreach ($active as $item) $seconds += $item['seconds'];
+            // Current membership cannot prove a historical population after a reassignment.
+            $value['activity_population_complete'] = !array_diff_key($active, $population);
+            $covered = $p['start'] >= $settings['activity_started_at'];
+            $value['known_activity_minutes'] = intdiv($seconds, 60); $value['activity_minutes'] = $covered ? $value['known_activity_minutes'] : null;
+            $value['active_rate'] = $covered && $value['activity_population_complete'] ? ($value['member_count'] ? bcdiv((string) $value['active_member_count'], (string) $value['member_count'], 6) : '0.000000') : null;
+            $value['order_count'] = count($value['_orders']); unset($value['_orders'], $value['_bettors']);
+            $value['quality']['sync_errors'] = (int) $hasErrors; $value['quality']['not_synchronized'] = !$ready;
+            if ($mode === 'daily') $value['quality']['missing_time'] += $key === 'all' ? 0 : $globalMissingTime;
+            $complete = !array_sum(array_map('intval', $value['quality'])); $value['complete'] = $complete;
+            $value['known_net_cash_flow'] = bcsub(bcsub($value['deposit_amount'], $value['withdraw_amount'], 10), bcadd($value['deposit_fee'], $value['withdraw_fee'], 10), 10);
+            $cost = '0'; foreach (['bonus_amount', 'agent_commission', 'share_commission', 'deposit_fee', 'withdraw_fee'] as $f) $cost = bcadd($cost, $value[$f], 10);
+            $value['known_company_profit'] = bcsub($value['game_gross_profit'], $cost, 10);
+            $value['net_cash_flow'] = $complete ? $value['known_net_cash_flow'] : null;
+            $value['company_profit'] = $complete && $p['settlement_status'] !== 'pending' ? $value['known_company_profit'] : null;
+            if (($mode === 'summary' && $key !== 'all') || $p['category'] !== 'all') {
+                foreach (self::CASH as $f) $value[$f] = null;
+                $value['net_cash_flow'] = $value['company_profit'] = $value['known_net_cash_flow'] = $value['known_company_profit'] = null;
+            }
+            if ($mode === 'daily' && $key !== 'all') { $value['activity_minutes'] = $value['active_rate'] = null; }
+        } unset($value);
+        if (isset($groups['unallocated'])) { $unallocated = $groups['unallocated']; unset($groups['unallocated']); }
+        return ['groups' => $groups, 'unallocated' => $unallocated, 'meta' => ['filters' => array_diff_key($p, ['start' => 1, 'end' => 1]),
+            'timezone' => 'Asia/Shanghai', 'currency' => (new ReportSync())->currency(), 'data_updated_at' => $syncAt,
+            'activity_started_at' => $settings['activity_started_at'], 'valid_deposit_min' => $policy['min_deposit'],
+            'valid_effective_bet_min' => $policy['min_effective_bet'], 'valid_member_policy' => $policy,
+            'commission_balances' => $this->commissionBalances($p, $users, $agents), 'server_time' => $now]];
+    }
+
+    private function commissionBalances(array $p, array $users, array $agents): array
+    {
+        $result = ['date_basis' => 'current_account_balance', 'as_of' => now('Asia/Shanghai')->toDateTimeString(),
+            'agent_unpaid' => null, 'share_unclaimed' => null, 'share_reserved' => null, 'share_debt' => null];
+        if ($p['platform'] !== 'all' || $p['category'] !== 'all') return $result;
+        if ($p['report_type'] !== 'sharing' && Schema::hasTable('agent_rebate_records') && empty($p['member_account']) && empty($p['inviter_account'])) {
+            $result['agent_unpaid'] = Money::zero();
+            foreach (DB::table('agent_rebate_records')->cursor() as $row) {
+                if (!empty($p['agent_account']) && !str_contains($agents[$row->agent_id]['username'] ?? '', $p['agent_account'])) continue;
+                foreach (['flow', 'profit'] as $part) if ($row->{$part . '_status'} === 'pending') $result['agent_unpaid'] = bcadd($result['agent_unpaid'], (string) $row->{$part . '_commission'}, 10);
+            }
+        }
+        if (Schema::hasTable('share_accounts')) {
+            foreach (['share_unclaimed', 'share_reserved', 'share_debt'] as $f) $result[$f] = Money::zero();
+            foreach (DB::table('share_accounts')->cursor() as $row) {
+                if (!isset($users[$row->user_id])) continue; $user = $users[$row->user_id];
+                $scope = ['user_id' => $row->user_id, 'agent_id' => $user['agent_id'] ?? null, 'inviter_id' => $user['inviter_id'] ?? null,
+                    'kind' => 'share_commission', 'client_platform' => 'unknown'];
+                if (!$this->scope($scope, $p, $users, $agents)) continue;
+                $balance = bcsub((string) $row->earned_amount, (string) $row->paid_amount, 10);
+                if (bccomp($balance, '0', 10) >= 0) $result['share_unclaimed'] = bcadd($result['share_unclaimed'], $balance, 10);
+                else $result['share_debt'] = bcsub($result['share_debt'], $balance, 10);
+                $result['share_reserved'] = bcadd($result['share_reserved'], (string) $row->reserved_amount, 10);
+            }
+        }
+        return $result;
+    }
+
+    private function orderQuery(array $p)
+    {
+        $q = DB::table('reporting_orders')->where(function ($q) use ($p) {
+            $q->where(function ($q) use ($p) { $q->where('bet_at', '>=', $p['start'])->where('bet_at', '<', $p['end']); })
+                ->orWhere(function ($q) use ($p) { $q->where('settled_at', '>=', $p['start'])->where('settled_at', '<', $p['end']); })
+                ->orWhere(function ($q) use ($p) { $q->whereNull('settled_at')->where('bet_at', '<', $p['end'])->where(function ($q) { $q->where('settled', 1)->orWhere('voided', 1); }); });
+        });
+        if ($p['settlement_status'] === 'settled') $q->where('settled', 1)->where('voided', 0);
+        if ($p['settlement_status'] === 'pending') $q->where('settled', 0)->where('voided', 0);
+        return $q;
+    }
+
+    private function eventQuery(array $p)
+    {
+        return DB::table('reporting_events as e')->leftJoin('reporting_reconciliations as r', 'r.event_id', '=', 'e.id')
+            ->where(function ($q) use ($p) {
+                $q->where(function ($q) use ($p) { $q->where('e.occurred_at', '>=', $p['start'])->where('e.occurred_at', '<', $p['end'])->whereNull('r.id'); })
+                    ->orWhere(function ($q) use ($p) { $q->where('r.occurred_at', '>=', $p['start'])->where('r.occurred_at', '<', $p['end']); })
+                    ->orWhere(function ($q) use ($p) { $q->whereNull('e.occurred_at')->whereNull('r.id')->where('e.candidate_at', '<', $p['end']); });
+            })->select('e.*', 'r.id as reconciliation_id', 'r.actual_amount as verified_amount', 'r.external_fee', 'r.occurred_at as verified_at');
+    }
+
+    private function people(array $p): array
+    {
+        $agents = []; if (Schema::hasTable('agents')) foreach (DB::table('agents')->get(['id', 'username']) as $r) $agents[$r->id] = (array) $r;
+        $invites = []; if (Schema::hasTable('share_members')) foreach (DB::table('share_members')->where('bound_at', '<', $p['end'])->get(['user_id', 'parent_user_id']) as $r) $invites[$r->user_id] = $r->parent_user_id;
+        $users = []; foreach (DB::table('users')->where(function ($q) { $q->whereNull('from')->orWhere('from', '<>', 2); })->where('created_at', '<', $p['end'])->get() as $r) {
+            $u = (array) $r; $u['inviter_id'] = $invites[$r->id] ?? null; $users[$r->id] = $u;
+        }
+        return [$users, $agents];
+    }
+
+    private function scope(array $row, array $p, array $users, array $agents): bool
+    {
+        $uid = $row['user_id'] ?? null; $agent = $row['agent_id'] ?? null; $inviter = $row['inviter_id'] ?? null;
+        if ($uid !== null && !isset($users[$uid])) return false;
+        if ($p['platform'] !== 'all' && ($row['client_platform'] ?? 'unknown') !== $p['platform']) return false;
+        if ($p['report_type'] === 'promotion' && !$agent) return false;
+        if ($p['report_type'] === 'sharing' && !$inviter && !in_array($row['kind'] ?? '', ['share_commission', 'share_accrual'], true)) return false;
+        if (!empty($p['member_account']) && (!$uid || !str_contains($this->account($users[$uid]), $p['member_account']))) return false;
+        if (!empty($p['agent_account']) && (!$agent || !str_contains($agents[$agent]['username'] ?? '', $p['agent_account']))) return false;
+        if (!empty($p['inviter_account']) && (!$inviter || !str_contains(isset($users[$inviter]) ? $this->account($users[$inviter]) : '', $p['inviter_account']))) return false;
+        return true;
+    }
+
+    private function empty(string $key, string $label): array
+    {
+        return ['group_id' => $key, 'label' => $label] + array_fill_keys(self::AMOUNTS, Money::zero()) + ['_orders' => [], '_bettors' => [],
+            'quality' => ['missing_time' => 0, 'missing_amount' => 0, 'unverified_fees' => 0, 'foreign_currency' => 0, 'unverified_attribution' => 0]];
+    }
+    private function addOrder(array &$value, array $row, array $p): void
+    {
+        if ($row['currency'] !== (new ReportSync())->currency()) { ++$value['quality']['foreign_currency']; return; }
+        if ($this->within($row['bet_at'], $p)) {
+            $this->add($value, 'bet_amount', $row['bet_amount']); $value['_orders'][$row['parent_source'] . ':' . $row['parent_id']] = true;
+            if (!$row['voided'] && bccomp($row['bet_amount'], '0', 10) > 0) $value['_bettors'][$row['user_id']] = true;
+        }
+        if (($row['settled'] || $row['voided']) && $row['settled_at'] === null) { ++$value['quality']['missing_time']; return; }
+        if ($this->within($row['settled_at'], $p)) {
+            if ($row['voided']) $this->add($value, 'refund_amount', $row['refund_amount']);
+            elseif ($row['settled']) {
+                $this->add($value, 'settled_bet_amount', bcsub($row['bet_amount'], $row['refund_amount'], 10)); $this->add($value, 'payout_amount', $row['payout_amount']);
+                $this->add($value, 'refund_amount', $row['refund_amount']);
+                $this->add($value, 'game_gross_profit', bcsub('0', $row['net_profit'], 10));
+            }
+        }
+    }
+    private function addEvent(array &$value, array $row): void
+    {
+        if ($row['currency'] !== (new ReportSync())->currency()) { ++$value['quality']['foreign_currency']; return; }
+        if ($row['occurred_at'] === null) { ++$value['quality']['missing_time']; return; }
+        if ($row['amount'] === null) { ++$value['quality']['missing_amount']; return; }
+        $map = ['deposit' => 'deposit_amount', 'withdraw' => 'withdraw_amount', 'agent_withdraw' => 'withdraw_amount', 'bonus' => 'bonus_amount',
+            'agent_commission' => 'agent_commission', 'share_commission' => 'share_commission', 'agent_accrual' => 'agent_accrual', 'share_accrual' => 'share_accrual', 'manual_credit' => 'manual_credit', 'manual_debit' => 'manual_debit'];
+        $this->add($value, $map[$row['kind']], $row['amount']);
+        if (in_array($row['kind'], ['deposit', 'withdraw', 'agent_withdraw'], true)) {
+            $this->add($value, 'customer_fee_income', $row['customer_fee']);
+            if ($row['kind'] !== 'deposit') $this->add($value, $row['kind'] === 'withdraw' ? 'member_withdraw_amount' : 'agent_withdraw_amount', $row['amount']);
+            if ($row['configured_fee'] !== null) $this->add($value, 'configured_fee', $row['configured_fee']);
+            if ($row['external_fee'] === null) ++$value['quality']['unverified_fees'];
+            else $this->add($value, $row['kind'] === 'deposit' ? 'deposit_fee' : 'withdraw_fee', $row['external_fee']);
+        }
+    }
+    private function activity(array $p, array $users, array $agents): array
+    {
+        $q = DB::table('reporting_activity')->where('observed_at', '>=', Carbon::parse($p['start'])->subMinutes(5)->toDateTimeString())->where('observed_at', '<', $p['end']);
+        if ($p['platform'] !== 'all') $q->where('client_platform', $p['platform']);
+        $result = ['members' => [], 'groups' => []]; $observations = []; $userId = null;
+        foreach ($q->orderBy('user_id')->orderBy('observed_at')->orderBy('id')->cursor() as $row) {
+            if (!isset($users[$row->user_id])) continue;
+            if ($userId !== null && $userId !== $row->user_id) {
+                $this->memberActivity($observations, $p, $users, $agents, $result); $observations = [];
+            }
+            $userId = $row->user_id; $observations[] = (array)$row;
+        }
+        if ($observations) $this->memberActivity($observations, $p, $users, $agents, $result);
+        return $result;
+    }
+
+    private function memberActivity(array $observations, array $p, array $users, array $agents, array &$result): void
+    {
+        $last = []; $intervals = []; $timeline = []; $windowStart = Carbon::parse($p['start'], 'Asia/Shanghai')->timestamp;
+        foreach ($observations as $row) {
+            $time = Carbon::parse($row['observed_at'], 'Asia/Shanghai')->timestamp; $session = $row['session_hash'];
+            if ($time >= $windowStart) $this->addActivity($result, $row, 0, $p, $users, $agents);
+            if (isset($last[$session]) && $time > $last[$session] && $time - $last[$session] <= 300) $intervals[] = [max($last[$session], $windowStart), $time];
+            $last[$session] = $time;
+            // Across devices, the latest observation owns time until the next observation.
+            // If observations share a minute, the last persisted snapshot wins consistently.
+            $timeline[$time] = $row;
+        }
+        usort($intervals, fn ($a, $b) => $a[0] <=> $b[0]); $merged = [];
+        foreach ($intervals as [$start, $end]) {
+            $i = count($merged) - 1;
+            if ($i >= 0 && $start <= $merged[$i][1]) $merged[$i][1] = max($merged[$i][1], $end); else $merged[] = [$start, $end];
+        }
+        $times = array_keys($timeline); $index = 0;
+        foreach ($merged as [$start, $end]) {
+            while (($times[$index + 1] ?? PHP_INT_MAX) <= $start) ++$index;
+            while ($start < $end) {
+                $next = min($end, $times[$index + 1] ?? $end);
+                $this->addActivity($result, $timeline[$times[$index]], $next - $start, $p, $users, $agents);
+                $start = $next;
+                if (isset($times[$index + 1]) && $start === $times[$index + 1]) ++$index;
+            }
+        }
+    }
+
+    private function addActivity(array &$result, array $row, int $seconds, array $p, array $users, array $agents): void
+    {
+        $row['kind'] = 'activity';
+        if (!$this->scope($row, $p, $users, $agents)) return;
+        $group = $this->group($row, $p);
+        if ($group === null || (isset($p['group_id']) && $group !== $p['group_id'])) return;
+        $uid = $row['user_id'];
+        $result['members'][$uid]['seconds'] = ($result['members'][$uid]['seconds'] ?? 0) + $seconds;
+        $result['groups'][$group][$uid]['seconds'] = ($result['groups'][$group][$uid]['seconds'] ?? 0) + $seconds;
+    }
+    private function within(?string $at, array $p): bool { return $at !== null && $at >= $p['start'] && $at < $p['end']; }
+    private function add(array &$value, string $field, string $amount): void { $value[$field] = bcadd($value[$field], $amount, 10); }
+    private function account(array $user): string { return (string) (($user['username'] ?? '') ?: (($user['account'] ?? '') ?: ($user['member_id'] ?? $user['user_id'] ?? $user['id']))); }
+    private function group(array $row, array $p): ?string
+    {
+        if ($p['report_type'] === 'business') return isset($row['user_id']) ? (string) $row['user_id'] : null;
+        if ($p['report_type'] === 'promotion') return isset($row['agent_id']) ? (string) $row['agent_id'] : null;
+        return in_array($row['kind'] ?? '', ['share_commission', 'share_accrual'], true) ? (string) $row['user_id'] : (isset($row['inviter_id']) ? (string) $row['inviter_id'] : null);
+    }
+    private function label(string $id, array $p, array $users, array $agents): string
+    {
+        if ($id === 'unallocated') return '未分配公共资金与成本';
+        if ($p['report_type'] === 'promotion') return (string) ($agents[$id]['username'] ?? $id);
+        return isset($users[$id]) ? $this->account($users[$id]) : $id;
+    }
+}

+ 210 - 0
app/Services/Reporting/ReportSync.php

@@ -0,0 +1,210 @@
+<?php
+
+namespace App\Services\Reporting;
+
+use App\Services\Sharing\BetFactSynchronizer;
+use App\Services\Sharing\EffectiveBet as Money;
+use Carbon\Carbon;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+
+/** Reporting projections only. Never settles orders, moves money or publishes sharing facts. */
+class ReportSync
+{
+    public const ORDER_SOURCES = ['lhc_order', 'sport_game_order', 'sport_order', 'third_game_orders', 'bets', 'jisu_game_order'];
+    public const MONEY_SOURCES = ['balance_logs', 'withdraws', 'payment_orders', 'agent_withdrawals', 'agent_balance_logs', 'share_ledger', 'agent_rebate_records'];
+
+    public function sync(bool $full = false): array
+    {
+        $result = ['scanned' => 0, 'published' => 0, 'errors' => 0, 'sources' => []];
+        foreach (array_merge(self::ORDER_SOURCES, self::MONEY_SOURCES, ['lhc_order_item']) as $source) {
+            if (!Schema::hasTable($source)) { $result['sources'][$source] = 'absent'; continue; }
+            $start = now('Asia/Shanghai')->format('Y-m-d H:i:s');
+            $state = DB::table('reporting_sync_state')->where('source', $source)->first();
+            $cursor = $state ? json_decode($state->state, true, 512, JSON_THROW_ON_ERROR) : null;
+            $query = DB::table($source); $hasUpdate = Schema::hasColumn($source, 'updated_at');
+            $column = $source === 'sport_order' ? 'update_time' : 'updated_at';
+            if (!$full && $cursor && ($hasUpdate || $source === 'sport_order')) {
+                $cut = Carbon::parse($cursor['checked_at'], 'Asia/Shanghai')->subMinutes(5);
+                $value = in_array($source, ['lhc_order', 'lhc_order_item', 'sport_order'], true) ? $cut->timestamp : $cut->format('Y-m-d H:i:s');
+                $query->where(function ($q) use ($column, $value, $cursor) { $q->where($column, '>=', $value)->orWhereNull($column)->orWhere('id', '>', $cursor['last_id']); });
+            } elseif (!$full && $cursor && in_array($source, ['share_ledger', 'agent_balance_logs'], true)) $query->where('id', '>', $cursor['last_id']);
+            $max = (int) DB::table($source)->max('id'); $errors = 0;
+            $query->where('id', '<=', $max)->orderBy('id')->chunkById(200, function ($rows) use ($source, &$result, &$errors) {
+                foreach ($rows as $row) {
+                    ++$result['scanned'];
+                    try {
+                        if ($source === 'lhc_order_item') {
+                            $parents = DB::table('lhc_order')->where('ordernum', $row->ordernum)->limit(2)->get();
+                            if ($parents->count() !== 1) throw new \RuntimeException('六合彩子单对应的父单不明确');
+                            $this->publish('lhc_order', (int) $parents[0]->id);
+                            DB::table('reporting_errors')->where('source', $source)->where('source_id', (string) $row->id)->delete();
+                        } else $this->publish($source, (int) $row->id);
+                        ++$result['published'];
+                    }
+                    catch (\Throwable $e) {
+                        ++$errors; ++$result['errors'];
+                        DB::table('reporting_errors')->updateOrInsert(['source' => $source, 'source_id' => (string) $row->id],
+                            ['candidate_at' => $this->time($row->created_at ?? $row->create_time ?? null), 'reason' => mb_substr($e->getMessage(), 0, 500), 'created_at' => now(), 'updated_at' => now()]);
+                    }
+                }
+            });
+            if (!$errors) DB::table('reporting_sync_state')->updateOrInsert(['source' => $source], ['state' => json_encode(['checked_at' => $start, 'last_id' => $max]), 'updated_at' => $start]);
+            $result['sources'][$source] = $errors ? 'errors' : 'ready';
+        }
+        DB::table('reporting_sync_state')->updateOrInsert(['source' => '__run__'], ['state' => json_encode(['complete' => $result['errors'] === 0, 'sources' => $result['sources']]), 'updated_at' => now()]);
+        return $result;
+    }
+
+    public function publish(string $source, int $id): void
+    {
+        if (!in_array($source, array_merge(self::ORDER_SOURCES, self::MONEY_SOURCES), true)) throw new \InvalidArgumentException('不支持的数据来源');
+        DB::transaction(function () use ($source, $id) {
+            $record = DB::table($source)->where('id', $id)->lockForUpdate()->first();
+            if (!$record) throw new \RuntimeException('原记录不存在');
+            $row = (array) $record;
+            $member = $row['member_id'] ?? null;
+            $virtual = $member !== null && DB::table('users')->where('member_id', (string) $member)->where('from', 2)->exists();
+            if ($virtual) {
+                DB::table('reporting_orders')->where('parent_source', $source)->where('parent_id', (string) $id)->delete();
+                DB::table('reporting_events')->where('source', $source)->where('source_id', (string) $id)->delete();
+                DB::table('reporting_errors')->where('source', $source)->where('source_id', (string) $id)->delete(); return;
+            }
+            if (in_array($source, self::ORDER_SOURCES, true)) $this->orders($source, $row);
+            else $this->money($source, $row);
+            DB::table('reporting_errors')->where('source', $source)->where('source_id', (string) $id)->delete();
+        });
+    }
+
+    private function orders(string $source, array $row): void
+    {
+        $at = $this->time($row['created_at'] ?? $row['create_time'] ?? null);
+        if ((int) ($row['is_faker'] ?? 0) === 1) { DB::table('reporting_orders')->where('parent_source', $source)->where('parent_id', (string) $row['id'])->delete(); return; }
+        $facts = [];
+        if (in_array($source, ['bets', 'jisu_game_order'], true)) {
+            $identity = ReportContext::identity($row, $at ?? now()->toDateTimeString(), $source);
+            if ($identity['user_id'] === null || $at === null) throw new \RuntimeException('旧订单缺少会员或下单时间');
+            $bet = Money::decimal($row['amount']);
+            $settled = $source === 'bets' ? (int) $row['status'] === 2 : in_array((int) $row['status'], [1, 2], true);
+            $void = (int) $row['status'] === 3;
+            $payout = $settled ? Money::decimal($source === 'bets' ? ($row['profit'] ?? 0) : ($row['win_amount'] ?? 0)) : Money::zero();
+            $facts[] = ['source' => $source, 'source_id' => (string) $row['id'], 'user_id' => $identity['user_id'],
+                'category' => 'self:lottery', 'bet_at' => $at, 'bet_amount' => $bet, 'settled' => $settled, 'voided' => $void,
+                'net_profit' => $settled ? bcsub($payout, $bet, 10) : Money::zero(), 'currency' => $this->currency()];
+        } else $facts = (new BetFactSynchronizer())->projectForReporting($source, (object) $row);
+        $keys = [];
+        foreach ($facts as $fact) {
+            $identity = ReportContext::identity($row + ['report_user_id' => $fact['user_id']], $fact['bet_at'], $source);
+            $context = DB::table('reporting_contexts')->where('source', $source)->where('source_id', (string) $row['id'])->first();
+            $settledAt = $context->succeeded_at ?? null;
+            if (!(bool) ($context->settlement_verified ?? false)) {
+                if ($source === 'third_game_orders') $settledAt = $this->time($row['last_update_time'] ?? null);
+                if ($source === 'sport_order') $settledAt = $this->time($row['settlement_time'] ?? null) ?? $this->time($row['return_operation_time'] ?? null) ?? $settledAt;
+            }
+            $settledAt = $fact['settled'] || $fact['voided'] ? $settledAt : null;
+            $payout = $fact['settled'] ? bcadd($fact['bet_amount'], $fact['net_profit'], 10) : Money::zero();
+            $refund = $fact['voided'] ? $fact['bet_amount'] : Money::zero();
+            if ($source === 'sport_order' && $fact['settled']) {
+                $refund = Money::decimal($row['return_amount'] ?? 0);
+                if (bccomp($refund, '0', 10) < 0 || bccomp($refund, $fact['bet_amount'], 10) > 0) throw new \RuntimeException('部分退款金额不正确');
+                $payout = bcsub($payout, $refund, 10);
+            }
+            if (bccomp($payout, '0', 10) < 0) throw new \RuntimeException('派奖金额不能为负');
+            $key = ['source' => $fact['source'], 'source_id' => $fact['source_id']]; $keys[] = $fact['source_id'];
+            $values = $identity + ['parent_source' => $source, 'parent_id' => (string) $row['id'], 'category' => $fact['category'],
+                'currency' => $source === 'third_game_orders' ? $fact['currency'] : $this->currency(), 'bet_at' => $fact['bet_at'], 'settled_at' => $settledAt,
+                'settlement_candidate_at' => $this->time($row['updated_at'] ?? $row['update_time'] ?? null),
+                'bet_amount' => $fact['bet_amount'], 'payout_amount' => $payout, 'net_profit' => $fact['net_profit'],
+                'refund_amount' => $refund, 'settled' => (int) $fact['settled'], 'voided' => (int) $fact['voided'],
+                'time_quality' => (!$fact['settled'] && !$fact['voided']) || $settledAt !== null ? 'confirmed' : 'missing_settlement_time'];
+            $old = DB::table('reporting_orders')->where($key)->first();
+            if (!$old) $values['created_at'] = now(); $values['updated_at'] = now();
+            DB::table('reporting_orders')->updateOrInsert($key, $values);
+        }
+        DB::table('reporting_orders')->where('parent_source', $source)->where('parent_id', (string) $row['id'])->when($keys, fn ($q) => $q->whereNotIn('source_id', $keys))->delete();
+    }
+
+    private function money(string $source, array $row): void
+    {
+        $candidate = $this->time($row['created_at'] ?? null);
+        if ($candidate === null) throw new \RuntimeException('资金记录缺少创建时间');
+        $context = DB::table('reporting_contexts')->where('source', $source)->where('source_id', (string) $row['id'])->first();
+        $at = $context->succeeded_at ?? null; $kind = null; $amount = null; $customerFee = Money::zero(); $configuredFee = null;
+        $category = null; $identityRow = $row;
+        if ($source === 'balance_logs') {
+            $amount = Money::decimal($row['amount']); $type = (string) $row['change_type'];
+            if (bccomp($amount, '0', 10) > 0 && in_array($type, ['充值', '三方充值'], true)) $kind = 'deposit';
+            elseif (bccomp($amount, '0', 10) > 0 && in_array($type, ['注册赠送', '优惠活动', '即充即送', '充值返现', '老用户回归', '比比返', '笔笔返', '返水', '回水', '余额宝利息', '流水解冻'], true) && (int) ($row['type'] ?? 1) === 1) $kind = 'bonus';
+            elseif ($type === '人工充值') $kind = isset($row['related_id']) && (string) $row['related_id'] === '0' ? 'bonus' : 'manual_credit';
+            elseif ($type === '人工扣款') { $kind = 'manual_debit'; $amount = bcsub('0', $amount, 10); }
+            elseif ($type === '分享佣金领取') {
+                $claim = DB::table('share_claims')->where('credited_log_id', $row['id'])->where('status', 'approved')->first();
+                if (!$claim) throw new \RuntimeException('分享领取缺少审核通过的原记录');
+                $kind = 'share_commission';
+            }
+            $at = $candidate;
+            if ($kind === 'deposit' && ($row['related_id'] ?? null)) {
+                $origin = $type === '三方充值' ? 'payment_orders' : 'recharges';
+                $originContext = DB::table('reporting_contexts')->where('source', $origin)->where('source_id', (string) $row['related_id'])->first();
+                if ($originContext && !$context) $context = $originContext;
+                if ($origin === 'payment_orders' && Schema::hasTable($origin)) {
+                    $original = DB::table($origin)->where('id', $row['related_id'])->where('member_id', $row['member_id'])->whereIn('type', [1, 3])->where('status', 2)->first();
+                    if ($original) $configuredFee = $original->fee ?? null;
+                }
+            }
+        } elseif ($source === 'withdraws' && (int) $row['status'] === 1) {
+            $kind = 'withdraw'; $rate = Money::decimal($row['exchange_rate']);
+            if (bccomp($rate, '0', 10) <= 0) throw new \RuntimeException('提现历史汇率无效');
+            $amount = isset($row['to_account']) && is_numeric($row['to_account']) ? bcmul((string) $row['to_account'], $rate, 10) : null;
+            $customerFee = bcmul(Money::decimal($row['service_charge'] ?? 0), $rate, 10);
+        } elseif ($source === 'payment_orders' && (int) $row['status'] === 2 && in_array((int) $row['type'], [2, 4], true)) {
+            $kind = 'withdraw'; $amount = (int) $row['type'] === 2 ? Money::decimal($row['amount']) : null; $configuredFee = $row['fee'] ?? null;
+        } elseif ($source === 'agent_withdrawals' && $row['status'] === 'paid') {
+            $kind = 'agent_withdraw'; $amount = Money::decimal($row['actual_amount']); $customerFee = Money::decimal($row['fee'] ?? 0); $at = $this->time($row['paid_at'] ?? null);
+        } elseif ($source === 'agent_balance_logs' && $row['type'] === 'commission' && $row['reference_type'] === 'agent_rebate_record') {
+            $kind = 'agent_commission'; $amount = Money::decimal($row['amount']); $at = $candidate;
+            $record = DB::table('agent_rebate_records')->where('id', $row['reference_id'])->first();
+            if ($record) $category = $this->agentCategory((string) $record->platform, (int) $record->game_type);
+        } elseif ($source === 'share_ledger') {
+            $kind = 'share_accrual'; $amount = Money::decimal($row['amount']); $at = $row['bet_date'] . ' 00:00:00'; $category = $row['category'];
+            $identityRow['report_user_id'] = $row['user_id'];
+        } elseif ($source === 'agent_rebate_records') {
+            $kind = 'agent_accrual'; $amount = Money::zero(); $at = $row['settlement_date'] . ' 00:00:00';
+            foreach (['flow', 'profit'] as $part) $amount = bcadd($amount, (string) $row[$part . '_commission'], 10);
+            $category = $this->agentCategory((string) $row['platform'], (int) $row['game_type']);
+        }
+        if (!$kind) {
+            $existing = DB::table('reporting_events')->where('source', $source)->where('source_id', (string) $row['id'])->first();
+            $verified = $existing ? DB::table('reporting_reconciliations')->where('event_id', $existing->id)->first() : null;
+            if ($verified) {
+                if (bccomp($verified->actual_amount, '0', 10) !== 0) throw new \RuntimeException('来源状态与已核实的收付款冲突,请重新核对');
+                return; // Confirmed zero payout may still have an actual external fee; preserve its audit and event identity.
+            }
+            DB::table('reporting_events')->where('source', $source)->where('source_id', (string) $row['id'])->delete(); return;
+        }
+        $identity = ReportContext::identity($identityRow, $at ?? $candidate, $source);
+        if ($context) $identity = ['user_id' => $context->user_id, 'agent_id' => $context->agent_id, 'inviter_id' => $context->inviter_id,
+            'client_platform' => $context->client_platform, 'attribution_quality' => $context->attribution_quality];
+        if (in_array($source, ['agent_withdrawals', 'agent_balance_logs', 'agent_rebate_records'], true)) $identity['agent_id'] = (int) $row['agent_id'];
+        if ($identity['user_id'] === null && $identity['agent_id'] === null) throw new \RuntimeException('资金记录没有可识别的归属');
+        $key = ['source' => $source, 'source_id' => (string) $row['id'], 'kind' => $kind];
+        $old = DB::table('reporting_events')->where($key)->first();
+        $values = $identity + ['category' => $category, 'currency' => $this->currency(), 'amount' => $amount,
+            'customer_fee' => $customerFee, 'configured_fee' => $configuredFee, 'occurred_at' => $at, 'candidate_at' => $candidate, 'updated_at' => now()];
+        if (!$old) $values['created_at'] = now();
+        DB::table('reporting_events')->updateOrInsert($key, $values);
+    }
+
+    private function agentCategory(string $platform, int $type): ?string
+    {
+        if ($platform === 'sport') return 'self:sport';
+        if (in_array($platform, ['lhc', 'jisu', 'pc28'], true)) return 'self:lottery';
+        return $type >= 1 && $type <= 7 && $platform !== 'legacy' ? 'third:' . $type : null;
+    }
+    public function currency(): string { return strtoupper((string) config('reporting.currency', 'CNY')); }
+    public function time($value): ?string
+    {
+        if ($value === null || $value === '' || $value === 0 || $value === '0') return null;
+        try { return Money::time($value)->setTimezone('Asia/Shanghai')->format('Y-m-d H:i:s'); } catch (\Throwable $e) { return null; }
+    }
+}

+ 88 - 0
app/Services/RewardGrantService.php

@@ -0,0 +1,88 @@
+<?php
+
+namespace App\Services;
+
+use App\Constants\HttpStatus;
+use App\Models\Backflow;
+use App\Models\Config;
+use App\Models\Rebate;
+use App\Models\Wallet;
+use Carbon\Carbon;
+use Illuminate\Support\Facades\DB;
+use RuntimeException;
+
+/** Shared transaction for manual and automatic payouts. Never trusts a previously loaded reward row. */
+class RewardGrantService
+{
+    public static function grant(string $type, int $id, string $operator, bool $skipPaid = false, ?string $scheduledCutoff = null): bool
+    {
+        if (!in_array($type, RewardGrantSettingsService::TYPES, true)) {
+            throw new RuntimeException('奖励类型错误', HttpStatus::CUSTOM_ERROR);
+        }
+
+        return DB::transaction(function () use ($type, $id, $operator, $skipPaid, $scheduledCutoff) {
+            $model = $type === 'rebate' ? Rebate::class : Backflow::class;
+            $reward = $model::whereKey($id)->lockForUpdate()->first();
+            if (!$reward) throw new RuntimeException('数据不存在', HttpStatus::CUSTOM_ERROR);
+            if ((int) $reward->status !== 0) {
+                if ($skipPaid) return false;
+                throw new RuntimeException('奖励已发放,无需再次发放', HttpStatus::CUSTOM_ERROR);
+            }
+            $cutoff = $type === 'rebate'
+                ? Carbon::now('America/New_York')->format('Y-m-d')
+                : Carbon::now('Asia/Shanghai')->format('Y-m');
+            if ((string) $reward->date >= $cutoff) {
+                throw new RuntimeException('未到发放时间', HttpStatus::CUSTOM_ERROR);
+            }
+            if ($scheduledCutoff !== null && (string) $reward->date >= $scheduledCutoff) return false;
+
+            if ($type === 'rebate') {
+                $ratio = self::configDecimal('rebate');
+                $basis = (string) $reward->effective_betting_amount;
+                if (bccomp($basis, '0', 2) < 0) throw new RuntimeException('有效投注金额异常', HttpStatus::CUSTOM_ERROR);
+                $amount = bcmul($basis, $ratio, 2);
+                $reward->rebate_ratio = $ratio;
+                $reward->audited_by = $operator;
+                $changeType = '笔笔返';
+                $remark = "日期:{$reward->date};有效投注:{$basis};比例:" . bcmul($ratio, '100', 2) . '%';
+            } else {
+                // Preserve the existing monthly formula: abs(recharge + signed withdrawal).
+                $basis = bcadd((string) $reward->recharge_amount, (string) $reward->withdrawal_amount, 2);
+                if (bccomp($basis, '0', 2) < 0) $basis = bcsub('0', $basis, 2);
+                $amount = '0.00';
+                $ratio = (string) $reward->backflow_ratio;
+                if (bccomp($basis, self::configDecimal('huishui_restriction'), 2) >= 0) {
+                    $ratio = self::configDecimal('huishui_percentage');
+                    $reward->backflow_ratio = $ratio;
+                    $amount = bcmul($basis, $ratio, 2);
+                }
+                $changeType = '回水';
+                $remark = "日期:{$reward->date};盈亏:-{$basis};回水比例:" . bcmul($ratio ?: '0', '100', 2) . '%';
+            }
+
+            if (bccomp($amount, '0', 2) > 0) {
+                $wallet = Wallet::where('member_id', $reward->member_id)->lockForUpdate()->first();
+                if (!$wallet) throw new RuntimeException('会员钱包不存在', HttpStatus::CUSTOM_ERROR);
+                $before = (string) $wallet->available_balance;
+                $wallet->available_balance = bcadd($before, $amount, 10);
+                if (!$wallet->save()) throw new RuntimeException('余额更新失败', HttpStatus::CUSTOM_ERROR);
+                BalanceLogService::addLog($reward->member_id, $amount, $before, $wallet->available_balance,
+                    $changeType, $reward->id, $remark);
+            }
+            $reward->amount = $amount;
+            $reward->status = 1;
+            $reward->grant_time = Carbon::now()->timestamp;
+            if (!$reward->save()) throw new RuntimeException('发放失败', HttpStatus::CUSTOM_ERROR);
+            return true;
+        }, 3);
+    }
+
+    private static function configDecimal(string $field): string
+    {
+        $value = trim((string) Config::where('field', $field)->value('val'));
+        if (!preg_match('/^\d+(?:\.\d+)?$/D', $value)) {
+            throw new RuntimeException('奖励比例或门槛配置异常', HttpStatus::CUSTOM_ERROR);
+        }
+        return $value;
+    }
+}

+ 66 - 0
app/Services/RewardGrantSettingsService.php

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

+ 88 - 0
app/Services/ShareCommissionDiagnostics.php

@@ -0,0 +1,88 @@
+<?php
+
+namespace App\Services;
+
+use App\Models\BalanceLog;
+use App\Models\Config;
+use App\Models\ShareCommissionRecord;
+use App\Models\User;
+use Illuminate\Support\Facades\DB;
+
+/** Read-only counts that locate the missing stage; never calculates or grants unpaid rewards. */
+class ShareCommissionDiagnostics
+{
+    public function inspect(?array $range): array
+    {
+        $rebates = BalanceLog::where('change_type', '充值返现')->selectRaw('COUNT(*) AS total')
+            ->selectRaw('COALESCE(SUM(CASE WHEN type = 1 THEN 1 ELSE 0 END), 0) AS type1')
+            ->selectRaw('COALESCE(SUM(CASE WHEN type = 1 AND amount > 0 THEN 1 ELSE 0 END), 0) AS positive')
+            ->selectRaw('MIN(CASE WHEN type = 1 AND amount > 0 THEN created_at END) AS first_rebate_at')
+            ->selectRaw('MAX(CASE WHEN type = 1 AND amount > 0 THEN created_at END) AS last_rebate_at')->first();
+        $selected = BalanceLog::where('change_type', '充值返现')->where('type', 1)->where('amount', '>', 0);
+        if ($range !== null) $selected->whereBetween('created_at', $range);
+
+        $invited = User::query()->from('users as invitee')
+            ->whereNotNull('invitee.agent_user_code')->where('invitee.agent_user_code', '<>', '');
+        $realInvited = (clone $invited)->where('invitee.from', 1);
+        $linked = (clone $realInvited)->where('invitee.agent_user_code', '<>', '0')->whereExists(function ($q) {
+            $q->selectRaw('1')->from('users as promoter')
+                ->whereColumn('promoter.user_code', 'invitee.agent_user_code')
+                ->whereNotNull('promoter.member_id')->where('promoter.member_id', '<>', '')->where('promoter.member_id', '<>', '0');
+        });
+
+        $deposits = BalanceLog::where('type', 1)->where('amount', '>', 0)
+            ->whereIn('change_type', ['充值', '三方充值']);
+        $realDeposits = (clone $deposits)->whereExists(function ($q) {
+            $q->selectRaw('1')->from('users as invitee')
+                ->whereColumn('invitee.member_id', 'balance_logs.member_id')->where('invitee.from', 1)
+                ->whereNotNull('invitee.agent_user_code')->where('invitee.agent_user_code', '<>', '');
+        });
+        $linkedDeposits = (clone $realDeposits)->whereExists(function ($q) {
+            $q->selectRaw('1')->from('users as invitee')
+                ->join('users as promoter', 'promoter.user_code', '=', 'invitee.agent_user_code')
+                ->whereColumn('invitee.member_id', 'balance_logs.member_id')
+                ->where('invitee.from', 1)->whereNotNull('invitee.agent_user_code')->where('invitee.agent_user_code', '<>', '')->where('invitee.agent_user_code', '<>', '0')
+                ->whereNotNull('promoter.member_id')->where('promoter.member_id', '<>', '')->where('promoter.member_id', '<>', '0');
+        });
+        $walletDeposits = (clone $linkedDeposits)->whereExists(function ($q) {
+            $q->selectRaw('1')->from('users as invitee')
+                ->join('users as promoter', 'promoter.user_code', '=', 'invitee.agent_user_code')
+                ->join('wallets as promoter_wallet', 'promoter_wallet.member_id', '=', 'promoter.member_id')
+                ->whereColumn('invitee.member_id', 'balance_logs.member_id')
+                ->where('invitee.from', 1)->whereNotNull('invitee.agent_user_code')->where('invitee.agent_user_code', '<>', '')->where('invitee.agent_user_code', '<>', '0')
+                ->whereNotNull('promoter.member_id')->where('promoter.member_id', '<>', '')->where('promoter.member_id', '<>', '0');
+        });
+        $activeRecords = ShareCommissionRecord::where('status', 1);
+        if ($range !== null) {
+            $activeRecords->whereBetween('stat_date', [substr($range[0], 0, 10), substr($range[1], 0, 10)]);
+        }
+        $rate = Config::where('field', 'recharge_rate')->value('val');
+        $setting = DB::connection()->getSchemaBuilder()->hasTable('share_settings')
+            ? 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,
+            'valid_member_min_deposit' => $setting->valid_member_min_deposit ?? null,
+            'valid_member_min_effective_bet' => $setting->valid_member_min_effective_bet ?? null,
+            'counts' => [
+                'rebate_logs_all' => (int)$rebates->total,
+                'type1_rebate_logs_all' => (int)$rebates->type1,
+                'positive_type1_rebate_logs_all' => (int)$rebates->positive,
+                'selected_rebate_logs' => $selected->count(),
+                'invited_members_all' => $invited->count(),
+                'real_invited_members_all' => $realInvited->count(),
+                'linked_real_invited_members_all' => $linked->count(),
+                'deposit_logs_all' => $deposits->count(),
+                'real_invitee_deposit_logs_all' => $realDeposits->count(),
+                'linked_real_invitee_deposit_logs_all' => $linkedDeposits->count(),
+                'with_promoter_wallet_deposit_logs_all' => $walletDeposits->count(),
+                'commission_records_all' => ShareCommissionRecord::count(),
+                'active_commission_records_selected' => $activeRecords->count(),
+            ],
+            'first_rebate_at' => $rebates->getRawOriginal('first_rebate_at'),
+            'last_rebate_at' => $rebates->getRawOriginal('last_rebate_at'),
+        ];
+    }
+}

+ 126 - 0
app/Services/ShareCommissionRecorder.php

@@ -0,0 +1,126 @@
+<?php
+
+namespace App\Services;
+
+use App\Models\BalanceLog;
+use App\Models\ShareCommissionRecord;
+use App\Models\User;
+use Brick\Math\BigDecimal;
+use Brick\Math\RoundingMode;
+use Carbon\Carbon;
+use Illuminate\Database\Eloquent\Builder;
+use Illuminate\Support\Facades\DB;
+
+/** Registers rebates that have already been paid; never credits a wallet. */
+class ShareCommissionRecorder
+{
+    public const SOURCE_TYPE = 'recharge_rebate_log';
+
+    public function preview(BalanceLog $rebate): array
+    {
+        $result = $this->prepare($rebate);
+        if ($result['status'] === 'skipped') return $result;
+        $result['status'] = $this->recordQuery($result['attributes'])->exists() ? 'existing' : 'pending';
+        return $result;
+    }
+
+    /** Context supplies the original base and percentage during a new rebate payout. */
+    public function record(BalanceLog $rebate, array $context = []): array
+    {
+        return DB::transaction(function () use ($rebate, $context) {
+            $locked = BalanceLog::where('id', $rebate->id)->lockForUpdate()->first();
+            if (!$locked) return ['status' => 'skipped', 'reason' => 'missing_log'];
+            $result = $this->prepare($locked, $context);
+            if ($result['status'] === 'skipped') return $result;
+            // Current read also protects callers whose transaction snapshot predates a backfill.
+            if ($this->recordQuery($result['attributes'])->lockForUpdate()->first()) {
+                $result['status'] = 'existing';
+                return $result;
+            }
+            ShareCommissionRecord::create($result['attributes']);
+            $result['status'] = 'created';
+            return $result;
+        });
+    }
+
+    private function recordQuery(array $attributes): Builder
+    {
+        return ShareCommissionRecord::where('source_type', self::SOURCE_TYPE)
+            ->where('source_id', $attributes['source_id'])
+            ->where('type', 'commission');
+    }
+
+    private function prepare(BalanceLog $rebate, array $context = []): array
+    {
+        if (!$rebate->exists || $rebate->change_type !== '充值返现' || (int)$rebate->type !== 1) {
+            return ['status' => 'skipped', 'reason' => 'not_recharge_rebate'];
+        }
+        $amount = $this->decimal($rebate->amount);
+        $before = $this->decimal($rebate->before_balance, 10);
+        $after = $this->decimal($rebate->after_balance, 10);
+        if ($amount === null || bccomp($amount, '0', 4) <= 0) {
+            return ['status' => 'skipped', 'reason' => 'invalid_amount'];
+        }
+        if ($before === null || $after === null || $this->decimal(bcsub($after, $before, 10)) !== $amount) {
+            return ['status' => 'skipped', 'reason' => 'unbalanced_log'];
+        }
+        $promoters = User::where('member_id', $rebate->member_id)->limit(2)->get(['id']);
+        if ($promoters->count() !== 1) {
+            return ['status' => 'skipped', 'reason' => $promoters->isEmpty() ? 'missing_promoter' : 'ambiguous_promoter'];
+        }
+        $rawDate = $rebate->getRawOriginal('created_at');
+        if (!$rawDate) return ['status' => 'skipped', 'reason' => 'missing_date'];
+        try {
+            $zone = config('app.timezone', 'Asia/Shanghai');
+            $date = Carbon::parse($rawDate, $zone)->setTimezone($zone)->toDateString();
+        } catch (\Throwable $e) {
+            return ['status' => 'skipped', 'reason' => 'invalid_date'];
+        }
+
+        $inviteeId = null;
+        $base = '0.0000';
+        $rate = '0.0000';
+        if ($context) {
+            $base = $this->decimal($context['base_amount'] ?? null);
+            $rate = $this->decimal($context['rate'] ?? null);
+            $inviteeId = $context['invitee_user_id'] ?? null;
+            if ($base === null || bccomp($base, '0', 4) <= 0 || $rate === null
+                || bccomp($rate, '0', 4) < 0 || bccomp($rate, '10000', 4) >= 0 || !$inviteeId) {
+                return ['status' => 'skipped', 'reason' => 'invalid_context'];
+            }
+        } elseif (preg_match('/^被邀请人:([0-9]{1,64})充值金额为:(\d{1,16}(?:\.\d{1,10})?)$/u', (string)$rebate->remark, $match)) {
+            // Use the historical receipt, not today's rate or the invitee's current referral link.
+            $base = $this->decimal($match[2]) ?? '0.0000';
+            $invitees = User::where('member_id', $match[1])->limit(2)->get(['id']);
+            if ($invitees->count() === 1) $inviteeId = $invitees->first()->id;
+            if (bccomp($base, '0', 4) > 0) {
+                $effectiveRate = $this->decimal(bcdiv(bcmul($amount, '100', 10), $base, 10));
+                if ($effectiveRate !== null && bccomp($effectiveRate, '10000', 4) < 0) $rate = $effectiveRate;
+            }
+        }
+        return [
+            'status' => 'pending',
+            'incomplete_metadata' => $inviteeId === null || bccomp($base, '0', 4) <= 0,
+            'attributes' => [
+                'stat_date' => $date,
+                'promoter_user_id' => $promoters->first()->id,
+                'invitee_user_id' => $inviteeId,
+                'type' => 'commission',
+                'category' => 'recharge',
+                'base_amount' => $base,
+                'rate' => $rate,
+                'amount' => $amount,
+                'source_type' => self::SOURCE_TYPE,
+                'source_id' => (string)$rebate->id,
+                'status' => 1,
+            ],
+        ];
+    }
+
+    private function decimal($value, int $scale = 4): ?string
+    {
+        if (is_float($value) && is_finite($value)) $value = sprintf('%.10F', $value);
+        if (!is_scalar($value) || !preg_match('/^-?\d{1,16}(?:\.\d+)?$/', (string)$value)) return null;
+        return (string)BigDecimal::of((string)$value)->toScale($scale, RoundingMode::HALF_UP);
+    }
+}

+ 38 - 25
app/Services/ShareEarningReportService.php

@@ -13,12 +13,14 @@ class ShareEarningReportService
 {
 {
     public function memberDays(array $params): array
     public function memberDays(array $params): array
     {
     {
-        [$start, $end] = $this->dateRange($params);
+        $range = !empty($params['start_date']) || !empty($params['end_date']) ? $this->dateRange($params) : null;
         $page = (int)($params['page'] ?? 1);
         $page = (int)($params['page'] ?? 1);
         $limit = (int)($params['limit'] ?? 20);
         $limit = (int)($params['limit'] ?? 20);
-        $dateQuery = User::query()->whereNotNull('agent_user_code')->where('agent_user_code', '<>', '')
-            ->whereBetween('created_at', [$start . ' 00:00:00', $end . ' 23:59:59'])
-            ->selectRaw('DATE(created_at) AS stat_date')
+        $dateQuery = User::query()->whereNotNull('agent_user_code')->where('agent_user_code', '<>', '');
+        if ($range !== null) {
+            $dateQuery->whereBetween('created_at', [$range[0] . ' 00:00:00', $range[1] . ' 23:59:59']);
+        }
+        $dateQuery->selectRaw('DATE(created_at) AS stat_date')
             ->groupByRaw('DATE(created_at)')
             ->groupByRaw('DATE(created_at)')
             ->orderByDesc('stat_date');
             ->orderByDesc('stat_date');
         $dates = $dateQuery->pluck('stat_date');
         $dates = $dateQuery->pluck('stat_date');
@@ -104,31 +106,46 @@ class ShareEarningReportService
         $promoterCodes = User::query()->whereNotNull('agent_user_code')->where('agent_user_code', '<>', '')
         $promoterCodes = User::query()->whereNotNull('agent_user_code')->where('agent_user_code', '<>', '')
             ->whereBetween('created_at', [$day . ' 00:00:00', $day . ' 23:59:59'])
             ->whereBetween('created_at', [$day . ' 00:00:00', $day . ' 23:59:59'])
             ->select('agent_user_code')->distinct();
             ->select('agent_user_code')->distinct();
-        $query = User::query()->whereIn('user_code', $promoterCodes);
-        if (!empty($params['member_id'])) $query->where('member_id', $params['member_id']);
-        if (!empty($params['account'])) $query->where('account', 'like', '%' . $params['account'] . '%');
+        $promoterAlias = DB::getTablePrefix() . 'promoter';
+        $entries = DB::query()->fromSub($promoterCodes, 'referral')
+            ->leftJoin('users as promoter', 'promoter.user_code', '=', 'referral.agent_user_code')
+            ->select('referral.agent_user_code')
+            ->selectRaw("COUNT({$promoterAlias}.id) AS promoter_matches")
+            ->groupBy('referral.agent_user_code');
+        // Keep historical codes even when the inviter was removed or changed their code.
+        // An ambiguous code cannot safely identify one member or their paid commission.
+        foreach (['id' => 'promoter_id', 'member_id' => 'member_id', 'account' => 'account', 'first_name' => 'nickname'] as $column => $alias) {
+            $entries->selectRaw("CASE WHEN COUNT({$promoterAlias}.id) = 1 THEN MAX({$promoterAlias}.{$column}) END AS {$alias}");
+        }
+        $query = DB::query()->fromSub($entries, 'promoter_entry');
+        if (!empty($params['member_id'])) $query->where('promoter_entry.member_id', $params['member_id']);
+        if (!empty($params['account'])) $query->where('promoter_entry.account', 'like', '%' . $params['account'] . '%');
         $total = $query->count();
         $total = $query->count();
-        $promoters = $query->orderByDesc('id')->forPage((int)($params['page'] ?? 1), (int)($params['limit'] ?? 20))->get();
+        $promoters = $query->orderByDesc('promoter_entry.promoter_id')->orderByDesc('promoter_entry.agent_user_code')
+            ->forPage((int)($params['page'] ?? 1), (int)($params['limit'] ?? 20))->get();
         $invitees = User::query()->whereBetween('created_at', [$day . ' 00:00:00', $day . ' 23:59:59'])
         $invitees = User::query()->whereBetween('created_at', [$day . ' 00:00:00', $day . ' 23:59:59'])
-            ->whereIn('agent_user_code', $promoters->pluck('user_code'))
+            ->whereIn('agent_user_code', $promoters->pluck('agent_user_code'))
             ->get(['id', 'member_id', 'agent_user_code']);
             ->get(['id', 'member_id', 'agent_user_code']);
         $records = ShareCommissionRecord::query()->where('stat_date', $day)->where('status', 1)
         $records = ShareCommissionRecord::query()->where('stat_date', $day)->where('status', 1)
-            ->whereIn('promoter_user_id', $promoters->pluck('id'))->get()->groupBy('promoter_user_id');
-        $setting = ShareSetting::query()->first();
+            ->whereIn('promoter_user_id', $promoters->pluck('promoter_id')->filter())->get()->groupBy('promoter_user_id');
         $metricsByMember = $this->moneyMetricsByMember($invitees->pluck('member_id')->all());
         $metricsByMember = $this->moneyMetricsByMember($invitees->pluck('member_id')->all());
-        $rows = $promoters->map(function ($promoter) use ($invitees, $records, $setting, $day, $metricsByMember) {
-            $children = $invitees->where('agent_user_code', $promoter->user_code);
+        $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());
             $metrics = $this->sumMetrics($metricsByMember, $children->pluck('member_id')->all());
-            $commission = $records->get($promoter->id, collect())->where('type', 'commission')->sum('amount');
+            $commission = $records->get($promoter->promoter_id, collect())->where('type', 'commission')->sum('amount');
             return [
             return [
                 'date' => $day,
                 'date' => $day,
-                'member_id' => (string)$promoter->member_id,
-                'account' => (string)$promoter->account,
-                'nickname' => (string)$promoter->first_name,
+                'promoter_code' => (string)$promoter->agent_user_code,
+                'promoter_status' => (int)$promoter->promoter_matches === 1 ? 'matched'
+                    : ((int)$promoter->promoter_matches === 0 ? 'missing' : 'ambiguous'),
+                'member_id' => (string)($promoter->member_id ?? ''),
+                'account' => (string)($promoter->account ?? ''),
+                'nickname' => (string)($promoter->nickname ?? ''),
                 'deposit_total' => $metrics['deposit'],
                 'deposit_total' => $metrics['deposit'],
                 'registered_count' => $children->count(),
                 'registered_count' => $children->count(),
                 'commission' => number_format((float)$commission, 4, '.', ''),
                 '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()];
         return ['total' => $total, 'data' => $rows->values()];
@@ -160,16 +177,17 @@ class ShareEarningReportService
 
 
     public function summary(array $params): array
     public function summary(array $params): array
     {
     {
-        [$start, $end] = $this->dateRange($params);
+        $range = !empty($params['start_date']) || !empty($params['end_date']) ? $this->dateRange($params) : null;
         $page = (int)($params['page'] ?? 1);
         $page = (int)($params['page'] ?? 1);
         $limit = (int)($params['limit'] ?? 20);
         $limit = (int)($params['limit'] ?? 20);
         $prefix = DB::getTablePrefix();
         $prefix = DB::getTablePrefix();
         $recordAlias = $prefix . 'share_record';
         $recordAlias = $prefix . 'share_record';
         $query = ShareCommissionRecord::query()->from('share_commission_records as share_record')
         $query = ShareCommissionRecord::query()->from('share_commission_records as share_record')
             ->leftJoin('users as promoter', 'promoter.id', '=', 'share_record.promoter_user_id')
             ->leftJoin('users as promoter', 'promoter.id', '=', 'share_record.promoter_user_id')
-            ->whereBetween('share_record.stat_date', [$start, $end])
             ->where('share_record.status', 1);
             ->where('share_record.status', 1);
+        if ($range !== null) $query->whereBetween('share_record.stat_date', $range);
         if (!empty($params['account'])) $query->where('promoter.account', 'like', '%' . $params['account'] . '%');
         if (!empty($params['account'])) $query->where('promoter.account', 'like', '%' . $params['account'] . '%');
+        $totalQuery = clone $query;
         $grouped = $query->select([
         $grouped = $query->select([
             'share_record.stat_date', 'share_record.promoter_user_id',
             'share_record.stat_date', 'share_record.promoter_user_id',
             'promoter.member_id as promoter_member_id', 'promoter.account as promoter_account',
             'promoter.member_id as promoter_member_id', 'promoter.account as promoter_account',
@@ -197,11 +215,6 @@ class ShareEarningReportService
                 'fee_waiver_rate' => number_format((float)$row->fee_waiver_rate, 4, '.', ''),
                 'fee_waiver_rate' => number_format((float)$row->fee_waiver_rate, 4, '.', ''),
             ]);
             ]);
 
 
-        $totalQuery = ShareCommissionRecord::query()->from('share_commission_records as share_record')
-            ->leftJoin('users as promoter', 'promoter.id', '=', 'share_record.promoter_user_id')
-            ->whereBetween('share_record.stat_date', [$start, $end])
-            ->where('share_record.status', 1);
-        if (!empty($params['account'])) $totalQuery->where('promoter.account', 'like', '%' . $params['account'] . '%');
         $totals = $totalQuery->selectRaw("SUM(CASE WHEN {$recordAlias}.type = 'commission' THEN {$recordAlias}.amount ELSE 0 END) AS total_commission")
         $totals = $totalQuery->selectRaw("SUM(CASE WHEN {$recordAlias}.type = 'commission' THEN {$recordAlias}.amount ELSE 0 END) AS total_commission")
             ->selectRaw("MAX(CASE WHEN {$recordAlias}.type = 'fee_waiver' THEN {$recordAlias}.rate ELSE 0 END) AS highest_fee_waiver_rate")->first();
             ->selectRaw("MAX(CASE WHEN {$recordAlias}.type = 'fee_waiver' THEN {$recordAlias}.rate ELSE 0 END) AS highest_fee_waiver_rate")->first();
         return [
         return [

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

@@ -0,0 +1,325 @@
+<?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'];
+
+    /** Read-only projection shared with reports; does not publish or alter sharing facts. */
+    public function projectForReporting(string $source, object $order): array
+    {
+        if (!in_array($source, ['lhc_order', 'sport_order', 'sport_game_order', 'third_game_orders'], true)) throw new InvalidArgumentException('Unsupported reporting source');
+        if ((int) ($order->is_faker ?? 0) === 1) return [];
+        $this->users = [];
+        return $source === 'lhc_order' ? $this->lotteryOrder($order, false)['facts'] : array_values(array_filter([$this->singleOrder($source, $order)]));
+    }
+
+    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) && Schema::hasTable('share_bet_facts')) {
+            $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;
+    }
+}

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

@@ -0,0 +1,267 @@
+<?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', 'indirect_bonus_rate'];
+    // Configuration is returned at cent precision; reject sub-cent input rather than truncate it on a later edit.
+    private const MONEY = ['regex:/^\d{1,14}(?:\.\d{1,2})?$/'];
+    private const RATE = ['regex:/^\d{1,3}(?:\.\d{1,4})?$/', 'numeric', 'between:0,100'];
+
+    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 ?? ''),
+            '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,
+            '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
+    {
+        // Retired fields have no effect, including when submitted by an older client.
+        unset($input['benefit_enabled'], $input['activation_date']);
+        foreach (self::RETIRED as $key) {
+            if (array_key_exists($key, $input)) $this->invalid($key, '旧分享参数已移除,请使用有效会员条件及分类返佣规则');
+        }
+        foreach (['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'],
+            'valid_member_min_deposit' => ['required', 'regex:/^\d{1,14}(?:\.\d{1,2})?$/'],
+            'valid_member_min_effective_bet' => array_merge(['required'], self::MONEY),
+        ])->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 (!DB::table('share_system_state')->where('key', 'initialized_at')->exists()) {
+                $this->invalid('initialized', '会员分享关系初始化完成后才可保存佣金配置');
+            }
+            $setting->fill($data + OperationAuditService::actor());
+            $setting->save();
+            $policy = $this->writablePolicy();
+            foreach (['enabled', 'valid_member_min_deposit', 'valid_member_min_effective_bet'] 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, '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['benefit_enabled']);
+        // Keep archived coefficients on past versions only; new versions use the receiver's bracket rate by depth.
+        $policy['indirect_bonus_rate'] = null;
+        $policy['rules'] = is_string($policy['rules']) ? json_decode($policy['rules'], true, 512, JSON_THROW_ON_ERROR) : $policy['rules'];
+        $policy['effective_date'] = $this->tomorrow();
+        return $policy;
+    }
+
+    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'] 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]);
+        unset($data['indirect_bonus_rate'], $data['benefit_enabled']);
+        $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]); }
+}

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

@@ -0,0 +1,128 @@
+<?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
+{
+    private const AUDIT_TYPE = 'agent_commission';
+
+    public function options(): array
+    {
+        return ['audit_types' => [['value' => self::AUDIT_TYPE, 'label' => '代理佣金']]];
+    }
+
+    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'])],
+            // This endpoint currently reads only agent-commission claims.
+            'audit_type' => ['nullable', 'string', Rule::in([self::AUDIT_TYPE])],
+            '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, 'audit_type' => self::AUDIT_TYPE, 'activity_name' => '代理佣金',
+            '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' => 'F1体育', 'self:lottery' => 'F1彩票'];
+    }
+
+    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);
+    }
+}

+ 50 - 0
app/Services/Sharing/ShareMemberQualification.php

@@ -0,0 +1,50 @@
+<?php
+
+namespace App\Services\Sharing;
+
+use Carbon\Carbon;
+use Illuminate\Support\Facades\DB;
+
+/** Read-only qualification shared by commission projection and reporting. */
+class ShareMemberQualification
+{
+    public function read(string $date, bool $withMembers = true): array
+    {
+        $value = Carbon::createFromFormat('!Y-m-d', $date, ShareDefaults::TIMEZONE);
+        if (!$value || $value->toDateString() !== $date) throw new \InvalidArgumentException('日期格式必须为Y-m-d');
+        $missing = [];
+        $schema = DB::connection()->getSchemaBuilder();
+        foreach (['share_policies', 'share_system_state', 'share_bet_facts', 'users', 'balance_logs'] as $table) {
+            if (!$schema->hasTable($table)) $missing[] = 'missing_table:' . $table;
+        }
+        $policy = in_array('missing_table:share_policies', $missing, true) ? null
+            : DB::table('share_policies')->where('effective_date', '<=', $date)->orderByDesc('effective_date')->first();
+        if (!$policy) $missing[] = 'no_effective_policy';
+        elseif (($policy->valid_member_min_deposit ?? null) === null || ($policy->valid_member_min_effective_bet ?? null) === null) $missing[] = 'incomplete_qualification_policy';
+        if (!in_array('missing_table:share_system_state', $missing, true)
+            && !DB::table('share_system_state')->where('key', 'initialized_at')->exists()) $missing[] = 'relations_not_initialized';
+        $meta = ['id' => $policy ? (int) $policy->id : null, 'effective_date' => $policy->effective_date ?? null,
+            'min_deposit' => isset($policy->valid_member_min_deposit) ? bcadd((string) $policy->valid_member_min_deposit, '0', 10) : null,
+            'min_effective_bet' => isset($policy->valid_member_min_effective_bet) ? bcadd((string) $policy->valid_member_min_effective_bet, '0', 10) : null,
+            'date' => $date, 'available' => !$missing, 'unavailable_reasons' => $missing];
+        $result = ['policy' => $policy, 'policy_meta' => $meta, 'currency' => (string) config('third_game.currency', 'CNY'),
+            'users' => collect(), 'deposits' => collect(), 'effective' => collect(), 'qualified' => []];
+        if ($missing || !$withMembers) return $result;
+
+        $end = $date . ' 23:59:59';
+        $result['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');
+        $result['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');
+        $result['effective'] = DB::table('share_bet_facts')->where('settled', 1)->where('voided', 0)
+            ->where('currency', $result['currency'])->where('bet_date', '<=', $date)
+            ->select('user_id')->selectRaw('SUM(effective_amount) AS total')->groupBy('user_id')->pluck('total', 'user_id');
+        foreach ($result['users'] as $id => $user) {
+            if (bccomp((string) ($result['deposits'][$user->member_id] ?? 0), $meta['min_deposit'], 10) >= 0
+                && bccomp((string) ($result['effective'][$id] ?? 0), $meta['min_effective_bet'], 10) >= 0) $result['qualified'][(int) $id] = true;
+        }
+        return $result;
+    }
+}

+ 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('分享关系表尚未初始化,请执行迁移');
+    }
+}

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

@@ -0,0 +1,329 @@
+<?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,bool $withDiagnostics=false): array
+    {
+        $qualification = (new ShareMemberQualification())->read($date);
+        $policy = $qualification['policy'];
+        $empty = ['date'=>$date,'policy'=>$policy,'rows'=>[]];
+        $initialized=DB::table('share_system_state')->where('key','initialized_at')->exists();
+        $depthRates = self::usesDepthRates($date);
+        $diagnostics=$withDiagnostics ? $this->diagnosticContext($date,$policy,$initialized,$depthRates) : null;
+        if ($withDiagnostics) $empty['diagnostics']=$diagnostics;
+        if (!$qualification['policy_meta']['available']) return $empty;
+        if (!$policy || $policy->valid_member_min_effective_bet === null
+            || (!$depthRates && $policy->indirect_bonus_rate === null)) return $empty;
+        $end = $date . ' 23:59:59';
+        $users = $qualification['users']; $deposits = $qualification['deposits'];
+        $currency = $qualification['currency']; $effective = $qualification['effective']; $qualified = $qualification['qualified'];
+        $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) {
+            $bettor=(int)$fact->user_id;
+            if (!isset($qualified[$bettor])) {
+                if ($withDiagnostics) {
+                    if (!$users->has($bettor)) ++$diagnostics['excluded']['bettor_unavailable'];
+                    else {
+                        if (bccomp((string)($deposits[$users[$bettor]->member_id] ?? 0),(string)$policy->valid_member_min_deposit,10)<0) ++$diagnostics['excluded']['deposit_below_threshold'];
+                        if (bccomp((string)($effective[$bettor] ?? 0),(string)$policy->valid_member_min_effective_bet,10)<0) ++$diagnostics['excluded']['effective_bet_below_threshold'];
+                    }
+                }
+                continue;
+            }
+            if (!array_key_exists($fact->category,ShareDefaults::categories())) {
+                if ($withDiagnostics) ++$diagnostics['excluded']['unsupported_category'];
+                continue;
+            }
+            $ancestors=$edges[$bettor] ?? [];
+            if ($withDiagnostics && !$ancestors) ++$diagnostics['excluded']['no_ancestor_relationship'];
+            foreach ($ancestors as $edge) {
+                $receiver=(int)$edge->ancestor_user_id;
+                if (!$users->has($receiver)) {
+                    if ($withDiagnostics) ++$diagnostics['excluded']['receiver_unavailable'];
+                    continue;
+                }
+                if ($edge->bound_at > $fact->bet_at) {
+                    if ($withDiagnostics) ++$diagnostics['excluded']['relationship_bound_after_bet'];
+                    continue;
+                }
+                if (!isset($rows[$receiver][$fact->category])) $rows[$receiver][$fact->category]=['direct_performance'=>'0','indirect_performance'=>'0','performance_by_depth'=>[]];
+                $field=(int)$edge->depth===1 ? 'direct_performance' : 'indirect_performance';
+                $rows[$receiver][$fact->category][$field]=bcadd($rows[$receiver][$fact->category][$field],(string)$fact->effective_amount,10);
+                $depth=(int)$edge->depth;
+                $byDepth=&$rows[$receiver][$fact->category]['performance_by_depth'];
+                $byDepth[$depth]=bcadd($byDepth[$depth] ?? '0',(string)$fact->effective_amount,10);
+                unset($byDepth);
+            }
+        }
+        $rules=is_string($policy->rules) ? json_decode($policy->rules,true,512,JSON_THROW_ON_ERROR) : (array)$policy->rules;
+        foreach ($rows as $id=>&$categories) foreach ($categories as $category=>&$row) {
+            $row['valid_direct_members']=$counts[$id] ?? 0;
+            $row['rate']=self::rate($rules[$category] ?? [],$row['valid_direct_members'],$row['direct_performance']);
+            // Keep the archived coefficient only for replay of days before the formula cutover.
+            $row['indirect_bonus_rate']=$depthRates ? '0' : (string)$policy->indirect_bonus_rate;
+            $row['direct_commission']=self::percentage($row['direct_performance'],$row['rate']);
+            if ($depthRates) {
+                $indirect='0';
+                foreach ($row['performance_by_depth'] as $depth=>$performance) {
+                    if ($depth<2) continue;
+                    $indirect=bcadd($indirect,self::depthCommission($performance,$row['rate'],$depth),30);
+                }
+                $row['indirect_commission']=bcadd($indirect,'0',10);
+            } else {
+                $row['indirect_commission']=self::percentage(self::percentage($row['indirect_performance'],$row['rate']),$row['indirect_bonus_rate']);
+            }
+            unset($row['performance_by_depth']);
+        }
+        unset($categories,$row);
+        $result=['date'=>$date,'policy'=>$policy,'rows'=>$rows];
+        if ($withDiagnostics) $result['diagnostics']=$diagnostics;
+        return $result;
+    }
+
+    /** Read-only explanation from the same projection and filters used by real settlement. */
+    public function diagnose(string $date): array
+    {
+        $projection=$this->projection($date,true);
+        $report=['date'=>$date,'timezone'=>ShareDefaults::TIMEZONE,'written'=>false]+$projection['diagnostics'];
+        $report['receivers']=count($projection['rows']);
+        $report['positive_receivers']=0;
+        $report['projected_commission']='0.0000000000';
+        foreach ($projection['rows'] as $categories) {
+            $total='0.0000000000';
+            foreach ($categories as $row) $total=bcadd($total,bcadd($row['direct_commission'],$row['indirect_commission'],10),10);
+            if (bccomp($total,'0',10)>0) ++$report['positive_receivers'];
+            $report['projected_commission']=bcadd($report['projected_commission'],$total,10);
+        }
+        return $report;
+    }
+
+    private function diagnosticContext(string $date,?object $policy,bool $initialized,bool $depthRates): array
+    {
+        $blockers=[];
+        if (!$initialized) $blockers[]=['code'=>'relations_not_initialized','message'=>'分享关系尚未初始化'];
+        if (!$policy) $blockers[]=['code'=>'no_effective_policy','message'=>'目标日期没有已生效的返佣配置版本'];
+        else {
+            if ($policy->valid_member_min_effective_bet===null) $blockers[]=['code'=>'missing_effective_bet_threshold','message'=>'目标日期未配置有效投注门槛'];
+            if (!$depthRates && $policy->indirect_bonus_rate===null) $blockers[]=['code'=>'legacy_policy_incomplete','message'=>'历史计佣版本缺少当时的间接佣金参数'];
+        }
+        $currency=(string)config('third_game.currency','CNY');
+        $stats=DB::table('share_bet_facts')->where('bet_date',$date)->select('source')->selectRaw(
+            'COUNT(*) AS total, SUM(CASE WHEN settled = 0 THEN 1 ELSE 0 END) AS unsettled,
+            SUM(CASE WHEN voided = 1 THEN 1 ELSE 0 END) AS voided,
+            SUM(CASE WHEN currency IS NULL OR currency <> ? THEN 1 ELSE 0 END) AS currency_mismatch,
+            SUM(CASE WHEN effective_amount <= 0 THEN 1 ELSE 0 END) AS zero_effective,
+            SUM(CASE WHEN settled = 1 AND voided = 0 AND currency = ? AND effective_amount > 0 THEN 1 ELSE 0 END) AS candidates',
+            [$currency,$currency]
+        )->groupBy('source')->get();
+        $counts=array_fill_keys(['total','unsettled','voided','currency_mismatch','zero_effective','candidates'],0);
+        $sources=[];
+        foreach ($stats as $row) {
+            $values=[];
+            foreach ($counts as $field=>$_) { $values[$field]=(int)$row->$field; $counts[$field]+=$values[$field]; }
+            $sources[$row->source]=$values;
+        }
+        return [
+            'initialized'=>$initialized,'currency'=>$currency,
+            'policy'=>$policy ? ['id'=>(int)$policy->id,'effective_date'=>$policy->effective_date,
+                'valid_member_min_deposit'=>$policy->valid_member_min_deposit,
+                'valid_member_min_effective_bet'=>$policy->valid_member_min_effective_bet] : null,
+            'next_policy_effective_date'=>DB::table('share_policies')->where('effective_date','>',$date)->orderBy('effective_date')->value('effective_date'),
+            'blockers'=>$blockers,'facts'=>$counts,'facts_by_source'=>$sources,
+            'excluded'=>array_fill_keys(['bettor_unavailable','deposit_below_threshold','effective_bet_below_threshold',
+                'unsupported_category','no_ancestor_relationship','receiver_unavailable','relationship_bound_after_bet'],0),
+        ];
+    }
+
+    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 static function usesDepthRates(string $date): bool
+    {
+        $start=DB::table('share_system_state')->where('key','commission_depth_rate_start')->value('value') ?? '1970-01-01';
+        return $date >= $start;
+    }
+
+    /** Keep sub-ledger precision until all depth buckets have been added together. */
+    public static function depthCommission(string $amount,string $rate,int $depth): string
+    {
+        if ($depth<1) throw new \InvalidArgumentException('佣金层级必须为正整数');
+        if (bccomp($rate,'0',4)===0) return bcadd('0','0',30);
+        if (bccomp($rate,'100',4)===0) return bcadd($amount,'0',30);
+        $result=$amount;
+        for ($level=0;$level<$depth;$level++) {
+            $result=bcdiv(bcmul($result,$rate,40),'100',30);
+            if (bccomp($result,'0',30)===0) break;
+        }
+        return $result;
+    }
+
+    public function settle(string $date,bool $write=true,bool $allowToday=false): array
+    {
+        $this->validateDate($date);
+        $today=Carbon::now(ShareDefaults::TIMEZONE)->toDateString();
+        if ($date>$today) throw new \InvalidArgumentException('未来日期佣金不能结算到账本');
+        if ($date===$today && !$allowToday) throw new \InvalidArgumentException('当天佣金只能预估,调试结算请显式使用--allow-today');
+        $result=['date'=>$date,'receivers'=>0,'changed'=>0,'delta'=>'0.0000000000','written'=>$write];
+        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,$today) {
+            DB::table('share_system_state')->where('key','relations_lock')->lockForUpdate()->first();
+            $projection=$this->projection($date); $rows=$projection['rows']; $policy=$projection['policy'];
+            $depthRates=self::usesDepthRates($date);
+            $previous=DB::table('share_settlements')->where('bet_date',$date)->get();
+            foreach ($previous as $old) if (!isset($rows[$old->user_id][$old->category])) {
+                $rows[$old->user_id][$old->category]=['direct_performance'=>'0','indirect_performance'=>'0','valid_direct_members'=>0,
+                    'rate'=>'0','indirect_bonus_rate'=>$depthRates ? '0' : (string)($policy->indirect_bonus_rate ?? 0),'direct_commission'=>'0','indirect_commission'=>'0'];
+            }
+            $result['receivers']=count($rows);
+            $now=Carbon::now(ShareDefaults::TIMEZONE)->format('Y-m-d H:i:s');
+            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 && $date<$today) {
+                $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]);
+            }
+            // A debug credit is still an open day: keep it pending for normal reconciliation after midnight.
+            if ($write && $date===$today) $this->requeue([$date]);
+            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
+    {
+        $today=Carbon::now(ShareDefaults::TIMEZONE)->toDateString();
+        $dates=DB::transaction(function () use ($today) {
+            // Match fact publication's lock order, before any repeatable-read snapshot.
+            $this->lockReconciliationRelations();
+            $row=DB::table('share_system_state')->where('key','facts_dirty_dates')->lockForUpdate()->first();
+            if (!$row) return [];
+            $pending=json_decode($row->value,true,512,JSON_THROW_ON_ERROR);
+            $closed=array_values(array_filter($pending,fn($date)=>$date < $today));
+            if (!$closed) return [];
+            $start=min($closed);
+            $facts=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_values(array_unique(array_merge($closed,$facts,$old))); sort($dates);
+            // Persist dependent days too: after the original day is acknowledged, a restart
+            // must still know that later cumulative qualifications require correction.
+            $pending=array_values(array_unique(array_merge($pending,$dates))); sort($pending);
+            DB::table('share_system_state')->where('key','facts_dirty_dates')->update([
+                'value'=>json_encode($pending,JSON_THROW_ON_ERROR),'updated_at'=>Carbon::now(ShareDefaults::TIMEZONE)->toDateTimeString(),
+            ]);
+            return $dates;
+        },3);
+        $result=['dates'=>0,'changed'=>0];
+        foreach ($dates as $date) {
+            $settled=DB::transaction(function () use ($date) {
+                $this->lockReconciliationRelations();
+                $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) : [];
+                // Another reconciler may already have completed this day while we waited.
+                if (!in_array($date,$pending,true)) return null;
+                $settled=$this->settle((string)$date);
+                $pending=array_values(array_filter($pending,fn($day)=>$day!==$date));
+                // The ledger and acknowledgement commit together. An interrupted transaction
+                // leaves the day pending; a later publisher can re-add it only after this lock.
+                DB::table('share_system_state')->where('key','facts_dirty_dates')->update([
+                    'value'=>json_encode($pending,JSON_THROW_ON_ERROR),'updated_at'=>Carbon::now(ShareDefaults::TIMEZONE)->toDateTimeString(),
+                ]);
+                return $settled;
+            },3);
+            if ($settled!==null) { ++$result['dates']; $result['changed']+=$settled['changed']; }
+        }
+        return $result;
+    }
+
+    private function lockReconciliationRelations(): void
+    {
+        if (!DB::table('share_system_state')->where('key','relations_lock')->lockForUpdate()->first()) {
+            throw new \RuntimeException('Sharing migration/relations lock is missing');
+        }
+    }
+
+    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');
+    }
+}

+ 87 - 0
app/Services/SiteConfigurationService.php

@@ -0,0 +1,87 @@
+<?php
+
+namespace App\Services;
+
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Validator;
+use Illuminate\Validation\ValidationException;
+
+class SiteConfigurationService
+{
+    public function get(): array
+    {
+        return DB::transaction(fn () => $this->format(DB::table('site_settings')->where('id', 1)->sharedLock()->first()));
+    }
+
+    public function save(array $input): array
+    {
+        $data = Validator::make($input, [
+            'translations' => ['present', 'array:zh,en,vi'],
+            'translations.*' => ['array:content'],
+            'translations.*.content' => ['nullable', 'string', 'max:2000'],
+            'domains' => ['present', 'array', 'max:100'],
+            'domains.*' => ['required', 'string', 'max:2048'],
+        ], [
+            'translations.present' => '请提交三语言跑马灯内容', 'translations.array' => '跑马灯仅支持zh/en/vi三种语言',
+            'translations.*.array' => '每种语言请提交content对象', 'translations.*.content.string' => '跑马灯内容必须为文本',
+            'translations.*.content.max' => '每种语言的跑马灯最多2000个字符',
+            'domains.present' => '请提交域名数组', 'domains.array' => '域名必须为数组', 'domains.max' => '域名最多100个',
+            'domains.*.required' => '域名不能为空', 'domains.*.string' => '每个域名必须为字符串', 'domains.*.max' => '每个域名最多2048个字符',
+        ])->validate();
+        if (array_values($data['domains']) !== $data['domains']) {
+            throw ValidationException::withMessages(['domains' => '域名必须为顺序数组']);
+        }
+        $domains = [];
+        foreach ($data['domains'] as $index => $domain) {
+            $url = $this->domain($domain, $index);
+            if (!in_array($url, $domains, true)) $domains[] = $url;
+        }
+        $translations = [];
+        foreach (['zh', 'en', 'vi'] as $language) $translations[$language] = ['content' => trim((string) ($data['translations'][$language]['content'] ?? ''))];
+        $payload = ['translations' => $translations, 'domains' => $domains];
+        return DB::transaction(function () use ($payload) {
+            DB::table('site_settings')->insertOrIgnore(['id' => 1, 'domains' => '[]', 'created_at' => now(), 'updated_at' => now()]);
+            $before = $this->format(DB::table('site_settings')->where('id', 1)->lockForUpdate()->first());
+            if ($before !== $payload) {
+                DB::table('site_settings')->where('id', 1)->update([
+                    'domains' => json_encode($payload['domains'], JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR), 'updated_at' => now()]);
+                DB::table('site_setting_translations')->where('site_setting_id', 1)->delete();
+                foreach ($payload['translations'] as $language => $text) {
+                    if ($text['content'] !== '') DB::table('site_setting_translations')->insert([
+                        'site_setting_id' => 1, 'language' => $language, 'content' => $text['content']]);
+                }
+                OperationAuditService::record('site_configuration', 1, 'update', $before, $payload);
+            }
+            return $payload;
+        }, 3);
+    }
+
+    private function format(?object $row): array
+    {
+        $domains = $row ? json_decode($row->domains, true, 512, JSON_THROW_ON_ERROR) : [];
+        if (!is_array($domains) || array_values($domains) !== $domains || count(array_filter($domains, 'is_string')) !== count($domains)) {
+            throw new \RuntimeException('站点域名配置格式异常');
+        }
+        $translations = ['zh' => ['content' => ''], 'en' => ['content' => ''], 'vi' => ['content' => '']];
+        if ($row) foreach (DB::table('site_setting_translations')->where('site_setting_id', 1)->get(['language', 'content']) as $text) {
+            if (isset($translations[$text->language])) $translations[$text->language] = ['content' => (string) $text->content];
+        }
+        return ['translations' => $translations, 'domains' => $domains];
+    }
+
+    private function domain(string $value, int $index): string
+    {
+        $url = trim($value);
+        if (!preg_match('~^[a-z][a-z0-9+.-]*://~i', $url)) $url = 'https://' . $url;
+        $parts = parse_url($url);
+        if (!filter_var($url, FILTER_VALIDATE_URL) || !$parts
+            || !in_array(strtolower($parts['scheme'] ?? ''), ['http', 'https'], true)
+            || empty($parts['host']) || isset($parts['user']) || isset($parts['pass'])
+            || isset($parts['query']) || isset($parts['fragment'])
+            || !in_array($parts['path'] ?? '', ['', '/'], true)
+            || (isset($parts['port']) && $parts['port'] < 1)) {
+            throw ValidationException::withMessages(['domains.' . $index => '域名仅支持HTTP/HTTPS站点地址,不带账号、路径、查询参数或片段']);
+        }
+        return strtolower($parts['scheme']) . '://' . strtolower($parts['host']) . (isset($parts['port']) ? ':' . $parts['port'] : '');
+    }
+}

+ 58 - 0
app/Services/SiteInboxService.php

@@ -0,0 +1,58 @@
+<?php
+
+namespace App\Services;
+
+use App\Support\LocalizedContent;
+use Carbon\Carbon;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Validator;
+use Illuminate\Validation\Rule;
+
+class SiteInboxService
+{
+    public function listing(string $kind, int $id, array $input): array
+    {
+        $params = Validator::make($input, [
+            'page' => ['nullable', 'integer', 'min:1'], 'limit' => ['nullable', 'integer', 'min:1', 'max:100'],
+            'category' => ['nullable', Rule::in(array_keys(SiteMessageService::CATEGORIES))], 'is_read' => ['nullable', 'integer', 'in:0,1'],
+        ])->validate();
+        $query = $this->query($kind, $id);
+        if (!empty($params['category'])) $query->where('m.category', $params['category']);
+        if (isset($params['is_read'])) { if ((int) $params['is_read'] === 1) $query->whereNotNull('r.read_at'); else $query->whereNull('r.read_at'); }
+        $count = (clone $query)->count();
+        $rows = $query->select('m.id', 'm.category', 'm.sent_at', 't.title', 't.subtitle', 'r.language', 'r.read_at')->orderByDesc('m.sent_at')->orderByDesc('m.id')
+            ->forPage((int) ($params['page'] ?? 1), (int) ($params['limit'] ?? 20))->get();
+        return ['count' => $count, 'list' => $rows->map(fn ($row) => $this->dto((array) $row))->all()];
+    }
+
+    public function detail(string $kind, int $recipient, int $id): array
+    {
+        $row = $this->query($kind, $recipient)->where('m.id', $id)->select('m.id', 'm.category', 'm.sent_at', 't.title', 't.subtitle', 't.content', 'r.language', 'r.read_at')->first();
+        if (!$row) LocalizedContent::invalid('id', '信息不存在');
+        return $this->dto((array) $row);
+    }
+
+    public function unread(string $kind, int $id): array { return ['count' => $this->query($kind, $id)->whereNull('r.read_at')->count()]; }
+
+    public function read(string $kind, int $recipient, int $id): array
+    {
+        return DB::transaction(function () use ($kind, $recipient, $id) {
+            $message = DB::table('site_messages')->where('id', $id)->where('status', 'sent')->lockForUpdate()->first();
+            if (!$message) LocalizedContent::invalid('id', '信息不存在');
+            $receipt = DB::table('site_message_recipients')->where('message_id', $id)->where('recipient_kind', $kind)->where('recipient_id', $recipient)->lockForUpdate()->first();
+            if (!$receipt) LocalizedContent::invalid('id', '信息不存在');
+            $readAt = $receipt->read_at ?? Carbon::now('Asia/Shanghai')->format('Y-m-d H:i:s');
+            if ($receipt->read_at === null) DB::table('site_message_recipients')->where('id', $receipt->id)->update(['read_at' => $readAt]);
+            return ['id' => $id, 'is_read' => true, 'read_at' => $readAt];
+        });
+    }
+
+    private function query(string $kind, int $id)
+    {
+        if (!in_array($kind, ['user', 'agent'], true) || $id < 1) LocalizedContent::invalid('recipient', '请先登录');
+        return DB::table('site_message_recipients as r')->join('site_messages as m', 'm.id', '=', 'r.message_id')
+            ->join('site_message_translations as t', function ($j) { $j->on('t.message_id', '=', 'r.message_id')->on('t.language', '=', 'r.language'); })
+            ->where('r.recipient_kind', $kind)->where('r.recipient_id', $id)->where('m.status', 'sent');
+    }
+    private function dto(array $row): array { $row['id'] = (int) $row['id']; $row['category_name'] = SiteMessageService::CATEGORIES[$row['category']]; $row['is_read'] = $row['read_at'] !== null; return $row; }
+}

+ 237 - 0
app/Services/SiteMessageService.php

@@ -0,0 +1,237 @@
+<?php
+
+namespace App\Services;
+
+use App\Support\LocalizedContent;
+use Carbon\Carbon;
+use Illuminate\Auth\Access\AuthorizationException;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Log;
+use Illuminate\Support\Facades\Validator;
+use Illuminate\Validation\Rule;
+
+class SiteMessageService
+{
+    public const CATEGORIES = ['discount' => '优惠活动', 'site_notice' => '网站公告', 'internal' => '站内信', 'promotion' => '活动宣传'];
+    public const TARGETS = ['all_users' => '所有人', 'specified_users' => '指定用户', 'all_agents' => '所有代理', 'specified_agents' => '指定代理'];
+    public const STATES = ['scheduled' => '待发送', 'sent' => '已发送', 'retracted' => '已撤回'];
+
+    public static function canManageAgents(): bool
+    {
+        return in_array((int) (request()->user->id ?? 0), config('agent.admin_ids', [1]), true);
+    }
+
+    public function options(): array
+    {
+        return ['categories' => $this->optionsFor(self::CATEGORIES), 'recipient_types' => $this->optionsFor(self::TARGETS),
+            'languages' => $this->optionsFor(LocalizedContent::LANGUAGES), 'can_manage_agents' => self::canManageAgents(), 'timezone' => 'Asia/Shanghai'];
+    }
+
+    public function recipients(string $kind, array $input): array
+    {
+        if ($kind === 'agent') $this->requireAgentAccess();
+        $params = Validator::make($input, $this->pageRules() + ['q' => ['nullable', 'string', 'max:100']])->validate();
+        $query = $this->eligible($kind);
+        if (isset($params['q']) && $params['q'] !== '') {
+            $q = $params['q'];
+            $query->where(function ($query) use ($kind, $q) {
+                foreach ($kind === 'agent' ? ['username', 'real_name'] : ['account', 'username', 'first_name'] as $field) $query->orWhere('u.' . $field, 'like', '%' . $q . '%');
+                if (ctype_digit($q)) {
+                    $query->orWhere('u.id', $q);
+                    if ($kind === 'user') $query->orWhere('u.user_id', $q)->orWhere('u.member_id', $q);
+                }
+            });
+        }
+        $total = (clone $query)->count();
+        $fields = $kind === 'agent' ? ['u.id', 'u.username', 'u.real_name', 'u.language', 'u.status']
+            : ['u.id', 'u.user_id', 'u.member_id', 'u.account', 'u.username', 'u.first_name', 'u.language'];
+        $rows = $query->select($fields)->orderByDesc('u.id')->forPage((int) ($params['page'] ?? 1), (int) ($params['limit'] ?? 20))->get();
+        return ['total' => $total, 'data' => $rows->map(function ($row) { $row->id = (int) $row->id; return $row; })->all()];
+    }
+
+    public function send(array $input): array
+    {
+        foreach (['sender_id', 'sender_name', 'sender_display'] as $key) if (array_key_exists($key, $input)) LocalizedContent::invalid($key, '发送者由登录管理员生成');
+        $params = Validator::make($input, [
+            'request_id' => ['required', 'string', 'regex:/^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$/D'],
+            'category' => ['sometimes', Rule::in(array_keys(self::CATEGORIES))],
+            'recipient_type' => ['required', Rule::in(array_keys(self::TARGETS))],
+            'recipient_ids' => ['nullable', 'array', 'max:500'], 'recipient_ids.*' => ['required', 'integer', 'min:1'],
+            'send_at' => ['nullable', 'date_format:Y-m-d H:i:s'], 'translations' => ['required', 'array'],
+        ])->validate();
+        if (empty($params['send_at'])) $params['send_at'] = null;
+        $type = $params['recipient_type']; $kind = str_ends_with($type, 'agents') ? 'agent' : 'user';
+        if ($kind === 'agent') $this->requireAgentAccess();
+        $ids = array_values(array_unique(array_map('intval', $params['recipient_ids'] ?? []))); sort($ids);
+        if (str_starts_with($type, 'specified_') && !$ids) LocalizedContent::invalid('recipient_ids', '请选择收件人');
+        if (str_starts_with($type, 'all_') && $ids) LocalizedContent::invalid('recipient_ids', '群发全部账号时请清空指定收件人');
+        $translations = LocalizedContent::parse($params['translations'], 500, 5000, true);
+        $actor = OperationAuditService::actor();
+        if (!$actor['operator_id'] || $actor['operator_name'] === '') LocalizedContent::invalid('sender_id', '请使用后台管理员身份');
+        $hash = hash('sha256', json_encode([$params['category'] ?? 'internal', $type, $ids, $params['send_at'] ?? null, $translations], JSON_THROW_ON_ERROR));
+
+        return DB::transaction(function () use ($params, $type, $kind, $ids, $translations, $actor, $hash) {
+            $now = $this->now();
+            // The unique key serializes concurrent retries before any receipts or audit records are created.
+            $created = DB::table('site_messages')->insertOrIgnore([
+                'category' => $params['category'] ?? 'internal', 'recipient_type' => $type,
+                'recipient_ids' => json_encode($ids, JSON_THROW_ON_ERROR), 'send_at' => $params['send_at'] ?? $now,
+                'status' => 'scheduled', 'sender_id' => $actor['operator_id'], 'sender_name' => $actor['operator_name'],
+                'request_id' => $params['request_id'], 'request_hash' => $hash, 'created_at' => $now, 'updated_at' => $now,
+            ]);
+            $message = DB::table('site_messages')->where('sender_id', $actor['operator_id'])->where('request_id', $params['request_id'])->lockForUpdate()->first();
+            if (!$message) throw new \RuntimeException('信息创建失败');
+            if ($message->request_hash !== $hash) LocalizedContent::invalid('request_id', '请求标识已用于其他内容,请重新生成');
+            if (!$created) return $this->detail((int) $message->id);
+            if ($ids && $this->eligible($kind)->whereIn('u.id', $ids)->count() !== count($ids)) LocalizedContent::invalid('recipient_ids', '部分账号不存在或不在可发送范围,请重新选择');
+            foreach ($translations as $language => $block) DB::table('site_message_translations')->insert($block + ['message_id' => $message->id, 'language' => $language]);
+            if ($message->send_at <= $now) $this->deliver((int) $message->id, $actor);
+            else OperationAuditService::record('site_message', (int) $message->id, 'schedule', [], $this->snapshot((int) $message->id), $now);
+            return $this->detail((int) $message->id);
+        }, 3);
+    }
+
+    public function deliver(int $id, ?array $actor = null): bool
+    {
+        return DB::transaction(function () use ($id, $actor) {
+            $message = DB::table('site_messages')->where('id', $id)->lockForUpdate()->first();
+            $now = $this->now();
+            if (!$message || $message->status !== 'scheduled' || $message->send_at > $now) return false;
+            $before = $this->snapshot($id);
+            $kind = str_ends_with($message->recipient_type, 'agents') ? 'agent' : 'user';
+            $ids = json_decode($message->recipient_ids, true, 512, JSON_THROW_ON_ERROR);
+            $base = $this->eligible($kind);
+            if ($ids) $base->whereIn('u.id', $ids);
+            $maximum = (int) (clone $base)->max('u.id');
+            $base->where('u.id', '<=', $maximum);
+            $targetCount = $ids ? count($ids) : (int) (clone $base)->count();
+            // One INSERT SELECT chooses each recipient's language once; no per-language re-scans or partial publication.
+            $source = (clone $base)->join('site_message_translations as t', function ($join) use ($id) {
+                $join->on('t.language', '=', 'u.language')->where('t.message_id', '=', $id);
+            })->selectRaw('? AS message_id, ? AS recipient_kind', [$id, $kind])
+                ->addSelect('u.id as recipient_id', 't.language')->selectRaw('? AS created_at', [$now]);
+            DB::table('site_message_recipients')->insertUsing(['message_id', 'recipient_kind', 'recipient_id', 'language', 'created_at'], $source);
+            $count = DB::table('site_message_recipients')->where('message_id', $id)->count();
+            DB::table('site_messages')->where('id', $id)->update(['status' => 'sent', 'sent_at' => $now,
+                'recipient_count' => $count, 'skipped_count' => max(0, $targetCount - $count), 'updated_at' => $now]);
+            OperationAuditService::record('site_message', $id, 'send', $before, $this->snapshot($id), $now,
+                $actor ?? ['operator_id' => null, 'operator_name' => '系统']);
+            SiteRealtimeService::changed('message', $id, 'send');
+            return true;
+        }, 3);
+    }
+
+    public function dispatchDue(): array
+    {
+        $result = ['sent' => 0, 'failed' => 0];
+        DB::table('site_messages')->where('status', 'scheduled')->where('send_at', '<=', $this->now())->orderBy('id')->chunkById(100, function ($rows) use (&$result) {
+            foreach ($rows as $row) {
+                try { if ($this->deliver((int) $row->id)) ++$result['sent']; }
+                catch (\Throwable $e) { ++$result['failed']; Log::error('site_message_delivery_failed', ['id' => $row->id, 'exception' => $e]); }
+            }
+        });
+        return $result;
+    }
+
+    public function retract(int $id): array
+    {
+        return DB::transaction(function () use ($id) {
+            $found = DB::table('site_messages')->where('id', $id)->lockForUpdate()->first();
+            if (!$found) LocalizedContent::invalid('id', '信息不存在');
+            $this->checkAccess($found->recipient_type);
+            if ($found->status === 'retracted') return $this->detail($id);
+            $before = $this->snapshot($id); $now = $this->now();
+            DB::table('site_messages')->where('id', $id)->update(['status' => 'retracted', 'retracted_at' => $now, 'updated_at' => $now]);
+            OperationAuditService::record('site_message', $id, 'retract', $before, $this->snapshot($id), $now);
+            SiteRealtimeService::changed('message', $id, 'retract');
+            return $this->detail($id);
+        }, 3);
+    }
+
+    public function listing(array $input): array
+    {
+        $params = Validator::make($input, $this->pageRules() + [
+            'category' => ['nullable', Rule::in(array_keys(self::CATEGORIES))], 'language' => ['nullable', Rule::in(array_keys(LocalizedContent::LANGUAGES))],
+            'title' => ['nullable', 'string', 'max:500'],
+            'start_date' => ['nullable', 'required_with:end_date', 'date_format:Y-m-d'],
+            'end_date' => ['nullable', 'required_with:start_date', 'date_format:Y-m-d', 'after_or_equal:start_date'],
+        ])->validate();
+        $query = DB::table('site_messages as m');
+        if (!self::canManageAgents()) $query->whereIn('m.recipient_type', ['all_users', 'specified_users']);
+        if (!empty($params['category'])) $query->where('m.category', $params['category']);
+        if (!empty($params['start_date'])) $query->where('m.send_at', '>=', $params['start_date'] . ' 00:00:00')->where('m.send_at', '<=', $params['end_date'] . ' 23:59:59');
+        if (!empty($params['language']) || (isset($params['title']) && $params['title'] !== '')) {
+            $query->whereExists(function ($q) use ($params) {
+                $q->selectRaw('1')->from('site_message_translations as t')->whereColumn('t.message_id', 'm.id');
+                if (!empty($params['language'])) $q->where('t.language', $params['language']);
+                if (isset($params['title']) && $params['title'] !== '') $q->where('t.title', 'like', '%' . $params['title'] . '%');
+            });
+        }
+        $total = (clone $query)->count();
+        $rows = $query->select('m.*')->orderByDesc('m.send_at')->orderByDesc('m.id')->forPage((int) ($params['page'] ?? 1), (int) ($params['limit'] ?? 20))->get();
+        $translations = [];
+        if ($rows->isNotEmpty()) foreach (DB::table('site_message_translations')->whereIn('message_id', $rows->pluck('id')->all())->get(['message_id', 'language', 'title']) as $text) {
+            $translations[$text->message_id][$text->language] = ['title' => $text->title];
+        }
+        return ['total' => $total, 'data' => $rows->map(fn ($row) => $this->dto((array) $row, false, $translations[$row->id] ?? []))->all()];
+    }
+
+    public function detail(int $id): array
+    {
+        $row = DB::table('site_messages')->where('id', $id)->first();
+        if (!$row) LocalizedContent::invalid('id', '信息不存在');
+        $this->checkAccess($row->recipient_type);
+        $item = $this->dto((array) $row, true);
+        $item['delivered_by_language'] = DB::table('site_message_recipients')->where('message_id', $id)->select('language')->selectRaw('COUNT(*) AS total')->groupBy('language')->pluck('total', 'language')->map(fn ($n) => (int) $n)->all();
+        return $item;
+    }
+
+    public function logs(array $input): array
+    {
+        return LocalizedAuditService::listing('site_message', $input, function ($q) {
+            $q->join('site_messages as m', 'm.id', '=', 'a.resource_id');
+            if (!self::canManageAgents()) $q->whereIn('m.recipient_type', ['all_users', 'specified_users']);
+        });
+    }
+
+    private function snapshot(int $id): array
+    {
+        $row = (array) DB::table('site_messages')->where('id', $id)->first();
+        unset($row['request_hash']);
+        $row['recipient_ids'] = json_decode($row['recipient_ids'], true, 512, JSON_THROW_ON_ERROR);
+        $row['translations'] = $this->translations($id);
+        return $row;
+    }
+
+    private function dto(array $row, bool $full, ?array $translations = null): array
+    {
+        $translations = $translations ?? $this->translations((int) $row['id']);
+        unset($row['request_hash']);
+        foreach (['id', 'sender_id', 'recipient_count', 'skipped_count'] as $key) $row[$key] = (int) $row[$key];
+        $row['recipient_ids'] = json_decode($row['recipient_ids'], true, 512, JSON_THROW_ON_ERROR);
+        $row['titles'] = (object) array_map(fn ($b) => $b['title'], $translations); $row['languages'] = array_keys($translations);
+        $row['category_name'] = self::CATEGORIES[$row['category']]; $row['recipient_label'] = self::TARGETS[$row['recipient_type']];
+        $row['status_name'] = self::STATES[$row['status']]; $row['sender_display'] = '批核者:' . $row['sender_name'];
+        if ($full) $row['translations'] = $translations;
+        return $row;
+    }
+
+    private function translations(int $id): array
+    {
+        $result = [];
+        foreach (DB::table('site_message_translations')->where('message_id', $id)->orderBy('language')->get() as $row) $result[$row->language] = ['title' => $row->title, 'subtitle' => $row->subtitle, 'content' => $row->content];
+        return $result;
+    }
+
+    private function eligible(string $kind)
+    {
+        $query = DB::table(($kind === 'agent' ? 'agents' : 'users') . ' as u');
+        if ($kind === 'user') $query->where('u.from', 1)->whereNull('u.delete_time');
+        return $query;
+    }
+    private function checkAccess(string $type): void { if (str_ends_with($type, 'agents')) $this->requireAgentAccess(); }
+    private function requireAgentAccess(): void { if (!self::canManageAgents()) throw new AuthorizationException('无代理管理权限'); }
+    private function optionsFor(array $values): array { $result = []; foreach ($values as $value => $label) $result[] = compact('value', 'label'); return $result; }
+    private function pageRules(): array { return ['page' => ['nullable', 'integer', 'min:1'], 'limit' => ['nullable', 'integer', 'min:1', 'max:200']]; }
+    private function now(): string { return Carbon::now('Asia/Shanghai')->format('Y-m-d H:i:s'); }
+}

+ 30 - 0
app/Services/SiteRealtimeService.php

@@ -0,0 +1,30 @@
+<?php
+
+namespace App\Services;
+
+use Carbon\Carbon;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+use Illuminate\Validation\ValidationException;
+use InvalidArgumentException;
+
+/** Written inside the same transaction as the change; consumed by the member WebSocket worker. */
+final class SiteRealtimeService
+{
+    public static function changed(string $module, int $id, string $action, array $boundaries = [], bool $notifyNow = true): void
+    {
+        if (!in_array($module, ['message', 'announcement', 'maintenance'], true) || $id < 1) throw new InvalidArgumentException('Invalid site event');
+        if (!Schema::hasTable('site_realtime_events')) {
+            throw ValidationException::withMessages(['realtime' => '实时通知服务未初始化,请先完成系统更新']);
+        }
+        $now = Carbon::now('Asia/Shanghai')->format('Y-m-d H:i:s');
+        $pending = DB::table('site_realtime_events')->where('module', $module)->where('resource_id', $id)->whereNull('dispatched_at');
+        if ($module !== 'announcement') $pending->where('available_at', '>', $now);
+        $pending->delete();
+        $rows = $notifyNow ? [['module' => $module, 'resource_id' => $id, 'action' => $action, 'available_at' => $now, 'created_at' => $now]] : [];
+        foreach ($boundaries as $edge => $at) {
+            if ($at !== null && $at > $now) $rows[] = ['module' => $module, 'resource_id' => $id, 'action' => $edge, 'available_at' => $at, 'created_at' => $now];
+        }
+        if ($rows) DB::table('site_realtime_events')->insert($rows);
+    }
+}

+ 24 - 7
app/Services/ThirdGameBalanceService.php

@@ -144,15 +144,25 @@ class ThirdGameBalanceService
     }
     }
 
 
     /**
     /**
-     * 新规则:直接 u + users.id,不补零、不做哈希。
+     * 使用 users.id;数字部分不足4位左补零,保持已有合规账号不变。
      */
      */
     public function playerIdFromUserId(int $userId): string
     public function playerIdFromUserId(int $userId): string
     {
     {
-        if ($userId <= 0) {
-            throw new \InvalidArgumentException('users.id 必须是正整数');
+        $prefix = $this->playerPrefix();
+        if ($userId <= 0 || strlen((string) $userId) > 11 - strlen($prefix)) {
+            throw new \InvalidArgumentException('users.id 超出三方玩家账号允许的长度');
         }
         }
 
 
-        return 'u' . $userId;
+        return $prefix . str_pad((string) $userId, 4, '0', STR_PAD_LEFT);
+    }
+
+    public function playerPrefix(): string
+    {
+        $prefix = trim((string) config('third_game.player_prefix', 'u'));
+        if (!in_array($prefix, ['u', 'tu'], true)) {
+            throw new \InvalidArgumentException('GAME_PLAYER_PREFIX 只能配置为u或tu');
+        }
+        return $prefix;
     }
     }
 
 
     public function agentPlayerId(int $agentId): string
     public function agentPlayerId(int $agentId): string
@@ -587,7 +597,14 @@ class ThirdGameBalanceService
 
 
     private function numericValue($value)
     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
     private function historyRangeError(string $startTime, string $endTime): ?string
@@ -720,12 +737,12 @@ class ThirdGameBalanceService
 
 
     private function cacheKey(string $memberId): string
     private function cacheKey(string $memberId): string
     {
     {
-        return 'third_game_total_balance:' . $memberId;
+        return 'third_game_total_balance:' . ($this->playerPrefix() === 'u' ? '' : 'tu:') . $memberId;
     }
     }
 
 
     private function detailCacheKey(string $memberId): string
     private function detailCacheKey(string $memberId): string
     {
     {
-        return 'third_game_balance_detail:' . $memberId;
+        return 'third_game_balance_detail:' . ($this->playerPrefix() === 'u' ? '' : 'tu:') . $memberId;
     }
     }
 
 
     private function historyRatePrefix(): string
     private function historyRatePrefix(): string

+ 17 - 9
app/Services/ThirdGameOrderService.php

@@ -55,6 +55,12 @@ class ThirdGameOrderService
         }
         }
 
 
         $orders = is_array($result['list'] ?? null) ? $result['list'] : [];
         $orders = is_array($result['list'] ?? null) ? $result['list'] : [];
+        $received = count($orders);
+        $testNamespace = $this->provider->playerPrefix() === 'tu';
+        $orders = array_values(array_filter($orders, static function (array $order) use ($testNamespace): bool {
+            $isTestPlayer = str_starts_with(trim((string) ($order['player_id'] ?? '')), 'tu');
+            return $testNamespace === $isTestPlayer;
+        }));
         $users = $this->usersByPlayerId(array_values(array_unique(array_filter(array_map(
         $users = $this->usersByPlayerId(array_values(array_unique(array_filter(array_map(
             static fn(array $order): string => trim((string) ($order['player_id'] ?? '')),
             static fn(array $order): string => trim((string) ($order['player_id'] ?? '')),
             $orders
             $orders
@@ -122,11 +128,11 @@ class ThirdGameOrderService
 
 
         $pageNo = (int) ($result['page_no'] ?? $page);
         $pageNo = (int) ($result['page_no'] ?? $page);
         $pageSize = (int) ($result['page_size'] ?? $limit);
         $pageSize = (int) ($result['page_size'] ?? $limit);
-        $total = (int) ($result['total'] ?? count($orders));
+        $total = (int) ($result['total'] ?? $received);
 
 
         return [
         return [
             'ok' => true,
             'ok' => true,
-            'received' => count($orders),
+            'received' => $received,
             'synced' => count($rows),
             'synced' => count($rows),
             'matched_users' => count($matchedUserIds),
             'matched_users' => count($matchedUserIds),
             'unmatched_orders' => count(array_filter($rows, static fn(array $row): bool => $row['user_id'] === null)),
             'unmatched_orders' => count(array_filter($rows, static fn(array $row): bool => $row['user_id'] === null)),
@@ -166,6 +172,8 @@ class ThirdGameOrderService
         $limit = min(200, max(1, (int) ($params['limit'] ?? 20)));
         $limit = min(200, max(1, (int) ($params['limit'] ?? 20)));
         $query = ThirdGameOrderModel::query()
         $query = ThirdGameOrderModel::query()
             ->leftJoin('users', 'users.id', '=', 'third_game_orders.user_id');
             ->leftJoin('users', 'users.id', '=', 'third_game_orders.user_id');
+        if ($this->provider->playerPrefix() === 'tu') $query->where('third_game_orders.player_id', 'like', 'tu%');
+        else $query->where('third_game_orders.player_id', 'not like', 'tu%');
 
 
         if (!empty($params['user_id'])) {
         if (!empty($params['user_id'])) {
             $userId = (int) $params['user_id'];
             $userId = (int) $params['user_id'];
@@ -262,17 +270,18 @@ class ThirdGameOrderService
      */
      */
     private function usersByPlayerId(array $playerIds): array
     private function usersByPlayerId(array $playerIds): array
     {
     {
-        $requested = [];
+        $requested = []; $prefix = $this->provider->playerPrefix();
+        $pattern = '/^' . preg_quote($prefix, '/') . '(\d{1,' . (11 - strlen($prefix)) . '})$/D';
         foreach ($playerIds as $playerId) {
         foreach ($playerIds as $playerId) {
             $playerId = trim((string) $playerId);
             $playerId = trim((string) $playerId);
-            if (!preg_match('/^u([1-9]\d*)$/D', $playerId, $matches)) {
+            if (!preg_match($pattern, $playerId, $matches)) {
                 continue;
                 continue;
             }
             }
-            $userId = filter_var($matches[1], FILTER_VALIDATE_INT, [
+            $userId = filter_var(ltrim($matches[1], '0'), FILTER_VALIDATE_INT, [
                 'options' => ['min_range' => 1, 'max_range' => PHP_INT_MAX],
                 'options' => ['min_range' => 1, 'max_range' => PHP_INT_MAX],
             ]);
             ]);
-            if ($userId !== false) {
-                $requested[(int) $userId] = $playerId;
+            if ($userId !== false && ($playerId === $prefix . $userId || $playerId === $this->provider->playerIdFromUserId($userId))) {
+                $requested[(int) $userId][$playerId] = true;
             }
             }
         }
         }
         if ($requested === []) {
         if ($requested === []) {
@@ -286,8 +295,7 @@ class ThirdGameOrderService
 
 
         $result = [];
         $result = [];
         foreach ($rows as $row) {
         foreach ($rows as $row) {
-            $playerId = $requested[(int) $row->id] ?? null;
-            if ($playerId !== null) {
+            foreach (array_keys($requested[(int) $row->id] ?? []) as $playerId) {
                 $result[$playerId] = $row;
                 $result[$playerId] = $row;
             }
             }
         }
         }

+ 3 - 0
app/Services/UserService.php

@@ -57,6 +57,9 @@ class UserService extends BaseService
         if (isset($search['member_id']) && !empty($search['member_id'])) {
         if (isset($search['member_id']) && !empty($search['member_id'])) {
             $where[] = ['users.member_id', '=', $search['member_id']];
             $where[] = ['users.member_id', '=', $search['member_id']];
         }
         }
+        if (isset($search['account']) && $search['account'] !== '') {
+            $where[] = ['users.account', '=', $search['account']];
+        }
         if (isset($search['first_name']) && !empty($search['first_name'])) {
         if (isset($search['first_name']) && !empty($search['first_name'])) {
             $where[] = ['users.first_name', '=', $search['first_name']];
             $where[] = ['users.first_name', '=', $search['first_name']];
         }
         }

Daži faili netika attēloti, jo izmaiņu fails ir pārāk liels