瀏覽代碼

活动、报表

doge 1 天之前
父節點
當前提交
c1b4269bc9
共有 79 個文件被更改,包括 6868 次插入 和 55 次删除
  1. 17 0
      app/Console/Commands/DispatchSiteMessages.php
  2. 31 0
      app/Console/Commands/SyncPromotions.php
  3. 18 0
      app/Console/Commands/SyncReporting.php
  4. 3 0
      app/Console/Kernel.php
  5. 1 0
      app/Http/Controllers/admin/Agent.php
  6. 42 0
      app/Http/Controllers/admin/Announcement.php
  7. 42 0
      app/Http/Controllers/admin/Maintenance.php
  8. 2 1
      app/Http/Controllers/admin/PaymentOrder.php
  9. 31 0
      app/Http/Controllers/admin/Promotion.php
  10. 31 0
      app/Http/Controllers/admin/Reporting.php
  11. 29 0
      app/Http/Controllers/admin/SiteMessage.php
  12. 5 4
      app/Http/Controllers/admin/Wallet.php
  13. 1 0
      app/Http/Controllers/agent/AuthController.php
  14. 15 0
      app/Http/Controllers/agent/MessageController.php
  15. 4 2
      app/Http/Controllers/agent/ProfileController.php
  16. 59 0
      app/Http/Controllers/api/Promotion.php
  17. 3 1
      app/Http/Controllers/api/TelegramWebHook.php
  18. 41 0
      app/Http/Middleware/MemberMaintenance.php
  19. 1 1
      app/Models/Agent/Agent.php
  20. 9 1
      app/Models/BaseModel.php
  21. 20 0
      app/Models/PromotionActivity.php
  22. 2 2
      app/Services/ActivityEligibilityService.php
  23. 2 2
      app/Services/Agent/AgentService.php
  24. 262 0
      app/Services/AnnouncementService.php
  25. 13 0
      app/Services/BalanceLogService.php
  26. 61 0
      app/Services/LocalizedAuditService.php
  27. 152 0
      app/Services/MaintenanceService.php
  28. 7 5
      app/Services/OperationAuditService.php
  29. 3 0
      app/Services/PaymentOrderService.php
  30. 98 0
      app/Services/PromotionAdminService.php
  31. 153 0
      app/Services/PromotionFacts.php
  32. 474 0
      app/Services/PromotionService.php
  33. 18 8
      app/Services/RechargeService.php
  34. 22 0
      app/Services/Reporting/ReportActivity.php
  35. 139 0
      app/Services/Reporting/ReportAdmin.php
  36. 64 0
      app/Services/Reporting/ReportContext.php
  37. 381 0
      app/Services/Reporting/ReportReader.php
  38. 210 0
      app/Services/Reporting/ReportSync.php
  39. 10 1
      app/Services/Sharing/BetFactSynchronizer.php
  40. 44 21
      app/Services/Sharing/ShareSettlementService.php
  41. 58 0
      app/Services/SiteInboxService.php
  42. 235 0
      app/Services/SiteMessageService.php
  43. 28 0
      app/Support/AnnouncementCatalog.php
  44. 40 0
      app/Support/LocalizedContent.php
  45. 92 0
      app/Support/MaintenancePolicy.php
  46. 66 0
      app/Support/PromotionRules.php
  47. 1 1
      config/cors.php
  48. 6 0
      config/reporting.php
  49. 39 0
      database/migrations/2026_10_26_100000_create_announcements.php
  50. 43 0
      database/migrations/2026_10_26_110000_create_site_messages.php
  51. 30 0
      database/migrations/2026_10_26_120000_create_maintenance_settings.php
  52. 54 0
      database/migrations/2026_10_26_140000_create_reporting_tables.php
  53. 84 0
      database/migrations/2026_10_26_160000_create_promotion_activities.php
  54. 72 0
      docs/代理/站内信息.md
  55. 11 0
      docs/前端接口/独立活动/会员接入.md
  56. 54 0
      docs/前端接口/独立活动/净亏邀请-会员接口.md
  57. 508 0
      docs/前端接口/独立活动/转盘播报示例.json
  58. 91 0
      docs/前端接口/独立活动/转盘活动-会员接口.md
  59. 45 0
      docs/前端接口/独立活动/首充奖励-会员接口.md
  60. 26 0
      docs/后端/分享返佣-历史重算任务恢复.md
  61. 89 0
      docs/后端/报表统计口径与数据补录.md
  62. 157 0
      docs/总后台/信息管理-后台接口.md
  63. 141 0
      docs/总后台/公告系统-后台接口.md
  64. 182 0
      docs/总后台/整站与游戏维护-后台接口.md
  65. 11 0
      docs/总后台/独立活动-后台接口.md
  66. 75 0
      docs/总后台/独立活动/净亏邀请-后台接口.md
  67. 102 0
      docs/总后台/独立活动/转盘活动-后台接口.md
  68. 73 0
      docs/总后台/独立活动/首充奖励-后台接口.md
  69. 203 0
      docs/总后台/龙虎榜资金流动与报表-接口.md
  70. 57 0
      routes/admin.php
  71. 5 0
      routes/agent.php
  72. 17 5
      routes/api.php
  73. 276 0
      tests/Integration/AnnouncementTest.php
  74. 215 0
      tests/Integration/MaintenanceTest.php
  75. 345 0
      tests/Integration/PromotionActivitiesTest.php
  76. 381 0
      tests/Integration/ReportingTest.php
  77. 135 0
      tests/Integration/SharingSettlementTest.php
  78. 249 0
      tests/Integration/SiteMessageTest.php
  79. 52 0
      tests/fixtures/sharing_reconcile_recovery_worker.php

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

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

+ 3 - 0
app/Console/Kernel.php

@@ -27,6 +27,9 @@ class Kernel extends ConsoleKernel
 
         $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')
             ->everyMinute()
             ->withoutOverlapping(5);

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

@@ -332,6 +332,7 @@ class Agent extends AgentApiController
     private function rules(bool $update = false): array
     {
         return [
+            'language' => ['sometimes', 'required', 'in:zh,en,vi'],
             'username' => $update
                 ? ['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'],

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

@@ -0,0 +1,42 @@
+<?php
+
+namespace App\Http\Controllers\admin;
+
+use App\Constants\HttpStatus;
+use App\Http\Controllers\Controller;
+use App\Services\AnnouncementService;
+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())); }
+
+    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, '公告服务暂不可用,请稍后重试');
+        }
+    }
+}

+ 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, '维护设置服务暂不可用,请稍后重试');
+        }
+    }
+}

+ 2 - 1
app/Http/Controllers/admin/PaymentOrder.php

@@ -109,9 +109,10 @@ class PaymentOrder extends Controller
                 $wallet = Wallet::where('member_id', $memberId)->lockForUpdate()->first();
                 if (!$wallet) throw new Exception('用户不存在', HttpStatus::CUSTOM_ERROR);
                 $availableBalance = bcadd($wallet->available_balance, $amount, 10);
-                BalanceLogService::addLog($memberId, $amount, $wallet->available_balance, $availableBalance, $changeType, $info->id, $remark);
+                $beforeBalance = (string)$wallet->available_balance;
                 $wallet->available_balance = $availableBalance;
                 $wallet->save();
+                BalanceLogService::addLog($memberId, $amount, $beforeBalance, $availableBalance, $changeType, $info->id, $remark);
             }
             
             DB::commit();

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

@@ -0,0 +1,31 @@
+<?php
+
+namespace App\Http\Controllers\admin;
+
+use App\Constants\HttpStatus;
+use App\Http\Controllers\Controller;
+use App\Services\PromotionAdminService;
+use Illuminate\Support\Facades\Log;
+use Illuminate\Validation\ValidationException;
+
+class Promotion extends Controller
+{
+    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('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, '报表暂不可用,请稍后重试'); }
+    }
+}

+ 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, '信息服务暂不可用,请稍后重试'); }
+    }
+}

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

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

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

@@ -21,6 +21,7 @@ class AuthController extends AgentApiController
                 'id' => (int) $agent->id,
                 'username' => $agent->username,
                 'real_name' => $agent->real_name,
+                'language' => $agent->language ?? 'zh',
                 'invitation_code' => $agent->invitation_code,
                 'level' => (int) $agent->level,
                 '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,
                 'username' => $agent->username,
                 'real_name' => $agent->real_name,
+                'language' => $agent->language ?? 'zh',
                 'parent_id' => $agent->parent_id,
                 'parent_username' => $agent->parent?->username,
                 'level' => (int) $agent->level,
@@ -45,9 +46,10 @@ class ProfileController extends AgentApiController
     public function update(Request $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->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();
             return [];
         });

+ 59 - 0
app/Http/Controllers/api/Promotion.php

@@ -0,0 +1,59 @@
+<?php
+
+namespace App\Http\Controllers\api;
+
+use App\Constants\HttpStatus;
+use App\Http\Controllers\Controller;
+use App\Services\PromotionService;
+use Illuminate\Support\Facades\Log;
+use Illuminate\Validation\ValidationException;
+
+class Promotion extends Controller
+{
+    private function execute(string $action, PromotionService $service)
+    {
+        try {
+            $rules = ['activity_id' => ['required', 'integer', 'min:1'], 'platform' => ['required', 'in:PC,H5,APP']];
+            if ($action === 'spin') $rules['request_key'] = ['required', 'string', 'regex:/^[a-zA-Z0-9_-]{16,64}$/'];
+            if ($action === 'claim') $rules['round_no'] = ['required', 'integer', 'min:1'];
+            if (in_array($action, ['records', 'rewards'], true)) $rules += ['page' => ['sometimes', 'integer', 'min:1'], 'limit' => ['sometimes', 'integer', 'between:1,100']];
+            $input = request()->validate($rules); $id = (int)$input['activity_id']; $userId = (int)request()->user->id;
+            $data = match ($action) {
+                'detail' => $service->detail($id, $userId, $input['platform']),
+                'progress' => $service->progress($id, $userId, $input['platform']),
+                'apply' => $service->apply($id, $userId, $input['platform']),
+                'spin' => $service->spin($id, $userId, $input['request_key'], $input['platform']),
+                'claim' => $service->claim($id, $userId, (int)$input['round_no'], $input['platform']),
+                'records' => $service->records($id, $userId, (int)($input['page'] ?? 1), (int)($input['limit'] ?? 20)),
+                'rewards' => $service->rewards($id, $userId, (int)($input['page'] ?? 1), (int)($input['limit'] ?? 20)),
+                'broadcasts' => $service->broadcasts($id),
+            };
+            return $this->success($data);
+        } catch (ValidationException $e) { return $this->error(HttpStatus::CUSTOM_ERROR, $e->validator->errors()->first()); }
+        catch (\Throwable $e) { Log::error('promotion_failed', ['action' => $action, 'exception' => $e]); return $this->error(HttpStatus::CUSTOM_ERROR, '活动服务暂不可用,请稍后重试'); }
+    }
+    public function detail(PromotionService $s) { return $this->execute('detail', $s); }
+    public function heartbeat(PromotionService $s)
+    {
+        try {
+            $p = request()->validate(['platform' => ['required', 'in:PC,H5,APP']]);
+            return $this->success($s->heartbeat((int)request()->user->id, (string)request()->header('Authorization'), $p['platform']));
+        } catch (ValidationException $e) { return $this->error(HttpStatus::CUSTOM_ERROR, $e->validator->errors()->first()); }
+        catch (\Throwable $e) { Log::error('promotion_heartbeat_failed', ['exception' => $e]); return $this->error(HttpStatus::CUSTOM_ERROR, '活动记录暂不可用'); }
+    }
+    public function index(PromotionService $s)
+    {
+        try {
+            $p = request()->validate(['platform' => ['required', 'in:PC,H5,APP'], 'lang' => ['sometimes', 'string', 'max:10'], 'type' => ['sometimes', 'integer', 'in:7,8,9']]);
+            return $this->success($s->listing((int)request()->user->id, $p['platform'], $p['lang'] ?? 'zh', isset($p['type']) ? (int)$p['type'] : null));
+        } catch (ValidationException $e) { return $this->error(HttpStatus::CUSTOM_ERROR, $e->validator->errors()->first()); }
+        catch (\Throwable $e) { Log::error('promotion_list_failed', ['exception' => $e]); return $this->error(HttpStatus::CUSTOM_ERROR, '活动服务暂不可用'); }
+    }
+    public function progress(PromotionService $s) { return $this->execute('progress', $s); }
+    public function apply(PromotionService $s) { return $this->execute('apply', $s); }
+    public function spin(PromotionService $s) { return $this->execute('spin', $s); }
+    public function claim(PromotionService $s) { return $this->execute('claim', $s); }
+    public function records(PromotionService $s) { return $this->execute('records', $s); }
+    public function rewards(PromotionService $s) { return $this->execute('rewards', $s); }
+    public function broadcasts(PromotionService $s) { return $this->execute('broadcasts', $s); }
+}

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

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

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

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

@@ -16,7 +16,7 @@ class Agent extends BaseModel
     protected $table = 'agents';
     protected $fillable = [
         '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',
         'profit_commission_rate', 'status', 'last_login_at', 'last_login_ip', 'created_by',
         'flow_commission_profile_id', 'profit_commission_profile_id',

+ 9 - 1
app/Models/BaseModel.php

@@ -24,6 +24,14 @@ class BaseModel extends Authenticatable
     protected $hidden = ['created_at', 'updated_at'];
     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
     {
         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');
     }
-}
+}

+ 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', 'sub_title', '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 = ['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; }
+}

+ 2 - 2
app/Services/ActivityEligibilityService.php

@@ -31,12 +31,12 @@ class ActivityEligibilityService
         ];
     }
 
-    public static function evaluate(ActivityReward $activity, ?array $member): array
+    public static function evaluate(ActivityReward|\App\Models\PromotionActivity $activity, ?array $member): array
     {
         return ActivityEligibility::evaluate($activity->toArray(), $member);
     }
 
-    public static function isPublishedOn(ActivityReward $activity, ?string $platform): bool
+    public static function isPublishedOn(ActivityReward|\App\Models\PromotionActivity $activity, ?string $platform): bool
     {
         return ActivityEligibility::isPublishedOn($activity->toArray(), $platform);
     }

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

@@ -71,7 +71,7 @@ class AgentService
                 'profit_commission_profile_id' => $profitProfileId,
                 'status' => (int) ($data['status'] ?? Agent::STATUS_ENABLED),
                 'created_by' => $createdBy,
-            ]);
+            ] + array_intersect_key($data, ['language' => true]));
             $agent->path = ($parent ? rtrim((string) $parent->path, '/') . '/' : '/') . $agent->id . '/';
             $agent->save();
             $this->flowAssignments->assign($agent, $flowProfile, now()->toDateString(), $createdBy);
@@ -116,7 +116,7 @@ class AgentService
             if (array_key_exists('real_name', $data)) {
                 $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) {
                     $agent->{$field} = $data[$field];
                 }

+ 262 - 0
app/Services/AnnouncementService.php

@@ -0,0 +1,262 @@
+<?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', 'activity_id'];
+
+    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'],
+            'activity_id' => ['nullable', 'integer', 'min:1'],
+        ])->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, 'activity_id' => 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', '请填写有效的外站链接或站内相对路径');
+                $row['activity_id'] = null;
+            } elseif ($row['click_type'] === 'activity') {
+                if (empty($row['activity_id']) || !DB::table('activity_rewards')->where('id', $row['activity_id'])->exists()) $this->invalid('activity_id', '请选择存在的优惠活动');
+                $row['link'] = null;
+            } else { $row['link'] = null; $row['activity_id'] = null; }
+            $row['status'] = (int) $row['status'];
+            $row['activity_id'] = $row['activity_id'] === null ? null : (int) $row['activity_id'];
+            $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);
+            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);
+            return $this->dto($after);
+        });
+    }
+
+    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];
+        if (isset($row['activity_id'])) $row['activity_id'] = (int) $row['activity_id'];
+        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]); }
+}

+ 13 - 0
app/Services/BalanceLogService.php

@@ -160,6 +160,18 @@ class BalanceLogService extends BaseService
      * @return {*}
      */
     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)) {
@@ -274,6 +286,7 @@ class BalanceLogService extends BaseService
             }
         }
         
+        if (in_array($change_type, ['充值', '三方充值'], true)) (new PromotionService())->onDeposit($result->refresh());
         return $result;
     }
 

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

+ 152 - 0
app/Services/MaintenanceService.php

@@ -0,0 +1,152 @@
+<?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'));
+            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'];
 
-    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;
-        OperationAudit::query()->create([
+        $attributes = [
             'resource_type' => $resourceType,
             'resource_id' => $resourceId,
             'action' => $action,
@@ -20,9 +20,11 @@ class OperationAuditService
                 'before' => self::sanitize(self::toArray($before)),
                 '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

+ 3 - 0
app/Services/PaymentOrderService.php

@@ -682,6 +682,9 @@ class PaymentOrderService extends BaseService
 
                 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');
                 $bonusAmount = $payAmount * $rate;
                 if ($rate > 0 && $bonusAmount > 0) {

+ 98 - 0
app/Services/PromotionAdminService.php

@@ -0,0 +1,98 @@
+<?php
+
+namespace App\Services;
+
+use App\Models\PromotionActivity;
+use App\Support\PromotionRules;
+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, '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
+    {
+        return array_replace($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')]);
+    }
+
+    public function save(array $input): array
+    {
+        $money = ['nullable', 'numeric', 'min:0', 'regex:/^\d{1,12}(\.\d{1,2})?$/'];
+        $data = Validator::make($input, ['id' => ['sometimes', 'integer', 'min:1'], 'title' => ['required', 'string', 'max:140'],
+            'sub_title' => ['sometimes', 'nullable', 'string', 'max:200'], 'type' => ['required', 'integer', 'in:7,8,9'], 'status' => ['required', 'integer', 'in:0,1'],
+            'start_time' => ['required', 'date'], 'end_time' => ['required', 'date', 'after:start_time'], 'lang' => ['sometimes', 'string', 'max:10'],
+            'sort' => ['sometimes', 'integer', 'between:0,4294967295'], 'params' => ['sometimes', 'array'], 'content' => ['sometimes', 'nullable', 'string', 'max:100000'],
+            '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]])->validate();
+        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']);
+            $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);
+    }
+
+    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' => '独立活动不存在']);
+            $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()];
+    }
+}

+ 153 - 0
app/Services/PromotionFacts.php

@@ -0,0 +1,153 @@
+<?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 (DB::table('promotion_presence')->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;
+    }
+}

+ 474 - 0
app/Services/PromotionService.php

@@ -0,0 +1,474 @@
+<?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 Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+use Illuminate\Validation\ValidationException;
+
+/** One wallet writer for both clients. Wallet -> activity -> account is the lock order. */
+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('lang', $lang)->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) {
+            if (!ActivityEligibilityService::isPublishedOn($activity, $platform)) continue;
+            $eligibility = ActivityEligibilityService::evaluate($activity, $context);
+            if (!$eligibility['is_eligible'] && !$activity->show_ineligible) continue;
+            $data[] = ['activity_id' => $activity->id, 'type' => (int)$activity->type, 'type_name' => $activity->type_name,
+                'title' => $activity->title, 'sub_title' => $activity->sub_title, 'image' => $activity->image, 'content' => $activity->content,
+                'start_time' => (int)$activity->start_time, 'end_time' => (int)$activity->end_time, 'rules' => $this->rules($activity)] + $eligibility;
+        }
+        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): 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);
+    }
+
+    public function progress(int $activityId, int $userId, ?string $platform = null): array
+    {
+        return $this->mutate($activityId, $userId, function ($activity, $member) use ($platform) {
+            $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);
+        });
+    }
+
+    public function apply(int $activityId, int $userId, ?string $platform = null): array
+    {
+        return $this->mutate($activityId, $userId, function ($activity, $member) use ($platform) {
+            $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);
+        });
+    }
+
+    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)->where('lang', $member->language ?: 'zh')->orderByDesc('sort')->orderByDesc('id')->get();
+            foreach ($activities as $candidate) {
+                $activity = $this->activity($candidate->id, true);
+                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): array
+    {
+        $rules = $this->rules($activity); $type = (int)$activity->type;
+        $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()];
+        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);
+    }
+}

+ 18 - 8
app/Services/RechargeService.php

@@ -485,17 +485,12 @@ class RechargeService extends BaseService
 
                 if ($data['confirmations'] >= TronHelper::CONFIRMED_NUMBER) {
                     $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) {
-                        $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);
 
                         $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
     {
         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)]); }
+    }
+}

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

@@ -0,0 +1,139 @@
+<?php
+
+namespace App\Services\Reporting;
+
+use App\Services\OperationAuditService;
+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) {
+            $data = Validator::make($input, ['valid_deposit_min' => ['required', 'regex:/^\d{1,14}(?:\.\d{1,2})?$/D']])->validate();
+            DB::transaction(function () use ($data) {
+                $before = (array) DB::table('reporting_settings')->where('id', 1)->lockForUpdate()->first();
+                DB::table('reporting_settings')->where('id', 1)->update(['valid_deposit_min' => $data['valid_deposit_min'], 'updated_at' => now()]);
+                $after = (array) DB::table('reporting_settings')->where('id', 1)->first();
+                if (bccomp($before['valid_deposit_min'], $after['valid_deposit_min'], 10) !== 0) OperationAuditService::record('reporting_settings', 1, 'update', $before, $after);
+            });
+        }
+        return (array) DB::table('reporting_settings')->where('id', 1)->first();
+    }
+
+    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'];
+    }
+}

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

@@ -0,0 +1,381 @@
+<?php
+
+namespace App\Services\Reporting;
+
+use App\Services\Sharing\EffectiveBet as Money;
+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 !== '') + ['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'];
+        $start = Carbon::parse($p['start_date'], 'Asia/Shanghai'); $end = Carbon::parse($p['end_date'], 'Asia/Shanghai')->addDay();
+        if ($end->lte($start) || $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;
+    }
+
+    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' => count($days), 'data' => array_slice($days, ((int) $p['page'] - 1) * (int) $p['limit'], (int) $p['limit'])];
+    }
+
+    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'];
+    }
+
+    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'];
+    }
+
+    private function compute(array $p, string $mode): array
+    {
+        [$users, $agents] = $this->people($p); $settings = (new ReportAdmin())->settings();
+        $groups = []; $unallocated = null; $deposits = []; $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', '总计');
+            for ($day = Carbon::parse($p['start']); $day->format('Y-m-d H:i:s') < $p['end']; $day->addDay()) $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)) $keys[] = substr($row['bet_at'], 0, 10);
+                if ($this->within($row['settled_at'], $p)) $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']; }
+            if ($row['kind'] === 'deposit' && $row['occurred_at'] !== null && $row['amount'] !== null && $row['currency'] === (new ReportSync())->currency()) $deposits[$row['user_id']] = bcadd($deposits[$row['user_id']] ?? '0', $row['amount'], 10);
+            $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) $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 = $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 = [];
+        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'] = 0; $value['active_member_count'] = 0; $seconds = 0;
+            foreach ($value['_bettors'] as $uid => $_) if (bccomp($deposits[$uid] ?? '0', $settings['valid_deposit_min'], 10) > 0) ++$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' => bcadd($settings['valid_deposit_min'], '0', 10),
+            '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; }
+    }
+}

+ 10 - 1
app/Services/Sharing/BetFactSynchronizer.php

@@ -15,6 +15,15 @@ class BetFactSynchronizer
         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) {
@@ -161,7 +170,7 @@ class BetFactSynchronizer
     private function singleOrder(string $table, object $order): ?array
     {
         $previous = null;
-        if ($table === 'third_game_orders' && !empty($order->order_key)) {
+        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 ?? ''));

+ 44 - 21
app/Services/Sharing/ShareSettlementService.php

@@ -270,30 +270,53 @@ class ShareSettlementService
 
     public function reconcile(): array
     {
-        $dirty=DB::transaction(function () {
+        $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 [];
-            $dates=json_decode($row->value,true,512,JSON_THROW_ON_ERROR);
-            DB::table('share_system_state')->where('key','facts_dirty_dates')->update(['value'=>'[]']);
-            return $dates;
-        });
-        if (!$dirty) return ['dates'=>0,'changed'=>0];
-        try {
-            $today=Carbon::now(ShareDefaults::TIMEZONE)->toDateString();
-            $open=array_values(array_filter($dirty,fn($date)=>$date >= $today));
-            // Keep open betting days until a later run can close them, even if the midnight cron is missed.
-            $this->requeue($open);
-            $dirty=array_values(array_filter($dirty,fn($date)=>$date < $today));
-            if (!$dirty) return ['dates'=>0,'changed'=>0];
-            $start=min($dirty);
-            $dates=DB::table('share_bet_facts')->where('bet_date','>=',$start)->where('bet_date','<',$today)->distinct()->pluck('bet_date')->all();
+            $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_unique(array_merge($dates,$old)); sort($dates); $changed=0;
-            foreach ($dates as $date) $changed+=$this->settle((string)$date)['changed'];
-            return ['dates'=>count($dates),'changed'=>$changed];
-        } catch (\Throwable $e) {
-            $this->requeue($dirty);
-            throw $e;
+            $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');
         }
     }
 

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

+ 235 - 0
app/Services/SiteMessageService.php

@@ -0,0 +1,235 @@
+<?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' => '系统']);
+            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);
+            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'); }
+}

+ 28 - 0
app/Support/AnnouncementCatalog.php

@@ -0,0 +1,28 @@
+<?php
+
+namespace App\Support;
+
+final class AnnouncementCatalog
+{
+    public const TIMEZONE = 'Asia/Shanghai';
+    public const CATEGORIES = ['content' => '弹出内容公告', 'image' => '弹出图片公告', 'update' => '弹出更新公告'];
+    public const PLATFORMS = ['PC' => 'PC', 'H5' => 'H5', 'APP' => 'App'];
+    public const FREQUENCIES = ['daily_first_login' => '每日首次登录', 'every_login' => '每次登录'];
+    public const CLICKS = ['none' => '无链接', 'external' => '外站链接', 'internal' => '站内相对路径', 'activity' => '优惠活动'];
+    public const STATES = ['disabled' => '关闭', 'scheduled' => '未开始', 'active' => '展示中', 'expired' => '已结束'];
+
+    public static function state(array $row, string $now): string
+    {
+        if ((int) $row['status'] !== 1) return 'disabled';
+        if ($row['publish_at'] > $now) return 'scheduled';
+        if ($row['end_at'] !== null && $row['end_at'] <= $now) return 'expired';
+        return 'active';
+    }
+
+    public static function options(array $values): array
+    {
+        $result = [];
+        foreach ($values as $value => $label) $result[] = compact('value', 'label');
+        return $result;
+    }
+}

+ 40 - 0
app/Support/LocalizedContent.php

@@ -0,0 +1,40 @@
+<?php
+
+namespace App\Support;
+
+use Illuminate\Support\Facades\Validator;
+use Illuminate\Validation\ValidationException;
+
+final class LocalizedContent
+{
+    public const LANGUAGES = ['zh' => '中文', 'en' => '英文', 'vi' => '越南语'];
+
+    public static function parse($input, int $titleLimit, int $contentLimit, bool $subtitle = false): array
+    {
+        if (!is_array($input) || array_diff(array_keys($input), array_keys(self::LANGUAGES))) {
+            self::invalid('translations', '请按zh/en/vi提交多语言内容');
+        }
+        $result = [];
+        foreach (self::LANGUAGES as $language => $_) {
+            if (!array_key_exists($language, $input)) continue;
+            $block = $input[$language];
+            if (!is_array($block)) self::invalid('translations.' . $language, '语言内容必须为对象');
+            $data = Validator::make($block, [
+                'title' => ['nullable', 'string', 'max:' . $titleLimit],
+                'content' => ['nullable', 'string', 'max:' . $contentLimit],
+                'subtitle' => ['nullable', 'string', 'max:500'],
+            ])->validate();
+            if (trim((string) ($data['content'] ?? '')) === '') continue;
+            if (trim((string) ($data['title'] ?? '')) === '') self::invalid('translations.' . $language . '.title', '已填写正文的语言必须填写标题');
+            $result[$language] = ['title' => trim($data['title']), 'content' => $data['content']];
+            if ($subtitle) $result[$language]['subtitle'] = (string) ($data['subtitle'] ?? '');
+        }
+        if (!$result) self::invalid('translations', '至少填写一个语言的标题和正文');
+        return $result;
+    }
+
+    public static function invalid(string $field, string $message): void
+    {
+        throw ValidationException::withMessages([$field => $message]);
+    }
+}

+ 92 - 0
app/Support/MaintenancePolicy.php

@@ -0,0 +1,92 @@
+<?php
+
+declare(strict_types=1);
+
+namespace App\Support;
+
+use DateTimeImmutable;
+use DateTimeZone;
+
+/** Framework independent; keep the member API copy identical except for its namespace. */
+final class MaintenancePolicy
+{
+    public const CATEGORIES = ['third:1' => '真人', 'third:2' => '电子', 'third:3' => '彩票',
+        'third:4' => '体育', 'third:5' => '电竞', 'third:6' => '捕鱼', 'third:7' => '棋牌',
+        'self:sport' => 'F1体育', 'self:lottery' => 'F1彩票'];
+    public const LANGUAGES = ['zh' => '中文', 'en' => '英文', 'vi' => '越南语'];
+    public const STATES = ['disabled' => '已关闭', 'scheduled' => '待开始', 'active' => '维护中', 'expired' => '已结束'];
+    public const SITE_CODE = 11001;
+    public const GAME_CODE = 11002;
+
+    public static function scopes(): array { return ['site' => '整站'] + self::CATEGORIES; }
+
+    public static function platform($value): ?string
+    {
+        return is_string($value) && in_array(strtoupper(trim($value)), ['PC', 'H5', 'APP'], true)
+            ? strtoupper(trim($value)) : null;
+    }
+
+    public static function language($value): ?string
+    {
+        if (!is_string($value)) return null;
+        $value = strtolower(str_replace('_', '-', trim($value)));
+        $value = explode('-', $value)[0];
+        return isset(self::LANGUAGES[$value]) ? $value : null;
+    }
+
+    public static function state(array $row, int $now): string
+    {
+        if (!(int) $row['enabled']) return 'disabled';
+        if (self::timestamp($row['starts_at']) > $now) return 'scheduled';
+        if ($row['ends_at'] !== null && self::timestamp($row['ends_at']) <= $now) return 'expired';
+        return 'active';
+    }
+
+    public static function platforms(array $row): array
+    {
+        return is_array($row['platforms']) ? $row['platforms'] : json_decode($row['platforms'], true, 512, JSON_THROW_ON_ERROR);
+    }
+
+    public static function matches(array $row, ?string $platform, int $now): bool
+    {
+        // Unknown clients conservatively match every platform; omission cannot bypass a rule.
+        return self::state($row, $now) === 'active' && ($platform === null || in_array($platform, self::platforms($row), true));
+    }
+
+    public static function select(array $rows, ?string $platform, ?string $category, int $now): ?array
+    {
+        $game = null;
+        foreach ($rows as $row) {
+            if (!self::matches($row, $platform, $now)) continue;
+            if ($row['scope_code'] === 'site') return $row;
+            if ($row['scope_code'] === $category) $game = $row;
+        }
+        return $game;
+    }
+
+    public static function publicRule(array $row, string $language): array
+    {
+        $zone = new DateTimeZone($row['timezone']);
+        $start = new DateTimeImmutable($row['starts_at'], new DateTimeZone('UTC'));
+        $end = $row['ends_at'] === null ? null : new DateTimeImmutable($row['ends_at'], new DateTimeZone('UTC'));
+        $fallback = ['zh' => '系统维护中,请稍后再试', 'en' => 'Maintenance in progress. Please try again later.',
+            'vi' => 'Hệ thống đang bảo trì. Vui lòng thử lại sau.'];
+        $content = (string) ($row['content'] ?? '');
+        return ['scope' => $row['scope_code'] === 'site' ? 'site' : 'game',
+            'category' => $row['scope_code'] === 'site' ? null : $row['scope_code'],
+            'platforms' => self::platforms($row), 'timezone' => $row['timezone'],
+            'starts_at' => $start->setTimezone($zone)->format('Y-m-d H:i:s'),
+            'ends_at' => $end ? $end->setTimezone($zone)->format('Y-m-d H:i:s') : null,
+            'starts_at_utc' => $start->format('Y-m-d\TH:i:s\Z'), 'ends_at_utc' => $end ? $end->format('Y-m-d\TH:i:s\Z') : null,
+            'language' => $language, 'content' => trim($content) === '' ? $fallback[$language] : $content];
+    }
+
+    public static function failure(array $row, ?string $platform, string $language, int $now): array
+    {
+        $data = self::publicRule($row, $language) + ['platform' => $platform, 'platform_unknown' => $platform === null,
+            'server_time' => gmdate('Y-m-d\TH:i:s\Z', $now)];
+        return ['code' => $row['scope_code'] === 'site' ? self::SITE_CODE : self::GAME_CODE, 'msg' => $data['content'], 'data' => $data];
+    }
+
+    private static function timestamp(string $value): int { return (new DateTimeImmutable($value, new DateTimeZone('UTC')))->getTimestamp(); }
+}

+ 66 - 0
app/Support/PromotionRules.php

@@ -0,0 +1,66 @@
+<?php
+
+namespace App\Support;
+
+use Illuminate\Support\Facades\Validator;
+
+final class PromotionRules
+{
+    public const LOSS = 7;
+    public const FIRST_DEPOSIT = 8;
+    public const WHEEL = 9;
+    public const TASKS = ['online', 'login', 'invite', 'deposit', 'team_deposit', 'bet', 'team_bet'];
+    public const DAILY = ['online', 'login'];
+    public const PRIZES = ['0-666', '100', '50', '200', '350', '500', '150', '366'];
+
+    public static function supports(int $type): bool { return in_array($type, [7, 8, 9], true); }
+    public static function types(): array { return [7 => '净亏邀请奖励', 8 => '首充奖励', 9 => '转盘活动']; }
+
+    public static function defaults(int $type): array
+    {
+        $common = ['turnover_ratio' => '0.00'];
+        return $common + match ($type) {
+            7 => ['loss_amount' => '500.00', 'invite_count' => 3, 'reward_amount' => '500.00'],
+            8 => ['min_deposit' => '100.00', 'reward_amount' => '10.00'],
+            9 => ['completion_probability' => '20.00', 'online_seconds' => 3600,
+                'deposit_amount' => '3000.00', 'team_deposit_amount' => '500.00',
+                'bet_amount' => '5000.00', 'team_bet_amount' => '3000.00'],
+            default => [],
+        };
+    }
+
+    public static function normalize(int $type, array $input): array
+    {
+        $defaults = self::defaults($type);
+        Validator::make(['params' => $input], ['params' => ['array:' . implode(',', array_keys($defaults))]])->validate();
+        $data = array_replace($defaults, $input);
+        $rules = [];
+        foreach ($data as $key => $value) {
+            $rules[$key] = match ($key) {
+                'invite_count' => ['required', 'integer', 'min:1', 'max:1000'],
+                'online_seconds' => ['required', 'integer', 'min:60', 'max:86400'],
+                'completion_probability' => ['required', 'numeric', 'between:0,100', 'regex:/^\d{1,3}(\.\d{1,2})?$/'],
+                'turnover_ratio' => ['required', 'numeric', 'between:0,10000', 'regex:/^\d{1,5}(\.\d{1,2})?$/'],
+                default => ['required', 'numeric', 'gt:0', 'regex:/^\d{1,12}(\.\d{1,2})?$/'],
+            };
+        }
+        Validator::make($data, $rules)->validate();
+        foreach ($data as $key => $value) $data[$key] = in_array($key, ['invite_count', 'online_seconds'], true) ? (int)$value : bcadd((string)$value, '0', 2);
+        return $data;
+    }
+
+    public static function draw(int $coins, bool $complete, string $probability, ?callable $random = null): array
+    {
+        $random ??= fn (int $min, int $max) => random_int($min, $max);
+        $remaining = 66600 - $coins;
+        if ($remaining <= 0) throw new \LogicException('请先领取本轮奖金');
+        $hit = $complete && $random(1, 10000) <= (int)bcmul($probability, '100', 0);
+        if ($hit) return ['prize_index' => 0, 'amount_cents' => $remaining, 'reached' => true];
+        // Choose eligible sectors before drawing. A fixed face value is never truncated.
+        $eligible = [0];
+        foreach (self::PRIZES as $i => $amount) if ($i && (int)$amount * 100 < $remaining) $eligible[] = $i;
+        $index = $eligible[$random(0, count($eligible) - 1)];
+        $amount = $index === 0 ? $random(0, $remaining - 1) : (int)self::PRIZES[$index] * 100;
+        return ['prize_index' => $index, 'amount_cents' => $amount, 'reached' => false];
+    }
+}

+ 1 - 1
config/cors.php

@@ -5,7 +5,7 @@ return [
     'allowed_methods' => ['GET', 'POST', 'OPTIONS'],// 允许的 HTTP 方法,* 代表所有方法
     'allowed_origins' => ['*'],// 允许的源,* 代表所有域
     'allowed_origins_patterns' => [],//允许的源,通过正则表达式设置,这比allowed_origins 更加灵活
-    'allowed_headers' => ['Content-Type', 'Authorization', 'lang','X-Requested-With'],// 允许的请求头,* 代表所有请求头
+    'allowed_headers' => ['Content-Type', 'Authorization', 'lang', 'Platform', 'X-Requested-With'],// 允许的请求头,* 代表所有请求头
     'exposed_headers' => ['Authorization'], // 允许暴露的响应头
     'max_age' => 0,// 缓存预检请求的时间(秒)
     'supports_credentials' => false,// 是否支持身份验证(例如 cookies)

+ 6 - 0
config/reporting.php

@@ -0,0 +1,6 @@
+<?php
+
+return [
+    // The main wallet is RMB. A supplier's GAME_CURRENCY must never rename wallet cash flows.
+    'currency' => 'CNY',
+];

+ 39 - 0
database/migrations/2026_10_26_100000_create_announcements.php

@@ -0,0 +1,39 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration {
+    public function up(): void
+    {
+        Schema::create('announcements', function (Blueprint $table) {
+            $table->engine = 'InnoDB';
+            $table->id();
+            $table->string('category', 16);
+            $table->json('publish_platforms');
+            $table->string('display_frequency', 24)->default('daily_first_login');
+            $table->dateTime('publish_at');
+            $table->dateTime('end_at')->nullable();
+            $table->unsignedTinyInteger('status')->default(1);
+            $table->string('click_type', 16)->default('none');
+            $table->string('link', 2048)->nullable();
+            $table->unsignedBigInteger('activity_id')->nullable();
+            $table->unsignedBigInteger('sender_id');
+            $table->string('sender_name', 100);
+            $table->timestamps();
+            $table->index(['status', 'publish_at'], 'announcement_publication');
+            $table->index('end_at');
+        });
+        Schema::create('announcement_translations', function (Blueprint $table) {
+            $table->engine = 'InnoDB'; $table->id(); $table->unsignedBigInteger('announcement_id');
+            $table->string('language', 8); $table->string('title', 200); $table->mediumText('content');
+            $table->unique(['announcement_id', 'language'], 'announcement_translation');
+        });
+    }
+
+    public function down(): void
+    {
+        Schema::dropIfExists('announcement_translations'); Schema::dropIfExists('announcements');
+    }
+};

+ 43 - 0
database/migrations/2026_10_26_110000_create_site_messages.php

@@ -0,0 +1,43 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration {
+    public function up(): void
+    {
+        Schema::table('agents', function (Blueprint $t) { $t->string('language', 8)->default('zh'); });
+        Schema::create('site_messages', function (Blueprint $t) {
+            $t->engine = 'InnoDB'; $t->id();
+            $t->string('category', 24)->default('internal');
+            $t->string('recipient_type', 24); $t->json('recipient_ids')->nullable();
+            $t->dateTime('send_at'); $t->dateTime('sent_at')->nullable(); $t->dateTime('retracted_at')->nullable();
+            $t->string('status', 16)->default('scheduled');
+            $t->unsignedBigInteger('sender_id'); $t->string('sender_name', 100);
+            $t->string('request_id', 64); $t->char('request_hash', 64);
+            $t->unsignedInteger('recipient_count')->default(0); $t->unsignedInteger('skipped_count')->default(0);
+            $t->timestamps();
+            $t->unique(['sender_id', 'request_id'], 'site_message_request');
+            $t->index(['status', 'send_at'], 'site_message_due');
+        });
+        Schema::create('site_message_translations', function (Blueprint $t) {
+            $t->engine = 'InnoDB'; $t->id(); $t->unsignedBigInteger('message_id');
+            $t->string('language', 8); $t->string('title', 500); $t->string('subtitle', 500)->default(''); $t->text('content');
+            $t->unique(['message_id', 'language'], 'site_message_translation');
+        });
+        Schema::create('site_message_recipients', function (Blueprint $t) {
+            $t->engine = 'InnoDB'; $t->id(); $t->unsignedBigInteger('message_id');
+            $t->string('recipient_kind', 8); $t->unsignedBigInteger('recipient_id'); $t->string('language', 8);
+            $t->dateTime('read_at')->nullable(); $t->dateTime('created_at');
+            $t->unique(['message_id', 'recipient_kind', 'recipient_id'], 'site_message_delivery');
+            $t->index(['recipient_kind', 'recipient_id', 'read_at'], 'site_message_inbox');
+        });
+    }
+
+    public function down(): void
+    {
+        Schema::dropIfExists('site_message_recipients'); Schema::dropIfExists('site_message_translations'); Schema::dropIfExists('site_messages');
+        Schema::table('agents', function (Blueprint $t) { $t->dropColumn('language'); });
+    }
+};

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

@@ -0,0 +1,30 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration {
+    public function up(): void
+    {
+        Schema::create('maintenance_settings', function (Blueprint $table) {
+            $table->id(); $table->string('scope_code', 32)->unique(); $table->json('platforms');
+            $table->boolean('enabled')->default(false)->index(); $table->dateTime('starts_at');
+            $table->dateTime('ends_at')->nullable(); $table->string('timezone', 64); $table->timestamps();
+        });
+        Schema::create('maintenance_translations', function (Blueprint $table) {
+            $table->id(); $table->unsignedBigInteger('maintenance_id'); $table->string('language', 8); $table->text('content');
+            $table->unique(['maintenance_id', 'language']);
+            $table->foreign('maintenance_id')->references('id')->on('maintenance_settings')->onDelete('cascade');
+        });
+        // Fixed scopes. Deployment must never start maintenance on its own.
+        $now = gmdate('Y-m-d H:i:s');
+        foreach (['site', 'third:1', 'third:2', 'third:3', 'third:4', 'third:5', 'third:6', 'third:7', 'self:sport', 'self:lottery'] as $scope) {
+            DB::table('maintenance_settings')->insert(['scope_code' => $scope, 'platforms' => '["PC","H5","APP"]',
+                'enabled' => false, 'starts_at' => $now, 'ends_at' => null, 'timezone' => 'Asia/Shanghai', 'created_at' => $now, 'updated_at' => $now]);
+        }
+    }
+
+    public function down(): void { Schema::dropIfExists('maintenance_translations'); Schema::dropIfExists('maintenance_settings'); }
+};

+ 54 - 0
database/migrations/2026_10_26_140000_create_reporting_tables.php

@@ -0,0 +1,54 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration {
+    public function up(): void
+    {
+        Schema::create('reporting_settings', function (Blueprint $t) { $t->unsignedInteger('id')->primary(); $t->decimal('valid_deposit_min', 24, 10)->default(20); $t->dateTime('activity_started_at'); $t->timestamps(); });
+        $now = now('Asia/Shanghai')->format('Y-m-d H:i:s');
+        DB::table('reporting_settings')->insert(['id' => 1, 'valid_deposit_min' => '20', 'activity_started_at' => $now, 'created_at' => $now, 'updated_at' => $now]);
+        Schema::create('reporting_contexts', function (Blueprint $t) {
+            $t->id(); $t->string('source', 40); $t->string('source_id', 80); $t->unique(['source', 'source_id']);
+            $t->unsignedBigInteger('user_id')->nullable(); $t->unsignedBigInteger('agent_id')->nullable(); $t->unsignedBigInteger('inviter_id')->nullable();
+            $t->string('client_platform', 8)->default('unknown'); $t->string('attribution_quality', 32)->default('event_snapshot'); $t->boolean('settlement_verified')->default(false); $t->dateTime('occurred_at'); $t->dateTime('succeeded_at')->nullable();
+        });
+        Schema::create('reporting_orders', function (Blueprint $t) {
+            $t->id(); $t->string('source', 40); $t->string('source_id', 80); $t->unique(['source', 'source_id']);
+            $t->string('parent_source', 40); $t->string('parent_id', 80); $t->unsignedBigInteger('user_id')->index();
+            $t->unsignedBigInteger('agent_id')->nullable()->index(); $t->unsignedBigInteger('inviter_id')->nullable()->index();
+            $t->string('attribution_quality', 32); $t->string('category', 32); $t->string('client_platform', 8)->default('unknown'); $t->string('currency', 8);
+            $t->dateTime('bet_at')->index(); $t->dateTime('settled_at')->nullable()->index(); $t->dateTime('settlement_candidate_at')->nullable();
+            foreach (['bet_amount', 'payout_amount', 'net_profit', 'refund_amount'] as $f) $t->decimal($f, 24, 10)->default(0);
+            $t->boolean('settled'); $t->boolean('voided'); $t->string('time_quality', 24); $t->timestamps();
+        });
+        Schema::create('reporting_events', function (Blueprint $t) {
+            $t->id(); $t->string('source', 40); $t->string('source_id', 80); $t->string('kind', 32); $t->unique(['source', 'source_id', 'kind']);
+            $t->unsignedBigInteger('user_id')->nullable()->index(); $t->unsignedBigInteger('agent_id')->nullable()->index(); $t->unsignedBigInteger('inviter_id')->nullable()->index();
+            $t->string('attribution_quality', 32); $t->string('category', 32)->nullable(); $t->string('client_platform', 8)->default('unknown');
+            $t->string('currency', 8); $t->decimal('amount', 24, 10)->nullable(); $t->decimal('customer_fee', 24, 10)->default(0); $t->decimal('configured_fee', 24, 10)->nullable();
+            $t->dateTime('occurred_at')->nullable()->index(); $t->dateTime('candidate_at')->index(); $t->timestamps();
+        });
+        Schema::create('reporting_reconciliations', function (Blueprint $t) {
+            $t->id(); $t->unsignedBigInteger('event_id')->unique(); $t->decimal('actual_amount', 24, 10); $t->decimal('external_fee', 24, 10);
+            $t->dateTime('occurred_at'); $t->string('currency', 8); $t->string('evidence', 1000); $t->unsignedBigInteger('operator_id'); $t->timestamps();
+        });
+        Schema::create('reporting_activity', function (Blueprint $t) {
+            $t->id(); $t->unsignedBigInteger('user_id'); $t->unsignedBigInteger('agent_id')->nullable(); $t->unsignedBigInteger('inviter_id')->nullable();
+            $t->string('client_platform', 8); $t->string('session_hash', 64); $t->dateTime('observed_at')->index();
+            $t->unique(['user_id', 'session_hash', 'observed_at'], 'reporting_activity_dedup');
+        });
+        Schema::create('reporting_sync_state', function (Blueprint $t) { $t->string('source', 40)->primary(); $t->text('state'); $t->dateTime('updated_at'); });
+        Schema::create('reporting_errors', function (Blueprint $t) {
+            $t->id(); $t->string('source', 40); $t->string('source_id', 80); $t->unique(['source', 'source_id']);
+            $t->dateTime('candidate_at')->nullable(); $t->string('reason', 500); $t->timestamps();
+        });
+    }
+    public function down(): void
+    {
+        foreach (['reporting_errors', 'reporting_sync_state', 'reporting_activity', 'reporting_reconciliations', 'reporting_events', 'reporting_orders', 'reporting_contexts', 'reporting_settings'] as $table) Schema::dropIfExists($table);
+    }
+};

+ 84 - 0
database/migrations/2026_10_26_160000_create_promotion_activities.php

@@ -0,0 +1,84 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+use Illuminate\Support\Facades\DB;
+
+return new class extends Migration {
+    public function up(): void
+    {
+        Schema::create('promotion_activities', function (Blueprint $t) {
+            $t->id(); $t->string('title', 140); $t->string('sub_title', 200)->nullable(); $t->unsignedTinyInteger('type');
+            $t->boolean('status')->default(false); $t->unsignedBigInteger('start_time'); $t->unsignedBigInteger('end_time');
+            $t->string('lang', 10)->default('zh'); $t->unsignedInteger('sort')->default(0); $t->text('params');
+            $t->text('content')->nullable(); $t->string('image', 2048)->nullable();
+            $t->json('publish_platforms')->nullable(); $t->json('register_platforms')->nullable();
+            $t->boolean('register_time_limited')->default(false); $t->dateTime('register_start_time')->nullable(); $t->dateTime('register_end_time')->nullable();
+            $t->boolean('deposit_limited')->default(false); $t->decimal('deposit_min', 24, 2)->nullable(); $t->decimal('deposit_max', 24, 2)->nullable();
+            $t->boolean('show_ineligible')->default(true); $t->boolean('quota_enabled')->default(true);
+            $t->unsignedInteger('reward_quota')->nullable(); $t->unsignedInteger('rewarded_count')->default(0);
+            $t->decimal('budget', 24, 2)->nullable(); $t->decimal('budget_used', 24, 2)->default(0);
+            $t->index(['type', 'status', 'lang']); $t->timestamps();
+        });
+        Schema::create('promotion_campaigns', function (Blueprint $t) {
+            $t->unsignedBigInteger('activity_id')->primary(); $t->dateTime('first_published_at')->nullable(); $t->timestamps();
+        });
+        Schema::create('promotion_accounts', function (Blueprint $t) {
+            $t->id(); $t->unsignedBigInteger('activity_id'); $t->unsignedBigInteger('user_id');
+            $t->unsignedInteger('round_no')->default(1); $t->unsignedInteger('spins')->default(0);
+            $t->dateTime('applied_at')->nullable(); $t->text('application_snapshot')->nullable();
+            $t->unique(['activity_id', 'user_id']); $t->timestamps();
+        });
+        Schema::create('promotion_rounds', function (Blueprint $t) {
+            $t->id(); $t->unsignedBigInteger('account_id'); $t->unsignedInteger('round_no');
+            $t->unsignedInteger('coins_cents')->default(0); $t->string('status', 16)->default('active');
+            $t->dateTime('started_at'); $t->unsignedBigInteger('deposit_cursor')->default(0); $t->text('bet_cursors');
+            $t->text('completed_tasks'); $t->dateTime('claimed_at')->nullable();
+            $t->unique(['account_id', 'round_no']); $t->timestamps();
+        });
+        Schema::create('promotion_task_grants', function (Blueprint $t) {
+            $t->id(); $t->unsignedBigInteger('account_id'); $t->unsignedInteger('round_no');
+            $t->string('task', 24); $t->string('event_key', 80); $t->text('evidence');
+            $t->unique(['account_id', 'task', 'event_key'], 'promotion_task_event'); $t->timestamps();
+        });
+        Schema::create('promotion_spins', function (Blueprint $t) {
+            $t->id(); $t->unsignedBigInteger('account_id'); $t->unsignedInteger('round_no');
+            $t->string('request_key', 64); $t->unsignedTinyInteger('prize_index');
+            $t->unsignedInteger('amount_cents'); $t->unsignedInteger('coins_cents');
+            $t->boolean('conditions_complete'); $t->decimal('probability', 5, 2);
+            $t->unique(['account_id', 'request_key']); $t->timestamps();
+        });
+        Schema::create('promotion_rewards', function (Blueprint $t) {
+            $t->id(); $t->unsignedBigInteger('activity_id'); $t->unsignedBigInteger('user_id');
+            $t->unsignedBigInteger('account_id'); $t->unsignedInteger('round_no');
+            $t->decimal('amount', 24, 2); $t->decimal('required_flow', 24, 2)->default(0);
+            $t->string('status', 16); $t->unsignedBigInteger('credited_log_id')->nullable();
+            $t->unsignedBigInteger('unlocked_log_id')->nullable(); $t->text('rule_snapshot'); $t->text('flow_snapshot')->nullable();
+            $t->dateTime('unlocked_at')->nullable(); $t->unique(['account_id', 'round_no']); $t->timestamps();
+        });
+        Schema::create('promotion_first_deposits', function (Blueprint $t) {
+            $t->unsignedBigInteger('user_id')->primary(); $t->unsignedBigInteger('deposit_log_id')->unique(); $t->timestamps();
+        });
+        Schema::create('promotion_presence', function (Blueprint $t) {
+            $t->id(); $t->unsignedBigInteger('user_id'); $t->string('session_hash', 64); $t->dateTime('observed_at');
+            $t->index(['user_id', 'observed_at']); $t->unique(['user_id', 'session_hash', 'observed_at'], 'promotion_presence_dedup');
+        });
+        if (Schema::hasTable('menus')) {
+            $now = now('Asia/Shanghai')->format('Y-m-d H:i:s');
+            $menuId = DB::table('menus')->where('uri', 'promotion')->where('type', 1)->value('id');
+            if (!$menuId) $menuId = DB::table('menus')->insertGetId(['parent_id' => 0, 'title' => '独立活动', 'icon' => null, 'uri' => 'promotion',
+                'permission_name' => 'promotion.manage', 'sort' => 0, 'status' => 1, 'type' => 1, 'created_at' => $now, 'updated_at' => $now]);
+            foreach (['admin/promotion' => '独立活动配置', 'admin/promotion/submit' => '独立活动保存与上下架', 'admin/promotion/records' => '独立活动记录'] as $uri => $title) {
+                if (!DB::table('menus')->where('uri', $uri)->exists()) DB::table('menus')->insert(['parent_id' => $menuId, 'title' => $title, 'icon' => null, 'uri' => $uri,
+                    'permission_name' => str_replace('/', '.', $uri), 'sort' => 0, 'status' => 1, 'type' => 2, 'created_at' => $now, 'updated_at' => $now]);
+            }
+        }
+    }
+
+    public function down(): void
+    {
+        if (Schema::hasTable('menus')) DB::table('menus')->where('permission_name', 'like', 'promotion.%')->orWhereIn('permission_name', ['admin.promotion', 'admin.promotion.submit', 'admin.promotion.records'])->delete();
+        foreach (['promotion_presence', 'promotion_first_deposits', 'promotion_rewards', 'promotion_spins', 'promotion_task_grants', 'promotion_rounds', 'promotion_accounts', 'promotion_campaigns', 'promotion_activities'] as $table) Schema::dropIfExists($table);
+    }
+};

+ 72 - 0
docs/代理/站内信息.md

@@ -0,0 +1,72 @@
+# 代理独立后台:站内信息
+
+接口位于 `bot-28` 的 `/agent`,使用代理独立登录态:
+
+```http
+Authorization: Bearer <agent_token>
+Content-Type: application/json
+```
+
+成功 `code=0`,业务错误 `code=-3`,登录失效 `code=101011`。代理 token 与后台管理员、会员 token 不通用。所有接口只操作当前登录代理本人,不接收指定代理或会员身份。
+
+## 接口
+
+| 方法 | 地址 | 参数 / 返回 |
+| --- | --- | --- |
+| GET | `/agent/messages` | `page/limit/category/is_read`;`data.count/list` |
+| GET | `/agent/messages/detail?id=123` | 完整正文,读取不自动标记已读 |
+| GET | `/agent/messages/unread-count` | `data.count` 未读数量 |
+| POST | `/agent/messages/read` | JSON `{"id":123}`,标记已读 |
+
+分页默认1/20,limit最大100。`category` 可选 `discount/site_notice/internal/promotion`,分别为优惠活动、网站公告、站内信、活动宣传。`is_read=0` 未读、`1` 已读;省略查全部,不能因0值而丢弃参数。
+
+列表按实际投递时间、信息ID倒序:
+
+```json
+{
+  "code": 0,
+  "data": {
+    "count": 1,
+    "list": [{
+      "id": 123,
+      "category": "internal",
+      "category_name": "站内信",
+      "sent_at": "2026-10-26 11:00:00",
+      "title": "账号通知",
+      "subtitle": "",
+      "language": "zh",
+      "read_at": null,
+      "is_read": false
+    }]
+  }
+}
+```
+
+详情在同样字段基础上增加 `content`。正文为纯文本,保留换行;使用文本组件展示,不使用HTML解析。会员资料、管理员账号、其他语言正文不返回。
+
+实际打开详情后调用已读接口,成功返回 `data.id/is_read/read_at`;重复调用保留首次阅读时间。拉取列表、预加载详情不视为已读。可在已读成功后刷新列表和未读数量。
+
+后台撤回后,列表、详情和未读数立即排除该信息;已撤回或不属于当前代理的ID统一提示“信息不存在”。没有代理个人删除接口。
+
+## 语言
+
+消息发送时依据 `agents.language` 选择已填写的翻译,值为 `zh/en/vi`。现有代理首次加字段默认 `zh`。空语言或缺少对应正文不会投递,也不使用其他语言替代。
+
+代理资料接口增加:
+
+```http
+GET /agent/profile
+POST /agent/profile
+```
+
+GET返回 `language`。切换账号语言可POST:
+
+```json
+{"language":"en"}
+```
+
+仅修改语言不会清空真实姓名。代理登录返回的 `agent` 对象也包含语言;后台代理创建/编辑接口可显式设置语言,默认中文。不需要把响应文案的 `lang` 参数当作保存偏好。
+
+收件记录保存投递语言,后来切换语言不会修改已收到的正文,也不会补收此前未投递的信息。信息只发给代理本人,不因其代理层级而扩展至旗下账号或会员。
+
+停用代理不能登录,但后台可以向其投递信息,恢复正常登录后可以读取既有收件记录。定时信息到达实际发送时间后才出现在收件箱,不需要代理端启动额外任务。

+ 11 - 0
docs/前端接口/独立活动/会员接入.md

@@ -0,0 +1,11 @@
+# 独立活动会员文档目录
+
+三个活动分别接入,各自文档包含认证、接口、状态、字段和业务规则:
+
+- [净亏邀请-会员接口](净亏邀请-会员接口.md)
+- [首充奖励-会员接口](首充奖励-会员接口.md)
+- [转盘活动-会员接口](转盘活动-会员接口.md)
+
+[后台文档目录](../../总后台/独立活动-后台接口.md) · [转盘播报联调示例,每组50条](转盘播报示例.json)
+
+上述活动使用bot-28主机的 `/api/promotion/*` 独立入口,不加入原活动列表。

+ 54 - 0
docs/前端接口/独立活动/净亏邀请-会员接口.md

@@ -0,0 +1,54 @@
+# 净亏邀请-会员接口
+
+业务类型为 `7`。
+
+本活动独立于原活动列表,通过 **bot-28主机** 的 `/api/promotion/*` 接口接入,与现有钱包接口使用相同Token。原 `/api/index/activity` 不返回本活动。
+
+请求头为原始 `Authorization: <token>`,无需Bearer前缀。每次传 `platform=PC/H5/APP`;身份由Token识别,不提交会员ID。成功 `code=0`,业务错误 `code=-3`,Token过期为HTTP401,展示实际 `msg`。所有金额按服务端字符串展示。
+
+独立入口调用 `GET /api/promotion/list?type=7&platform=H5`,可加 `lang=zh`,返回 `total/data`。各条包含 `activity_id/type/type_name/title/image/content/rules/is_eligible/ineligible_reason`。不要在原活动类型选项中追加此类型。
+
+`GET /api/promotion/detail?activity_id=1&platform=H5` 只读返回本人状态;不要把旧活动ID当成独立活动ID。提交操作始终以服务端校验为准。
+
+## 页面与接口
+
+| 方法 | 路径 | 参数及用途 |
+|---|---|---|
+| GET | `/api/promotion/list` | `type=7`,独立活动入口 |
+| GET | `/api/promotion/detail` | `activity_id`,净亏、申请及邀请进度 |
+| POST | `/api/promotion/apply` | `activity_id`,达到净亏门槛后申请 |
+| POST | `/api/promotion/progress` | `activity_id`,返回最新参与状态 |
+| POST | `/api/promotion/claim` | `activity_id/round_no=1`,邀请达标后领奖 |
+| GET | `/api/promotion/rewards` | `activity_id/page/limit`,本人奖金记录 |
+
+分页默认1/20,`limit` 最大100。详情和progress返回:
+
+```json
+{
+  "activity_id": 1, "type": 7, "round_no": 1,
+  "rules": {"loss_amount":"500.00","invite_count":3,"reward_amount":"500.00","turnover_ratio":"0.00"},
+  "loss": "500.00", "applied_at": "2026-10-26 15:00:00",
+  "qualified_invitees": 2, "status": "collecting",
+  "valid_member_policy": {"id":1,"effective_date":"2026-10-26","min_deposit":"200","min_effective_bet":"300"},
+  "rewards": []
+}
+```
+
+门槛数值仅为示例,有效推广的充值、投注条件使用 `valid_member_policy`,不写死200。
+
+## 状态与领奖流程
+
+| status | 含义 | 页面操作 |
+|---|---|---|
+| not_applied | 尚未成功申请 | 净亏达到 `rules.loss_amount` 后可申请 |
+| collecting | 已申请,继续邀请 | 展示 `qualified_invitees/rules.invite_count` |
+| ready | 有效邀请人数达标 | 传 `round_no=1` 领取 |
+| claimed | 已领取 | 展示奖金记录,关闭重复领取入口 |
+
+净亏从本活动第一次上架后下单的已结算、未撤销真实注单累计,盈利与亏损相抵,不是充值减提现。申请后新注册并建立直属关系的下级,累计真实充值和有效投注同时达标,才增加有效人数。有效投注逐笔取 `min(本金, |净输赢|)`;人工上分、赠送不计充值。
+
+每人同一活动只有一次机会。重复申请返回原进度,无效申请不消耗机会;下架再上架不重置。申请成功时固定奖励规则,页面显示服务端返回规则;奖励配置后来修改不影响该申请快照。领取重试始终使用 `round_no=1`,不会重复入账。
+
+`rewards.status=paid` 表示已可用,`locked` 表示已发入冻结余额待完成流水。倍数0直接可用,大于0时独立累计奖金发放后有效投注,达到“奖金×倍数”自动解冻。活动下架后仍可查询本人rewards,历史记录保留。
+
+后台配置见[《净亏邀请-后台接口》](../../总后台/独立活动/净亏邀请-后台接口.md)。

+ 508 - 0
docs/前端接口/独立活动/转盘播报示例.json

@@ -0,0 +1,508 @@
+{
+  "is_demo": true,
+  "description": "仅用于页面联调展示的随机示例,不代表真实中奖。正式播报读取活动接口真实记录。",
+  "ordinary": [
+    {
+      "account": "2859****4729",
+      "amount": "174.31",
+      "is_demo": true
+    },
+    {
+      "account": "8963****7626",
+      "amount": "117.43",
+      "is_demo": true
+    },
+    {
+      "account": "1142****7562",
+      "amount": "464.86",
+      "is_demo": true
+    },
+    {
+      "account": "6411****6596",
+      "amount": "251.89",
+      "is_demo": true
+    },
+    {
+      "account": "5440****9161",
+      "amount": "447.76",
+      "is_demo": true
+    },
+    {
+      "account": "7101****1866",
+      "amount": "286.70",
+      "is_demo": true
+    },
+    {
+      "account": "7118****9132",
+      "amount": "271.13",
+      "is_demo": true
+    },
+    {
+      "account": "9131****3114",
+      "amount": "508.04",
+      "is_demo": true
+    },
+    {
+      "account": "7702****6044",
+      "amount": "285.35",
+      "is_demo": true
+    },
+    {
+      "account": "9597****7420",
+      "amount": "566.11",
+      "is_demo": true
+    },
+    {
+      "account": "6285****5666",
+      "amount": "91.76",
+      "is_demo": true
+    },
+    {
+      "account": "6542****7113",
+      "amount": "194.42",
+      "is_demo": true
+    },
+    {
+      "account": "7928****6264",
+      "amount": "402.57",
+      "is_demo": true
+    },
+    {
+      "account": "8829****7037",
+      "amount": "612.83",
+      "is_demo": true
+    },
+    {
+      "account": "6538****4244",
+      "amount": "73.77",
+      "is_demo": true
+    },
+    {
+      "account": "3503****9993",
+      "amount": "268.13",
+      "is_demo": true
+    },
+    {
+      "account": "3550****4668",
+      "amount": "623.95",
+      "is_demo": true
+    },
+    {
+      "account": "4928****1995",
+      "amount": "557.25",
+      "is_demo": true
+    },
+    {
+      "account": "7440****2836",
+      "amount": "517.36",
+      "is_demo": true
+    },
+    {
+      "account": "6074****9763",
+      "amount": "14.04",
+      "is_demo": true
+    },
+    {
+      "account": "9292****4662",
+      "amount": "90.24",
+      "is_demo": true
+    },
+    {
+      "account": "8415****3090",
+      "amount": "512.41",
+      "is_demo": true
+    },
+    {
+      "account": "3692****1909",
+      "amount": "366.05",
+      "is_demo": true
+    },
+    {
+      "account": "5489****2655",
+      "amount": "466.10",
+      "is_demo": true
+    },
+    {
+      "account": "2560****5136",
+      "amount": "385.80",
+      "is_demo": true
+    },
+    {
+      "account": "8563****2330",
+      "amount": "542.38",
+      "is_demo": true
+    },
+    {
+      "account": "2309****5852",
+      "amount": "13.49",
+      "is_demo": true
+    },
+    {
+      "account": "5939****9612",
+      "amount": "412.10",
+      "is_demo": true
+    },
+    {
+      "account": "2327****5375",
+      "amount": "63.57",
+      "is_demo": true
+    },
+    {
+      "account": "1194****9224",
+      "amount": "75.48",
+      "is_demo": true
+    },
+    {
+      "account": "2804****6770",
+      "amount": "242.14",
+      "is_demo": true
+    },
+    {
+      "account": "8332****6774",
+      "amount": "512.45",
+      "is_demo": true
+    },
+    {
+      "account": "9076****1577",
+      "amount": "283.33",
+      "is_demo": true
+    },
+    {
+      "account": "2362****8277",
+      "amount": "374.92",
+      "is_demo": true
+    },
+    {
+      "account": "6173****9036",
+      "amount": "571.15",
+      "is_demo": true
+    },
+    {
+      "account": "3834****6947",
+      "amount": "395.91",
+      "is_demo": true
+    },
+    {
+      "account": "7268****8390",
+      "amount": "563.14",
+      "is_demo": true
+    },
+    {
+      "account": "7965****9477",
+      "amount": "591.86",
+      "is_demo": true
+    },
+    {
+      "account": "2287****3179",
+      "amount": "416.91",
+      "is_demo": true
+    },
+    {
+      "account": "6459****2274",
+      "amount": "48.62",
+      "is_demo": true
+    },
+    {
+      "account": "8837****7079",
+      "amount": "469.50",
+      "is_demo": true
+    },
+    {
+      "account": "4152****6016",
+      "amount": "396.06",
+      "is_demo": true
+    },
+    {
+      "account": "5295****9454",
+      "amount": "390.51",
+      "is_demo": true
+    },
+    {
+      "account": "3602****6814",
+      "amount": "450.24",
+      "is_demo": true
+    },
+    {
+      "account": "7417****9624",
+      "amount": "538.99",
+      "is_demo": true
+    },
+    {
+      "account": "8539****4198",
+      "amount": "479.53",
+      "is_demo": true
+    },
+    {
+      "account": "7923****6264",
+      "amount": "191.17",
+      "is_demo": true
+    },
+    {
+      "account": "5927****8494",
+      "amount": "111.04",
+      "is_demo": true
+    },
+    {
+      "account": "4498****7293",
+      "amount": "451.60",
+      "is_demo": true
+    },
+    {
+      "account": "9803****7849",
+      "amount": "185.24",
+      "is_demo": true
+    }
+  ],
+  "reached_666": [
+    {
+      "account": "8478****5155",
+      "amount": "666.00",
+      "is_demo": true
+    },
+    {
+      "account": "8369****6961",
+      "amount": "666.00",
+      "is_demo": true
+    },
+    {
+      "account": "4180****7761",
+      "amount": "666.00",
+      "is_demo": true
+    },
+    {
+      "account": "4585****4184",
+      "amount": "666.00",
+      "is_demo": true
+    },
+    {
+      "account": "3589****5549",
+      "amount": "666.00",
+      "is_demo": true
+    },
+    {
+      "account": "6854****3088",
+      "amount": "666.00",
+      "is_demo": true
+    },
+    {
+      "account": "3159****6563",
+      "amount": "666.00",
+      "is_demo": true
+    },
+    {
+      "account": "7671****8885",
+      "amount": "666.00",
+      "is_demo": true
+    },
+    {
+      "account": "9661****5853",
+      "amount": "666.00",
+      "is_demo": true
+    },
+    {
+      "account": "7683****2603",
+      "amount": "666.00",
+      "is_demo": true
+    },
+    {
+      "account": "9751****2503",
+      "amount": "666.00",
+      "is_demo": true
+    },
+    {
+      "account": "9838****9598",
+      "amount": "666.00",
+      "is_demo": true
+    },
+    {
+      "account": "5897****2829",
+      "amount": "666.00",
+      "is_demo": true
+    },
+    {
+      "account": "8690****8388",
+      "amount": "666.00",
+      "is_demo": true
+    },
+    {
+      "account": "9922****3200",
+      "amount": "666.00",
+      "is_demo": true
+    },
+    {
+      "account": "3891****7737",
+      "amount": "666.00",
+      "is_demo": true
+    },
+    {
+      "account": "1319****2860",
+      "amount": "666.00",
+      "is_demo": true
+    },
+    {
+      "account": "1919****3404",
+      "amount": "666.00",
+      "is_demo": true
+    },
+    {
+      "account": "8675****4655",
+      "amount": "666.00",
+      "is_demo": true
+    },
+    {
+      "account": "6543****1031",
+      "amount": "666.00",
+      "is_demo": true
+    },
+    {
+      "account": "4469****6461",
+      "amount": "666.00",
+      "is_demo": true
+    },
+    {
+      "account": "5721****3289",
+      "amount": "666.00",
+      "is_demo": true
+    },
+    {
+      "account": "1256****1952",
+      "amount": "666.00",
+      "is_demo": true
+    },
+    {
+      "account": "5086****4557",
+      "amount": "666.00",
+      "is_demo": true
+    },
+    {
+      "account": "1712****2075",
+      "amount": "666.00",
+      "is_demo": true
+    },
+    {
+      "account": "6427****8397",
+      "amount": "666.00",
+      "is_demo": true
+    },
+    {
+      "account": "3520****2204",
+      "amount": "666.00",
+      "is_demo": true
+    },
+    {
+      "account": "2257****7995",
+      "amount": "666.00",
+      "is_demo": true
+    },
+    {
+      "account": "7850****6715",
+      "amount": "666.00",
+      "is_demo": true
+    },
+    {
+      "account": "8923****7251",
+      "amount": "666.00",
+      "is_demo": true
+    },
+    {
+      "account": "2171****6450",
+      "amount": "666.00",
+      "is_demo": true
+    },
+    {
+      "account": "9095****5087",
+      "amount": "666.00",
+      "is_demo": true
+    },
+    {
+      "account": "8947****7329",
+      "amount": "666.00",
+      "is_demo": true
+    },
+    {
+      "account": "7171****8370",
+      "amount": "666.00",
+      "is_demo": true
+    },
+    {
+      "account": "2312****4005",
+      "amount": "666.00",
+      "is_demo": true
+    },
+    {
+      "account": "1036****4558",
+      "amount": "666.00",
+      "is_demo": true
+    },
+    {
+      "account": "2683****1497",
+      "amount": "666.00",
+      "is_demo": true
+    },
+    {
+      "account": "9270****2673",
+      "amount": "666.00",
+      "is_demo": true
+    },
+    {
+      "account": "6287****1165",
+      "amount": "666.00",
+      "is_demo": true
+    },
+    {
+      "account": "2001****3388",
+      "amount": "666.00",
+      "is_demo": true
+    },
+    {
+      "account": "8160****4972",
+      "amount": "666.00",
+      "is_demo": true
+    },
+    {
+      "account": "7408****1467",
+      "amount": "666.00",
+      "is_demo": true
+    },
+    {
+      "account": "3442****6641",
+      "amount": "666.00",
+      "is_demo": true
+    },
+    {
+      "account": "9833****5356",
+      "amount": "666.00",
+      "is_demo": true
+    },
+    {
+      "account": "9540****8696",
+      "amount": "666.00",
+      "is_demo": true
+    },
+    {
+      "account": "2681****8316",
+      "amount": "666.00",
+      "is_demo": true
+    },
+    {
+      "account": "6355****4935",
+      "amount": "666.00",
+      "is_demo": true
+    },
+    {
+      "account": "8178****5499",
+      "amount": "666.00",
+      "is_demo": true
+    },
+    {
+      "account": "2695****5063",
+      "amount": "666.00",
+      "is_demo": true
+    },
+    {
+      "account": "7170****9570",
+      "amount": "666.00",
+      "is_demo": true
+    }
+  ]
+}

+ 91 - 0
docs/前端接口/独立活动/转盘活动-会员接口.md

@@ -0,0 +1,91 @@
+# 转盘活动-会员接口
+
+业务类型为 `9`。
+
+本活动独立于原活动列表,通过 **bot-28主机** 的 `/api/promotion/*` 接口接入,与现有钱包接口使用相同Token。原 `/api/index/activity` 不返回本活动。
+
+请求头为原始 `Authorization: <token>`,无需Bearer前缀。每次传 `platform=PC/H5/APP`;身份由Token识别,不提交会员ID。成功 `code=0`,业务错误 `code=-3`,Token过期为HTTP401,展示实际 `msg`。所有金额按服务端字符串展示。
+
+独立入口调用 `GET /api/promotion/list?type=9&platform=H5`,可加 `lang=zh`,返回 `total/data`。各条包含 `activity_id/type/type_name/title/image/content/rules/is_eligible/ineligible_reason`。不要在原活动类型选项中追加此类型。
+
+`GET /api/promotion/detail?activity_id=1&platform=H5` 只读返回本人状态;不要把旧活动ID当成独立活动ID。提交操作始终以服务端校验为准。
+
+## 接口与页面流程
+
+| 方法 | 路径 | 参数及用途 |
+|---|---|---|
+| GET | `/api/promotion/list` | 独立入口数据,固定 `type=9`、`lang=zh` |
+| GET | `/api/promotion/detail` | `activity_id`,规则与本人状态;只读 |
+| POST | `/api/promotion/heartbeat` | 认证进入可见页面时调用,随后每60秒调用;记录每日登录及实际在线观测 |
+| POST | `/api/promotion/progress` | `activity_id`,初始化参与状态并结算已完成任务次数,返回最新状态 |
+| POST | `/api/promotion/spin` | `activity_id/request_key`,转盘抽奖 |
+| POST | `/api/promotion/claim` | `activity_id/round_no`,领取转盘奖金 |
+| GET | `/api/promotion/records` | `activity_id/page/limit`,本人真实转盘记录 |
+| GET | `/api/promotion/rewards` | `activity_id/page/limit`,本人奖金记录,活动下架后仍可查询 |
+| GET | `/api/promotion/broadcasts` | `activity_id`,普通及达到666播报 |
+
+分页默认1/20,limit最大100。以上各接口均须传platform。
+
+进入转盘页面后发送heartbeat,再调用progress;页面可见时每60秒发送heartbeat并刷新progress,隐藏/离开后停止。已登录用户当天首次认证进入活动也计“每日登录”;日登录次数只发一次。在线以服务端时间采集,相邻观测超过5分钟不连算,多标签页、多端重叠去重;不能用前端倒计时直接申报已完成,也不能用登录到现在的时差代替在线时长。已有报表采集的真实观测可合并使用。
+
+## 转盘状态
+
+```json
+{
+  "activity_id": 1, "type": 9, "round_no": 1,
+  "spins": 7, "coins": "600.25", "remaining": "65.75", "status": "active",
+  "rules": {"completion_probability": "20.00", "online_seconds": 3600},
+  "conditions_complete": true,
+  "completed_tasks": {"online":true,"login":true,"invite":true,"deposit":true,"team_deposit":true,"bet":true,"team_bet":true},
+  "today_granted_tasks": ["login", "online", "deposit"],
+  "tasks": [{"key":"online","round_completed":true,"value":3600,"target":3600,"today_rewarded":true,"reward_spins":1,"remaining_seconds":0}],
+  "valid_member_policy": {"id":1,"effective_date":"2026-10-26","min_deposit":"200","min_effective_bet":"300"},
+  "prizes": ["0-666","100","50","200","350","500","150","366"]
+}
+```
+
+示例省略部分规则和任务。实际 `tasks` 返回七项:
+
+| key | 标题 | 默认门槛 |
+|---|---|---|
+| online | 每日在线 | 3600秒 |
+| login | 每日登录 | 1 |
+| invite | 每有效推广一人 | 同时达到生效的系统充值与有效投注门槛 |
+| deposit | 本人当日累计充值 | 3000 |
+| team_deposit | 直属下级当日累计充值合计 | 500 |
+| bet | 本人当日累计有效投注 | 5000 |
+| team_bet | 直属下级当日累计有效投注合计 | 3000 |
+
+`round_completed` 是累计任务资格,`value` 对四项金额任务表示本轮今天的金额;`invite.value` 为本轮已贡献的有效人数,`online.value` 为今天在线秒数。不要因为某天已完成任务,就把今天的金额显示为昨天的金额。`today_rewarded` 表示今天是否已发该每日任务次数;推广是每个独立下级事件加次,使用实际 `spins` 更新中心按钮。
+
+当日金额不跨天拼接:昨天充值2000、今天1000,不算达标3000。某一天达到3000后,本轮 `deposit` 完成标记可以保留。七项各完成一次即可,不必同一天完成。
+
+有效投注逐笔计算 `min(本金, |净输赢|)`,只计已结算且未撤销的真实注单;例如本金100、净亏50,有效为50。团队金额只计直属下级建立关系后的贡献,试玩不计,充值不含人工上分或赠送。
+
+### 抽奖、概率、领取
+
+抽奖前生成16~64位的字母数字/下划线/短横线 `request_key`;重试同一次操作必须保留原值,新抽奖才换值。推荐使用UUID。同一键只扣一次次数、返回同一结果。服务端成功后再按 `prize_index` 播放动画,禁止客户端先随机再上传结果。
+
+抽奖返回 `spin` 和剩余 `spins`,例如:
+
+```json
+{"spin":{"id":12,"round_no":1,"prize_index":0,"prize":"0-666","amount":"65.75","coins":"666.00","reached":true,"conditions_complete":true,"probability":"20.00","created_at":"2026-10-26 15:00:00"},"spins":6,"replayed":false}
+```
+
+未完成七项时概率固定0;全部完成后每次按后台配置概率判定(默认20%)。普通奖池不会让累计达到666:只抽累计后小于666的固定奖项及浮动奖项;可用奖项等概率,浮动奖项按分等概率取值。完成条件并命中时由浮动奖项补齐差额。界面展示服务端返回的 `ordinary_pool_rule`,固定面额不会被暗中截减。
+
+状态 `not_started/active/reached` 分别表示尚未初始化、正在累计、达到666待领取。达到后停抽,领取传本轮的 `round_no`。领取重试必须保留旧轮号,不用新轮号替换重试请求。奖金已预留,达到后下架也可领取。
+
+领取后金币清零,未用次数保留,进入下一轮。登录、在线完成标记保留且当天不重复加次;其他五项归零,只认领取后新充值、新投注及尚未计奖的新有效推广。当天领取前的金额不能重新用于下一轮。下级整个活动只贡献一次有效推广次数,但其之后的新充值、新投注仍可用于团队金额任务。
+
+`rewards.status`:`reserved` 已达到且待领取,`locked` 已发入冻结余额等待流水,`paid` 已可用。流水倍数0直接可用,大于0时按每笔奖金发放后的新增有效投注达到所需流水后自动解冻;不把解冻当作再次中奖。
+
+## 我的记录及播报
+
+“我的记录”使用records,是本人真实抽奖历史,含轮次、实际金币、累计金币、时间,空数组显示暂无内容。奖金历史使用rewards单独展示。
+
+播报返回 `ordinary`、`reached_666` 两组真实记录,各最多50条,账号脱敏;数据不足时返回实际条数。联调数据见同目录 `转盘播报示例.json`,每组各50条随机样例,带 `is_demo=true`,仅作示例展示,不作为真实用户中奖播报。
+
+关闭活动暂停新增参与、任务结算和抽奖;历史记录及已达到666的领取权益保留。重新上架延续原记录和进度。
+
+后台配置见[《转盘活动-后台接口》](../../总后台/独立活动/转盘活动-后台接口.md)。

+ 45 - 0
docs/前端接口/独立活动/首充奖励-会员接口.md

@@ -0,0 +1,45 @@
+# 首充奖励-会员接口
+
+业务类型为 `8`。
+
+本活动独立于原活动列表,通过 **bot-28主机** 的 `/api/promotion/*` 接口接入,与现有钱包接口使用相同Token。原 `/api/index/activity` 不返回本活动。
+
+请求头为原始 `Authorization: <token>`,无需Bearer前缀。每次传 `platform=PC/H5/APP`;身份由Token识别,不提交会员ID。成功 `code=0`,业务错误 `code=-3`,Token过期为HTTP401,展示实际 `msg`。所有金额按服务端字符串展示。
+
+独立入口调用 `GET /api/promotion/list?type=8&platform=H5`,可加 `lang=zh`,返回 `total/data`。各条包含 `activity_id/type/type_name/title/image/content/rules/is_eligible/ineligible_reason`。不要在原活动类型选项中追加此类型。
+
+`GET /api/promotion/detail?activity_id=1&platform=H5` 只读返回本人状态;不要把旧活动ID当成独立活动ID。提交操作始终以服务端校验为准。
+
+## 页面与接口
+
+| 方法 | 路径 | 参数及用途 |
+|---|---|---|
+| GET | `/api/promotion/list` | `type=8`,首充独立入口 |
+| GET | `/api/promotion/detail` | `activity_id`,查看首充规则及是否已有充值 |
+| GET | `/api/promotion/rewards` | `activity_id/page/limit`,本人奖金记录 |
+
+分页默认1/20,`limit` 最大100。详情示例:
+
+```json
+{
+  "activity_id": 2, "type": 8,
+  "rules": {"min_deposit":"100.00","reward_amount":"25.00","turnover_ratio":"0.00"},
+  "has_deposited": false, "rewards": []
+}
+```
+
+页面展示“首次成功充值达到多少、固定送多少”,直接读取 `rules.min_deposit/reward_amount`。无需申请、转盘或手动领奖按钮,使用原充值入口,充值成功后刷新详情和钱包。
+
+## 首次与到账规则
+
+- 首次指会员全历史第一笔正向成功入账的“充值/三方充值”,不是活动期间第一笔或每日第一笔。
+- 失败订单、人工上分、活动赠送不计;前台真实充值凭证审核成功计入。
+- 首笔不足门槛或发生在活动下架时,第二笔不能补领;已有充值历史的会员不因活动上架恢复首充资格。
+- 成功后自动赠送,重复支付回调、补单或重复审核不会重复发奖。与既有充值活动分别判断资格,可叠加。
+- `has_deposited=true` 表示已有成功充值,不代表一定获得首充奖金,是否获奖以 `rewards` 为准。
+
+奖金记录 `status=paid` 表示已可用,`locked` 表示已发入冻结余额待流水。`turnover_ratio=0` 直接可用;大于0时,各笔奖金独立累计发放后的新有效投注,达到“奖金×倍数”后自动解冻。有效投注逐笔按 `min(本金, |净输赢|)`,未结算和撤销注单不计。
+
+活动下架后rewards仍可查,空数组显示暂无奖金。重复刷新不能改变首充资格。
+
+后台配置见[《首充奖励-后台接口》](../../总后台/独立活动/首充奖励-后台接口.md)。

+ 26 - 0
docs/后端/分享返佣-历史重算任务恢复.md

@@ -0,0 +1,26 @@
+# 分享返佣:历史重算任务恢复
+
+`share:settle --reconcile` 继续使用 `share_system_state.facts_dirty_dates` 日期队列,不新增表或迁移,不改变原投注日政策、差额记账、当日预估和主钱包规则。
+
+## 持久化与确认
+
+1. 准备阶段先锁 `relations_lock`,再锁日期队列。把原始待修正历史日期,以及该日起后续事实/已有结算涉及的历史日期合并,完整写回队列并提交。不会先清空队列;原始日期即使已无事实或结算记录,也保留为可确认的空任务。
+2. 每个日期使用独立事务,先取相同的关系锁,再重查该日是否仍待办。仍待办才调用正常 `settle()`,在同一事务中提交账本、账户修正和本日出队。
+3. 单日失败或进程被终止时,该事务整体回滚,日期仍待处理;已完成日期保留已提交结果。下一次命令或既有每五分钟计划任务可继续处理。
+4. 当天及未来日期始终留在队列,成为已结束日后再处理。并发消费者发现某日已被其他消费者完成时跳过,不重复确认。
+
+后续受影响日期必须提前持久化。例如24日修正影响25、26日累计资格,处理24日成功后即使进程退出,25、26日仍分别待办,不依赖24日再次被标脏。
+
+## 并发与重复执行
+
+锁顺序与事实同步保持一致:先关系守卫,再队列/结算相关记录。单日持锁期间事实发布不能穿过投影和确认之间:之前提交的修正会参与本次投影,之后提交的修正会重新标记日期。处理期间新出现的更早日期继续留在队列,下一轮扩展受影响日期。
+
+结算沿用输入哈希及差额账本的幂等判断;重复运行不会重复增加应付佣金,也不直接变动主钱包。返回 `dates` 为本次实际完成确认的历史日期数,包含无事实的空任务;当日/未来日及已被其他消费者完成的日期不计入。
+
+## 验证范围
+
+`SharingSettlementTest` 新增验证:完整扩展集合先落库、部分完成后恢复、账本写入后异常回滚、当天/未来日期保留、无事实日期确认。
+
+另有两个独立进程 `SIGKILL` 场景,使用专属临时SQLite副本,在“首日完成后”和“单日账本写入但尚未确认”处强制终止,再使用新的服务实例恢复,核对剩余队列、最终差额和重复执行的账本笔数。测试不会加载项目 `.env` 或访问现有业务数据库。该测试证明本地持久化及中断恢复边界,不等同于真实MySQL并发压测。
+
+本次修复保护新的待办处理过程;如果此前已有任务在旧逻辑中丢失,需按核实后的原投注日及受影响后续日期另行补结算,本次没有执行生产历史补算。

+ 89 - 0
docs/后端/报表统计口径与数据补录.md

@@ -0,0 +1,89 @@
+# 统一报表:数据来源、核对和部署
+
+## 范围与数据层
+
+新增 reporting_* 表保存统计投影、事件上下文、核对结果、活动观测、游标和异常。它们不是钱包或结算账本,回填、核对和报表查询都不得修改原业务金额、支付状态、游戏状态或分享政策。
+
+九类沿用返佣分类。覆盖源:lhc_order/lhc_order_item、sport_game_order、sport_order、third_game_orders、旧 bets、旧 jisu_game_order;现代六合彩按子单金额统计,订单数量按父单去重,不同时累计父金额与子金额。
+
+当前真实游戏的状态/会员/净输赢适配复用 BetFactSynchronizer 新增的只读 projectForReporting;不会调用分享写入、冻结关系或结算逻辑,不依赖把 share_settlements 祖先业绩相加。旧TG订单有独立适配。游戏分类未知、会员映射冲突、部分退款不一致等写 reporting_errors,不静默忽略。
+
+原始订单和资金归属使用上下文快照;历史代理只能参考当前关系时标 historical_current_agent,推广统计最终净值视为不完整。不会按最新登录端追溯归装置。provider platform 与 client_platform 是两种不同字段。
+
+## 资金来源与去重
+
+| 指标 | 来源 |
+|---|---|
+| 成功存款 | 正向 balance_logs 的充值/三方充值;已是钱包本位币,不再相加 recharges/payment_orders 本金 |
+| 会员链上出款 | withdraws.status=1,to_account乘该笔exchange_rate;service_charge乘历史汇率作为客户收费收入 |
+| 三方/人工出款 | payment_orders.status=2且type=2/4;type4实际打款金额缺失时等核对 |
+| 代理出款 | agent_withdrawals.status=paid 的 actual_amount、paid_at;向代理收的fee不是平台实际手续费 |
+| 实发优惠 | 可用余额的奖励、返现、笔笔返、回水、利息及实际解冻;不重复算冻结时的金额 |
+| 分享实发 | approved的share_claims.credited_log_id对应“分享佣金领取”账变 |
+| 代理实发佣金 | agent_balance_logs.type=commission且reference_type=agent_rebate_record |
+| 应计佣金 | share_ledger差额(含负数修正)、agent_rebate_records业务日应计总额;与实发分开 |
+
+人工上/扣分单列。提款申请的负向账变只是预扣,不直接当成功出款;资产转移、游戏钱包转入转出、余额宝内部划转不是新的外部收支。
+
+金额按DECIMAL和BCMath保留10位计算,API返回字符串。异币种没有历史可靠换算依据时标记foreign_currency,不直接相加。核对输入统一为钱包本位币,填写凭据时应包含原币、历史汇率及外部账单依据。
+
+钱包本位币由config/reporting.php独立定义,当前为CNY;不随第三方GAME_CURRENCY改名。自营金额按钱包币种,第三方订单保留供应商回传币种,不同则标记待核对。
+
+部分体育退款分离为refund_amount;settled_bet_amount为扣除退款本金后的结算投注净额,payout_amount为派奖,game_gross_profit为平台毛利。未结算订单不产生毛利。
+
+营业从每笔唯一交易计一次;代理、分享视图可重叠。代理分组只看直属,不重复加子团队。分享按直接邀请人分组,实际佣金按接收人计成本;禁止累计各分享祖先的direct/indirect_performance当系统投注额。
+
+## 时间与费用
+
+报表日期统一Asia/Shanghai:资金成功/发放时间、投注下单时间、派奖与毛利结算时间。源时间戳先按实际时间转换,不能复用分享业务日键当所有事件的发生时间。
+
+新增模型上下文捕获创建端、直属关系和终态变化时间;直接SQL更新或历史记录缺少可靠终态时间时保留未知。updated_at只作为候选和游标,不冒充实际支付/结算时刻。游戏订单可按凭据通过order-reconciliation补录结算时间;补录优先于后续同步并保留审计。
+
+payment_orders.fee是配置费率快照,withdraws.service_charge是向客户收费,两者都不能自动改名为支付商实际费用。实际费用在reporting_reconciliations按event_id唯一保存:actual_amount、external_fee、occurred_at、currency、evidence及操作人。缺少核实费用时最终净值为null,已知小计独立返回。
+
+净入金=收款本金-对外打款净额-实际平台外部费用。公司盈利=游戏毛利-实发优惠-代理佣金-分享佣金-实际平台费用。客户收费收入单列;佣金账户记账不直接作为新的外部现金流,实际提款时才计现金出款。
+
+核对写入与操作审计同一事务,审计失败整体回滚。不触发任何支付,不清理原记录。已核对金额与来源的新失败状态冲突时保留证据并报错;根据实际凭据重新核对,不能静默删除已确认现金流。
+
+历史未知时间的事件可能影响从创建日起的后续日期,因此不能只按创建日塞入单天。日报给出待核对提示,总计使用全区间、全分页的事实结果。
+
+## 活动和装置
+
+melbet_sport-api新增认证POST /api/activity/heartbeat;前台可见时每60秒发送,隐藏/退出后停止。成功的前台POST业务操作也记活动,普通GET轮询不记,整站维护状态轮询不记。TG真实用户操作独立记录为unknown装置。
+
+服务端时间按分钟去重,session_hash不存原token。活动区间只累计连续观测之间最多5分钟的间隔,超过5分钟拆段;同一会员的多端重叠合并。起始采集时间在reporting_settings,之前历史不伪造,已知小计与null区分。
+
+活跃率为期间活跃会员数/范围期末真实会员数;并非历史并发在线率。首次上线当天查询整天可能覆盖采集前时间,因此显示不完整并给已知分钟,属于预期。
+
+历史活动统计按reporting_activity内的agent_id/inviter_id快照归属,不再使用users当前绑定关系。统计先对同会员多端区间去重,再按观测时间线切分归属变化;区间从起始观测的快照开始,直到下一条观测,空归属也保留原值。汇总、明细及代理/邀请人筛选共用该口径。
+
+现有会员人口参考仍基于当前绑定关系。历史活跃会员已不在该人口范围时,返回activity_population_complete=false、active_rate=null,不把不可靠的分母当作精确历史活跃率;真实活动分钟和活跃人数照常返回。全站范围依旧按会员去重,同一会员跨代理的历史分钟不会在全站重复累加。
+
+客户端装置是H5/PC/APP,源头通过Platform头记录;不改变原登录平台字段。第三方回单目前无可靠装置键,仍归unknown,不能用该用户最近一次启动游戏或登录的平台猜测。
+
+## 同步与上线
+
+新迁移:database/migrations/2026_10_26_140000_create_reporting_tables.php。默认表前缀bot_,依赖既有operation_audits;新分享模块按其已有部署流程存在。有效会员独立门槛默认20,活动采集时间默认为迁移执行时刻。
+
+1. 先执行迁移,再发布bot-28和melbet_sport-api,刷新实际部署环境的缓存及常驻进程。
+2. 执行历史初始化:
+
+```bash
+php artisan reporting:sync --full
+```
+
+3. 默认增量命令已接入每分钟调度:
+
+```bash
+php artisan reporting:sync
+```
+
+4. 原始数据按200条分批;source+source_id保持幂等,六合彩子单变化也会重算父单。游标重叠5分钟处理迟到更新;有错误不推进对应来源游标,错误解决后重跑会清除异常。
+5. 首次同步未完成或存在异常时不提供最终净值。命令输出各来源ready/absent/errors及数量;reporting_errors保留具体来源、ID和原因。上线前核对这些异常,完成实际费用与真实时间补录后再开放对应菜单。
+6. 新业务采集失败只记录日志,不阻止原支付、投注等主业务;未捕获的信息由统计缺失提示和凭据核对处理。投影读取失败不会自动进行结算或移动资金。
+
+每分钟任务的更新存在正常同步延迟,前端展示data_updated_at;上游供应商订单自身同步更晚时以其实际回传为准。大规模首次回填应按部署窗口执行,监控任务时长,避免与后续周期并发。
+
+统计异常日志关键字reporting_context_failed、reporting_activity_failed、reporting_telegram_activity_failed、reporting_request_failed。生产MySQL、实际支付商账单和完整历史数据仍需部署后核对;隔离SQLite回归不替代这些核对。
+
+回滚先停reporting:sync及新采集,回滚两个项目代码,再处理新表。down删除统计投影、核对及活动数据;核对证据和审计应先备份,原钱包/业务订单不会被down删除。

+ 157 - 0
docs/总后台/信息管理-后台接口.md

@@ -0,0 +1,157 @@
+# 信息管理:后台接入
+
+接口项目 `bot-28`,请求使用后台 API 域名和 `Authorization: Bearer <admin_token>`;POST 为 JSON。成功 `code=0`,参数/业务错误 `code=-3`,权限不足 `code=-1`,登录失效沿用后台拦截器。
+
+本模块是持久化站内信息,不发送短信、邮件或 Telegram,不等同于弹窗公告。
+
+## 接口清单
+
+| 方法 | 地址 | 作用 |
+| --- | --- | --- |
+| GET | `/admin/message` | 信息管理列表 |
+| GET | `/admin/message/options` | 对象、分类、语言枚举及权限信息 |
+| GET | `/admin/message/recipients/users` | 指定用户搜索按钮 |
+| GET | `/admin/message/recipients/agents` | 指定代理搜索按钮 |
+| GET | `/admin/message/detail?id=123` | 完整翻译、投递统计、发送对象 |
+| POST | `/admin/message/send` | 新增发送或安排定时发送 |
+| POST | `/admin/message/delete` | 撤回,保留后台记录 |
+| GET | `/admin/message/logs` | 信息日记 |
+
+沿用后台登录和按钮权限。`options/detail` 复用 `admin/message`,收件人搜索复用 `admin/message/send`;发送、撤回和日志各用对应接口按钮权限。代理搜索及代理类信息的发送、查看、撤回需要现有代理管理授权(`AGENT_ADMIN_IDS`)。无代理授权的管理员,列表和日志也只返回用户类信息。
+
+## 选项
+
+`GET /admin/message/options` 返回 `data.categories/recipient_types/languages`,结构均为 `[{value,label}]`,另含 `can_manage_agents` 和 `timezone=Asia/Shanghai`。
+
+| 字段 | 值与标签 |
+| --- | --- |
+| `recipient_type` | `all_users` 所有用户;`specified_users` 指定用户;`all_agents` 所有代理;`specified_agents` 指定代理 |
+| `category` | `discount` 优惠活动;`site_notice` 网站公告;`internal` 站内信;`promotion` 活动宣传 |
+| `language` | `zh` 中文;`en` 英文;`vi` 越南语 |
+
+用户范围固定为 `users.from=1` 且未删除,只包含前台注册会员。代理是独立代理账号本人,不包括旗下会员或下级代理。停用账号仍可作为收件人,能否登录继续由现有登录规则控制。
+
+## 指定收件人的搜索按钮
+
+请使用以下轻量接口,不使用附带钱包、三方余额的完整会员/代理列表接口:
+
+```http
+GET /admin/message/recipients/users?q=alice&page=1&limit=20
+GET /admin/message/recipients/agents?q=agent&page=1&limit=20
+```
+
+共同参数:`q` 可空,最长100字符;`page` 默认1;`limit` 默认20,最大200。
+
+| 搜索对象 | 匹配字段 | 结果字段 | 发送时保存的 ID |
+| --- | --- | --- | --- |
+| 用户 | 账号、用户名、昵称模糊匹配;数字关键字还精确匹配编号 | `id,user_id,member_id,account,username,first_name,language` | **`users.id`** |
+| 代理 | `username/real_name` 模糊匹配;数字关键字精确匹配代理 ID | `id,username,real_name,language,status` | **`agents.id`** |
+
+两个接口统一返回 `data.total` 和 `data.data[]`。不要把业务 `user_id`、`member_id` 或会员的 `agent_id` 当作选中 ID。建议搜索弹窗显示账号、昵称和语言,支持跨页多选,前端按 `id` 去重,单条最多指定500个账号。用户和代理 ID 属于不同命名空间,不能混选。
+
+## 多语言发送表单
+
+`POST /admin/message/send`
+
+```json
+{
+  "request_id": "message-20261026-001",
+  "recipient_type": "specified_users",
+  "recipient_ids": [12, 23],
+  "category": "internal",
+  "send_at": null,
+  "translations": {
+    "zh": {"title":"安全提醒","subtitle":"请查看","content":"请妥善保管您的账号。"},
+    "en": {"title":"Security notice","subtitle":"","content":"Keep your account secure."},
+    "vi": {"title":"","subtitle":"","content":""}
+  }
+}
+```
+
+| 字段 | 规则 |
+| --- | --- |
+| `request_id` | 必填,每次新发送生成新标识,最多64字符;首位字母/数字,后续允许字母数字及 `._:-`。超时重试必须沿用同一值 |
+| `recipient_type` | 必填,四个对象之一 |
+| `recipient_ids` | 指定对象时必填非空正整数数组,最多500个;服务端去重。发送全部时省略或传 `[]` |
+| `category` | 四种分类之一,省略默认 `internal`;分类只作标签,不自动创建活动或弹窗公告 |
+| `send_at` | 可空;空/省略立即发送,未来时间定时发送;格式 `YYYY-MM-DD HH:mm:ss`,北京时间 |
+| `translations` | 必填,以 `zh/en/vi` 为键的对象;内容独立存入翻译表 |
+| 每语言 `title` | 正文非空时必填,最多500字 |
+| 每语言 `subtitle` | 可空,最多500字 |
+| 每语言 `content` | 纯文本,最多5000字;空或纯空白时该语言不投递 |
+
+前端为每种语言分别提供标题、副标题和正文输入框。至少一个语言需要完整标题和正文;三个正文都空时拒绝发送。未填写的语言可省略,也可提交空对象/空字符串字段。已选用户语言没有对应正文时跳过,不使用其他语言替代。未知或空账号语言同样不兜底。
+
+发送者由登录管理员生成,返回 `sender_name` 和 `sender_display=批核者:账号`,不可由前端指定。没有独立审批流程,也不提供草稿或发送后编辑。
+
+语言依据会员 `users.language`、代理 `agents.language`,按实际发送时的值匹配。不要用本次后台请求头语言决定收件人。
+
+### 返回与重复提交
+
+成功时 `data` 返回完整信息详情,包括:
+
+- `id/request_id/category/category_name/recipient_type/recipient_label/recipient_ids`。
+- `send_at` 计划发件时间、`sent_at` 实际投递时间、`retracted_at` 撤回时间。
+- `status/status_name`:`scheduled` 待发送、`sent` 已发送、`retracted` 已撤回。
+- `sender_id/sender_name/sender_display/created_at/updated_at`。
+- `languages`、`titles`(语言→标题)、完整 `translations`。
+- `recipient_count` 实际投递数、`skipped_count` 被跳过数;`delivered_by_language` 按语言统计实际投递数。
+
+同一管理员以相同 `request_id` 和相同内容重试,返回原信息,不重复投递;相同标识换内容会报错。用户选择顺序变化、重复 ID 不会造成重复投递。
+
+`sent` 表示本次投递处理已完成,不保证人数大于0。全部对象无对应翻译或定时等待期间账号已删除时,可能完成为0人,请展示实际人数。
+
+## 管理列表和详情
+
+`GET /admin/message` 参数:
+
+| 参数 | 含义 |
+| --- | --- |
+| `page/limit` | 默认1/20,limit最多200 |
+| `category` | 分类,不传不限 |
+| `language` | `zh/en/vi`;只查有该语言非空版本的信息 |
+| `title` | 最多500字,模糊查询;有language时只匹配该语言标题,否则匹配任意已填写语言 |
+| `start_date/end_date` | 成对 `YYYY-MM-DD`,按 `send_at` 筛选,包含首尾两天 |
+
+返回 `data.total/data.data[]`,按 `send_at`、ID 倒序。一条多语言信息只占一行,总数不会因语言或收件人数重复。列表只返回 `languages/titles`,不批量返回正文;详情接口返回完整 `translations`。标题列可在未选语言时分语言展示 `titles`,选语言后读取 `titles[language]`。
+
+后台保留已撤回记录,显示 `status_name=已撤回` 并禁用重复“删除”按钮。
+
+## 撤回与收件人快照
+
+```http
+POST /admin/message/delete
+```
+
+```json
+{"id":123}
+```
+
+撤回只移除所有收件人的前台可见性,不物理删除后台记录、翻译和审计。已读、未读均隐藏;未读数同步扣除。定时消息撤回后不会发送。重复撤回幂等,返回完整详情。
+
+“所有用户/代理”以**实际投递时**已有账号为准。定时等待时新注册的账号可被纳入;投递完成后新注册账号不补收。收件记录保存当时的语言,之后改语言不替换旧正文,也不补收历史未投递信息。
+
+## 信息日记
+
+`GET /admin/message/logs`,支持 `page/limit`、`start_date/end_date`(按操作时间)、`operator_name` 模糊匹配、`language`、可选 `resource_id`(信息ID)。
+
+返回 `data.total/data.data[]`,每项含 `id/resource_id/created_at/operator_name/action/languages/titles/recipient_type/recipient_label/old_values/new_values`。
+
+`action=schedule/send/retract` 为安排定时、实际发送、撤回;自动投递操作员为“系统”,发送者始终保留原管理员。前后值为变化字段对象,包括完整翻译快照。语言筛选匹配该操作前后包含的语言,不与当前翻译或当前账号语言关联,历史记录不会被后来变化改写。
+
+## 上线与调度
+
+语言内容表分别为 `site_message_translations`、`announcement_translations`;收件记录在 `site_message_recipients`。不要复用旧的 Telegram `messages` 表。
+
+在项目现有代理表和审计表已建立的前提下执行:
+
+```bash
+php artisan migrate --path=database/migrations/2026_10_26_100000_create_announcements.php --force
+php artisan migrate --path=database/migrations/2026_10_26_110000_create_site_messages.php --force
+```
+
+公告尚未上线,本次直接调整其原建表迁移,无旧数据转换。信息迁移同时新增 `agents.language`,现有代理默认中文。会员既有 `users.language` 不重写。
+
+确保服务器已有每分钟 `php artisan schedule:run` 调度;不要重复配置。新增 `messages:dispatch` 每分钟检查到期信息,失败回滚并写 `site_message_delivery_failed`,后续继续重试。命令也可手动执行,仅处理已到时间且未撤回的信息。
+
+会员项目及代理前端应按各自独立文档接收和标记已读。字段或语言无效、对象越界、相同请求标识换内容均返回业务错误;内部异常统一“信息服务暂不可用,请稍后重试”。

+ 141 - 0
docs/总后台/公告系统-后台接口.md

@@ -0,0 +1,141 @@
+# 公告系统:后台接口(多语言)
+
+后台项目 `bot-28`。请求头 `Authorization: Bearer <admin_token>`;POST 使用JSON。成功 `code=0`,校验失败 `code=-3`,权限和登录失效沿用后台拦截器。
+
+## 接口
+
+| 方法 | 地址 | 作用 |
+|---|---|---|
+| GET | `/admin/announcement` | 分页管理列表 |
+| GET | `/admin/announcement/options` | 分类、语言、平台、频率、点击类型、状态及默认值 |
+| GET | `/admin/announcement/detail?id=123` | 多语言详情及编辑回显 |
+| POST | `/admin/announcement/submit` | 新增、编辑 |
+| POST | `/admin/announcement/status` | 开启/关闭,JSON `{"id":123,"status":0}` |
+| GET | `/admin/announcement/logs` | 公告日记 |
+
+沿用管理员登录和按钮权限,`options/detail` 复用 `admin/announcement`,启停复用 `admin/announcement/submit`,日志使用 `admin/announcement/logs`。本次无删除、独立审批和展示回执接口。
+
+## 分类与语言表单
+
+选项接口返回 `categories/languages/platforms/display_frequencies/click_types/statuses`,均为 `[{value,label}]`;另返回 `timezone=Asia/Shanghai` 和 `defaults`。
+
+| 字段 | 值 |
+|---|---|
+| category | `content` 弹出内容公告;`image` 弹出图片公告;`update` 弹出更新公告 |
+| language | `zh` 中文;`en` 英文;`vi` 越南语 |
+| publish_platforms | 非空、去重数组,元素为 `PC/H5/APP`;三项全选为全部 |
+| display_frequency | `daily_first_login` 每日首次登录;`every_login` 每次登录 |
+| click_type | `none` 无链接;`external` 外站;`internal` 站内路径;`activity` 优惠活动 |
+| status | 整数 `0` 关闭、`1` 开启 |
+
+创建默认:内容公告、全部平台、每日首次登录、立即发布、不结束、开启、无链接。**标题和正文改用translations,不再提交顶层title/content。** 每个语言独立填写标题和正文;图片公告每个语言分别上传一张图,仍用content保存图片地址。
+
+```json
+{
+  "category":"content",
+  "translations":{
+    "zh":{"title":"网站维护","content":"今晚维护,请提前安排。"},
+    "en":{"title":"Maintenance","content":"Scheduled maintenance tonight."},
+    "vi":{"title":"","content":""}
+  },
+  "publish_platforms":["PC","H5","APP"],
+  "display_frequency":"daily_first_login",
+  "publish_immediately":false,
+  "publish_at":"2026-10-27 09:00:00",
+  "no_end":false,
+  "end_at":"2026-10-28 09:00:00",
+  "status":1,
+  "click_type":"none",
+  "link":null,
+  "activity_id":null
+}
+```
+
+- `translations` 是以zh/en/vi为键的对象,标题与正文存入独立 `announcement_translations` 表,公共配置存在 `announcements`。
+- 正文空或纯空白时该语言不保存、不展示,不自动使用其他语言代替。至少一个语言有完整标题和正文;正文非空时标题必填。
+- 标题最多200字;两种文本公告正文最多20000字,按纯文本渲染。图片公告content最长2048字符,使用jpg/jpeg/png/webp图片地址。
+- 编辑传id,省略translations时保留旧内容;提交translations时**完整替换**所有语言,空正文或未包含的语言版本会被移除。切换分类必须重新提交translations。
+- sender_id/sender_name/sender_display不可提交指定,发送者由创建时登录管理员生成。后续修改不改变原发送者,实际操作员另记日记。
+
+## 图片与点击行为
+
+图片上传复用 `POST /admin/upload/uploadFile`,multipart字段image,最大10MB,支持jpg/jpeg/png/webp,返回data.path。该地址放入对应语言 `translations.zh.content` 等;图片类型没有额外文字正文,另外两类没有版本升级功能。
+
+所有语言共用click_type、link、activity_id:
+
+- none清空两个目标字段。
+- external需要完整HTTP/HTTPS link,禁止其他协议和URL账号密码。
+- internal需要单个 `/` 开头的站内路径,禁止 `//外站` 和反斜杠。
+- activity需要已存在的activity_id,link清空;活动选择器复用 `GET /admin/ActivityReward`,取ID和标题。
+
+活动未开始、失效、被删、语言不符或对当前会员不可见时,公告照常展示,会员接口只禁用跳转。活动可见但会员暂不能参加时仍允许打开详情。
+
+## 时间与状态
+
+时间统一北京时间Asia/Shanghai,格式 `YYYY-MM-DD HH:mm:ss`。新增未提供时间设置默认立即、不结束。
+
+`publish_immediately=false` 的定时新增必须填publish_at;true会把未来计划改为当前时间,已发布公告编辑时保留原发布时间。编辑省略时间字段保留原值。详情publish_immediately是“发布时间是否已经到达”的回显值。
+
+`no_end=true` 清空end_at;false必须有结束时间,且严格晚于发布时间。到期不会改写手动status,也无需专用定时任务。
+
+| publication_state | 中文 | 条件 |
+|---|---|---|
+| disabled | 关闭 | status=0 |
+| scheduled | 未开始 | 开启但尚未到publish_at |
+| active | 展示中 | 开启、开始时间已到、未结束 |
+| expired | 已结束 | 开启且结束时间已到 |
+
+返回effective_status为当前时间条件是否有效的0/1。该值不代表每个语言和平台都有正文;会员实际可见还要匹配账号语言及平台。状态相同的重复保存不重复写日记。
+
+## 管理列表、详情与语言筛选
+
+`GET /admin/announcement?page=1&limit=20&language=en&title=Maintenance`
+
+| 参数 | 说明 |
+|---|---|
+| page/limit | 默认1/20,limit最大200 |
+| category | 可选三种分类 |
+| platform | PC/H5/APP,匹配发布平台数组 |
+| status | 手动开关0/1,0不能被前端过滤 |
+| language | zh/en/vi;不传不限语言 |
+| title | 标题模糊查询;选language时只匹配该语言,否则匹配任意已有语言 |
+| start_date/end_date | 成对YYYY-MM-DD,含首尾两天,筛选publish_at |
+
+一条多语言公告只占一行,语言和标题过滤在统计分页前完成。按publish_at、ID倒序,返回 `data.total/data.data`。列表提供 `languages:["zh","en"]`、`titles:{"zh":"网站维护","en":"Maintenance"}`,不批量返回正文;选择语言后标题列读取titles[language],不选择时可按语言标签分行展示。
+
+公共返回字段为id、category/category_name、publish_platforms/platform_label、display_frequency/display_frequency_name、publish_at、end_at、status、click_type、link、activity_id、sender_id/sender_name/sender_display、created_at/updated_at、publication_state/publication_state_name、effective_status、publish_immediately、no_end。
+
+“发送时间”列对应publish_at,“发送者”使用sender_display,例如 `批核者:admin`,账号不可写死。“不结束”对应end_at=null。
+
+详情和保存成功data在上述字段上增加完整translations对象。客户端应先按options.languages初始化三个空表单,再合并已有内容,不把titles当正文提交。
+
+## 公告日记
+
+`GET /admin/announcement/logs` 支持page/limit、start_date/end_date(操作日期)、operator_name(模糊)、language、resource_id。兼容announcement_id作为资源ID参数。
+
+返回 `data.total/data.data`,按操作时间和ID倒序,每项包含:
+
+```json
+{
+  "id":8,"resource_id":123,"created_at":"2026-10-26 10:00:00",
+  "operator_name":"admin","action":"update",
+  "languages":["zh","en"],"titles":{"zh":"网站维护","en":"Maintenance"},
+  "recipient_type":"all_users","recipient_label":"所有人",
+  "old_values":{"translations":{"en":{"title":"Maintenance","content":"Old"}}},
+  "new_values":{"translations":null}
+}
+```
+
+action为create/update/status。old_values/new_values只含变化字段。语言筛选匹配**该次操作前后**的语言快照,所以后来移除英文版本,英文历史操作仍能查询。选择语言后,翻译前后值仅呈现该语言,其他公共配置变化照常返回。原管理员、历史标题和内容不会被后续编辑改写。
+
+公告与审计同事务,审计失败全部回滚;自然到期不生成管理员操作日志。
+
+## 上线
+
+原公告尚未上线,本次直接调整其建表迁移,新增独立翻译表,不做旧数据转换:
+
+```bash
+php artisan migrate --path=database/migrations/2026_10_26_100000_create_announcements.php --force
+```
+
+依赖现有operation_audits表,后台及melbet_sport-api共享业务库并同步部署。校验/不存在错误code=-3,内部异常统一“公告服务暂不可用,请稍后重试”。

+ 182 - 0
docs/总后台/整站与游戏维护-后台接口.md

@@ -0,0 +1,182 @@
+# 整站与游戏维护:后台接入
+
+后台项目 `bot-28`,前台项目 `melbet_sport-api`。本文件为新增独立文档。
+
+## 权限与返回
+
+请求头 `Authorization: Bearer <admin_token>`,POST 使用 JSON。后台成功 `code=0`,校验失败 `code=-3`;沿用管理员登录与按钮权限,并要求登录管理员属于 `config/operations.php` 配置的运营管理员(默认 ID=1)。
+
+| 方法 | 地址 | 作用 / 参数 |
+|---|---|---|
+| GET | `/admin/maintenance` | 管理列表 |
+| GET | `/admin/maintenance/options` | 范围、分类、平台、语言、状态、时区、默认值与业务 code |
+| GET | `/admin/maintenance/detail?id=1` | 设置详情,含三个语言的已填写文案 |
+| POST | `/admin/maintenance/submit` | 编辑固定设置,必须传 `id` |
+| POST | `/admin/maintenance/status` | 开关,`{"id":1,"enabled":0}` |
+| GET | `/admin/maintenance/logs` | 操作日志 |
+
+`options/detail` 复用按钮 URI `admin/maintenance`;`status` 复用 `admin/maintenance/submit`;日志 URI 为 `admin/maintenance/logs`。菜单和按钮由后台前端按这些 URI 接入。
+
+## 固定范围
+
+初始化有 10 条设置,**全部默认关闭**。前端从列表或 options 获取范围,不写死数据库 ID。仅编辑和启停,无新增/删除设置操作。
+
+| scope_code | 名称 |
+|---|---|
+| `site` | 整站 |
+| `third:1` | 真人 |
+| `third:2` | 电子 |
+| `third:3` | 彩票 |
+| `third:4` | 体育 |
+| `third:5` | 电竞 |
+| `third:6` | 捕鱼 |
+| `third:7` | 棋牌 |
+| `self:sport` | F1体育 |
+| `self:lottery` | F1彩票 |
+
+九个游戏分类与返佣分类相同;第三方体育与 F1 体育分别配置。
+
+## 表单与保存
+
+options 返回 `scopes/categories/platforms/languages/states`,格式均为 `[{"value":"...","label":"..."}]`;`timezones` 是标准 IANA 时区字符串数组;`defaults` 含默认关闭、全部平台、Asia/Shanghai 时区和无结束时间;`business_codes` 为 `{"site":11001,"game":11002}`。
+
+```json
+{
+  "id": 1,
+  "enabled": 1,
+  "platforms": ["PC", "H5", "APP"],
+  "timezone": "Asia/Shanghai",
+  "start_now": false,
+  "starts_at": "2026-10-27 02:00:00",
+  "ends_at": "2026-10-27 04:00:00",
+  "translations": {
+    "zh": {"content": "系统维护中,预计北京时间04:00恢复。"},
+    "en": {"content": "Scheduled maintenance. Expected to resume at 04:00 (UTC+8)."},
+    "vi": {"content": "Hệ thống đang bảo trì. Vui lòng thử lại sau."}
+  }
+}
+```
+
+| 字段 | 说明 |
+|---|---|
+| id | 必填,列表返回的设置 ID |
+| enabled | 整数 `0/1`,开关 |
+| platforms | 非空去重数组,仅 `PC/H5/APP`,可多选 |
+| timezone | 标准时区,例 `Asia/Shanghai`、`America/New_York`、`UTC`,使用 options 候选 |
+| start_now | 布尔值;true 将开始时间设为服务器当前时间,优先于 starts_at |
+| starts_at | 所选时区的本地时间,格式 `YYYY-MM-DD HH:mm:ss` |
+| ends_at | 同一时区的本地时间,必须晚于开始;传 null 清空,之后手动关闭 |
+| translations | 以 `zh/en/vi` 为键;每个语言仅 content,最多 5000 字符,按纯文本展示 |
+
+- 编辑省略字段时保留原值。`scope_code` 固定,不可提交修改。
+- `start_now=true` 每次明确提交都会重新设置开始时间,编辑普通文案时应省略此字段。若原结束时间已经过去,立即开启时同时传新的 ends_at 或 `ends_at:null`。
+- 提交 translations 时完整替换所有语言;未包含、正文空或纯空白的语言不保存。允许全部留空,该语言的用户将看到系统默认维护提示;**空文案不会跳过维护**。
+- 用户填写的文案不会被其他语言替换。系统默认提示分别有中文、英文、越南语版本。
+- 仅修改 timezone 且省略起止时间时,保留已经保存的实际时间点,按新时区回显。若要更改时间点,一并提交对应本地时间。
+- 拒绝该时区中不存在或有夏令时重复歧义的本地时间;选择其他明确时间后重试。
+- 关闭保留时间、平台和文案。再次开启不会自动重排时间;已过期的设置仍为“已结束”,需要修改结束时间或清空。
+
+保存与详情返回相同的完整记录:
+
+```json
+{
+  "code": 0,
+  "msg": "OK",
+  "data": {
+    "id": 1,
+    "scope_code": "site",
+    "scope_name": "整站",
+    "scope": "site",
+    "category": null,
+    "enabled": 1,
+    "state": "scheduled",
+    "platforms": ["PC", "H5", "APP"],
+    "timezone": "Asia/Shanghai",
+    "starts_at": "2026-10-27 02:00:00",
+    "ends_at": "2026-10-27 04:00:00",
+    "starts_at_utc": "2026-10-26T18:00:00Z",
+    "ends_at_utc": "2026-10-26T20:00:00Z",
+    "translations": {
+      "zh": {"content": "系统维护中,预计北京时间04:00恢复。"},
+      "en": {"content": "Scheduled maintenance. Expected to resume at 04:00 (UTC+8)."},
+      "vi": {"content": "Hệ thống đang bảo trì. Vui lòng thử lại sau."}
+    }
+  }
+}
+```
+
+成功包装同时有 timestamp,按项目通用拦截器处理。无语言文案时 translations 返回 `{}`。
+
+## 状态与优先级
+
+| state | 含义 |
+|---|---|
+| disabled | 手动关闭 |
+| scheduled | 已开启,但未到开始时间 |
+| active | 已开启且在维护区间内 |
+| expired | 已开启,但已到或超过结束时间 |
+
+有效区间是 `starts_at <= 当前时间 < ends_at`;结束为空则持续到手动关闭。按 UTC 的实际时间判断,无需每分钟任务改写 enabled;界面应同时展示开关和计算状态。
+
+整站维护只有在**当前客户端平台及当前时间都命中**时,才优先于游戏分类。整站未开启、待开始、已结束或仅维护其他平台时,仍判断当前游戏分类。整站和分类规则互不覆盖保存值,整站恢复后原有分类规则继续独立生效。
+
+每个新请求读取当前配置,无跨请求配置缓存。已经通过检查的进行中请求不强制中断。
+
+## 列表筛选
+
+`GET /admin/maintenance?page=1&limit=20&platform=H5&enabled=1&state=active&language=en`
+
+| 参数 | 说明 |
+|---|---|
+| page / limit | 默认 1 / 20,limit 最大 100,page 最大 1000000 |
+| scope_code | 固定范围代码,精确匹配 |
+| platform | PC/H5/APP,包含该平台 |
+| enabled | 0/1,手动开关 |
+| state | disabled/scheduled/active/expired,当前计算状态 |
+| language | zh/en/vi,仅筛选该语言有填写文案的设置,不影响实际维护拦截 |
+
+返回 `data:{"total":10,"data":[详情结构...]}`。所有筛选先执行,再计数和分页。
+
+## 操作日志
+
+`GET /admin/maintenance/logs?resource_id=1&operator_name=admin&language=en&start_date=2026-10-26&end_date=2026-10-27&page=1&limit=20`
+
+支持 resource_id、操作员账号包含搜索、语言、日期和分页。日期是操作日志的北京时间,起止日期一起传;limit 最大 200。返回 `data:{total,data:[]}`,条目含 id、resource_id、created_at、operator_name、action、languages、titles、old_values、new_values。维护日志 action 为 update,old/new_values 是修改前后有差异的字段。
+
+日志的 translations 中 title 是系统生成的“整站/体育/F1体育”等范围名称,content 是当时文案快照;前端维护表单只提交 content。语言筛选同时查询修改前和修改后,删除某语言后仍可查其历史。重复保存相同配置不新增日志。设置、多语言内容和日志在同一事务保存,日志失败则整体回滚。
+
+## 实际拦截范围
+
+- `melbet_sport-api`:整站维护拦截前台请求,包括免登录读取和登录/注册;响应业务 code=11001。
+- 分类维护:第三方 gameUrl/demoUrl/play 返回 code=11002,在玩家创建、资金转入和调用供应商入口前检查;gameCode 对应分类由服务端目录解析。
+- F1 体育:Order 的 submit/submit_delete、SportGameOrder 的 submit,以及进入赛事详情/足球游戏配置的请求。
+- F1 彩票:Game.bet、LhcOrder.submit、JisuGameOrder.submit,以及 PC28、六合彩、极速游戏的进入/状态请求。
+- 保留维护状态查询、User.logout、Egame.recycle、Order.refund、LhcOrder.refund、Wallet.withdrawCancel 的原有认证和业务处理。
+- `bot-28` 现有前台 `/api/wallet/*`、`/api/ActivityReward/*`、`/api/newPc/*`、`/api/issue/*`、`/api/pcissue/*` 同步检查整站规则,沿用其 JSON 包装和相同维护业务 code。
+- 支付回调、系统结算、Telegram webhook/机器人、管理后台、代理独立后台和后台任务继续处理。
+- 本功能按 H5/PC/APP 限制会员网页及 App 入口,不改变机器人渠道已有的独立开关、原 PC28 日常维护及供应商/游戏上架开关。
+
+客户端统一传 `Platform: PC|H5|APP`(请求头优先,兼容请求参数 platform)。未传或非法值按平台未知处理,保守命中当前有效的维护规则,不能通过省略平台绕过维护。平台是客户端声明值,应由各客户端统一固定,不能作为可信身份凭据。
+
+第三方已经打开的游戏会话无法仅靠本站入口检查强制停止;需要供应商停服/踢出能力。维护不撤销旧订单,结算、回调与资金回收继续。
+
+## 数据与上线
+
+新增迁移:`database/migrations/2026_10_26_120000_create_maintenance_settings.php`。使用项目既有数据库前缀,默认实体表为 `bot_maintenance_settings`、`bot_maintenance_translations`。开始、结束及设置记录的时间按 UTC 保存;操作日志按现有北京时间约定记录。
+
+部署顺序:
+
+1. 在 `bot-28` 数据库执行新迁移,再发布两个项目的维护拦截代码;避免拦截代码先访问尚不存在的表。确认既有 `2026_08_30_120000_create_operation_configuration_tables.php` 已提供 operation_audits。
+2. 发布 `melbet_sport-api` 的服务、控制器、中间件、route 配置、游戏入口和跨域请求头变更;两个项目连接同一业务数据库。
+3. 刷新部署环境的路由/代码缓存及常驻进程,接入两个前端的 Platform 请求头和业务 code。
+4. 初始十条规则全部关闭,核对正常请求后,再通过后台开启需要的范围。
+
+本功能不新增定时命令。回滚代码前先关闭所有维护,停止引用新表后再考虑回滚迁移;down 会删除维护配置与文案。
+
+仅执行本次新增迁移的命令(在 bot-28 项目目录,按部署流程使用生产确认参数):
+
+```bash
+php artisan migrate --path=database/migrations/2026_10_26_120000_create_maintenance_settings.php
+```
+
+验收:平台隔离;整站优先且只在自身生效时优先;精确开始/结束边界;无结束时间;三语言及空文案默认提示;后台语言筛选和日志;真实/试玩入口与自营下注受控;被拦截请求不创建玩家、不转入资金、不创建订单;回调、结算、退款及资金回收继续。

+ 11 - 0
docs/总后台/独立活动-后台接口.md

@@ -0,0 +1,11 @@
+# 独立活动后台文档目录
+
+三个活动各自维护完整接入文档,接口和数据独立于原活动列表。
+
+| 活动 | 独立后台文档 | 对应会员文档 |
+|---|---|---|
+| 净亏邀请 | [净亏邀请-后台接口](独立活动/净亏邀请-后台接口.md) | [净亏邀请-会员接口](../前端接口/独立活动/净亏邀请-会员接口.md) |
+| 首充奖励 | [首充奖励-后台接口](独立活动/首充奖励-后台接口.md) | [首充奖励-会员接口](../前端接口/独立活动/首充奖励-会员接口.md) |
+| 转盘活动 | [转盘活动-后台接口](独立活动/转盘活动-后台接口.md) | [转盘活动-会员接口](../前端接口/独立活动/转盘活动-会员接口.md) |
+
+本页仅保留导航;每个活动的参数、示例、上下架、资格和发奖说明均在各自文档内。

+ 75 - 0
docs/总后台/独立活动/净亏邀请-后台接口.md

@@ -0,0 +1,75 @@
+# 净亏邀请-后台接口
+
+业务类型固定为 `7`。本页为该活动的完整后台接入说明。
+
+本活动使用独立的 `promotion_activities` 配置和 `/admin/promotion` 接口,不加入原活动列表或原活动类型目录。以下接口使用现有后台登录和按钮权限,成功 `code=0`,参数或业务错误 `code=-3`,展示实际 `msg`。
+
+## 配置接口
+
+| 方法 | 路径 | 参数与用途 |
+|---|---|---|
+| GET | `/admin/promotion?type=7` | 仅查看本活动类型;可选 `page/limit/status/title` |
+| GET | `/admin/promotion/options` | 读取 `defaults["7"]` 默认规则 |
+| GET | `/admin/promotion/detail?id=1` | 配置详情,返回 `params` 对象及 `first_published_at` |
+| POST | `/admin/promotion/submit` | 新增或编辑本活动,固定 `type=7` |
+| POST | `/admin/promotion/status` | 传 `id/status` 上下架 |
+| GET | `/admin/promotion/records` | 查看参与或发奖记录 |
+
+列表默认 `page=1/limit=20`,`limit` 最大100。`status=0` 为下架、`1` 为上架,不能丢弃0值筛选。新增省略 `id`,编辑带 `id`;创建后不能改变业务类型。
+
+保存必填根字段:`title/type/status/start_time/end_time`。时间按UTC+8填写,结束必须晚于开始。可选 `sub_title/lang/sort/content/image/publish_platforms`;平台为 `PC/H5/APP`,空数组表示全部。`params` 传对象,未知规则键报错;新增省略的项使用默认值,编辑局部规则保留其余原值。
+
+可选通用资格:`register_time_limited/register_start_time/register_end_time/register_platforms`、`deposit_limited/deposit_min/deposit_max`、`show_ineligible`。新增默认不限制,编辑省略字段保留原值。
+
+可选 `reward_quota` 限制不同获奖会员数,`budget` 限制累计奖励金额;`null` 为不限制,0为无剩余额度。`rewarded_count/budget_used/first_published_at` 为服务端字段,不能手动改写。
+
+## 保存示例
+
+```json
+{
+  "title": "净亏邀请奖励",
+  "type": 7,
+  "status": 0,
+  "start_time": "2026-10-26 00:00:00",
+  "end_time": "2027-10-26 23:59:59",
+  "publish_platforms": [
+    "PC",
+    "H5",
+    "APP"
+  ],
+  "params": {
+    "loss_amount": "500.00",
+    "invite_count": 3,
+    "reward_amount": "500.00",
+    "turnover_ratio": "0.00"
+  }
+}
+```
+
+## 业务规则与字段
+
+| params字段 | 默认值 | 含义 |
+|---|---|---|
+| loss_amount | 500.00 | 申请所需累计净亏 |
+| invite_count | 3 | 申请后需新增的有效直属会员数 |
+| reward_amount | 500.00 | 达标后领取奖金 |
+| turnover_ratio | 0.00 | 奖金所需有效投注倍数 |
+
+1. 从本活动第一次上架后下单的真实注单累计净亏:`max(0,-已结算且未撤销注单净输赢合计)`,盈亏相抵。充值、提现、赠送和转账不算游戏损益。
+2. 达到净亏门槛后会员点击申请;申请成功固定当时的奖励规则、净亏快照及申请时间。无效申请不消耗机会,重复有效申请返回原进度。
+3. 只计申请后新注册且建立直属关系的下级。必须同时达到系统累计真实充值、累计有效投注双门槛;使用当日已生效政策,系统修改门槛按既有规则次日生效,不写死截图200。
+4. 有效投注逐笔按 `min(本金, |净输赢|)` 计算,只计已结算未撤销的真实注单。人工上分、活动赠送不计真实充值。
+5. 新增有效直属会员达到人数后,会员自行领取。每名会员在本活动只能领取一次,下架再上架不重置机会或首次上架时间。
+6. 奖金按申请时保存的规则发放。`turnover_ratio=0` 直接进入可用余额,大于0进入冻结余额,发放后新增有效投注达到“奖金×倍数”后自动解冻,各笔奖金独立计算。
+
+## 参与与发奖记录
+
+`GET /admin/promotion/records?activity_id=1&kind=accounts&page=1&limit=20` 查看申请记录,`kind=rewards` 查看奖金,可追加会员数据库ID `user_id`。返回 `total/data`。
+
+申请记录包含 `applied_at/application_snapshot`;奖金有 `reserved/locked/paid` 状态和资金流水关联。没有有效申请前不允许领奖,重复领取不会重复入账。停止活动使用下架,不删除参与历史。
+
+## 权限与上线
+
+独立菜单为 `promotion`;配置、保存与上下架、记录权限分别为 `admin/promotion`、`admin/promotion/submit`、`admin/promotion/records`。上线执行新增迁移 `2026_10_26_160000_create_promotion_activities.php`,计划任务每分钟运行 `promotions:sync`;有效投注沿用已有分享注单同步。
+
+对应会员端文档:[《净亏邀请-会员接口》](../../前端接口/独立活动/净亏邀请-会员接口.md)。

+ 102 - 0
docs/总后台/独立活动/转盘活动-后台接口.md

@@ -0,0 +1,102 @@
+# 转盘活动-后台接口
+
+业务类型固定为 `9`。本页为该活动的完整后台接入说明。
+
+本活动使用独立的 `promotion_activities` 配置和 `/admin/promotion` 接口,不加入原活动列表或原活动类型目录。以下接口使用现有后台登录和按钮权限,成功 `code=0`,参数或业务错误 `code=-3`,展示实际 `msg`。
+
+## 配置接口
+
+| 方法 | 路径 | 参数与用途 |
+|---|---|---|
+| GET | `/admin/promotion?type=9` | 仅查看本活动类型;可选 `page/limit/status/title` |
+| GET | `/admin/promotion/options` | 读取 `defaults["9"]` 默认规则 |
+| GET | `/admin/promotion/detail?id=1` | 配置详情,返回 `params` 对象及 `first_published_at` |
+| POST | `/admin/promotion/submit` | 新增或编辑本活动,固定 `type=9` |
+| POST | `/admin/promotion/status` | 传 `id/status` 上下架 |
+| GET | `/admin/promotion/records` | 查看参与或发奖记录 |
+
+列表默认 `page=1/limit=20`,`limit` 最大100。`status=0` 为下架、`1` 为上架,不能丢弃0值筛选。新增省略 `id`,编辑带 `id`;创建后不能改变业务类型。
+
+保存必填根字段:`title/type/status/start_time/end_time`。时间按UTC+8填写,结束必须晚于开始。可选 `sub_title/lang/sort/content/image/publish_platforms`;平台为 `PC/H5/APP`,空数组表示全部。`params` 传对象,未知规则键报错;新增省略的项使用默认值,编辑局部规则保留其余原值。
+
+可选通用资格:`register_time_limited/register_start_time/register_end_time/register_platforms`、`deposit_limited/deposit_min/deposit_max`、`show_ineligible`。新增默认不限制,编辑省略字段保留原值。
+
+可选 `reward_quota` 限制不同获奖会员数,`budget` 限制累计奖励金额;`null` 为不限制,0为无剩余额度。`rewarded_count/budget_used/first_published_at` 为服务端字段,不能手动改写。
+
+## 保存示例
+
+```json
+{
+  "title": "幸运转盘",
+  "type": 9,
+  "status": 0,
+  "start_time": "2026-10-26 00:00:00",
+  "end_time": "2027-10-26 23:59:59",
+  "publish_platforms": [
+    "PC",
+    "H5",
+    "APP"
+  ],
+  "params": {
+    "completion_probability": "20.00",
+    "online_seconds": 3600,
+    "deposit_amount": "3000.00",
+    "team_deposit_amount": "500.00",
+    "bet_amount": "5000.00",
+    "team_bet_amount": "3000.00",
+    "turnover_ratio": "0.00"
+  }
+}
+```
+
+## 概率、任务与奖金配置
+
+| params字段 | 默认值 | 含义 |
+|---|---|---|
+| completion_probability | 20.00 | 全部七项完成后,每抽一次达到666的概率百分比 |
+| online_seconds | 3600 | 每日在线秒数 |
+| deposit_amount | 3000.00 | 本人当日充值门槛 |
+| team_deposit_amount | 500.00 | 直属下级当日充值合计门槛 |
+| bet_amount | 5000.00 | 本人当日有效投注门槛 |
+| team_bet_amount | 3000.00 | 直属下级当日有效投注合计门槛 |
+| turnover_ratio | 0.00 | 领取奖金所需有效投注倍数 |
+
+`completion_probability` 允许0~100,最多两位小数,默认20。保存0必须仍为0,不回退20。修改只影响后续抽奖,不修改历史结果、概率记录或已预留奖金。未完成全部七项条件时,达到666概率固定为0。
+
+## 七项任务与每日口径
+
+| 任务key | 规则 | 获得次数 |
+|---|---|---:|
+| online | 每日累计在线达到配置秒数 | 每天+1 |
+| login | 每日登录,认证进入活动也计入 | 每天+1 |
+| invite | 每名未计奖的有效直属下级 | 每人+1 |
+| deposit | 本人当日累计真实充值达到门槛 | 每轮每天+1 |
+| team_deposit | 直属下级当日真实充值合计达到门槛 | 每轮每天+1 |
+| bet | 本人当日有效投注达到门槛 | 每轮每天+1 |
+| team_bet | 直属下级当日有效投注合计达到门槛 | 每轮每天+1 |
+
+七项各完成一次即可,可以分别在不同日期完成,完成标记可跨天保留;“当日累计”的金额必须在同一个UTC+8自然日达到,不能跨天拼接。例如昨天充值2000、今天1000,不算达到3000。
+
+有效推广读取当日已生效的系统累计充值、有效投注双门槛,不写死截图200。有效投注逐笔按 `min(本金, |净输赢|)` 计算,未结算和撤销注单不计;人工上分、活动赠送不计充值。团队金额只计直属关系建立后的贡献。
+
+## 奖池与循环领取
+
+奖项顺序固定:`0–666、100、50、200、350、500、150、366`,浮动奖项支持两位小数。金币是活动进度,只有累计达到666并领取才发放666奖金。
+
+全部条件完成后,每次抽奖先按配置概率判定;命中时浮动奖项补齐剩余差额。未命中或条件未完成时进入普通奖池:只保留累计后小于666的固定奖项及浮动奖项;可用奖项等概率,浮动金额在0至剩余差额减0.01之间按分等概率抽取。固定500不会被截成较小金额,页面须展示奖池规则。
+
+达到666时立即预留奖金,停止本轮抽奖;领取不会再次消耗预算,已达到后活动下架仍可领取。领取成功后:金币清零、进入下一轮、未用次数保留、登录和在线完成标记保留,其余五项清零重做。当日登录/在线不因换轮重复发次;旧充值、旧投注及已经计奖的有效下级不能重复贡献。
+
+同一会员多轮只占一个获奖人数名额,每轮666均消耗金额预算。`turnover_ratio=0` 直接进入可用余额,大于0进入冻结余额,各笔奖金独立累计发放后有效投注,达到“666×倍数”自动解冻。
+
+## 记录与播报
+
+`GET /admin/promotion/records?activity_id=1&kind=spins&page=1&limit=20`,`kind` 支持 `accounts/rounds/tasks/spins/rewards`,可加 `user_id`,返回 `total/data`。抽奖记录保存实际概率、是否全部达标、奖项和累计金币;领奖记录有关联资金流水。达到666后的状态依次为 `reserved` 待领取、`locked` 冻结待流水或 `paid` 已可用。
+
+会员“我的记录”使用真实抽奖记录。普通播报及达到666播报各最多50条真实脱敏记录;联调随机样例每组50条,见[转盘播报示例](../../前端接口/独立活动/转盘播报示例.json),均带 `is_demo=true`,不作真实中奖记录。
+
+## 权限与上线
+
+独立菜单为 `promotion`;配置、保存与上下架、记录权限分别为 `admin/promotion`、`admin/promotion/submit`、`admin/promotion/records`。上线执行新增迁移 `2026_10_26_160000_create_promotion_activities.php`,计划任务每分钟运行 `promotions:sync`;有效投注沿用已有分享注单同步。
+
+对应会员端文档:[《转盘活动-会员接口》](../../前端接口/独立活动/转盘活动-会员接口.md)。

+ 73 - 0
docs/总后台/独立活动/首充奖励-后台接口.md

@@ -0,0 +1,73 @@
+# 首充奖励-后台接口
+
+业务类型固定为 `8`。本页为该活动的完整后台接入说明。
+
+本活动使用独立的 `promotion_activities` 配置和 `/admin/promotion` 接口,不加入原活动列表或原活动类型目录。以下接口使用现有后台登录和按钮权限,成功 `code=0`,参数或业务错误 `code=-3`,展示实际 `msg`。
+
+## 配置接口
+
+| 方法 | 路径 | 参数与用途 |
+|---|---|---|
+| GET | `/admin/promotion?type=8` | 仅查看本活动类型;可选 `page/limit/status/title` |
+| GET | `/admin/promotion/options` | 读取 `defaults["8"]` 默认规则 |
+| GET | `/admin/promotion/detail?id=1` | 配置详情,返回 `params` 对象及 `first_published_at` |
+| POST | `/admin/promotion/submit` | 新增或编辑本活动,固定 `type=8` |
+| POST | `/admin/promotion/status` | 传 `id/status` 上下架 |
+| GET | `/admin/promotion/records` | 查看参与或发奖记录 |
+
+列表默认 `page=1/limit=20`,`limit` 最大100。`status=0` 为下架、`1` 为上架,不能丢弃0值筛选。新增省略 `id`,编辑带 `id`;创建后不能改变业务类型。
+
+保存必填根字段:`title/type/status/start_time/end_time`。时间按UTC+8填写,结束必须晚于开始。可选 `sub_title/lang/sort/content/image/publish_platforms`;平台为 `PC/H5/APP`,空数组表示全部。`params` 传对象,未知规则键报错;新增省略的项使用默认值,编辑局部规则保留其余原值。
+
+可选通用资格:`register_time_limited/register_start_time/register_end_time/register_platforms`、`deposit_limited/deposit_min/deposit_max`、`show_ineligible`。新增默认不限制,编辑省略字段保留原值。
+
+可选 `reward_quota` 限制不同获奖会员数,`budget` 限制累计奖励金额;`null` 为不限制,0为无剩余额度。`rewarded_count/budget_used/first_published_at` 为服务端字段,不能手动改写。
+
+## 保存示例
+
+```json
+{
+  "title": "首充奖励",
+  "type": 8,
+  "status": 0,
+  "start_time": "2026-10-26 00:00:00",
+  "end_time": "2027-10-26 23:59:59",
+  "publish_platforms": [
+    "PC",
+    "H5",
+    "APP"
+  ],
+  "params": {
+    "min_deposit": "100.00",
+    "reward_amount": "25.00",
+    "turnover_ratio": "0.00"
+  }
+}
+```
+
+## 业务规则与字段
+
+| params字段 | 示例值 | 含义 |
+|---|---|---|
+| min_deposit | 100.00 | 历史首笔成功充值的最低实际到账金额 |
+| reward_amount | 25.00 | 固定赠送金额,可自行设置 |
+| turnover_ratio | 0.00 | 奖金所需有效投注倍数 |
+
+1. 判断会员全历史第一笔正向成功入账的“充值/三方充值”,不是活动期间第一笔、当天第一笔或活动开启后的第一次充值。
+2. 以该笔实际到账换算金额判断门槛。失败订单、人工上分、活动赠送不计首充;前台提交凭证并审核成功的真实充值计入。
+3. 第一笔不足门槛或发生在活动下架时,后续第二笔不能补领;已有充值历史的会员不会因新活动上架重新获得资格。
+4. 充值成功自动赠送,不提供会员申请或领取按钮。重复支付回调、补单、重复审核不能重复发奖,下架再上架不重置首充资格。
+5. 多个首充配置同时符合时,按同语言的排序值、ID倒序选择一项;与原有即充即送等充值奖励分别判定,可叠加。
+6. `turnover_ratio=0` 直接进入可用余额,大于0进入冻结余额,发放后新的有效投注达到“奖金×倍数”后自动解冻,各笔奖金独立计算。有效投注逐笔取 `min(本金, |净输赢|)`,未结算和撤销注单不计。
+
+## 发奖记录
+
+`GET /admin/promotion/records?activity_id=1&kind=rewards&page=1&limit=20` 查看奖金,可加会员数据库ID `user_id`,返回 `total/data`;`kind=accounts` 查看参与账号。
+
+奖金状态 `locked` 表示已进入冻结余额待完成流水,`paid` 表示已进入可用余额。可通过 `credited_log_id/unlocked_log_id` 核对资金流水,`rule_snapshot` 保存使用的规则和首充流水来源。停止活动使用下架,历史记录保留。
+
+## 权限与上线
+
+独立菜单为 `promotion`;配置、保存与上下架、记录权限分别为 `admin/promotion`、`admin/promotion/submit`、`admin/promotion/records`。上线执行新增迁移 `2026_10_26_160000_create_promotion_activities.php`,计划任务每分钟运行 `promotions:sync`;有效投注沿用已有分享注单同步。
+
+对应会员端文档:[《首充奖励-会员接口》](../../前端接口/独立活动/首充奖励-会员接口.md)。

+ 203 - 0
docs/总后台/龙虎榜资金流动与报表-接口.md

@@ -0,0 +1,203 @@
+# 龙虎榜、资金流动、营业/推广/分享报表:后台前端接入
+
+项目 `bot-28`,本文件为新增独立接口文档。后台只提供 API;页面按参考图接入。
+
+## 通用约定与接口
+
+请求头 `Authorization: Bearer <admin_token>`。POST 使用 JSON,后台成功 code=0、参数错误 code=-3,登录与权限错误沿用现有处理。普通接口需管理员登录和按钮权限;设置写入、费用核对、订单时间核对额外要求运营管理员。
+
+| 方法 | 完整路径 | 用途 |
+|---|---|---|
+| GET | `/admin/reporting/options` | 下拉选项和统计规则 |
+| GET | `/admin/reporting/leaderboard` | 龙虎榜 |
+| GET | `/admin/reporting/fund-flow` | 总资金流与每日列表 |
+| GET | `/admin/reporting/summary` | 三类报表汇总、九类游戏卡片 |
+| GET | `/admin/reporting/details` | 会员/直属代理/直接邀请人明细 |
+| GET | `/admin/reporting/orders` | 明细的原始游戏交易追溯 |
+| GET | `/admin/reporting/events` | 存提款、优惠、佣金等资金记录追溯 |
+| GET / POST | `/admin/reporting/settings` | 读取/设置有效会员最低存款 |
+| GET / POST | `/admin/reporting/payment-reconciliation` | 收付款待核对列表/保存核对结果(运营管理员) |
+| POST | `/admin/reporting/order-reconciliation` | 补录已结算/已撤销订单的真实结算时间(运营管理员) |
+| GET | `/admin/reporting/data-quality` | 统计导入异常、来源状态,支持page/limit(运营管理员) |
+
+options/details/orders/events/settings 的读取权限复用 `admin/reporting/summary`。三个菜单分别使用 `admin/reporting/leaderboard`、`admin/reporting/fund-flow`、`admin/reporting/summary`;写入配置和核对分别配置对应按钮。后台操作不会触发打款、扣款或重新结算。
+
+## 公共筛选
+
+| 参数 | 允许值/默认值 |
+|---|---|
+| start_date / end_date | `YYYY-MM-DD`;默认当天,北京时间,含结束日,最多366天,不允许未来日期 |
+| report_type | `business` 营业、`promotion` 代理推广、`sharing` 新分享;默认 business |
+| platform | `all/H5/PC/APP/unknown`;默认 all。这里是业务发生时的客户端装置 |
+| category | `all` 或九类游戏代码,默认 all;明细和追溯可按卡片范围查询 |
+| settlement_status | `all/pending/settled`,默认 all,只筛游戏订单,不筛资金或佣金的发放状态 |
+| member_account | 会员账号包含搜索;账号优先 username,再 account,再会员编号 |
+| agent_account | 代理账号包含搜索 |
+| inviter_account | 直接邀请人账号包含搜索 |
+| group_id | 从明细返回,继续追溯该组交易;作为字符串传递 |
+| page / limit | 默认1/20,limit最大100,page最大1000000 |
+
+资金流固定为全系统、所有游戏、所有结算状态。金额为钱包本位币,响应 currency 标明币种,通常 CNY。所有金额是十进制字符串,前端格式化为两位小数,不能先转浮点再进行金额汇总。
+
+options 返回 report_types、categories(键值标签对象)、platforms、settlement_statuses、ranking_metrics、timezone、currency、max_days、rules。
+
+九类代码:third:1真人、third:2电子、third:3彩票、third:4体育、third:5电竞、third:6捕鱼、third:7棋牌、self:sport F1体育、self:lottery F1彩票。
+
+## 龙虎榜
+
+`GET /admin/reporting/leaderboard?start_date=2026-10-26&end_date=2026-10-26&metric=deposit_amount&page=1&limit=10`
+
+项目下拉由 metric 控制排名,可选 deposit_amount、bet_amount、payout_amount、withdraw_amount、activity_minutes,默认存款降序,同值按稳定组 ID 排序,rank 跨页连续。
+
+返回 data 包含 total、data 数组,以及下文的通用统计元数据。行字段:rank、group_id、member_account、nickname、deposit_amount、bet_amount、payout_amount、withdraw_amount、activity_minutes。
+
+消费总额展示 bet_amount(投注总额),销售总额展示 payout_amount(派奖总额),建议页面直接使用“投注总额/派奖总额”。在线时间展示 activity_minutes,单位分钟;历史缺失时是 null,不能显示成0。选择活动时长排名时,缺失时长的行不参与排名。
+
+## 三类报表
+
+```http
+GET /admin/reporting/summary?report_type=promotion&platform=H5&settlement_status=settled&start_date=2026-10-26&end_date=2026-10-26
+```
+
+返回 data.summary 为“所有订单”,data.categories 为九类卡片数组。每个卡片以 group_id 表示 all 或分类代码,label 为展示名称;所有卡片结构一致。
+
+完整的“所有订单”指标示例(省略其他指标):
+
+```json
+{
+  "group_id": "all",
+  "label": "所有订单",
+  "deposit_amount": "1000.0000000000",
+  "withdraw_amount": "686.0000000000",
+  "member_withdraw_amount": "686.0000000000",
+  "agent_withdraw_amount": "0.0000000000",
+  "deposit_fee": "2.0000000000",
+  "withdraw_fee": "1.0000000000",
+  "customer_fee_income": "14.0000000000",
+  "bet_amount": "100.0000000000",
+  "settled_bet_amount": "100.0000000000",
+  "payout_amount": "60.0000000000",
+  "refund_amount": "0.0000000000",
+  "game_gross_profit": "40.0000000000",
+  "bonus_amount": "5.0000000000",
+  "agent_commission": "7.0000000000",
+  "share_commission": "3.0000000000",
+  "company_profit": "22.0000000000",
+  "net_cash_flow": "311.0000000000",
+  "valid_member_count": 1,
+  "member_count": 4,
+  "active_member_count": 1,
+  "active_rate": "0.250000",
+  "activity_minutes": 2,
+  "order_count": 1,
+  "complete": true,
+  "quality": {
+    "missing_time": 0,
+    "missing_amount": 0,
+    "unverified_fees": 0,
+    "foreign_currency": 0,
+    "unverified_attribution": 0,
+    "sync_errors": 0,
+    "not_synchronized": false
+  }
+}
+```
+
+| 字段 | 含义 |
+|---|---|
+| deposit_amount | 期间成功收款本金,采用钱包本位币 |
+| withdraw_amount | 实际对外提款净额,含会员和代理成功出款;不是预扣申请额 |
+| deposit_fee / withdraw_fee | 已核对的平台实际外部手续费,未知费用不能当0 |
+| customer_fee_income | 向会员/代理内扣的手续费收入,单列,不当平台成本重复扣 |
+| configured_fee | 原订单按配置算出的费率金额,仅参考,不冒充实际费用 |
+| bet_amount | 期间下单投注本金;取消/退款金额另外展示 |
+| settled_bet_amount | 期间实际结算的投注净额,扣除退款本金 |
+| payout_amount / refund_amount | 期间实际结算的派奖/退款,部分退款与派奖分开 |
+| game_gross_profit | 已结算投注净额减派奖,平台盈利为正 |
+| bonus_amount | 实发优惠,含笔笔返、回水、可用奖励、利息及冻结奖励实际解冻;不重复计原冻结金额 |
+| agent_commission / share_commission | 实际计入代理余额/审核通过计入会员钱包的佣金 |
+| agent_accrual / share_accrual | 期间佣金业务日的应计金额,分享包含修正差额;不再次作为实发成本扣减 |
+| manual_credit / manual_debit | 人工上分/扣分,独立展示,不直接冒充现金存提款 |
+| company_profit | 游戏毛利减实发优惠、代理佣金、分享佣金、实际平台费用;未结算筛选下为null |
+| net_cash_flow | 成功收款减实际对外打款减实际平台费用;优惠和内部佣金不直接当外部现金再减 |
+| valid_member_count | 期间存款严格超过门槛且至少一笔非撤销投注的去重人数 |
+| active_rate | 区间活跃会员占比,例如0.25展示25%;采集不覆盖或归属分母无法核实时为null,不是查询时实时在线率 |
+| activity_population_complete | 活动快照中的活跃会员是否全部仍在当前人口筛选范围内;改绑造成范围不一致时为false |
+
+分类卡片只展示可可靠归属的游戏指标和分类佣金。存提款、公共优惠、通用手续费、净入金及公司净收益无法直接分配到九类游戏,返回 null;用“—”展示,不复制总金额、不按投注比例摊分。分类毛利可用 game_gross_profit 展示。各分类人数可能重复,不能相加当总人数。
+
+营业包含系统真实用户和 Telegram 真实业务;排除试玩和虚拟投注。推广与分享不是互斥集合,同一会员可能同时属于两者,**营业总计不能通过推广加分享得出**。
+
+## 点击卡片后的明细
+
+```http
+GET /admin/reporting/details?report_type=promotion&category=self:lottery&agent_account=agent&page=1&limit=20
+```
+
+返回 total、data、unallocated。data 行有 group_id、label 和相同统计指标:
+
+- business:按会员分组,member_account、nickname、agent_account、inviter_account 用于回显。
+- promotion:按直属代理分组,agent_account;父代理不重复包含下级代理会员。
+- sharing:按直接邀请人分组,label/member_account 为邀请人账号;实际佣金按接收人单列。
+- unallocated 是无法分给会员的公共资金/成本,例如独立代理账户成本,不算一名会员。界面可在明细底部展示;为空时没有此类数据。
+
+分页后的行不能代替完整总计。完整总计使用相同筛选的 summary;人员、费用归属和日期基础不同,不自行将子报表拼成营业总计。
+
+追溯游戏交易:`GET /admin/reporting/orders?report_type=promotion&group_id=10&category=self:lottery`。返回 total、data,行含 source、source_id、parent_source、parent_id、user_id、category、client_platform、currency、bet_at、settled_at、bet_amount、payout_amount、refund_amount、settled、voided、time_quality、attribution_quality。
+
+追溯资金:`GET /admin/reporting/events?report_type=business&group_id=2&kind=deposit`。kind 可用 deposit/withdraw/agent_withdraw/bonus/agent_commission/share_commission/agent_accrual/share_accrual/manual_credit/manual_debit。返回原始 amount、occurred_at、candidate_at,及核对后的 verified_amount、verified_at、external_fee、reconciliation_id;有核对记录时以 verified_* 为准。未确认时间的记录列为待核对,不用创建时间冒充成功时间。
+
+## 资金流动
+
+`GET /admin/reporting/fund-flow?start_date=2026-10-01&end_date=2026-10-26&page=1&limit=10`
+
+返回 summary(全日期总计)、total(天数)、data(每日列表)。日期倒序、没有业务的日期也有记录;每日 date 为 YYYY-MM-DD。金额使用上表字段;图中的“总盈利”使用 game_gross_profit,并另外展示 company_profit,图中资金流公式改为已确认的实际净入金公式。
+
+日期无法确认的收付款会使相应汇总处于待核对状态;每日列表也提示缺失时间的影响,不强行将其放到申请日。
+
+## 完整性、更新与在线数据
+
+统计接口同时返回 filters、timezone、currency、data_updated_at、server_time、activity_started_at、valid_deposit_min、commission_balances。
+
+- complete=false 时 company_profit/net_cash_flow 为 null。known_company_profit/known_net_cash_flow 是**已知小计**,必须带“未完成核对”的说明,不能改名为最终结果。
+- quality 分别提示缺失时间/金额、未核对费用、非本位币、未核实的历史代理归属、导入错误、尚未完成首次同步。0金额与缺失null不是同一个含义。
+- attribution_quality=event_snapshot 是事件快照;historical_current_agent 是没有历史快照时参考当前代理关系。推广报表会将这种历史归属标为待核对,不冒充精确历史业绩。
+- 装置没有可靠证据时为 unknown,包括第三方回单;供应商 platform 不能当H5/PC/APP。
+- 活动时长从新采集上线后记录,依据有效前台操作、每60秒可见页面心跳及TG用户操作;间隔超过5分钟拆段、多端重叠去重。active_rate分母是范围内期末真实会员,不按注册端分组。
+- 历史活动的时长和活跃人数按观测保存的agent_id/inviter_id筛选、分组,会员改绑不会将历史活动转给新代理或邀请人。多端重叠先合并覆盖区间,再按时间线上最新观测的归属划分,每秒只计算一次;快照为null时不回填当前关系。
+- member_count仍为现有人口范围的参考人数。若历史活动中的会员已因改绑离开该范围,activity_population_complete=false,active_rate返回null,前端显示“—”;activity_minutes、known_activity_minutes及active_member_count仍返回快照范围内的真实值,不用当前人数反推历史比例。
+- 日期早于 activity_started_at 时活动分钟和活跃率为null,同时给 known_activity_minutes。资金流日列表不提供每日在线指标。
+- commission_balances 的 date_basis=current_account_balance,为查询时账户快照,不是期间新增成本;包含 agent_unpaid、share_unclaimed(含预留)、share_reserved、share_debt。不能分配到装置或分类时为null。
+
+## 有效会员配置
+
+`POST /admin/reporting/settings`,例 `{"valid_deposit_min":"20.00"}`。门槛默认20,可为0,最多14位整数及2位小数;“超过”为严格大于。保存后查询按当前门槛重算,接口回显使用值;不会修改分享返佣的门槛或政策。
+
+## 核对界面(仅运营管理员)
+
+列表:`GET /admin/reporting/payment-reconciliation?status=pending&kind=withdraw&page=1&limit=20`。status=all/pending/verified,kind=deposit/withdraw/agent_withdraw;返回 total、data。
+
+保存:
+
+```json
+{
+  "event_id": 123,
+  "actual_amount": "686.00",
+  "external_fee": "1.00",
+  "occurred_at": "2026-10-26 10:00:00",
+  "currency": "CNY",
+  "evidence": "支付账单或转账记录编号"
+}
+```
+
+金额按钱包本位币填写;external_fee 是平台真实承担的外部费用,确认为没有费用时明确填0。occurred_at 是真实成功发生的北京时间,不得晚于当前时间。重复保存同一 event_id 更新该笔核对,有审计,不重复创建资金记录。原配置费率金额仅作参考。
+
+订单时间核对:`POST /admin/reporting/order-reconciliation`,例 `{"id":456,"settled_at":"2026-10-26 10:00:00","evidence":"结算日志编号"}`。id 为 orders 返回的报表记录ID;只补录已结算/撤销订单的时间,不改变原游戏状态,同一父单的已结算明细一起更新。
+
+历史归属核对:两个核对POST还可按凭据提交agent_id(直属代理ID,可null表示直营)、inviter_id(直接邀请人的users.id,可null)、client_platform(H5/PC/APP/unknown)。不能改写会员身份或独立代理账户的实际收付款接收人。order-reconciliation可只提交id、归属字段、evidence,不填settled_at,此时不修改结算时间,待结算订单也可核对归属。明确核对agent_id后,historical_current_agent变为verified;只补装置不会顺便假定代理归属也已核实。保存只改变报表快照,原会员绑定关系不改。
+
+data-quality返回total、data异常列表(source、source_id、candidate_at、reason)及sources同步状态。用于定位无法解析的来源记录,解决后重跑同步;不要仅修改图表显示来隐藏异常。
+
+来源状态与已核实的非零收付款冲突时,统计暂停给出最终净值;根据凭据核对原状态及实际金额,真实未打款可以更正核对金额为0,发生的外部费用仍单列。不得为了消除提示随意填写0。
+
+前台会员端还需接入新增文档《统计装置与活动采集-前台接口.md》中的 Platform 和可见页面心跳,否则新增业务可能仍归未知端。

+ 57 - 0
routes/admin.php

@@ -54,6 +54,10 @@ use App\Http\Controllers\admin\PaymentConfiguration;
 use App\Http\Controllers\admin\AppConfiguration;
 use App\Http\Controllers\admin\ShareEarning;
 use App\Http\Controllers\admin\SharingManagement;
+use App\Http\Controllers\admin\Announcement;
+use App\Http\Controllers\admin\SiteMessage;
+use App\Http\Controllers\admin\Maintenance;
+use App\Http\Controllers\admin\Reporting;
 use App\Http\Controllers\admin\RewardGrantSettings;
 
 Route::post('/login', [Admin::class, 'login']);
@@ -79,6 +83,59 @@ Route::middleware(['admin.jwt'])->group(function () {
 
 
     Route::middleware(['check.button.uri'])->group(function () {
+        Route::prefix('/promotion')->group(function () {
+            Route::get('/', [\App\Http\Controllers\admin\Promotion::class, 'index']);
+            Route::get('/options', [\App\Http\Controllers\admin\Promotion::class, 'options'])->defaults('permission_uri', 'admin/promotion');
+            Route::get('/detail', [\App\Http\Controllers\admin\Promotion::class, 'detail'])->defaults('permission_uri', 'admin/promotion');
+            Route::post('/submit', [\App\Http\Controllers\admin\Promotion::class, 'submit']);
+            Route::post('/status', [\App\Http\Controllers\admin\Promotion::class, 'status'])->defaults('permission_uri', 'admin/promotion/submit');
+            Route::get('/records', [\App\Http\Controllers\admin\Promotion::class, 'records']);
+        });
+        Route::prefix('/reporting')->group(function () {
+            Route::get('/options', [Reporting::class, 'options'])->defaults('permission_uri', 'admin/reporting/summary');
+            Route::get('/leaderboard', [Reporting::class, 'leaderboard']);
+            Route::get('/fund-flow', [Reporting::class, 'funds']);
+            Route::get('/summary', [Reporting::class, 'summary']);
+            Route::get('/details', [Reporting::class, 'details'])->defaults('permission_uri', 'admin/reporting/summary');
+            Route::get('/orders', [Reporting::class, 'orders'])->defaults('permission_uri', 'admin/reporting/summary');
+            Route::get('/events', [Reporting::class, 'events'])->defaults('permission_uri', 'admin/reporting/summary');
+            Route::get('/settings', [Reporting::class, 'settings'])->defaults('permission_uri', 'admin/reporting/summary');
+            Route::post('/settings', [Reporting::class, 'settings'])->middleware('operations.admin');
+            Route::get('/payment-reconciliation', [Reporting::class, 'payments'])->middleware('operations.admin');
+            Route::post('/payment-reconciliation', [Reporting::class, 'payments'])->middleware('operations.admin');
+            Route::post('/order-reconciliation', [Reporting::class, 'orderTime'])->middleware('operations.admin');
+            Route::get('/data-quality', [Reporting::class, 'quality'])->middleware('operations.admin');
+        });
+
+        Route::prefix('/maintenance')->middleware('operations.admin')->group(function () {
+            Route::get('/', [Maintenance::class, 'index']);
+            Route::get('/options', [Maintenance::class, 'options'])->defaults('permission_uri', 'admin/maintenance');
+            Route::get('/detail', [Maintenance::class, 'detail'])->defaults('permission_uri', 'admin/maintenance');
+            Route::post('/submit', [Maintenance::class, 'submit']);
+            Route::post('/status', [Maintenance::class, 'status'])->defaults('permission_uri', 'admin/maintenance/submit');
+            Route::get('/logs', [Maintenance::class, 'logs']);
+        });
+
+        Route::prefix('/message')->group(function () {
+            Route::get('/', [SiteMessage::class, 'index']);
+            Route::get('/options', [SiteMessage::class, 'options'])->defaults('permission_uri', 'admin/message');
+            Route::get('/recipients/users', [SiteMessage::class, 'users'])->defaults('permission_uri', 'admin/message/send');
+            Route::get('/recipients/agents', [SiteMessage::class, 'agents'])->middleware('agent.admin')->defaults('permission_uri', 'admin/message/send');
+            Route::get('/detail', [SiteMessage::class, 'detail'])->defaults('permission_uri', 'admin/message');
+            Route::post('/send', [SiteMessage::class, 'send']);
+            Route::post('/delete', [SiteMessage::class, 'delete']);
+            Route::get('/logs', [SiteMessage::class, 'logs']);
+        });
+
+        Route::prefix('/announcement')->group(function () {
+            Route::get('/', [Announcement::class, 'index']);
+            Route::get('/options', [Announcement::class, 'options'])->defaults('permission_uri', 'admin/announcement');
+            Route::get('/detail', [Announcement::class, 'detail'])->defaults('permission_uri', 'admin/announcement');
+            Route::post('/submit', [Announcement::class, 'submit']);
+            Route::post('/status', [Announcement::class, 'status'])->defaults('permission_uri', 'admin/announcement/submit');
+            Route::get('/logs', [Announcement::class, 'logs']);
+        });
+
         Route::prefix('/agent')->middleware('agent.admin')->group(function () {
             Route::get('/', [AgentAdmin::class, 'index']);
             Route::get('/show', [AgentAdmin::class, 'show']);

+ 5 - 0
routes/agent.php

@@ -3,6 +3,7 @@
 use App\Http\Controllers\agent\AuthController;
 use App\Http\Controllers\agent\CommissionController;
 use App\Http\Controllers\agent\MemberController;
+use App\Http\Controllers\agent\MessageController;
 use App\Http\Controllers\agent\ProfileController;
 use App\Http\Controllers\agent\ReferralController;
 use App\Http\Controllers\agent\ReportController;
@@ -15,6 +16,10 @@ Route::get('/referral/resolve', [ReferralController::class, 'resolve'])->middlew
 Route::post('/referral/bind', [ReferralController::class, 'bind'])->middleware(['check.token', 'throttle:20,1']);
 
 Route::middleware('agent.auth')->group(function () {
+    Route::get('/messages', [MessageController::class, 'index']);
+    Route::get('/messages/detail', [MessageController::class, 'detail']);
+    Route::get('/messages/unread-count', [MessageController::class, 'unread']);
+    Route::post('/messages/read', [MessageController::class, 'read']);
     Route::post('/auth/logout', [AuthController::class, 'logout']);
     Route::get('/profile', [ProfileController::class, 'me']);
     Route::post('/profile', [ProfileController::class, 'update']);

+ 17 - 5
routes/api.php

@@ -20,7 +20,7 @@ Route::get("/setMyCommands", [Home::class, 'setMyCommands']);
 Route::get("/setA", [Home::class, 'setA']);
 
 
-Route::prefix('/ActivityReward')->group(function () {
+Route::prefix('/ActivityReward')->middleware(\App\Http\Middleware\MemberMaintenance::class)->group(function () {
     Route::get('/', [ActivityReward::class, 'index']);
     Route::post('/participate', [ActivityReward::class, 'participate']);
     Route::post('/sendPhoneCode', [ActivityReward::class, 'sendPhoneCode']);
@@ -31,7 +31,7 @@ Route::prefix('/ActivityReward')->group(function () {
 });
 
 
-Route::prefix('/newPc')->group(function () {
+Route::prefix('/newPc')->middleware(\App\Http\Middleware\MemberMaintenance::class)->group(function () {
     Route::get("/yuanTou", [NewPc::class, 'yuanTou']);
     Route::get("/history", [NewPc::class, 'history']);
     Route::get("/prediction", [NewPc::class, 'prediction']);
@@ -40,7 +40,7 @@ Route::prefix('/newPc')->group(function () {
     Route::get("/cao", [NewPc::class, 'cao']);
 });
 
-Route::prefix('/issue')->group(function () {
+Route::prefix('/issue')->middleware(\App\Http\Middleware\MemberMaintenance::class)->group(function () {
     Route::get("/", [Issue::class, 'index']);
     Route::get("/cao", [Issue::class, 'cao']);
     Route::get("/countdown", [Issue::class, 'countdown']);
@@ -52,7 +52,7 @@ Route::prefix('/issue')->group(function () {
 });
 
 //足球app请求的接口,根据游戏模式返回不同的数据
-Route::prefix('/pcissue')->group(function () {
+Route::prefix('/pcissue')->middleware(\App\Http\Middleware\MemberMaintenance::class)->group(function () {
     Route::get("/", [PcIssue::class, 'index']);
     Route::get("/cao", [PcIssue::class, 'cao']);
     Route::get("/countdown", [PcIssue::class, 'countdown']);
@@ -79,7 +79,19 @@ Route::fallback(function () {
 });
 
 // 足球app充值提现的接口需要校验 token 的接口
-Route::middleware('check.token')->group(function () {
+Route::middleware([\App\Http\Middleware\MemberMaintenance::class, 'check.token'])->group(function () {
+    Route::prefix('/promotion')->group(function () {
+        Route::post('/heartbeat', [\App\Http\Controllers\api\Promotion::class, 'heartbeat']);
+        Route::get('/list', [\App\Http\Controllers\api\Promotion::class, 'index']);
+        Route::get('/detail', [\App\Http\Controllers\api\Promotion::class, 'detail']);
+        Route::post('/progress', [\App\Http\Controllers\api\Promotion::class, 'progress']);
+        Route::post('/apply', [\App\Http\Controllers\api\Promotion::class, 'apply']);
+        Route::post('/spin', [\App\Http\Controllers\api\Promotion::class, 'spin']);
+        Route::post('/claim', [\App\Http\Controllers\api\Promotion::class, 'claim']);
+        Route::get('/records', [\App\Http\Controllers\api\Promotion::class, 'records']);
+        Route::get('/rewards', [\App\Http\Controllers\api\Promotion::class, 'rewards']);
+        Route::get('/broadcasts', [\App\Http\Controllers\api\Promotion::class, 'broadcasts']);
+    });
     Route::prefix('/wallet')->group(function () {
         Route::get("/scan", [Wallet::class, 'scan']);
         Route::post("/recharge", [Wallet::class, 'recharge']);

+ 276 - 0
tests/Integration/AnnouncementTest.php

@@ -0,0 +1,276 @@
+<?php
+
+namespace Tests\Integration;
+
+use App\Http\Controllers\admin\Announcement as AnnouncementController;
+use App\Services\AnnouncementService;
+use App\Support\AnnouncementCatalog;
+use Carbon\Carbon;
+use Illuminate\Config\Repository;
+use Illuminate\Database\Capsule\Manager;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Events\Dispatcher;
+use Illuminate\Foundation\Application;
+use Illuminate\Http\JsonResponse;
+use Illuminate\Http\Request;
+use Illuminate\Routing\Router;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Facade;
+use Illuminate\Support\Facades\Schema;
+use Illuminate\Translation\ArrayLoader;
+use Illuminate\Translation\Translator;
+use Illuminate\Validation\Factory;
+use Illuminate\Validation\ValidationException;
+use PHPUnit\Framework\TestCase;
+
+/** Actual admin controller, migration, publication and audit queries, with isolated SQLite only. */
+class AnnouncementTest extends TestCase
+{
+    private Application $app;
+    private AnnouncementService $service;
+
+    protected function setUp(): void
+    {
+        parent::setUp();
+        Carbon::setTestNow(Carbon::parse('2026-10-26 10:00:00', 'Asia/Shanghai'));
+        Facade::clearResolvedInstances();
+        $this->app = new Application(dirname(__DIR__, 2));
+        $this->app->instance('config', new Repository(['app' => ['locale' => 'zh', 'timezone' => 'Asia/Shanghai', 'url' => 'https://admin.example.test']]));
+        Facade::setFacadeApplication($this->app);
+        $db = new Manager($this->app);
+        $db->addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => 'bot_']);
+        $db->setAsGlobal(); $db->bootEloquent();
+        $this->app->instance('db', $db->getDatabaseManager());
+        $this->app->bind('db.schema', fn () => $db->schema());
+        $translator = new Translator(new ArrayLoader(), 'zh');
+        $this->app->instance('translator', $translator);
+        $this->app->instance('validator', new Factory($translator, $this->app));
+        $this->app->instance('log', new \Psr\Log\NullLogger());
+        $this->app->instance(\Illuminate\Contracts\Routing\ResponseFactory::class, new class {
+            public function json($data, $status = 200, $headers = [], $options = 0) { return new JsonResponse($data, $status, $headers, $options); }
+        });
+        Request::macro('validate', function (array $rules) { return app('validator')->make($this->all(), $rules)->validate(); });
+        Schema::create('operation_audits', function (Blueprint $t) {
+            $t->id(); $t->string('resource_type'); $t->integer('resource_id'); $t->string('action'); $t->json('changes');
+            $t->integer('operator_id'); $t->string('operator_name'); $t->timestamp('created_at')->useCurrent();
+        });
+        Schema::create('activity_rewards', function (Blueprint $t) { $t->id(); $t->string('title'); });
+        DB::table('activity_rewards')->insert(['id' => 1, 'title' => '活动']);
+        $this->migration()->up();
+        $this->service = new AnnouncementService();
+        $this->request();
+    }
+
+    protected function tearDown(): void
+    {
+        Carbon::setTestNow(); DB::disconnect(); Facade::clearResolvedInstances(); parent::tearDown();
+    }
+
+    private function migration() { return require dirname(__DIR__, 2) . '/database/migrations/2026_10_26_100000_create_announcements.php'; }
+
+    private function request(array $params = [], string $operator = 'admin'): void
+    {
+        $request = Request::create('/admin/announcement/submit', 'POST', $params);
+        $request->user = (object) ['id' => $operator === 'admin' ? 1 : 2, 'username' => $operator];
+        $this->app->instance('request', $request);
+    }
+
+    private function create(array $params = []): array
+    {
+        if (!array_key_exists('translations', $params)) $params['translations'] = ['zh' => ['title' => $params['title'] ?? '网站维护', 'content' => $params['content'] ?? '维护通知']];
+        unset($params['title'], $params['content']);
+        return $this->service->save($params);
+    }
+
+    public function test_options_defaults_create_sender_and_no_approval_state(): void
+    {
+        $options = $this->service->options();
+        $this->assertSame(['content', 'image', 'update'], array_column($options['categories'], 'value'));
+        $this->assertSame(['PC', 'H5', 'APP'], array_column($options['platforms'], 'value'));
+        $row = $this->create();
+        $this->assertSame('active', $row['publication_state']); $this->assertSame(1, $row['effective_status']);
+        $this->assertSame('2026-10-26 10:00:00', $row['publish_at']);
+        $this->assertSame('全部', $row['platform_label']); $this->assertNull($row['end_at']);
+        $this->assertSame('daily_first_login', $row['display_frequency']);
+        $this->assertSame('批核者:admin', $row['sender_display']); $this->assertSame(1, $row['sender_id']);
+        $this->assertSame(1, DB::table('operation_audits')->where('action', 'create')->count());
+        $this->assertArrayNotHasKey('approval_status', $row);
+    }
+
+    public function test_three_categories_share_content_and_images_round_trip_without_text_html_rendering(): void
+    {
+        $literal = '<b>纯文本</b>\n请阅读';
+        $row = $this->create(['content' => $literal]);
+        $this->assertSame($literal, $row['translations']['zh']['content']);
+        $image = $this->create(['category' => 'image', 'content' => 'https://admin.example.test/storage/images/example.png']);
+        $this->assertSame('/storage/images/example.png', DB::table('announcement_translations')->where('announcement_id', $image['id'])->value('content'));
+        $this->assertSame('https://admin.example.test/storage/images/example.png', $image['translations']['zh']['content']);
+        $update = $this->create(['category' => 'update', 'content' => '新版内容']);
+        $this->assertSame('新版内容', $update['translations']['zh']['content']);
+        $this->expectException(ValidationException::class);
+        $this->service->save(['id' => $row['id'], 'category' => 'image']);
+    }
+
+    public function test_scheduling_exact_boundaries_expiration_and_disabling_do_not_rewrite_stored_status(): void
+    {
+        $row = $this->create(['publish_immediately' => false, 'publish_at' => '2026-10-26 11:00:00',
+            'no_end' => false, 'end_at' => '2026-10-26 12:00:00']);
+        $this->assertSame('scheduled', $row['publication_state']);
+        Carbon::setTestNow(Carbon::parse('2026-10-26 11:00:00', 'Asia/Shanghai'));
+        $this->assertSame('active', $this->service->detail($row['id'])['publication_state']);
+        Carbon::setTestNow(Carbon::parse('2026-10-26 12:00:00', 'Asia/Shanghai'));
+        $this->assertSame('expired', $this->service->detail($row['id'])['publication_state']);
+        $this->assertSame(1, DB::table('announcements')->value('status'));
+        $this->assertSame('disabled', $this->service->status($row['id'], 0)['publication_state']);
+        $this->assertSame('expired', $this->service->status($row['id'], 1)['publication_state']);
+    }
+
+    public function test_edit_preserves_original_sender_publish_time_and_omitted_fields_but_records_current_operator(): void
+    {
+        $row = $this->create(['click_type' => 'external', 'link' => 'https://example.com/a']);
+        Carbon::setTestNow(Carbon::parse('2026-10-26 11:00:00', 'Asia/Shanghai'));
+        $this->request([], 'editor');
+        $edited = $this->service->save(['id' => $row['id'], 'translations' => ['zh' => ['title' => '网站维护', 'content' => '修改内容']], 'publish_immediately' => true]);
+        $this->assertSame('admin', $edited['sender_name']);
+        $this->assertSame($row['publish_at'], $edited['publish_at']);
+        $this->assertSame('https://example.com/a', $edited['link']);
+        $logs = $this->service->logs(['operator_name' => 'editor', 'announcement_id' => $row['id']]);
+        $this->assertSame(1, $logs['total']);
+        $this->assertSame('维护通知', ((array) $logs['data'][0]['old_values'])['translations']['zh']['content']);
+        $this->assertSame('修改内容', ((array) $logs['data'][0]['new_values'])['translations']['zh']['content']);
+        $this->assertSame('所有人', $logs['data'][0]['recipient_label']);
+        $count = DB::table('operation_audits')->count();
+        $this->service->save(['id' => $row['id'], 'translations' => ['zh' => ['title' => '网站维护', 'content' => '修改内容']]]);
+        $this->service->status($row['id'], 1);
+        $this->assertSame($count, DB::table('operation_audits')->count());
+        $scheduled = $this->create(['publish_immediately' => false, 'publish_at' => '2026-10-27 10:00:00']);
+        $this->assertSame('2026-10-26 11:00:00', $this->service->save(['id' => $scheduled['id'], 'publish_immediately' => true])['publish_at']);
+    }
+
+    public function test_filters_apply_before_pagination_and_time_filter_is_publish_time(): void
+    {
+        $a = $this->create(['title' => '维护PC', 'publish_platforms' => ['PC'], 'status' => 0]);
+        $this->create(['title' => '维护H5', 'publish_platforms' => ['H5'], 'category' => 'update']);
+        $future = $this->create(['title' => '未来维护', 'publish_at' => '2026-10-27 02:00:00', 'publish_immediately' => false, 'status' => 0]);
+        $filtered = $this->service->listing(['platform' => 'PC', 'status' => 0, 'title' => '维护', 'page' => 1, 'limit' => 1]);
+        $this->assertSame(2, $filtered['total']); $this->assertSame($future['id'], $filtered['data'][0]['id']);
+        $day = $this->service->listing(['start_date' => '2026-10-27', 'end_date' => '2026-10-27']);
+        $this->assertSame(1, $day['total']); $this->assertSame($future['id'], $day['data'][0]['id']);
+        $this->assertSame($a['id'], $this->service->listing(['platform' => 'PC', 'page' => 2, 'limit' => 1])['data'][0]['id']);
+        $this->assertSame(1, $this->service->listing(['category' => 'update'])['total']);
+    }
+
+    /** @dataProvider invalidInputs */
+    public function test_invalid_input_is_rejected_without_announcement_or_log_writes(array $values): void
+    {
+        try { $this->create($values); $this->fail('Expected invalid input'); }
+        catch (ValidationException $e) { $this->assertNotEmpty($e->errors()); }
+        $this->assertSame(0, DB::table('announcements')->count());
+        $this->assertSame(0, DB::table('operation_audits')->count());
+    }
+
+    public static function invalidInputs(): array
+    {
+        return array_map(fn ($value) => [$value], [
+            ['title' => ''], ['category' => 'other'], ['content' => ''], ['publish_platforms' => []],
+            ['publish_platforms' => ['PC', 'PC']], ['publish_platforms' => ['IOS']], ['display_frequency' => 'always'],
+            ['sender_name' => 'spoof'], ['sender_id' => 42], ['category' => 'image', 'content' => 'text'],
+            ['category' => 'image', 'content' => '//evil.test/a.png'], ['category' => 'image', 'content' => '/a.svg'],
+            ['publish_immediately' => false], ['publish_at' => '2026-02-30 10:00:00'], ['no_end' => false],
+            ['end_at' => '2026-10-26 10:00:00'], ['status' => 2], ['click_type' => 'external', 'link' => 'javascript:alert(1)'],
+            ['click_type' => 'external', 'link' => 'https://user:pass@example.com'], ['click_type' => 'internal', 'link' => '//example.com'],
+            ['click_type' => 'internal', 'link' => '/\\evil'], ['click_type' => 'activity', 'activity_id' => 99],
+        ]);
+    }
+
+    public function test_click_targets_use_explicit_types_and_stale_targets_are_cleared(): void
+    {
+        $row = $this->create(['click_type' => 'activity', 'activity_id' => 1]);
+        $this->assertSame(1, $row['activity_id']); $this->assertNull($row['link']);
+        $row = $this->service->save(['id' => $row['id'], 'click_type' => 'internal', 'link' => '/activity/1']);
+        $this->assertNull($row['activity_id']);
+        $row = $this->service->save(['id' => $row['id'], 'click_type' => 'none']);
+        $this->assertNull($row['activity_id']); $this->assertNull($row['link']);
+    }
+
+    public function test_audit_failure_rolls_back_create_edit_and_status(): void
+    {
+        $row = $this->create();
+        DB::statement("CREATE TRIGGER reject_ann_audit BEFORE INSERT ON bot_operation_audits BEGIN SELECT RAISE(ABORT, 'audit failed'); END");
+        foreach ([fn () => $this->create(), fn () => $this->service->save(['id' => $row['id'], 'translations' => ['zh' => ['title' => 'bad', 'content' => '维护通知']]]), fn () => $this->service->status($row['id'], 0)] as $action) {
+            try { $action(); $this->fail('Expected audit failure'); } catch (\Illuminate\Database\QueryException $e) { $this->assertStringContainsString('audit failed', $e->getMessage()); }
+            $this->assertSame(1, DB::table('announcements')->count());
+            $this->assertSame('网站维护', DB::table('announcement_translations')->value('title'));
+            $this->assertSame(1, DB::table('announcements')->value('status'));
+            $this->assertSame(1, DB::table('operation_audits')->count());
+        }
+    }
+
+    public function test_controller_routes_are_authenticated_and_status_reuses_save_permission(): void
+    {
+        $router = new Router(new Dispatcher($this->app), $this->app);
+        $this->app->instance('router', $router);
+        $router->group(['prefix' => 'admin'], function () { require dirname(__DIR__, 2) . '/routes/admin.php'; });
+        foreach (['' => 'GET', '/options' => 'GET', '/detail' => 'GET', '/logs' => 'GET', '/submit' => 'POST', '/status' => 'POST'] as $suffix => $method) {
+            $route = $router->getRoutes()->match(Request::create('/admin/announcement' . $suffix, $method));
+            $this->assertContains('admin.jwt', $route->middleware()); $this->assertContains('check.button.uri', $route->middleware());
+        }
+        $this->assertSame('admin/announcement/submit', $route->defaults['permission_uri']);
+        $this->request(['translations' => ['zh' => ['title' => '测试', 'content' => '内容']]]);
+        $result = (new AnnouncementController())->submit($this->service)->getData(true);
+        $this->assertSame(0, $result['code']);
+        $this->assertSame('admin', $result['data']['sender_name']);
+        $this->request(['title' => '']);
+        $this->assertSame(-3, (new AnnouncementController())->submit($this->service)->getData(true)['code']);
+    }
+
+    public function test_log_date_range_uses_business_time_and_historical_title_snapshot(): void
+    {
+        $row = $this->create();
+        $this->request([], 'editor');
+        Carbon::setTestNow(Carbon::parse('2026-10-27 00:10:00', 'Asia/Shanghai'));
+        $this->service->save(['id' => $row['id'], 'translations' => ['zh' => ['title' => '修改后的标题', 'content' => '维护通知']]]);
+        $old = $this->service->logs(['start_date' => '2026-10-26', 'end_date' => '2026-10-26']);
+        $this->assertSame(1, $old['total']);
+        $this->assertSame('网站维护', ((array) $old['data'][0]['titles'])['zh']);
+        $this->assertSame('2026-10-26 10:00:00', $old['data'][0]['created_at']);
+        $new = $this->service->logs(['operator_name' => 'edit', 'start_date' => '2026-10-27', 'end_date' => '2026-10-27', 'limit' => 1]);
+        $this->assertSame(1, $new['total']); $this->assertSame('修改后的标题', ((array) $new['data'][0]['titles'])['zh']);
+        $this->assertSame('2026-10-27 00:10:00', $new['data'][0]['created_at']);
+    }
+
+    public function test_end_time_can_be_cleared_and_partial_edits_preserve_existing_end_time(): void
+    {
+        $row = $this->create(['no_end' => false, 'end_at' => '2026-10-27 10:00:00']);
+        $edited = $this->service->save(['id' => $row['id'], 'translations' => ['zh' => ['title' => '新标题', 'content' => '维护通知']]]);
+        $this->assertSame($row['end_at'], $edited['end_at']);
+        $cleared = $this->service->save(['id' => $row['id'], 'no_end' => true]);
+        $this->assertNull($cleared['end_at']); $this->assertTrue($cleared['no_end']);
+        $this->migration()->down();
+        $this->assertFalse(Schema::hasTable('announcements'));
+        $this->assertTrue(Schema::hasTable('operation_audits'));
+    }
+
+    public function test_languages_are_separate_rows_and_filters_count_a_parent_once_without_cross_language_title_matches(): void
+    {
+        $row = $this->create(['translations' => [
+            'zh' => ['title' => '中文标题', 'content' => '中文'], 'en' => ['title' => 'English title', 'content' => 'English'],
+            'vi' => ['title' => '', 'content' => '   '],
+        ]]);
+        $this->assertSame(2, DB::table('announcement_translations')->count());
+        $this->assertSame(1, $this->service->listing([])['total']);
+        $this->assertSame(1, $this->service->listing(['language' => 'en', 'title' => 'English'])['total']);
+        $this->assertSame(0, $this->service->listing(['language' => 'en', 'title' => '中文'])['total']);
+        $this->assertSame(0, $this->service->listing(['language' => 'vi'])['total']);
+        $this->assertSame(['zh', 'en'], $row['languages']);
+        $this->assertArrayNotHasKey('content', $this->service->listing([])['data'][0]);
+        $this->service->save(['id' => $row['id'], 'translations' => ['zh' => ['title' => '中文标题', 'content' => '中文']]]);
+        $this->assertSame(0, $this->service->listing(['language' => 'en'])['total']);
+        $logs = $this->service->logs(['language' => 'en']);
+        $this->assertSame(2, $logs['total']);
+        $this->assertSame('English', ((array) $logs['data'][0]['old_values'])['translations']['en']['content']);
+        $this->assertNull(((array) $logs['data'][0]['new_values'])['translations']);
+        $this->assertSame('English title', ((array) $logs['data'][0]['titles'])['en']);
+        $this->assertSame(0, $this->service->logs(['language' => 'vi'])['total']);
+    }
+}

+ 215 - 0
tests/Integration/MaintenanceTest.php

@@ -0,0 +1,215 @@
+<?php
+
+namespace Tests\Integration;
+
+use App\Http\Controllers\admin\Maintenance as MaintenanceController;
+use App\Http\Middleware\MemberMaintenance;
+use App\Http\Middleware\OperationsAdminMiddleware;
+use App\Services\MaintenanceService;
+use App\Services\Sharing\ShareDefaults;
+use App\Support\MaintenancePolicy as Policy;
+use Carbon\Carbon;
+use Illuminate\Config\Repository;
+use Illuminate\Database\Capsule\Manager;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Events\Dispatcher;
+use Illuminate\Foundation\Application;
+use Illuminate\Http\JsonResponse;
+use Illuminate\Http\Request;
+use Illuminate\Routing\Router;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Facade;
+use Illuminate\Support\Facades\Schema;
+use Illuminate\Translation\ArrayLoader;
+use Illuminate\Translation\Translator;
+use Illuminate\Validation\Factory;
+use Illuminate\Validation\ValidationException;
+use PHPUnit\Framework\TestCase;
+
+class MaintenanceTest extends TestCase
+{
+    private Application $app;
+    private MaintenanceService $service;
+
+    protected function setUp(): void
+    {
+        Carbon::setTestNow(Carbon::parse('2026-10-26 10:00:00', 'Asia/Shanghai'));
+        Facade::clearResolvedInstances();
+        $this->app = new Application(dirname(__DIR__, 2));
+        $this->app->instance('config', new Repository(['app' => ['locale' => 'zh', 'timezone' => 'Asia/Shanghai'], 'operations' => ['admin_ids' => [1]]]));
+        Facade::setFacadeApplication($this->app);
+        $db = new Manager($this->app); $db->addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => 'bot_']);
+        $db->setAsGlobal(); $db->bootEloquent(); $this->app->instance('db', $db->getDatabaseManager());
+        $this->app->bind('db.schema', fn () => $db->schema());
+        $translator = new Translator(new ArrayLoader(), 'zh'); $this->app->instance('translator', $translator);
+        $this->app->instance('validator', new Factory($translator, $this->app)); $this->app->instance('log', new \Psr\Log\NullLogger());
+        $this->app->instance(\Illuminate\Contracts\Routing\ResponseFactory::class, new class {
+            public function json($data, $status = 200, $headers = [], $options = 0) { return new JsonResponse($data, $status, $headers, $options); }
+        });
+        Request::macro('validate', function (array $rules) { return app('validator')->make($this->all(), $rules)->validate(); });
+        Schema::create('operation_audits', function (Blueprint $t) {
+            $t->id(); $t->string('resource_type'); $t->integer('resource_id'); $t->string('action'); $t->json('changes');
+            $t->integer('operator_id'); $t->string('operator_name'); $t->timestamp('created_at')->useCurrent();
+        });
+        Schema::create('users', function (Blueprint $t) { $t->id(); $t->string('user_id'); $t->string('language'); $t->bigInteger('delete_time')->nullable(); $t->integer('is_banned')->default(0); });
+        Schema::create('user_session', function (Blueprint $t) { $t->id(); $t->string('user_id'); $t->string('token'); $t->bigInteger('expire_time'); $t->bigInteger('create_time'); $t->bigInteger('update_time'); });
+        $this->migration()->up(); $this->service = new MaintenanceService(); $this->request();
+    }
+
+    protected function tearDown(): void { Carbon::setTestNow(); DB::disconnect(); Facade::clearResolvedInstances(); parent::tearDown(); }
+    private function migration() { return require dirname(__DIR__, 2) . '/database/migrations/2026_10_26_120000_create_maintenance_settings.php'; }
+    private function request(array $params = []): Request
+    {
+        $request = Request::create('/admin/maintenance/submit', 'POST', $params); $request->user = (object) ['id' => 1, 'username' => 'admin'];
+        $this->app->instance('request', $request); return $request;
+    }
+    private function id(string $scope = 'site'): int { return (int) DB::table('maintenance_settings')->where('scope_code', $scope)->value('id'); }
+    private function save(array $values = [], string $scope = 'site'): array
+    {
+        return $this->service->save($values + ['id' => $this->id($scope), 'enabled' => 1, 'starts_at' => '2026-10-26 09:00:00']);
+    }
+    private function decide(?string $platform = 'PC', ?string $category = null): ?array
+    {
+        return Policy::select($this->service->runtimeRows('zh'), $platform, $category, now()->getTimestamp());
+    }
+
+    public function test_migration_defaults_and_catalog_match_commission_types(): void
+    {
+        $this->assertSame(10, DB::table('maintenance_settings')->count());
+        $this->assertSame(0, DB::table('maintenance_settings')->where('enabled', 1)->count());
+        $this->assertSame(ShareDefaults::categories(), Policy::CATEGORIES);
+        $this->assertSame(10, $this->service->listing([])['total']); $this->assertNull($this->decide());
+        $this->assertContains('UTC', $this->service->options()['timezones']);
+        $this->assertSame(['zh', 'en', 'vi'], array_column($this->service->options()['languages'], 'value'));
+        $this->migration()->down(); $this->assertFalse(Schema::hasTable('maintenance_settings')); $this->assertTrue(Schema::hasTable('users'));
+    }
+
+    public function test_utc_conversion_and_exact_start_end_without_scheduler(): void
+    {
+        $row = $this->save(['starts_at' => '2026-10-26 10:00:00', 'ends_at' => '2026-10-26 11:00:00']);
+        $this->assertSame('active', $row['state']); $this->assertSame('2026-10-26T02:00:00Z', $row['starts_at_utc']);
+        $this->assertSame('2026-10-26 02:00:00', DB::table('maintenance_settings')->where('id', $row['id'])->value('starts_at'));
+        Carbon::setTestNow(Carbon::parse('2026-10-26 09:59:59', 'Asia/Shanghai')); $this->assertNull($this->decide());
+        Carbon::setTestNow(Carbon::parse('2026-10-26 10:00:00', 'Asia/Shanghai')); $this->assertSame('site', $this->decide()['scope_code']);
+        Carbon::setTestNow(Carbon::parse('2026-10-26 11:00:00', 'Asia/Shanghai')); $this->assertNull($this->decide());
+        $this->assertSame('expired', $this->service->detail($row['id'])['state']);
+        $this->assertSame(1, DB::table('maintenance_settings')->where('id', $row['id'])->value('enabled'));
+        $this->assertSame('expired', $this->service->status($row['id'], 1)['state']);
+        $this->assertSame('active', $this->service->save(['id' => $row['id'], 'start_now' => true, 'ends_at' => null])['state']);
+    }
+
+    public function test_site_precedence_applies_only_to_effective_platform_and_time(): void
+    {
+        $this->save(['platforms' => ['H5']]); $this->save(['platforms' => ['PC', 'H5']], 'third:4');
+        $this->assertSame('site', $this->decide('H5', 'third:4')['scope_code']);
+        $this->assertSame('third:4', $this->decide('PC', 'third:4')['scope_code']);
+        $this->assertNull($this->decide('APP', 'third:4')); $this->assertNull($this->decide('PC', 'self:sport'));
+        $this->assertSame('site', $this->decide(null, 'third:4')['scope_code']);
+        $this->save(['starts_at' => '2026-10-27 00:00:00']);
+        $this->assertSame('third:4', $this->decide('H5', 'third:4')['scope_code']);
+        $this->save(['ends_at' => '2026-10-26 10:00:00']);
+        $this->assertSame('third:4', $this->decide('H5', 'third:4')['scope_code']);
+        $this->assertSame('third:4', $this->decide(null, 'third:4')['scope_code']);
+    }
+
+    public function test_translations_blank_fallback_partial_update_and_audit_history(): void
+    {
+        $this->save(['translations' => ['zh' => ['content' => '<b>维护</b>'], 'en' => ['content' => 'Maintenance'], 'vi' => ['content' => '   ']]]);
+        $this->assertSame(2, DB::table('maintenance_translations')->count());
+        $this->assertSame('<b>维护</b>', $this->decide()['content']);
+        $this->assertSame('Maintenance', Policy::failure($this->service->runtimeRows('en')[0], 'PC', 'en', now()->getTimestamp())['msg']);
+        $fallback = Policy::failure($this->service->runtimeRows('vi')[0], 'PC', 'vi', now()->getTimestamp());
+        $this->assertSame(11001, $fallback['code']); $this->assertStringContainsString('bảo trì', $fallback['msg']);
+        $this->service->save(['id' => $this->id(), 'platforms' => ['PC']]); $this->assertSame(2, DB::table('maintenance_translations')->count());
+        $before = DB::table('operation_audits')->count(); $this->service->status($this->id(), 1); $this->assertSame($before, DB::table('operation_audits')->count());
+        $this->service->save(['id' => $this->id(), 'translations' => ['zh' => ['content' => '中文']]]);
+        $logs = $this->service->logs(['language' => 'en', 'operator_name' => 'adm', 'resource_id' => $this->id()]);
+        $this->assertSame(3, $logs['total']);
+        $this->assertSame('整站', ((array) $logs['data'][0]['old_values'])['translations']['en']['title']);
+        $audit = json_decode(DB::table('operation_audits')->orderBy('id')->value('changes'), true);
+        $this->assertSame('site', $audit['after']['scope_code']);
+        $this->assertSame(0, $this->service->listing(['language' => 'en'])['total']);
+    }
+
+    public function test_filters_are_applied_before_pagination_and_timezone_edit_preserves_instants(): void
+    {
+        $row = $this->save(['platforms' => ['APP'], 'translations' => ['en' => ['content' => 'Text']]]);
+        $this->save(['platforms' => ['APP'], 'translations' => ['en' => ['content' => 'Game']]], 'third:2');
+        $list = $this->service->listing(['enabled' => 1, 'state' => 'active', 'platform' => 'APP', 'language' => 'en', 'limit' => 1, 'page' => 2]);
+        $this->assertSame(2, $list['total']); $this->assertSame('third:2', $list['data'][0]['scope_code']);
+        $edited = $this->service->save(['id' => $row['id'], 'timezone' => 'America/New_York']);
+        $this->assertSame($row['starts_at_utc'], $edited['starts_at_utc']); $this->assertSame('2026-10-25 21:00:00', $edited['starts_at']);
+        $this->assertSame(1, $this->service->listing(['scope_code' => 'third:2'])['total']);
+    }
+
+    /** @dataProvider invalidInputs */
+    public function test_invalid_schedules_scopes_and_payloads_leave_no_changes(array $input): void
+    {
+        $before = (array) DB::table('maintenance_settings')->where('id', $this->id())->first();
+        try { $this->save($input); $this->fail('Expected validation failure'); }
+        catch (ValidationException $e) { $this->assertNotEmpty($e->errors()); }
+        $this->assertSame($before, (array) DB::table('maintenance_settings')->where('id', $this->id())->first());
+        $this->assertSame(0, DB::table('operation_audits')->count());
+    }
+    public static function invalidInputs(): array
+    {
+        return array_map(fn ($input) => [$input], [
+            ['id' => 999], ['scope_code' => 'third:4'], ['platforms' => []], ['platforms' => ['IOS']], ['platforms' => ['PC', 'PC']],
+            ['enabled' => 2], ['timezone' => '+08:00'], ['timezone' => 'Not/AZone'], ['starts_at' => '2026-02-30 00:00:00'],
+            ['ends_at' => '2026-10-26 08:00:00'], ['translations' => ['th' => ['content' => 'bad']]], ['translations' => ['zh' => 'bad']],
+            ['translations' => ['en' => ['content' => str_repeat('x', 5001)]]],
+            ['timezone' => 'America/New_York', 'starts_at' => '2026-03-08 02:30:00'],
+            ['timezone' => 'America/New_York', 'starts_at' => '2026-11-01 01:30:00'],
+        ]);
+    }
+
+    public function test_audit_failure_rolls_back_rule_and_translations(): void
+    {
+        DB::statement("CREATE TRIGGER reject_maintenance_audit BEFORE INSERT ON bot_operation_audits BEGIN SELECT RAISE(ABORT, 'audit failed'); END");
+        try { $this->save(['translations' => ['zh' => ['content' => '维护']]]); $this->fail('Expected audit failure'); }
+        catch (\Illuminate\Database\QueryException $e) { $this->assertStringContainsString('audit failed', $e->getMessage()); }
+        $this->assertSame(0, DB::table('maintenance_settings')->where('enabled', 1)->count());
+        $this->assertSame(0, DB::table('maintenance_translations')->count());
+    }
+
+    public function test_legacy_middleware_blocks_before_business_and_uses_saved_language_without_renewal(): void
+    {
+        $this->save(['translations' => ['en' => ['content' => 'Saved English']]]);
+        DB::table('users')->insert(['user_id' => '101', 'language' => 'en']);
+        DB::table('user_session')->insert(['user_id' => '101', 'token' => 'valid', 'create_time' => now()->getTimestamp() - 3600,
+            'update_time' => now()->getTimestamp() - 3600, 'expire_time' => now()->getTimestamp() + 3600]);
+        $before = (array) DB::table('user_session')->first(); $called = false;
+        $request = Request::create('/api/wallet/createPay', 'POST'); $request->headers->add(['Authorization' => 'valid', 'Lang' => 'vi', 'Platform' => 'PC']);
+        $response = (new MemberMaintenance())->handle($request, function () use (&$called) { $called = true; return new JsonResponse(['code' => 0]); });
+        $this->assertFalse($called); $this->assertSame(200, $response->getStatusCode());
+        $this->assertSame(11001, $response->getData(true)['code']); $this->assertSame('Saved English', $response->getData(true)['msg']);
+        $this->assertSame($before, (array) DB::table('user_session')->first());
+        $this->assertStringContainsString('no-store', $response->headers->get('Cache-Control'));
+        $this->service->status($this->id(), 0);
+        $this->assertSame(0, (new MemberMaintenance())->handle($request, fn () => new JsonResponse(['code' => 0]))->getData(true)['code']);
+    }
+
+    public function test_routes_permissions_and_callbacks_settlement_and_admin_remain_outside_gate(): void
+    {
+        $router = new Router(new Dispatcher($this->app), $this->app); $this->app->instance('router', $router);
+        if (!class_exists('Route')) class_alias(\Illuminate\Support\Facades\Route::class, 'Route');
+        $router->group(['prefix' => 'admin'], function () { require dirname(__DIR__, 2) . '/routes/admin.php'; });
+        $router->group(['prefix' => 'api'], function () { require dirname(__DIR__, 2) . '/routes/api.php'; require dirname(__DIR__, 2) . '/routes/api_private.php'; });
+        foreach (['' => 'GET', '/options' => 'GET', '/detail' => 'GET', '/logs' => 'GET', '/submit' => 'POST', '/status' => 'POST'] as $suffix => $method) {
+            $route = $router->getRoutes()->match(Request::create('/admin/maintenance' . $suffix, $method));
+            foreach (['admin.jwt', 'check.button.uri', 'operations.admin'] as $middleware) $this->assertContains($middleware, $route->middleware());
+            $this->assertNotContains(MemberMaintenance::class, $route->middleware());
+        }
+        $this->assertSame('admin/maintenance/submit', $route->defaults['permission_uri']);
+        foreach (['/api/wallet/createPay' => 'POST', '/api/ActivityReward' => 'GET', '/api/newPc' => 'GET', '/api/issue' => 'GET', '/api/pcissue' => 'GET'] as $uri => $method) {
+            $this->assertContains(MemberMaintenance::class, $router->getRoutes()->match(Request::create($uri, $method))->middleware());
+        }
+        foreach (['/api/pay/notify', '/api/pay/harvest', '/api/onMessage', '/api/pcissue/verify'] as $uri) {
+            $this->assertNotContains(MemberMaintenance::class, $router->getRoutes()->match(Request::create($uri, 'POST'))->middleware());
+        }
+        $request = $this->request(); $request->user->id = 99;
+        $this->assertSame(-1, (new OperationsAdminMiddleware())->handle($request, fn () => new JsonResponse(['code' => 0]))->getData(true)['code']);
+        $this->request(['id' => $this->id(), 'enabled' => 1]);
+        $this->assertSame(0, (new MaintenanceController())->submit($this->service)->getData(true)['code']);
+    }
+}

+ 345 - 0
tests/Integration/PromotionActivitiesTest.php

@@ -0,0 +1,345 @@
+<?php
+
+namespace Tests\Integration;
+
+use App\Http\Controllers\admin\ActivityReward as AdminActivity;
+use App\Models\PromotionActivity as ActivityReward;
+use App\Models\BalanceLog;
+use App\Services\PromotionAdminService;
+use App\Services\PromotionFacts;
+use App\Services\PromotionService;
+use App\Support\PromotionRules;
+use Carbon\Carbon;
+use Illuminate\Config\Repository;
+use Illuminate\Database\Capsule\Manager;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Foundation\Application;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Facade;
+use Illuminate\Translation\ArrayLoader;
+use Illuminate\Translation\Translator;
+use Illuminate\Validation\Factory;
+use Illuminate\Validation\ValidationException;
+use PHPUnit\Framework\TestCase;
+
+/** Runs real migrations, qualification, wallet writes and state machines in SQLite, without .env/providers. */
+class PromotionActivitiesTest extends TestCase
+{
+    private Application $app;
+    private PromotionService $service;
+    private string $originalTimezone;
+    protected function setUp(): void
+    {
+        $this->originalTimezone = date_default_timezone_get(); date_default_timezone_set('Asia/Shanghai'); Carbon::setTestNow(Carbon::parse('2026-10-26 12:00:00', 'Asia/Shanghai'));
+        Facade::clearResolvedInstances(); $this->app = new Application(dirname(__DIR__, 2));
+        $this->app->instance('config', new Repository(['app' => ['locale' => 'zh', 'timezone' => 'Asia/Shanghai'], 'third_game' => ['currency' => 'CNY']])); Facade::setFacadeApplication($this->app);
+        $db = new Manager($this->app); $db->addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => 'bot_']);
+        $db->setAsGlobal(); $db->bootEloquent(); $this->app->instance('db', $db->getDatabaseManager()); $this->app->bind('db.schema', fn () => $db->schema());
+        $this->app->instance('translator', new Translator(new ArrayLoader(), 'zh'));
+        $this->app->instance('validator', new Factory($this->app['translator'], $this->app));
+        $this->app->instance('log', new \Psr\Log\NullLogger()); $this->app->instance('request', Request::create('/api/promotion/progress'));
+        $this->app->instance(\Illuminate\Contracts\Routing\ResponseFactory::class, new class {
+            public function json($data, $status = 200, $headers = [], $options = 0) { return new \Illuminate\Http\JsonResponse($data, $status, $headers, $options); }
+        });
+        Request::macro('validate', function ($rules) { return app('validator')->make($this->all(), $rules)->validate(); });
+        foreach ([
+            'activity_rewards' => 'id INTEGER PRIMARY KEY AUTOINCREMENT,title TEXT,sub_title TEXT,type INTEGER,status INTEGER,start_time INTEGER,end_time INTEGER,lang TEXT,params TEXT,detail_image TEXT,pc_image TEXT,detail_banner TEXT,pc_banner TEXT,content TEXT,created_at TEXT,updated_at TEXT',
+            'users' => 'id INTEGER PRIMARY KEY,member_id TEXT UNIQUE,user_id TEXT,language TEXT,"from" INTEGER,level INTEGER DEFAULT 0,recharge_channel_group_id INTEGER DEFAULT 1,created_at TEXT,updated_at TEXT',
+            'wallets' => 'id INTEGER PRIMARY KEY AUTOINCREMENT,member_id TEXT UNIQUE,available_balance TEXT DEFAULT "0",frozen_balance TEXT DEFAULT "0",created_at TEXT,updated_at TEXT',
+            'balance_logs' => 'id INTEGER PRIMARY KEY AUTOINCREMENT,member_id TEXT,type INTEGER DEFAULT 1,amount TEXT,change_type TEXT,before_balance TEXT,after_balance TEXT,related_id INTEGER,remark TEXT,frozen_status INTEGER,created_at TEXT,updated_at TEXT',
+            'share_members' => 'user_id INTEGER PRIMARY KEY,parent_user_id INTEGER,bound_at TEXT',
+            'share_policies' => 'id INTEGER PRIMARY KEY,effective_date TEXT,valid_member_min_deposit TEXT,valid_member_min_effective_bet TEXT',
+            'share_bet_facts' => 'id INTEGER PRIMARY KEY AUTOINCREMENT,user_id INTEGER,source TEXT,source_id TEXT,bet_at TEXT,bet_amount TEXT,net_profit TEXT,effective_amount TEXT,currency TEXT,settled INTEGER DEFAULT 1,voided INTEGER DEFAULT 0',
+            'user_login' => 'id INTEGER PRIMARY KEY AUTOINCREMENT,user_id TEXT,status INTEGER,platform TEXT,created_at TEXT',
+            'reporting_activity' => 'id INTEGER PRIMARY KEY AUTOINCREMENT,user_id INTEGER,session_hash TEXT,observed_at TEXT',
+            'menus' => 'id INTEGER PRIMARY KEY AUTOINCREMENT,parent_id INTEGER,title TEXT,icon TEXT,uri TEXT,permission_name TEXT,sort INTEGER,status INTEGER,type INTEGER,created_at TEXT,updated_at TEXT',
+        ] as $table => $columns) DB::statement('CREATE TABLE bot_' . $table . ' (' . $columns . ')');
+        foreach (['2026_10_13_120000_add_activity_eligibility_fields.php', '2026_10_14_120000_add_activity_reward_quotas.php', '2026_10_25_120000_add_game_category_to_activity_rewards.php', '2026_10_26_160000_create_promotion_activities.php'] as $migration) (require dirname(__DIR__, 2) . '/database/migrations/' . $migration)->up();
+        $this->member(1); $this->member(2, 1);
+        DB::table('share_policies')->insert(['id' => 1, 'effective_date' => '2026-10-01', 'valid_member_min_deposit' => '200', 'valid_member_min_effective_bet' => '300']);
+        $this->service = new PromotionService();
+    }
+    protected function tearDown(): void { Carbon::setTestNow(); date_default_timezone_set($this->originalTimezone); DB::disconnect(); Facade::clearResolvedInstances(); parent::tearDown(); }
+    private function travel(string $time): void { Carbon::setTestNow(Carbon::parse($time, 'Asia/Shanghai')); }
+    private function member(int $id, ?int $parent = null, string $at = '2026-10-01 00:00:00'): void
+    {
+        DB::table('users')->insert(['id' => $id, 'member_id' => 'm' . $id, 'user_id' => 'u' . $id, 'language' => 'zh', 'from' => 1, 'created_at' => $at, 'updated_at' => $at]);
+        DB::table('wallets')->insert(['member_id' => 'm' . $id, 'available_balance' => '0', 'frozen_balance' => '0']);
+        if ($parent) DB::table('share_members')->insert(['user_id' => $id, 'parent_user_id' => $parent, 'bound_at' => $at]);
+    }
+    private function activity(int $type = 9, array $params = [], int $status = 1): int
+    {
+        (new PromotionAdminService())->save(['title' => '测试活动', 'sub_title' => '规则', 'type' => $type, 'status' => $status,
+            'start_time' => '2026-10-01 00:00:00', 'end_time' => '2026-12-31 23:59:59', 'lang' => 'zh', 'params' => $params]);
+        return (int)DB::table('promotion_activities')->max('id');
+    }
+    private function deposit(int $id, string $amount, ?string $time = null, string $type = '充值'): BalanceLog
+    {
+        return BalanceLog::create(['member_id' => 'm' . $id, 'type' => 1, 'amount' => $amount, 'change_type' => $type, 'created_at' => $time ?? PromotionFacts::now()]);
+    }
+    private function rawDeposit(int $id, string $amount, string $time, string $type = '充值'): int
+    {
+        return DB::table('balance_logs')->insertGetId(['member_id' => 'm' . $id, 'type' => 1, 'amount' => $amount, 'change_type' => $type, 'created_at' => $time, 'updated_at' => $time]);
+    }
+    private function bet(int $id, string $stake, string $profit, string $time, array $extra = []): int
+    {
+        $next = (int)DB::table('share_bet_facts')->max('id') + 1;
+        return DB::table('share_bet_facts')->insertGetId($extra + ['user_id' => $id, 'source' => 'sport_order', 'source_id' => (string)$next,
+            'bet_at' => $time, 'bet_amount' => $stake, 'net_profit' => $profit, 'effective_amount' => '999999', 'currency' => 'CNY', 'settled' => 1, 'voided' => 0]);
+    }
+    private function invalid(callable $callback, string $message): void
+    {
+        try { $callback(); $this->fail('Expected validation failure: ' . $message); }
+        catch (ValidationException $e) { $this->assertStringContainsString($message, $e->validator->errors()->first()); }
+    }
+    private function complete(int $activity): array
+    {
+        $this->rawDeposit(1, '3000', '2026-10-26 12:01:00'); $this->rawDeposit(2, '500', '2026-10-26 12:01:00');
+        $this->bet(1, '6000', '-5000', '2026-10-26 12:01:00'); $this->bet(2, '4000', '-3000', '2026-10-26 12:01:00');
+        DB::table('user_login')->insert(['user_id' => 'm1', 'status' => 1, 'platform' => 'H5', 'created_at' => '2026-10-26 12:01:00']);
+        for ($i = 0; $i <= 60; ++$i) DB::table('reporting_activity')->insert(['user_id' => 1, 'session_hash' => 'a', 'observed_at' => Carbon::parse('2026-10-26 12:01:00')->addMinutes($i)->format('Y-m-d H:i:s')]);
+        $this->travel('2026-10-26 14:00:00'); return $this->service->progress($activity, 1, 'H5');
+    }
+
+    public function test_probability_default_zero_boundaries_and_incomplete_gate(): void
+    {
+        $this->assertSame('20.00', PromotionRules::normalize(9, [])['completion_probability']);
+        $this->assertSame('0.00', PromotionRules::normalize(9, ['completion_probability' => 0])['completion_probability']);
+        $this->assertTrue(PromotionRules::draw(66599, true, '20.00', fn ($a, $b) => $a === 1 ? 2000 : 0)['reached']);
+        $this->assertFalse(PromotionRules::draw(66599, true, '20.00', fn ($a, $b) => $a === 1 ? 2001 : 0)['reached']);
+        $this->assertFalse(PromotionRules::draw(66599, false, '100.00', fn ($a, $b) => $a)['reached']);
+        $this->invalid(fn () => PromotionRules::normalize(9, ['completion_probability' => 100.01]), 'between');
+    }
+
+    public function test_day_amounts_do_not_add_together_but_completed_flags_survive_midnight(): void
+    {
+        $id = $this->activity(); $this->service->progress($id, 1);
+        $this->rawDeposit(1, '2000', '2026-10-26 13:00:00');
+        $this->travel('2026-10-27 14:00:00'); $this->rawDeposit(1, '1000', '2026-10-27 12:00:00');
+        $state = $this->service->progress($id, 1);
+        $this->assertFalse($state['completed_tasks']['deposit']); $this->assertSame(0, (int)$state['spins']);
+        $this->rawDeposit(1, '2000', '2026-10-27 13:00:00'); $state = $this->service->progress($id, 1);
+        $this->assertTrue($state['completed_tasks']['deposit']); $this->assertSame(1, (int)$state['spins']);
+        $this->travel('2026-10-28 14:00:00'); $state = $this->service->progress($id, 1);
+        $this->assertTrue($state['completed_tasks']['deposit']); $this->assertSame(1, (int)$state['spins']);
+    }
+
+    public function test_repeated_rounds_keep_unused_spins_and_daily_flags_but_require_new_other_events(): void
+    {
+        $id = $this->activity(9, ['completion_probability' => 100]); $state = $this->complete($id);
+        $this->assertTrue($state['conditions_complete']); $this->assertSame(7, (int)$state['spins']);
+        $spin = $this->service->spin($id, 1, 'request_spin_00001');
+        $this->assertTrue($spin['spin']['reached']); $this->assertSame('666.00', $spin['spin']['coins']);
+        $this->assertSame($spin['spin'], $this->service->spin($id, 1, 'request_spin_00001')['spin']);
+        $claim = $this->service->claim($id, 1, 1); $this->assertSame(2, $claim['round_no']); $this->assertSame(6, (int)$claim['spins']);
+        $this->assertTrue($this->service->claim($id, 1, 1)['replayed']);
+        $state = $this->service->progress($id, 1); $this->assertSame('0.00', $state['coins']);
+        $this->assertSame(6, (int)$state['spins']); $this->assertTrue($state['completed_tasks']['online']); $this->assertTrue($state['completed_tasks']['login']);
+        foreach (['invite', 'deposit', 'team_deposit', 'bet', 'team_bet'] as $task) $this->assertFalse($state['completed_tasks'][$task]);
+        $this->rawDeposit(1, '3000', '2026-10-26 14:00:00'); $this->rawDeposit(2, '500', '2026-10-26 14:00:00');
+        $this->bet(1, '6000', '-5000', '2026-10-26 14:00:00'); $this->bet(2, '4000', '-3000', '2026-10-26 14:00:00');
+        $this->member(3, 1, '2026-10-26 14:00:00'); $this->rawDeposit(3, '200', '2026-10-26 14:00:00'); $this->bet(3, '300', '-300', '2026-10-26 14:00:00');
+        $state = $this->service->progress($id, 1); $this->assertTrue($state['conditions_complete']); $this->assertSame(11, (int)$state['spins']);
+        $this->assertTrue($this->service->spin($id, 1, 'request_spin_00002')['spin']['reached']); $this->service->claim($id, 1, 2);
+        $this->assertSame('1332.0000000000', (string)DB::table('wallets')->where('member_id', 'm1')->value('available_balance'));
+        $this->assertSame(2, DB::table('promotion_rewards')->count()); $this->assertSame(1, (int)DB::table('promotion_activities')->where('id', $id)->value('rewarded_count'));
+    }
+
+    public function test_probability_changes_affect_new_spins_and_reserved_reward_can_be_claimed_after_down(): void
+    {
+        $id = $this->activity(9, ['completion_probability' => 0]); $this->complete($id);
+        $first = $this->service->spin($id, 1, 'request_spin_00001'); $this->assertFalse($first['spin']['reached']);
+        $row = ActivityReward::find($id); $rules = json_decode($row->params, true); $rules['completion_probability'] = '100.00'; $row->params = json_encode($rules); $row->save();
+        $next = $this->service->spin($id, 1, 'request_spin_00002'); $this->assertTrue($next['spin']['reached']);
+        $this->assertSame('0', (string)DB::table('promotion_spins')->orderBy('id')->value('probability'));
+        DB::table('promotion_activities')->where('id', $id)->update(['status' => 0]);
+        $this->service->claim($id, 1, 1); $this->invalid(fn () => $this->service->progress($id, 1), '未上架');
+        $this->assertTrue($this->service->claim($id, 1, 1)['replayed']);
+    }
+
+    public function test_first_deposit_is_historical_first_and_does_not_refill_on_relisting(): void
+    {
+        $id = $this->activity(8, ['min_deposit' => '100', 'reward_amount' => '25']);
+        $first = $this->deposit(1, '99'); $this->service->onDeposit($first); $this->service->onDeposit($first);
+        $second = $this->deposit(1, '1000'); $this->service->onDeposit($second);
+        $this->assertSame(0, DB::table('promotion_rewards')->count());
+        $other = $this->deposit(2, '100'); $this->service->onDeposit($other); $this->service->onDeposit($other);
+        $this->assertSame(1, DB::table('promotion_rewards')->count());
+        $this->assertSame('25.0000000000', (string)DB::table('wallets')->where('member_id', 'm2')->value('available_balance'));
+        $published = DB::table('promotion_campaigns')->where('activity_id', $id)->value('first_published_at');
+        $this->travel('2026-10-27 12:00:00'); $row = ActivityReward::find($id); $row->status = 0; $row->save(); PromotionService::publish($row);
+        $row->status = 1; $row->save(); PromotionService::publish($row);
+        $this->assertSame($published, DB::table('promotion_campaigns')->where('activity_id', $id)->value('first_published_at'));
+        $this->service->onDeposit($this->deposit(2, '500')); $this->assertSame(1, DB::table('promotion_rewards')->count());
+    }
+
+    public function test_loss_uses_post_publication_net_profit_then_only_new_direct_invitees_with_both_thresholds(): void
+    {
+        $this->bet(1, '900', '-900', '2026-10-25 12:00:00'); $id = $this->activity(7);
+        $this->invalid(fn () => $this->service->apply($id, 1), '净亏');
+        $this->bet(1, '600', '-600', '2026-10-26 12:00:00'); $this->bet(1, '200', '100', '2026-10-26 12:00:00');
+        // A referral created in the same second, but before applying, is still an existing user.
+        DB::table('users')->where('id', 2)->update(['created_at' => '2026-10-26 12:00:00']);
+        DB::table('share_members')->where('user_id', 2)->update(['bound_at' => '2026-10-26 12:00:00']);
+        $this->service->apply($id, 1); $this->service->apply($id, 1);
+        $this->rawDeposit(2, '500', '2026-10-26 12:00:00'); $this->bet(2, '300', '-300', '2026-10-26 12:00:00');
+        foreach ([3, 4, 5] as $child) {
+            $this->member($child, 1, '2026-10-26 12:00:00'); $this->rawDeposit($child, '200', '2026-10-26 12:00:00');
+            if ($child !== 5) $this->bet($child, '300', '-300', '2026-10-26 12:00:00');
+        }
+        $this->invalid(fn () => $this->service->claim($id, 1, 1), '人数不足');
+        $this->bet(5, '1000', '-300', '2026-10-26 12:00:00'); $this->service->claim($id, 1, 1); $this->service->claim($id, 1, 1);
+        $this->assertSame(1, DB::table('promotion_rewards')->count()); $this->assertSame('500.0000000000', (string)DB::table('wallets')->where('member_id', 'm1')->value('available_balance'));
+    }
+
+    public function test_successful_online_time_deduplicates_devices_and_does_not_count_closed_session_as_online(): void
+    {
+        $id = $this->activity();
+        foreach (['a', 'b'] as $session) for ($i = 0; $i <= 30; ++$i) DB::table('reporting_activity')->insert(['user_id' => 1, 'session_hash' => $session,
+            'observed_at' => Carbon::parse('2026-10-26 12:00:00')->addMinutes($i)->format('Y-m-d H:i:s')]);
+        $this->travel('2026-10-26 16:00:00'); $state = $this->service->progress($id, 1);
+        $this->assertArrayNotHasKey('online', $state['completed_tasks']);
+        $this->assertSame(1800, (new PromotionFacts())->online(1, '2026-10-26 12:00:00')['2026-10-26']);
+    }
+
+    public function test_effective_minimum_void_pending_and_next_day_policy_are_respected(): void
+    {
+        DB::table('share_policies')->insert(['id' => 2, 'effective_date' => '2026-10-27', 'valid_member_min_deposit' => '9999', 'valid_member_min_effective_bet' => '9999']);
+        $facts = new PromotionFacts(); $this->assertSame(1, $facts->policy()['id']);
+        $this->bet(1, '1000', '50', '2026-10-26 12:00:00'); $this->bet(1, '500', '-500', '2026-10-26 12:00:00', ['voided' => 1]);
+        $this->bet(1, '500', '-500', '2026-10-26 12:00:00', ['settled' => 0]);
+        $this->assertSame('50.0000000000', $facts->total($facts->betting([1], '2026-10-26 12:00:00')['effective']));
+        $this->travel('2026-10-27 00:00:00'); $this->assertSame(2, $facts->policy()['id']);
+    }
+
+    public function test_turnover_rewards_only_unlock_from_new_bets_and_only_once(): void
+    {
+        $id = $this->activity(8, ['min_deposit' => 100, 'reward_amount' => 25, 'turnover_ratio' => 2]);
+        $this->bet(1, '100', '-100', '2026-10-26 12:00:00');
+        $this->service->onDeposit($this->deposit(1, '100')); $this->assertSame(0, $this->service->unlock(1));
+        $this->bet(1, '1000', '-49', '2026-10-26 12:00:00'); $this->assertSame(0, $this->service->unlock(1));
+        $this->bet(1, '1', '-1', '2026-10-26 12:00:00'); $this->assertSame(1, $this->service->unlock(1)); $this->assertSame(0, $this->service->unlock(1));
+        $this->assertSame('25.0000000000', (string)DB::table('wallets')->where('member_id', 'm1')->value('available_balance'));
+        $this->assertSame('0.0000000000', (string)DB::table('wallets')->where('member_id', 'm1')->value('frozen_balance'));
+    }
+
+    public function test_quota_is_checked_before_consuming_a_spin_and_a_win_reserves_its_slot(): void
+    {
+        $id = $this->activity(9, ['completion_probability' => 100]); $this->complete($id);
+        DB::table('promotion_activities')->where('id', $id)->update(['reward_quota' => 0]);
+        $this->invalid(fn () => $this->service->spin($id, 1, 'request_spin_00001'), '份数');
+        $this->assertSame(7, (int)DB::table('promotion_accounts')->value('spins'));
+        DB::table('promotion_activities')->where('id', $id)->update(['reward_quota' => 1]);
+        $this->service->spin($id, 1, 'request_spin_00001');
+        $this->assertSame('reserved', DB::table('promotion_rewards')->value('status')); $this->assertSame(1, (int)DB::table('promotion_activities')->where('id', $id)->value('rewarded_count'));
+    }
+
+    public function test_independent_campaigns_do_not_touch_old_catalog_or_activity_list_and_routes_require_auth(): void
+    {
+        $id = $this->activity(9); $this->activity(7); $this->activity(8);
+        $this->assertSame(0, DB::table('activity_rewards')->count());
+        $this->assertArrayNotHasKey(9, \App\Support\ActivityCatalog::activityTypes());
+        $this->assertSame(3, $this->service->listing(1, 'H5')['total']);
+        $this->assertSame($id, $this->service->listing(1, 'H5', 'zh', 9)['data'][0]['activity_id']);
+        $this->assertSame(3, DB::table('menus')->where('type', 2)->where('uri', 'like', 'admin/promotion%')->count());
+        $router = new \Illuminate\Routing\Router(new \Illuminate\Events\Dispatcher($this->app), $this->app); $this->app->instance('router', $router);
+        $router->group(['prefix' => 'api'], function () { require dirname(__DIR__, 2) . '/routes/api.php'; });
+        $route = $router->getRoutes()->match(Request::create('/api/promotion/spin', 'POST'));
+        $this->assertContains('check.token', $route->middleware());
+        $this->assertContains(\App\Http\Middleware\MemberMaintenance::class, $route->middleware());
+        $router->group(['prefix' => 'admin'], function () { require dirname(__DIR__, 2) . '/routes/admin.php'; });
+        $route = $router->getRoutes()->match(Request::create('/admin/promotion/submit', 'POST'));
+        $this->assertContains('admin.jwt', $route->middleware()); $this->assertContains('check.button.uri', $route->middleware());
+    }
+
+    public function test_admin_partial_rule_edit_preserves_other_settings_and_zero_probability(): void
+    {
+        $id = $this->activity(9, ['turnover_ratio' => 3, 'deposit_amount' => 5000]);
+        $saved = (new PromotionAdminService())->save(['id' => $id, 'title' => '独立转盘', 'type' => 9, 'status' => 1,
+            'start_time' => '2026-10-01', 'end_time' => '2026-12-31', 'params' => ['completion_probability' => 0]]);
+        $this->assertSame('0.00', $saved['params']['completion_probability']); $this->assertSame('3.00', $saved['params']['turnover_ratio']);
+        $this->assertSame('5000.00', $saved['params']['deposit_amount']); $this->assertSame(0, DB::table('activity_rewards')->count());
+    }
+
+    public function test_voided_task_progress_is_not_eligible_for_completion_probability(): void
+    {
+        $id = $this->activity(9, ['completion_probability' => 100]); $this->complete($id);
+        DB::table('share_bet_facts')->where('user_id', 2)->update(['voided' => 1]);
+        $state = $this->service->progress($id, 1); $this->assertFalse($state['conditions_complete']);
+        $this->assertFalse($state['completed_tasks']['invite']); $this->assertFalse($state['completed_tasks']['team_bet']);
+        $this->assertFalse($this->service->spin($id, 1, 'request_spin_voided')['spin']['reached']);
+    }
+
+    public function test_real_chain_credit_hook_awards_first_deposit_once_and_rolls_back_a_failed_award(): void
+    {
+        foreach ([
+            'config' => 'id INTEGER PRIMARY KEY AUTOINCREMENT,field TEXT,val TEXT',
+            'level' => 'id INTEGER PRIMARY KEY,level INTEGER,recharge TEXT',
+            'recharge_channel_group' => 'id INTEGER PRIMARY KEY,activity_type TEXT,deleted_at TEXT',
+            'backflow' => 'id INTEGER PRIMARY KEY AUTOINCREMENT,date TEXT,member_id TEXT,recharge_amount TEXT DEFAULT "0",withdrawal_amount TEXT DEFAULT "0",backflow_ratio TEXT,amount TEXT,created_at TEXT,updated_at TEXT',
+            'recharges' => 'id INTEGER PRIMARY KEY AUTOINCREMENT,member_id TEXT,status INTEGER,amount TEXT,created_at TEXT,updated_at TEXT',
+        ] as $table => $columns) DB::statement('CREATE TABLE bot_' . $table . ' (' . $columns . ')');
+        foreach (['huishui_percentage' => '0', 'huishui_restriction' => '0', 'recharge_rate' => '0'] as $field => $val) DB::table('config')->insert(compact('field', 'val'));
+        $id = $this->activity(8, ['min_deposit' => 100, 'reward_amount' => 25]);
+        $source = DB::table('recharges')->insertGetId(['member_id' => 'm1', 'status' => 0, 'amount' => 100]);
+        $recharge = \App\Models\Recharge::find($source);
+        $this->assertTrue(\App\Services\RechargeService::creditConfirmedRecharge($recharge, ['status' => 1], '100.00'));
+        $this->assertFalse(\App\Services\RechargeService::creditConfirmedRecharge($recharge, ['status' => 1], '100.00'));
+        $this->assertSame('125.0000000000', (string)DB::table('wallets')->where('member_id', 'm1')->value('available_balance'));
+        $this->assertSame(1, DB::table('promotion_rewards')->count());
+        $source = DB::table('recharges')->insertGetId(['member_id' => 'm2', 'status' => 0, 'amount' => 100]);
+        DB::statement("CREATE TRIGGER reject_award BEFORE INSERT ON bot_promotion_rewards BEGIN SELECT RAISE(ABORT, 'award failure'); END");
+        try { \App\Services\RechargeService::creditConfirmedRecharge(\App\Models\Recharge::find($source), ['status' => 1], '100.00'); $this->fail('Expected failed award'); }
+        catch (\Illuminate\Database\QueryException $e) { $this->assertStringContainsString('award failure', $e->getMessage()); }
+        $this->assertSame(0, (int)DB::table('recharges')->where('id', $source)->value('status'));
+        $this->assertSame('0', (string)DB::table('wallets')->where('member_id', 'm2')->value('available_balance'));
+        $this->assertSame(0, DB::table('balance_logs')->where('member_id', 'm2')->count());
+    }
+
+    public function test_promotion_heartbeat_records_login_once_and_online_without_other_reporting_tables(): void
+    {
+        DB::statement('DROP TABLE bot_reporting_activity'); $id = $this->activity();
+        for ($i = 0; $i <= 60; ++$i) {
+            $this->travel(Carbon::parse('2026-10-26 12:00:00')->addMinutes($i)->format('Y-m-d H:i:s'));
+            $this->service->heartbeat(1, 'secret-token', 'H5'); $this->service->heartbeat(1, 'secret-token', 'H5');
+        }
+        $state = $this->service->progress($id, 1);
+        $this->assertTrue($state['completed_tasks']['online']); $this->assertTrue($state['completed_tasks']['login']); $this->assertSame(2, (int)$state['spins']);
+        $this->assertSame(61, DB::table('promotion_presence')->count());
+        $this->assertStringNotContainsString('secret-token', DB::table('promotion_presence')->first()->session_hash);
+        $this->assertCount(7, $state['tasks']); $this->assertSame(0, $state['tasks'][0]['remaining_seconds']);
+        $this->travel('2026-10-27 00:01:00'); $this->service->heartbeat(1, 'secret-token', 'H5');
+        $this->assertSame(3, (int)$this->service->progress($id, 1)['spins']);
+    }
+
+    public function test_missing_old_first_deposit_hook_is_not_back_awarded_when_campaign_starts_later(): void
+    {
+        $deposit = $this->deposit(1, '1000'); $this->travel('2026-10-26 13:00:00'); $this->activity(8);
+        $this->service->onDeposit($deposit);
+        $this->assertSame(0, DB::table('promotion_rewards')->count());
+    }
+
+    public function test_manual_payment_approval_keeps_first_deposit_bonus_instead_of_overwriting_it_with_principal(): void
+    {
+        foreach ([
+            'config' => 'id INTEGER PRIMARY KEY AUTOINCREMENT,field TEXT,val TEXT',
+            'level' => 'id INTEGER PRIMARY KEY,level INTEGER,recharge TEXT',
+            'recharge_channel_group' => 'id INTEGER PRIMARY KEY,activity_type TEXT,deleted_at TEXT',
+            'backflow' => 'id INTEGER PRIMARY KEY AUTOINCREMENT,date TEXT,member_id TEXT,recharge_amount TEXT DEFAULT "0",withdrawal_amount TEXT DEFAULT "0",backflow_ratio TEXT,amount TEXT,created_at TEXT,updated_at TEXT',
+            'payment_orders' => 'id INTEGER PRIMARY KEY,member_id TEXT,status INTEGER,type INTEGER,amount TEXT,remark TEXT,state INTEGER,created_at TEXT,updated_at TEXT',
+        ] as $table => $columns) DB::statement('CREATE TABLE bot_' . $table . ' (' . $columns . ')');
+        foreach (['huishui_percentage' => '0', 'huishui_restriction' => '0', 'recharge_rate' => '0'] as $field => $val) DB::table('config')->insert(compact('field', 'val'));
+        $this->activity(8, ['min_deposit' => 100, 'reward_amount' => 25]);
+        DB::table('payment_orders')->insert(['id' => 1, 'member_id' => 'm1', 'status' => 5, 'type' => 3, 'amount' => '100']);
+        $this->app->instance('request', Request::create('/admin/paymentOrder/manualAudit', 'POST', ['id' => 1, 'status' => 2]));
+        $body = (new \App\Http\Controllers\admin\PaymentOrder())->manualAudit()->getData(true);
+        $this->assertSame(0, $body['code'], $body['msg']);
+        $this->assertSame('125.0000000000', (string)DB::table('wallets')->where('member_id', 'm1')->value('available_balance'));
+        $deposit = DB::table('balance_logs')->where('change_type', '充值')->first();
+        $this->assertSame('0', (string)$deposit->before_balance); $this->assertSame('100.0000000000', (string)$deposit->after_balance);
+        $this->assertSame(1, DB::table('promotion_rewards')->count());
+        $this->assertNotSame(0, (new \App\Http\Controllers\admin\PaymentOrder())->manualAudit()->getData(true)['code']);
+        $this->assertSame(1, DB::table('promotion_rewards')->count());
+    }
+}

+ 381 - 0
tests/Integration/ReportingTest.php

@@ -0,0 +1,381 @@
+<?php
+
+namespace Tests\Integration;
+
+use App\Services\Reporting\ReportAdmin;
+use App\Services\Reporting\ReportReader;
+use App\Services\Reporting\ReportSync;
+use Carbon\Carbon;
+use Illuminate\Config\Repository;
+use Illuminate\Database\Capsule\Manager;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Events\Dispatcher;
+use Illuminate\Foundation\Application;
+use Illuminate\Http\JsonResponse;
+use Illuminate\Http\Request;
+use Illuminate\Routing\Router;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Facade;
+use Illuminate\Support\Facades\Schema;
+use Illuminate\Translation\ArrayLoader;
+use Illuminate\Translation\Translator;
+use Illuminate\Validation\Factory;
+use Illuminate\Validation\ValidationException;
+use PHPUnit\Framework\TestCase;
+
+/** Real source adapters, financial reconciliation, filters and aggregation; isolated SQLite only. */
+class ReportingTest extends TestCase
+{
+    private Application $app;
+    private ReportSync $sync;
+    private ReportReader $reader;
+    private ReportAdmin $admin;
+
+    protected function setUp(): void
+    {
+        Carbon::setTestNow(Carbon::parse('2026-10-26 16:00:00', 'Asia/Shanghai'));
+        Facade::clearResolvedInstances(); $this->app = new Application(dirname(__DIR__, 2));
+        $this->app->instance('config', new Repository(['app' => ['locale' => 'zh', 'timezone' => 'Asia/Shanghai'], 'third_game' => ['currency' => 'CNY']])); Facade::setFacadeApplication($this->app);
+        $db = new Manager($this->app); $db->addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => 'bot_']);
+        $db->setEventDispatcher(new Dispatcher($this->app)); $db->setAsGlobal(); $db->bootEloquent(); $this->app->instance('db', $db->getDatabaseManager()); $this->app->bind('db.schema', fn () => $db->schema());
+        $translator = new Translator(new ArrayLoader(), 'zh'); $this->app->instance('translator', $translator); $this->app->instance('validator', new Factory($translator, $this->app));
+        $this->app['validator']->setPresenceVerifier(new \Illuminate\Validation\DatabasePresenceVerifier($db->getDatabaseManager()));
+        $this->app->instance('log', new \Psr\Log\NullLogger()); $this->app->instance(\Illuminate\Contracts\Routing\ResponseFactory::class, new class {
+            public function json($data, $status = 200, $headers = [], $options = 0) { return new JsonResponse($data, $status, $headers, $options); }
+        });
+        Request::macro('validate', function ($rules) { return app('validator')->make($this->all(), $rules)->validate(); });
+        $request = Request::create('/admin/reporting/summary'); $request->user = (object) ['id' => 1, 'username' => 'admin']; $this->app->instance('request', $request);
+        foreach ([
+            'users' => 'id INTEGER PRIMARY KEY,member_id TEXT,user_id TEXT,username TEXT,first_name TEXT,agent_id INTEGER,"from" INTEGER,created_at TEXT',
+            'agents' => 'id INTEGER PRIMARY KEY,username TEXT',
+            'share_members' => 'user_id INTEGER PRIMARY KEY,parent_user_id INTEGER,bound_at TEXT',
+            'balance_logs' => 'id INTEGER PRIMARY KEY AUTOINCREMENT,member_id TEXT,amount TEXT,change_type TEXT,related_id INTEGER,type INTEGER DEFAULT 1,created_at TEXT,updated_at TEXT,before_balance TEXT,after_balance TEXT,remark TEXT',
+            'withdraws' => 'id INTEGER PRIMARY KEY,member_id TEXT,amount TEXT,to_account TEXT,service_charge TEXT,exchange_rate TEXT,status INTEGER,created_at TEXT,updated_at TEXT',
+            'payment_orders' => 'id INTEGER PRIMARY KEY,member_id TEXT,type INTEGER,amount TEXT,fee TEXT,status INTEGER,created_at TEXT,updated_at TEXT',
+            'bets' => 'id INTEGER PRIMARY KEY,member_id TEXT,amount TEXT,profit TEXT,status INTEGER,created_at TEXT,updated_at TEXT',
+            'share_claims' => 'id INTEGER PRIMARY KEY,user_id INTEGER,status TEXT,credited_log_id INTEGER',
+            'share_ledger' => 'id INTEGER PRIMARY KEY,user_id INTEGER,amount TEXT,bet_date TEXT,category TEXT,created_at TEXT',
+            'agent_balance_logs' => 'id INTEGER PRIMARY KEY,agent_id INTEGER,type TEXT,amount TEXT,reference_type TEXT,reference_id INTEGER,created_at TEXT',
+            'agent_rebate_records' => 'id INTEGER PRIMARY KEY,agent_id INTEGER,platform TEXT,game_type INTEGER,settlement_date TEXT,flow_commission TEXT,profit_commission TEXT,flow_status TEXT,profit_status TEXT,created_at TEXT,updated_at TEXT',
+            'operation_audits' => 'id INTEGER PRIMARY KEY AUTOINCREMENT,resource_type TEXT,resource_id INTEGER,action TEXT,changes TEXT,operator_id INTEGER,operator_name TEXT,created_at TEXT DEFAULT CURRENT_TIMESTAMP',
+        ] as $name => $columns) DB::statement('CREATE TABLE bot_' . $name . ' (' . $columns . ')');
+        (require dirname(__DIR__, 2) . '/database/migrations/2026_10_26_140000_create_reporting_tables.php')->up();
+        DB::table('reporting_settings')->where('id', 1)->update(['activity_started_at' => '2026-10-25 00:00:00']);
+        DB::table('agents')->insert([['id' => 10, 'username' => 'agent10'], ['id' => 20, 'username' => 'agent20']]);
+        foreach ([[1, '100', null, 1], [2, '200', 10, 1], [3, '300', 20, 1], [4, '400', 10, 2], [5, '500', null, -1]] as [$id, $member, $agent, $from]) {
+            DB::table('users')->insert(['id' => $id, 'member_id' => $member, 'user_id' => 'u' . $member, 'username' => 'member' . $id, 'first_name' => '昵称' . $id, 'agent_id' => $agent, 'from' => $from, 'created_at' => '2026-10-01 00:00:00']);
+            DB::table('share_members')->insert(['user_id' => $id, 'parent_user_id' => in_array($id, [2, 3]) ? 1 : null, 'bound_at' => '2026-10-01 00:00:00']);
+        }
+        $this->sync = new ReportSync(); $this->reader = new ReportReader(); $this->admin = new ReportAdmin();
+        $this->ready();
+    }
+    protected function tearDown(): void { Carbon::setTestNow(); DB::disconnect(); Facade::clearResolvedInstances(); parent::tearDown(); }
+    private function ready(): void { DB::table('reporting_sync_state')->updateOrInsert(['source' => '__run__'], ['state' => '{"complete":true}', 'updated_at' => now()]); }
+    private function context(string $source, int $id, int $user = 2, string $platform = 'H5', ?string $success = null): void
+    {
+        DB::table('reporting_contexts')->insert(['source' => $source, 'source_id' => (string) $id, 'user_id' => $user, 'agent_id' => $user === 2 ? 10 : null,
+            'inviter_id' => $user === 2 ? 1 : null, 'client_platform' => $platform, 'occurred_at' => '2026-10-26 09:00:00', 'succeeded_at' => $success]);
+    }
+    private function bet(int $id = 1, array $extra = []): void
+    {
+        DB::table('bets')->insert($extra + ['id' => $id, 'member_id' => '200', 'amount' => '100', 'profit' => '60', 'status' => 2, 'created_at' => '2026-10-26 09:00:00', 'updated_at' => '2026-10-26 10:00:00']);
+        $this->context('bets', $id, ($extra['member_id'] ?? '200') === '500' ? 5 : 2, 'H5', '2026-10-26 10:00:00'); $this->sync->publish('bets', $id);
+    }
+    private function deposit(int $id = 1, string $amount = '1000', string $member = '200'): int
+    {
+        DB::table('balance_logs')->insert(['id' => $id, 'member_id' => $member, 'amount' => $amount, 'change_type' => '充值', 'created_at' => '2026-10-26 09:00:00', 'updated_at' => '2026-10-26 09:00:00']);
+        $this->sync->publish('balance_logs', $id); return (int) DB::table('reporting_events')->where('source', 'balance_logs')->where('source_id', (string) $id)->value('id');
+    }
+    private function verify(int $id, string $amount = '1000', string $fee = '2'): void
+    {
+        $this->admin->reconcile(['event_id' => $id, 'actual_amount' => $amount, 'external_fee' => $fee, 'occurred_at' => '2026-10-26 10:00:00', 'currency' => 'CNY', 'evidence' => '到账凭据#' . $id]);
+    }
+
+    public function test_source_projection_is_idempotent_and_cross_day_metrics_use_actual_events(): void
+    {
+        $this->bet(1, ['created_at' => '2026-10-25 23:00:00']); $this->sync->publish('bets', 1);
+        $this->assertSame(1, DB::table('reporting_orders')->count());
+        $row = $this->reader->summary([])['summary'];
+        $this->assertSame('0.0000000000', $row['bet_amount']); $this->assertSame('100.0000000000', $row['settled_bet_amount']);
+        $this->assertSame('60.0000000000', $row['payout_amount']); $this->assertSame('40.0000000000', $row['game_gross_profit']);
+        $this->assertSame('40.0000000000', $row['company_profit']);
+        $yesterday = $this->reader->summary(['start_date' => '2026-10-25', 'end_date' => '2026-10-25'])['summary'];
+        $this->assertSame('100.0000000000', $yesterday['bet_amount']); $this->assertSame('0.0000000000', $yesterday['payout_amount']);
+    }
+
+    public function test_missing_fees_and_success_time_never_produce_fabricated_net_values(): void
+    {
+        $event = $this->deposit(); $this->bet();
+        $row = $this->reader->summary([])['summary']; $this->assertNull($row['net_cash_flow']); $this->assertNull($row['company_profit']);
+        $this->assertSame(1, $row['quality']['unverified_fees']); $this->assertSame('1000.0000000000', $row['deposit_amount']);
+        $this->verify($event); $row = $this->reader->summary([])['summary'];
+        $this->assertSame('998.0000000000', $row['net_cash_flow']); $this->assertSame('38.0000000000', $row['company_profit']);
+        $before = DB::table('balance_logs')->get()->toArray(); $this->verify($event, '1000', '3'); $this->sync->publish('balance_logs', 1);
+        $this->assertSame(1, DB::table('reporting_reconciliations')->count()); $this->assertEquals($before, DB::table('balance_logs')->get()->toArray());
+        $this->assertSame('997.0000000000', $this->reader->summary([])['summary']['net_cash_flow']);
+    }
+
+    public function test_chain_withdraw_uses_recipient_amount_and_customer_fee_is_not_an_expense(): void
+    {
+        DB::table('withdraws')->insert(['id' => 1, 'member_id' => '200', 'amount' => '100', 'to_account' => '98', 'service_charge' => '2', 'exchange_rate' => '7', 'status' => 1, 'created_at' => '2026-10-25 08:00:00', 'updated_at' => '2026-10-26 10:00:00']);
+        $this->sync->publish('withdraws', 1); $row = $this->reader->summary([])['summary'];
+        $this->assertSame(1, $row['quality']['missing_time']); $this->assertNull($row['net_cash_flow']);
+        $event = (int) DB::table('reporting_events')->value('id'); $this->verify($event, '686', '1');
+        $row = $this->reader->summary([])['summary'];
+        $this->assertSame('686.0000000000', $row['withdraw_amount']); $this->assertSame('14.0000000000', $row['customer_fee_income']);
+        $this->assertSame('1.0000000000', $row['withdraw_fee']); $this->assertSame('-687.0000000000', $row['net_cash_flow']);
+        DB::table('withdraws')->where('id', 1)->update(['status' => 2]); $result = $this->sync->sync(true);
+        $this->assertSame(1, $result['errors']); $this->assertNull($this->reader->summary([])['summary']['net_cash_flow']);
+        $this->verify($event, '0', '1'); $this->sync->sync(true);
+        $this->assertSame('0.0000000000', $this->reader->summary([])['summary']['withdraw_amount']);
+    }
+
+    public function test_real_member_agent_sharing_overlap_is_not_added_twice_and_details_are_direct(): void
+    {
+        $this->bet(); $event = $this->deposit(); $this->verify($event);
+        foreach (['business', 'promotion', 'sharing'] as $type) {
+            $row = $this->reader->summary(['report_type' => $type])['summary']; $this->assertSame('100.0000000000', $row['bet_amount']);
+            $this->assertSame('1000.0000000000', $row['deposit_amount']);
+        }
+        $this->assertSame('2', $this->reader->details([])['data'][0]['group_id']);
+        $this->assertSame('10', $this->reader->details(['report_type' => 'promotion'])['data'][0]['group_id']);
+        $this->assertSame('1', $this->reader->details(['report_type' => 'sharing'])['data'][0]['group_id']);
+        $category = $this->reader->summary([])['categories'][8]; $this->assertNull($category['deposit_amount']); $this->assertNull($category['company_profit']);
+        $this->assertSame('100.0000000000', $category['bet_amount']);
+    }
+
+    public function test_valid_member_threshold_is_strict_and_independent_from_sharing(): void
+    {
+        $this->bet(); $id = $this->deposit(1, '20'); $this->verify($id, '20', '0');
+        $this->assertSame(0, $this->reader->summary([])['summary']['valid_member_count']);
+        $this->admin->settings(['valid_deposit_min' => '19.99']);
+        $this->assertSame(1, $this->reader->summary([])['summary']['valid_member_count']);
+        $this->assertSame(1, DB::table('operation_audits')->where('resource_type', 'reporting_settings')->count());
+    }
+
+    public function test_platform_settlement_filters_tg_virtual_exclusion_and_rank_pagination(): void
+    {
+        $this->bet(); $this->bet(2, ['member_id' => '500', 'amount' => '200']);
+        $this->bet(3, ['status' => 1, 'profit' => '0']);
+        DB::table('bets')->insert(['id' => 4, 'member_id' => '400', 'amount' => '99999', 'profit' => '0', 'status' => 1, 'created_at' => '2026-10-26 09:00:00']); $this->sync->publish('bets', 4);
+        $this->assertSame(3, DB::table('reporting_orders')->count());
+        $this->assertSame('0.0000000000', $this->reader->summary(['platform' => 'PC'])['summary']['bet_amount']);
+        $this->assertSame('100.0000000000', $this->reader->summary(['settlement_status' => 'pending'])['summary']['bet_amount']);
+        $this->assertNull($this->reader->summary(['settlement_status' => 'pending'])['summary']['company_profit']);
+        $ranks = $this->reader->details(['metric' => 'bet_amount', 'page' => 2, 'limit' => 1], true);
+        $this->assertSame(2, $ranks['total']); $this->assertSame(2, $ranks['data'][0]['rank']);
+    }
+
+    public function test_activity_intervals_merge_devices_split_idle_and_history_is_explicitly_missing(): void
+    {
+        foreach ([['a', '10:00:00'], ['b', '10:00:00'], ['a', '10:01:00'], ['b', '10:01:00'], ['a', '10:20:00'], ['a', '10:21:00']] as [$session, $at]) {
+            DB::table('reporting_activity')->insert(['user_id' => 2, 'agent_id' => 10, 'inviter_id' => 1, 'client_platform' => 'H5', 'session_hash' => $session, 'observed_at' => '2026-10-26 ' . $at]);
+        }
+        $row = $this->reader->summary([])['summary'];
+        $this->assertSame(2, $row['activity_minutes']); $this->assertSame(1, $row['active_member_count']); $this->assertSame(4, $row['member_count']);
+        $this->assertSame('0.250000', $row['active_rate']);
+        $old = $this->reader->summary(['start_date' => '2026-10-24', 'end_date' => '2026-10-26'])['summary'];
+        $this->assertNull($old['activity_minutes']); $this->assertSame(2, $old['known_activity_minutes']);
+    }
+
+    public function test_full_sync_and_repeated_sync_do_not_change_source_data_or_double_count(): void
+    {
+        $this->bet(); $this->deposit(); $before = DB::table('balance_logs')->get()->toArray();
+        $first = $this->sync->sync(true); $second = $this->sync->sync(true);
+        $this->assertSame(0, $first['errors']); $this->assertSame(0, $second['errors']);
+        $this->assertSame(1, DB::table('reporting_orders')->count()); $this->assertSame(1, DB::table('reporting_events')->count());
+        $this->assertEquals($before, DB::table('balance_logs')->get()->toArray());
+    }
+
+    private function activityObservation(string $at, ?int $agent = 10, ?int $inviter = 1, string $session = 'a'): void
+    {
+        DB::table('reporting_activity')->insert(['user_id' => 2, 'agent_id' => $agent, 'inviter_id' => $inviter,
+            'client_platform' => 'H5', 'session_hash' => $session, 'observed_at' => $at]);
+    }
+
+    public function test_activity_snapshot_survives_agent_reassignment_in_summary_details_and_filters(): void
+    {
+        $this->activityObservation('2026-10-26 10:00:00'); $this->activityObservation('2026-10-26 10:02:00');
+        $before = $this->reader->summary(['report_type' => 'promotion', 'agent_account' => 'agent10'])['summary'];
+        DB::table('users')->where('id', 2)->update(['agent_id' => 20]);
+        $after = $this->reader->summary(['report_type' => 'promotion', 'agent_account' => 'agent10'])['summary'];
+        $this->assertSame(2, $before['activity_minutes']); $this->assertSame(2, $after['activity_minutes']);
+        $this->assertSame(1, $after['active_member_count']); $this->assertFalse($after['activity_population_complete']); $this->assertNull($after['active_rate']);
+        $other = $this->reader->summary(['report_type' => 'promotion', 'agent_account' => 'agent20'])['summary'];
+        $this->assertSame(0, $other['activity_minutes']); $this->assertSame(0, $other['active_member_count']);
+        $details = $this->reader->details(['report_type' => 'promotion']);
+        $this->assertSame(1, $details['total']); $this->assertSame('10', $details['data'][0]['group_id']); $this->assertSame(2, $details['data'][0]['activity_minutes']);
+        $byGroup = $this->reader->summary(['report_type' => 'promotion', 'group_id' => '10'])['summary'];
+        $this->assertSame(2, $byGroup['activity_minutes']);
+        $business = $this->reader->summary(['agent_account' => 'agent10'])['summary']; $this->assertSame(2, $business['activity_minutes']);
+
+        $this->activityObservation('2026-10-26 11:00:00', 20); $this->activityObservation('2026-10-26 11:03:00', 20);
+        $groups = collect($this->reader->details(['report_type' => 'promotion'])['data'])->keyBy('group_id');
+        $this->assertSame(2, $groups[10]['activity_minutes']); $this->assertSame(3, $groups[20]['activity_minutes']);
+        $this->assertSame('0.500000', $groups[20]['active_rate']);
+        $all = $this->reader->summary([])['summary']; $this->assertSame(5, $all['activity_minutes']); $this->assertSame(1, $all['active_member_count']);
+        $this->assertSame('0.250000', $all['active_rate']);
+    }
+
+    public function test_agent_transition_with_overlapping_devices_counts_each_second_only_once(): void
+    {
+        $this->activityObservation('2026-10-26 10:00:00', 10, 1, 'a');
+        $this->activityObservation('2026-10-26 10:01:00', 20, 1, 'b');
+        $this->activityObservation('2026-10-26 10:02:00', 20, 1, 'a');
+        $this->activityObservation('2026-10-26 10:03:00', 20, 1, 'b');
+        DB::table('users')->where('id', 2)->update(['agent_id' => 20]);
+        $groups = collect($this->reader->details(['report_type' => 'promotion'])['data'])->keyBy('group_id');
+        $this->assertSame(1, $groups[10]['activity_minutes']); $this->assertSame(2, $groups[20]['activity_minutes']);
+        $all = $this->reader->summary([])['summary']; $this->assertSame(3, $all['activity_minutes']); $this->assertSame(1, $all['active_member_count']);
+        $this->assertSame(1, $this->reader->summary(['report_type' => 'promotion', 'agent_account' => 'agent10'])['summary']['activity_minutes']);
+        $this->assertSame(2, $this->reader->summary(['report_type' => 'promotion', 'agent_account' => 'agent20'])['summary']['activity_minutes']);
+    }
+
+    public function test_activity_uses_inviter_snapshot_and_does_not_inherit_current_null_attribution(): void
+    {
+        $this->activityObservation('2026-10-26 10:00:00'); $this->activityObservation('2026-10-26 10:04:00');
+        DB::table('share_members')->where('user_id', 2)->update(['parent_user_id' => 3]);
+        $groups = collect($this->reader->details(['report_type' => 'sharing'])['data'])->keyBy('group_id');
+        $this->assertSame(4, $groups[1]['activity_minutes']); $this->assertArrayNotHasKey(3, $groups->all());
+        $this->assertSame(4, $this->reader->summary(['report_type' => 'sharing', 'inviter_account' => 'member1'])['summary']['activity_minutes']);
+        $this->assertSame(0, $this->reader->summary(['report_type' => 'sharing', 'inviter_account' => 'member3'])['summary']['activity_minutes']);
+
+        DB::table('reporting_activity')->delete();
+        $this->activityObservation('2026-10-26 11:00:00', null, null); $this->activityObservation('2026-10-26 11:02:00', null, null);
+        $this->assertSame(2, $this->reader->summary([])['summary']['activity_minutes']);
+        $this->assertSame(0, $this->reader->summary(['report_type' => 'promotion'])['summary']['activity_minutes']);
+        $this->assertSame(0, $this->reader->summary(['report_type' => 'sharing'])['summary']['activity_minutes']);
+    }
+
+    public function test_activity_interval_crossing_query_start_keeps_the_starting_snapshot(): void
+    {
+        $this->activityObservation('2026-10-25 23:59:00', 10); $this->activityObservation('2026-10-26 00:01:00', 20);
+        DB::table('users')->where('id', 2)->update(['agent_id' => 20]);
+        $groups = collect($this->reader->details(['report_type' => 'promotion'])['data'])->keyBy('group_id');
+        $this->assertSame(1, $groups[10]['activity_minutes']); $this->assertSame(0, $groups[20]['activity_minutes']);
+        $this->assertSame(1, $this->reader->summary([])['summary']['activity_minutes']);
+    }
+
+    public function test_order_time_reconciliation_survives_reimport_and_does_not_settle_source(): void
+    {
+        DB::table('bets')->insert(['id' => 1, 'member_id' => '200', 'amount' => '100', 'profit' => '60', 'status' => 2, 'created_at' => '2026-10-25 09:00:00', 'updated_at' => '2026-10-26 10:00:00']);
+        $this->sync->publish('bets', 1); $this->assertNull($this->reader->summary([])['summary']['company_profit']);
+        $id = (int) DB::table('reporting_orders')->value('id'); $before = (array) DB::table('bets')->first();
+        $this->admin->reconcileOrder(['id' => $id, 'settled_at' => '2026-10-26 10:00:00', 'evidence' => '开奖结算日志']); $this->sync->publish('bets', 1);
+        $this->assertSame('40.0000000000', $this->reader->summary([])['summary']['company_profit']);
+        $this->assertSame($before, (array) DB::table('bets')->first());
+    }
+
+    public function test_new_routes_have_admin_auth_and_only_operators_can_write_reconciliations(): void
+    {
+        $router = new Router(new Dispatcher($this->app), $this->app); $this->app->instance('router', $router);
+        $router->group(['prefix' => 'admin'], function () { require dirname(__DIR__, 2) . '/routes/admin.php'; });
+        foreach (['options', 'leaderboard', 'fund-flow', 'summary', 'details', 'orders', 'settings', 'payment-reconciliation'] as $action) {
+            $r = $router->getRoutes()->match(Request::create('/admin/reporting/' . $action));
+            $this->assertContains('admin.jwt', $r->middleware()); $this->assertContains('check.button.uri', $r->middleware());
+        }
+        foreach (['settings', 'payment-reconciliation', 'order-reconciliation'] as $action) $this->assertContains('operations.admin', $router->getRoutes()->match(Request::create('/admin/reporting/' . $action, 'POST'))->middleware());
+    }
+
+    public function test_share_accrual_and_claim_are_separate_and_agent_cost_only_counts_actual_credit(): void
+    {
+        $this->bet();
+        DB::table('share_ledger')->insert([['id' => 1, 'user_id' => 1, 'amount' => '20', 'bet_date' => '2026-10-26', 'category' => 'self:lottery', 'created_at' => '2026-10-26 10:00:00'],
+            ['id' => 2, 'user_id' => 1, 'amount' => '-3', 'bet_date' => '2026-10-26', 'category' => 'self:lottery', 'created_at' => '2026-10-26 11:00:00']]);
+        DB::table('share_claims')->insert(['id' => 1, 'user_id' => 1, 'status' => 'approved', 'credited_log_id' => 10]);
+        DB::table('balance_logs')->insert(['id' => 10, 'member_id' => '100', 'amount' => '5', 'change_type' => '分享佣金领取', 'created_at' => '2026-10-26 11:00:00']);
+        DB::table('agent_rebate_records')->insert(['id' => 1, 'agent_id' => 10, 'platform' => 'pc28', 'game_type' => 3, 'settlement_date' => '2026-10-26', 'flow_commission' => '7', 'profit_commission' => '2', 'flow_status' => 'credited', 'profit_status' => 'pending', 'created_at' => '2026-10-26 10:00:00']);
+        DB::table('agent_balance_logs')->insert(['id' => 1, 'agent_id' => 10, 'type' => 'commission', 'amount' => '7', 'reference_type' => 'agent_rebate_record', 'reference_id' => 1, 'created_at' => '2026-10-26 11:00:00']);
+        foreach ([['share_ledger', 1], ['share_ledger', 2], ['balance_logs', 10], ['agent_rebate_records', 1], ['agent_balance_logs', 1]] as [$source, $id]) $this->sync->publish($source, $id);
+        $row = $this->reader->summary([])['summary'];
+        $this->assertSame('17.0000000000', $row['share_accrual']); $this->assertSame('5.0000000000', $row['share_commission']);
+        $this->assertSame('9.0000000000', $row['agent_accrual']); $this->assertSame('7.0000000000', $row['agent_commission']);
+        $this->assertSame('28.0000000000', $row['company_profit']);
+        $details = $this->reader->details([]); $this->assertSame('7.0000000000', $details['unallocated']['agent_commission']);
+        $this->assertSame('5.0000000000', $this->reader->summary(['report_type' => 'sharing'])['summary']['share_commission']);
+    }
+
+    public function test_daily_total_is_for_all_pages_and_source_errors_are_visible_until_fixed(): void
+    {
+        $this->bet(1, ['created_at' => '2026-10-25 09:00:00']);
+        $r = $this->reader->funds(['start_date' => '2026-10-25', 'end_date' => '2026-10-26', 'limit' => 1, 'page' => 2]);
+        $this->assertSame(2, $r['total']); $this->assertSame('2026-10-25', $r['data'][0]['group_id']);
+        $this->assertSame('100.0000000000', $r['summary']['settled_bet_amount']);
+        DB::table('bets')->insert(['id' => 2, 'member_id' => 'missing', 'amount' => '10', 'status' => 1, 'created_at' => '2026-10-26 10:00:00']);
+        $r = $this->sync->sync(true); $this->assertSame(1, $r['errors']);
+        $this->assertNull($this->reader->summary([])['summary']['company_profit']); $this->assertSame(1, DB::table('reporting_errors')->count());
+        DB::table('bets')->where('id', 2)->update(['member_id' => '200']); $this->sync->sync(true);
+        $this->assertSame(0, DB::table('reporting_errors')->count()); $this->assertNotNull($this->reader->summary([])['summary']['company_profit']);
+    }
+
+    public function test_eloquent_context_observer_records_status_transition_once_and_tg_activity_is_deduplicated(): void
+    {
+        $withdraw = new \App\Models\Withdraw(); $withdraw->forceFill(['id' => 1, 'member_id' => '200', 'amount' => '10', 'to_account' => '9', 'service_charge' => '1', 'exchange_rate' => '7', 'status' => 0]); $withdraw->save();
+        $context = DB::table('reporting_contexts')->where('source', 'withdraws')->first(); $this->assertNotNull($context); $this->assertNull($context->succeeded_at);
+        $withdraw->status = 1; $withdraw->save(); $this->assertSame('2026-10-26 16:00:00', DB::table('reporting_contexts')->where('source', 'withdraws')->value('succeeded_at'));
+        Carbon::setTestNow(Carbon::parse('2026-10-26 16:10:00', 'Asia/Shanghai')); $withdraw->amount = '11'; $withdraw->save();
+        $this->assertSame('2026-10-26 16:00:00', DB::table('reporting_contexts')->where('source', 'withdraws')->value('succeeded_at'));
+        $request = Request::create('/api/onMessage', 'POST'); $this->app->instance('request', $request); $user = (object) ['id' => 5, 'agent_id' => null, 'from' => -1];
+        \App\Services\Reporting\ReportActivity::telegram($user); \App\Services\Reporting\ReportActivity::telegram($user);
+        $this->assertSame(1, DB::table('reporting_activity')->count()); $this->assertSame('unknown', DB::table('reporting_activity')->value('client_platform'));
+    }
+
+    public function test_current_game_adapters_cover_parent_children_third_party_and_partial_sports_refunds(): void
+    {
+        DB::statement('CREATE TABLE bot_lhc_order (id INTEGER PRIMARY KEY,member_id TEXT,ordernum TEXT,amount TEXT,total_amount TEXT,type INTEGER,lottery_status INTEGER,is_faker INTEGER,created_at INTEGER,updated_at INTEGER)');
+        DB::statement('CREATE TABLE bot_lhc_order_item (id INTEGER PRIMARY KEY,member_id TEXT,ordernum TEXT,lottery_status INTEGER,win_amount TEXT,updated_at INTEGER)');
+        DB::statement('CREATE TABLE bot_sport_order (id INTEGER PRIMARY KEY,user_id TEXT,amount TEXT,win_amount TEXT,return_amount TEXT,status INTEGER,pay_status INTEGER,settlement_status INTEGER,return_status INTEGER,create_time INTEGER,update_time INTEGER,settlement_time INTEGER)');
+        DB::statement('CREATE TABLE bot_third_game_orders (id INTEGER PRIMARY KEY,order_key TEXT,member_id TEXT,user_id INTEGER,game_type INTEGER,bet_amount TEXT,settled_amount TEXT,status INTEGER,currency TEXT,bet_time TEXT,last_update_time TEXT,created_at TEXT,updated_at TEXT)');
+        $betAt = Carbon::parse('2026-10-26 09:00:00', 'Asia/Shanghai')->timestamp; $settledAt = $betAt + 3600;
+        DB::table('lhc_order')->insert(['id' => 1, 'member_id' => '200', 'ordernum' => 'L1', 'amount' => '10', 'total_amount' => '20', 'type' => 6, 'lottery_status' => 2, 'is_faker' => 0, 'created_at' => $betAt, 'updated_at' => $settledAt]);
+        DB::table('lhc_order_item')->insert([['id' => 1, 'member_id' => '200', 'ordernum' => 'L1', 'lottery_status' => 1, 'win_amount' => '0', 'updated_at' => $settledAt], ['id' => 2, 'member_id' => '200', 'ordernum' => 'L1', 'lottery_status' => 2, 'win_amount' => '18', 'updated_at' => $settledAt]]);
+        $this->context('lhc_order', 1, 2, 'PC', '2026-10-26 10:00:00'); $this->sync->publish('lhc_order', 1);
+        DB::table('sport_order')->insert(['id' => 1, 'user_id' => '200', 'amount' => '100', 'win_amount' => '0', 'return_amount' => '50', 'status' => 1, 'pay_status' => 1, 'settlement_status' => 1, 'return_status' => 1, 'create_time' => $betAt, 'update_time' => $settledAt, 'settlement_time' => $settledAt]);
+        $this->context('sport_order', 1, 2, 'APP'); $this->sync->publish('sport_order', 1);
+        DB::table('third_game_orders')->insert(['id' => 1, 'order_key' => str_repeat('a', 64), 'member_id' => '200', 'user_id' => 2, 'game_type' => 4, 'bet_amount' => '30', 'settled_amount' => '-30', 'status' => 1, 'currency' => 'CNY', 'bet_time' => '2026-10-26 09:00:00', 'last_update_time' => '2026-10-26 10:00:00', 'created_at' => '2026-10-26 09:00:00', 'updated_at' => '2026-10-26 10:00:00']);
+        $this->sync->publish('third_game_orders', 1);
+        $r = $this->reader->summary([])['summary'];
+        $this->assertSame(3, $r['order_count']); $this->assertSame('150.0000000000', $r['bet_amount']);
+        $this->assertSame('100.0000000000', $r['settled_bet_amount']); $this->assertSame('18.0000000000', $r['payout_amount']);
+        $this->assertSame('50.0000000000', $r['refund_amount']); $this->assertSame('82.0000000000', $r['game_gross_profit']);
+        $unknown = $this->reader->summary(['platform' => 'unknown'])['summary']; $this->assertSame('30.0000000000', $unknown['bet_amount']);
+        $this->assertFalse(Schema::hasTable('share_bet_facts')); // Reports did not publish or create sharing facts.
+        $this->assertSame(0, $this->sync->sync(true)['errors']);
+        Carbon::setTestNow(Carbon::parse('2026-10-26 16:10:00', 'Asia/Shanghai'));
+        DB::table('lhc_order_item')->where('id', 2)->update(['win_amount' => '68', 'updated_at' => now()->timestamp]);
+        $this->assertSame(0, $this->sync->sync()['errors']);
+        $this->assertSame('32.0000000000', $this->reader->summary([])['summary']['game_gross_profit']);
+        $this->assertSame(4, DB::table('reporting_orders')->count());
+    }
+
+    public function test_audit_failure_rolls_back_reconciliation_and_foreign_currency_is_not_summed(): void
+    {
+        $event = $this->deposit();
+        DB::statement("CREATE TRIGGER reject_reporting_audit BEFORE INSERT ON bot_operation_audits BEGIN SELECT RAISE(ABORT, 'audit failed'); END");
+        try { $this->verify($event); $this->fail('Expected audit failure'); } catch (\Illuminate\Database\QueryException $e) { $this->assertStringContainsString('audit failed', $e->getMessage()); }
+        $this->assertSame(0, DB::table('reporting_reconciliations')->count());
+        $this->bet(); DB::table('reporting_orders')->update(['currency' => 'USDT']);
+        $r = $this->reader->summary([])['summary']; $this->assertSame('0.0000000000', $r['bet_amount']); $this->assertSame(1, $r['quality']['foreign_currency']); $this->assertNull($r['company_profit']);
+    }
+
+    public function test_verified_attribution_is_a_reporting_snapshot_and_survives_later_imports(): void
+    {
+        $event = $this->deposit(); $this->bet();
+        $this->admin->reconcile(['event_id' => $event, 'actual_amount' => '1000', 'external_fee' => '0', 'occurred_at' => '2026-10-26 10:00:00',
+            'currency' => 'CNY', 'evidence' => '核对原绑定及装置', 'agent_id' => 20, 'inviter_id' => 3, 'client_platform' => 'APP']);
+        $this->sync->publish('balance_logs', 1);
+        $e = DB::table('reporting_events')->where('id', $event)->first(); $this->assertSame(20, $e->agent_id); $this->assertSame('verified', $e->attribution_quality);
+        $this->assertSame('APP', $e->client_platform); $this->assertSame(10, DB::table('users')->where('id', 2)->value('agent_id'));
+        $id = (int) DB::table('reporting_orders')->value('id');
+        $this->admin->reconcileOrder(['id' => $id, 'agent_id' => 20, 'evidence' => '原始推广凭据']); $this->sync->publish('bets', 1);
+        $this->assertSame(20, DB::table('reporting_orders')->value('agent_id'));
+        $this->assertSame('1000.0000000000', $this->reader->summary(['report_type' => 'promotion', 'agent_account' => 'agent20'])['summary']['deposit_amount']);
+        $this->assertSame(1, $this->reader->events(['group_id' => '2', 'kind' => 'deposit'])['total']);
+        $this->assertSame(0, $this->admin->quality([])['total']);
+        $this->assertNotEmpty($this->admin->quality([])['sources']);
+    }
+
+    /** @dataProvider invalidQueries */
+    public function test_invalid_filters_do_not_query_unbounded_dates(array $input): void { $this->expectException(ValidationException::class); $this->reader->summary($input); }
+    public static function invalidQueries(): array { return [[['start_date' => '2026-02-30']], [['end_date' => '2026-10-27']], [['start_date' => '2020-01-01']], [['platform' => 'ios']], [['settlement_status' => 'paid']], [['limit' => 999]]]; }
+}

+ 135 - 0
tests/Integration/SharingSettlementTest.php

@@ -228,6 +228,141 @@ class SharingSettlementTest extends TestCase
         $this->assertSame($wallet,DB::table('wallets')->get()->toJson());
     }
 
+    private function queueDates(array $dates): void
+    {
+        DB::table('share_system_state')->updateOrInsert(['key'=>'facts_dirty_dates'],['value'=>json_encode($dates)]);
+    }
+
+    private function queuedDates(): array
+    {
+        return json_decode(DB::table('share_system_state')->where('key','facts_dirty_dates')->value('value') ?? '[]',true);
+    }
+
+    private function correctionAffectingLaterDays(): void
+    {
+        Carbon::setTestNow(Carbon::parse('2026-10-27 12:00:00','Asia/Shanghai'));
+        $original=$this->fact(2,'200');
+        $this->fact(2,'50','2026-10-25'); $this->fact(5,'200','2026-10-25');
+        $this->fact(2,'40','2026-10-26'); $this->fact(5,'200','2026-10-26');
+        foreach (['2026-10-24','2026-10-25','2026-10-26'] as $date) $this->service->settle($date);
+        $this->assertSame('0.6900000000',$this->amount(1));
+        DB::table('share_bet_facts')->where('id',$original)->update(['voided'=>1,'effective_amount'=>'0','source_version'=>'voided']);
+        $this->queueDates(['2026-10-24']);
+    }
+
+    public function test_reconciliation_persists_all_dependent_days_before_starting_work(): void
+    {
+        $this->correctionAffectingLaterDays();
+        $service=new class extends ShareSettlementService {
+            public function settle(string $date,bool $write=true,bool $allowToday=false): array
+            {
+                $pending=json_decode(DB::table('share_system_state')->where('key','facts_dirty_dates')->value('value'),true);
+                \PHPUnit\Framework\Assert::assertSame(['2026-10-24','2026-10-25','2026-10-26'],$pending);
+                throw new \RuntimeException('stop before settlement');
+            }
+        };
+        try { $service->reconcile(); $this->fail('interruption expected'); }
+        catch (\RuntimeException $e) { $this->assertSame('stop before settlement',$e->getMessage()); }
+        $this->assertSame(['2026-10-24','2026-10-25','2026-10-26'],$this->queuedDates());
+        $this->assertSame('0.6900000000',$this->amount(1)); $this->assertSame(0,DB::transactionLevel());
+    }
+
+    public function test_partial_reconciliation_preserves_dependent_days_and_resume_does_not_double_credit(): void
+    {
+        $this->correctionAffectingLaterDays();
+        $service=new class extends ShareSettlementService {
+            public function settle(string $date,bool $write=true,bool $allowToday=false): array
+            {
+                if ($date==='2026-10-25') throw new \RuntimeException('stop before second date');
+                return parent::settle($date,$write,$allowToday);
+            }
+        };
+        try { $service->reconcile(); $this->fail('interruption expected'); }
+        catch (\RuntimeException $e) { $this->assertSame('stop before second date',$e->getMessage()); }
+        $this->assertSame(['2026-10-25','2026-10-26'],$this->queuedDates());
+        $this->assertSame('0.4900000000',$this->amount(1));
+        $result=$this->service->reconcile(); $this->assertSame(2,$result['dates']);
+        $this->assertSame('0.4000000000',$this->amount(1)); $this->assertSame([],$this->queuedDates());
+        $entries=DB::table('share_ledger')->count();
+        $this->assertSame(['dates'=>0,'changed'=>0],$this->service->reconcile());
+        $this->assertSame($entries,DB::table('share_ledger')->count());
+    }
+
+    public function test_daily_reconciliation_rolls_back_ledger_if_interrupted_before_acknowledgement(): void
+    {
+        $this->correctionAffectingLaterDays(); $before=DB::table('share_ledger')->get()->toJson();
+        $service=new class extends ShareSettlementService {
+            public function settle(string $date,bool $write=true,bool $allowToday=false): array
+            {
+                parent::settle($date,$write,$allowToday);
+                throw new \RuntimeException('stop after ledger writes');
+            }
+        };
+        try { $service->reconcile(); $this->fail('interruption expected'); }
+        catch (\RuntimeException $e) { $this->assertSame('stop after ledger writes',$e->getMessage()); }
+        $this->assertSame($before,DB::table('share_ledger')->get()->toJson());
+        $this->assertSame('0.6900000000',$this->amount(1));
+        $this->assertSame(['2026-10-24','2026-10-25','2026-10-26'],$this->queuedDates());
+        $this->service->reconcile(); $this->assertSame('0.4000000000',$this->amount(1));
+    }
+
+    public function test_empty_historical_day_is_acknowledged_but_open_days_survive_until_midnight(): void
+    {
+        $this->queueDates(['2026-10-23','2026-10-25','2026-10-26']);
+        $this->assertSame(['dates'=>1,'changed'=>0],$this->service->reconcile());
+        $this->assertSame(['2026-10-25','2026-10-26'],$this->queuedDates());
+        Carbon::setTestNow(Carbon::parse('2026-10-26 00:01:00','Asia/Shanghai'));
+        $this->assertSame(['dates'=>1,'changed'=>0],$this->service->reconcile());
+        $this->assertSame(['2026-10-26'],$this->queuedDates()); $this->assertSame(0,DB::table('share_ledger')->count());
+    }
+
+    public static function recoveryKillPoints(): array
+    {
+        return [
+            'after first date committed'=>['2026-10-25','before',['2026-10-25','2026-10-26'],'0.4900000000'],
+            'after ledger write before acknowledgement'=>['2026-10-24','after',['2026-10-24','2026-10-25','2026-10-26'],'0.6900000000'],
+        ];
+    }
+
+    /** @dataProvider recoveryKillPoints */
+    public function test_sigkill_and_fresh_service_resume_persisted_dates(string $date,string $point,array $pending,string $earned): void
+    {
+        if (!function_exists('posix_kill') || !defined('SIGKILL') || !function_exists('proc_open')) $this->markTestSkipped('Requires POSIX signals and subprocesses');
+        $this->correctionAffectingLaterDays();
+        $directory=sys_get_temp_dir().'/bot28-share-reconcile-'.bin2hex(random_bytes(6));
+        mkdir($directory,0700); $database=$directory.'/db.sqlite';
+        $default=DB::getDefaultConnection(); $process=null; $pipes=[];
+        try {
+            // Clone the isolated fixture, never an application or existing database.
+            DB::statement('VACUUM INTO ?',[$database]);
+            $process=proc_open([PHP_BINARY,'-d','error_reporting=22527',dirname(__DIR__).'/fixtures/sharing_reconcile_recovery_worker.php',$database,$date,$point],
+                [0=>['pipe','r'],1=>['pipe','w'],2=>['pipe','w']],$pipes);
+            $this->assertIsResource($process); fclose($pipes[0]);
+            $deadline=microtime(true)+5;
+            do { $status=proc_get_status($process); if (!$status['running']) break; usleep(1000); } while (microtime(true)<$deadline);
+            $this->assertFalse($status['running'],'Recovery worker did not terminate within five seconds');
+            $stdout=stream_get_contents($pipes[1]); $stderr=stream_get_contents($pipes[2]); fclose($pipes[1]); fclose($pipes[2]);
+            proc_close($process); $process=null;
+            $this->assertTrue($status['signaled'],$stdout.$stderr.json_encode($status)); $this->assertSame(SIGKILL,$status['termsig']);
+            $marker=json_decode(file_get_contents($directory.'/interrupted.json'),true,512,JSON_THROW_ON_ERROR);
+            $this->assertSame($date,$marker['date']); $this->assertSame($point,$marker['point']);
+            app('config')->set('database.connections.reconcile_recovery',['driver'=>'sqlite','database'=>$database,'prefix'=>'bot_']);
+            DB::setDefaultConnection('reconcile_recovery');
+            $this->assertSame($pending,$this->queuedDates()); $this->assertSame($earned,$this->amount(1));
+            $restarted=new ShareSettlementService(); $result=$restarted->reconcile();
+            $this->assertSame(count($pending),$result['dates']); $this->assertSame([],$this->queuedDates());
+            $this->assertSame('0.4000000000',$this->amount(1));
+            $entries=DB::table('share_ledger')->count(); $this->assertSame(['dates'=>0,'changed'=>0],$restarted->reconcile());
+            $this->assertSame($entries,DB::table('share_ledger')->count());
+        } finally {
+            if (is_resource($process)) { proc_terminate($process,9); proc_close($process); }
+            foreach ($pipes as $pipe) if (is_resource($pipe)) fclose($pipe);
+            DB::setDefaultConnection($default); DB::purge('reconcile_recovery');
+            foreach (glob($directory.'/*') ?: [] as $file) unlink($file);
+            rmdir($directory);
+        }
+    }
+
     public function test_policy_snapshot_is_used_for_replay_and_preview_is_read_only(): void
     {
         $fact=$this->fact(2,'200'); $this->service->settle('2026-10-24');

+ 249 - 0
tests/Integration/SiteMessageTest.php

@@ -0,0 +1,249 @@
+<?php
+
+namespace Tests\Integration;
+
+use App\Http\Controllers\admin\SiteMessage;
+use App\Http\Controllers\agent\ProfileController;
+use App\Models\Agent\Agent;
+use App\Services\SiteInboxService;
+use App\Services\SiteMessageService;
+use Carbon\Carbon;
+use Illuminate\Auth\Access\AuthorizationException;
+use Illuminate\Config\Repository;
+use Illuminate\Database\Capsule\Manager;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Events\Dispatcher;
+use Illuminate\Foundation\Application;
+use Illuminate\Http\JsonResponse;
+use Illuminate\Http\Request;
+use Illuminate\Routing\Router;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Facade;
+use Illuminate\Support\Facades\Schema;
+use Illuminate\Translation\ArrayLoader;
+use Illuminate\Translation\Translator;
+use Illuminate\Validation\Factory;
+use Illuminate\Validation\ValidationException;
+use PHPUnit\Framework\TestCase;
+
+/** Real dispatch SQL, translations, ownership, retraction and audit transactions; SQLite only. */
+class SiteMessageTest extends TestCase
+{
+    private Application $app;
+    private SiteMessageService $service;
+    private SiteInboxService $inbox;
+    private int $sequence = 0;
+
+    protected function setUp(): void
+    {
+        parent::setUp(); Facade::clearResolvedInstances();
+        Carbon::setTestNow(Carbon::parse('2026-10-26 10:00:00', 'Asia/Shanghai'));
+        $this->app = new Application(dirname(__DIR__, 2));
+        $this->app->instance('config', new Repository(['app' => ['locale' => 'zh', 'timezone' => 'Asia/Shanghai'], 'agent' => ['admin_ids' => [1]]]));
+        Facade::setFacadeApplication($this->app);
+        $db = new Manager($this->app); $db->addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => 'bot_']);
+        $db->setAsGlobal(); $db->bootEloquent(); $this->app->instance('db', $db->getDatabaseManager());
+        $this->app->bind('db.schema', fn () => $db->schema());
+        $translator = new Translator(new ArrayLoader(), 'zh'); $this->app->instance('translator', $translator);
+        $this->app->instance('validator', new Factory($translator, $this->app)); $this->app->instance('log', new \Psr\Log\NullLogger());
+        $this->app->instance(\Illuminate\Contracts\Routing\ResponseFactory::class, new class {
+            public function json($data, $status = 200, $headers = [], $options = 0) { return new JsonResponse($data, $status, $headers, $options); }
+        });
+        Request::macro('validate', function (array $rules) { return app('validator')->make($this->all(), $rules)->validate(); });
+        Schema::create('users', function (Blueprint $t) {
+            $t->id(); $t->string('user_id'); $t->string('member_id'); $t->string('account'); $t->string('username'); $t->string('first_name');
+            $t->string('language')->nullable(); $t->integer('from'); $t->dateTime('delete_time')->nullable(); $t->timestamps();
+        });
+        Schema::create('agents', function (Blueprint $t) { $t->id(); $t->string('username'); $t->string('real_name'); $t->integer('status')->default(1); $t->timestamps(); });
+        Schema::create('operation_audits', function (Blueprint $t) {
+            $t->id(); $t->string('resource_type'); $t->integer('resource_id'); $t->string('action'); $t->json('changes');
+            $t->integer('operator_id')->nullable(); $t->string('operator_name'); $t->timestamp('created_at')->useCurrent();
+        });
+        $this->migration()->up();
+        foreach ([1 => ['zh', 1], 2 => ['en', 1], 3 => ['vi', 1], 4 => [null, 1], 5 => ['zh', -1], 6 => ['zh', 2], 7 => ['zh', 0], 8 => ['zh', 1]] as $id => [$lang, $from]) {
+            DB::table('users')->insert(['id' => $id, 'user_id' => (string) (100 + $id), 'member_id' => 'M' . $id, 'account' => 'member' . $id,
+                'username' => 'name' . $id, 'first_name' => '昵称' . $id, 'language' => $lang, 'from' => $from, 'delete_time' => $id === 8 ? now() : null]);
+        }
+        foreach ([1 => 'zh', 2 => 'en', 3 => 'vi'] as $id => $lang) DB::table('agents')->insert(['id' => $id, 'username' => 'agent' . $id, 'real_name' => '姓名' . $id, 'language' => $lang, 'status' => $id === 3 ? 0 : 1]);
+        $this->service = new SiteMessageService(); $this->inbox = new SiteInboxService(); $this->request();
+    }
+
+    protected function tearDown(): void { Carbon::setTestNow(); DB::disconnect(); Facade::clearResolvedInstances(); parent::tearDown(); }
+    private function migration() { return require dirname(__DIR__, 2) . '/database/migrations/2026_10_26_110000_create_site_messages.php'; }
+    private function request(array $params = [], int $admin = 1): Request
+    {
+        $request = Request::create('/admin/message/send', 'POST', $params); $request->user = (object) ['id' => $admin, 'username' => 'admin' . $admin];
+        $this->app->instance('request', $request); return $request;
+    }
+    private function send(array $extra = []): array
+    {
+        return $this->service->send($extra + ['request_id' => 'fixture-' . ++$this->sequence, 'recipient_type' => 'all_users',
+            'translations' => ['zh' => ['title' => '中文标题', 'subtitle' => '副标题', 'content' => '中文正文'], 'en' => ['title' => 'English', 'content' => 'English body'], 'vi' => ['content' => '']]]);
+    }
+
+    public function test_language_specific_delivery_is_real_frontend_only_and_retries_are_idempotent(): void
+    {
+        $a = $this->send(['request_id' => 'same']); $b = $this->send(['request_id' => 'same']);
+        $this->assertSame($a['id'], $b['id']); $this->assertSame('sent', $a['status']);
+        $this->assertSame(2, $a['recipient_count']); $this->assertSame(2, $a['skipped_count']);
+        $this->assertSame(2, DB::table('site_message_translations')->count());
+        $this->assertSame([1, 2], DB::table('site_message_recipients')->orderBy('recipient_id')->pluck('recipient_id')->all());
+        $this->assertSame('中文正文', $this->inbox->detail('user', 1, $a['id'])['content']);
+        $this->assertSame('English body', $this->inbox->detail('user', 2, $a['id'])['content']);
+        $this->assertSame(0, $this->inbox->unread('user', 3)['count']);
+        $this->assertSame(0, $this->inbox->unread('agent', 1)['count']);
+        $this->assertSame(1, DB::table('operation_audits')->count());
+        $this->expectException(ValidationException::class); $this->send(['request_id' => 'same', 'category' => 'promotion']);
+    }
+
+    public function test_specified_users_and_agents_use_distinct_id_namespaces_and_retraction_hides_every_read_path(): void
+    {
+        $user = $this->send(['recipient_type' => 'specified_users', 'recipient_ids' => [1, 2, 1], 'request_id' => 'select']);
+        $again = $this->send(['recipient_type' => 'specified_users', 'recipient_ids' => [2, 1], 'request_id' => 'select']);
+        $this->assertSame($user['id'], $again['id']);
+        $agent = $this->send(['recipient_type' => 'specified_agents', 'recipient_ids' => [1, 3], 'translations' => ['zh' => ['title' => '代理', 'content' => '仅代理本人']]]);
+        $this->assertSame(1, $agent['recipient_count']);
+        $this->assertSame('仅代理本人', $this->inbox->detail('agent', 1, $agent['id'])['content']);
+        try { $this->inbox->detail('user', 1, $agent['id']); $this->fail('Cross-kind access'); } catch (ValidationException $e) { $this->assertNotEmpty($e->errors()); }
+        $this->assertSame(1, $this->inbox->unread('agent', 1)['count']);
+        $this->service->retract($agent['id']); $logs = DB::table('operation_audits')->count();
+        $this->service->retract($agent['id']); $this->assertSame($logs, DB::table('operation_audits')->count());
+        $this->assertSame(0, $this->inbox->listing('agent', 1, [])['count']); $this->assertSame(0, $this->inbox->unread('agent', 1)['count']);
+        foreach ([fn () => $this->inbox->detail('agent', 1, $agent['id']), fn () => $this->inbox->read('agent', 1, $agent['id'])] as $call) {
+            try { $call(); $this->fail('Retracted read'); } catch (ValidationException $e) { $this->assertNotEmpty($e->errors()); }
+        }
+        $this->assertSame('retracted', $this->service->detail($agent['id'])['status']);
+        $this->assertSame(2, $this->service->listing([])['total']);
+    }
+
+    public function test_read_is_explicit_owned_and_preserves_first_read_time_and_delivery_language(): void
+    {
+        $message = $this->send(); $id = $message['id'];
+        $this->inbox->detail('user', 1, $id); $this->assertSame(1, $this->inbox->unread('user', 1)['count']);
+        $first = $this->inbox->read('user', 1, $id);
+        Carbon::setTestNow(now()->addHour());
+        $this->assertSame($first, $this->inbox->read('user', 1, $id));
+        $this->assertSame(0, $this->inbox->listing('user', 1, ['is_read' => 0])['count']);
+        $this->assertSame(1, $this->inbox->listing('user', 1, ['is_read' => 1])['count']);
+        DB::table('users')->where('id', 1)->update(['language' => 'vi']);
+        $this->assertSame('zh', $this->inbox->detail('user', 1, $id)['language']);
+        $this->assertSame('中文正文', $this->inbox->detail('user', 1, $id)['content']);
+        $this->expectException(ValidationException::class); $this->inbox->read('user', 3, $id);
+    }
+
+    public function test_scheduled_delivery_snapshots_accounts_and_language_at_actual_send_and_can_be_cancelled(): void
+    {
+        $message = $this->send(['send_at' => '2026-10-26 11:00:00']);
+        $cancel = $this->send(['send_at' => '2026-10-26 11:00:00']); $this->service->retract($cancel['id']);
+        $this->assertSame('scheduled', $message['status']); $this->assertSame(0, DB::table('site_message_recipients')->count());
+        DB::table('users')->where('id', 2)->update(['language' => 'vi']);
+        DB::table('users')->insert(['id' => 9, 'user_id' => '109', 'member_id' => 'M9', 'account' => 'late', 'username' => '', 'first_name' => 'later', 'language' => 'zh', 'from' => 1]);
+        $this->assertSame(0, $this->service->dispatchDue()['sent']);
+        Carbon::setTestNow(Carbon::parse('2026-10-26 11:00:00', 'Asia/Shanghai'));
+        $this->assertSame(1, $this->service->dispatchDue()['sent']);
+        $this->assertSame([1, 9], DB::table('site_message_recipients')->orderBy('recipient_id')->pluck('recipient_id')->all());
+        DB::table('users')->insert(['id' => 10, 'user_id' => '110', 'member_id' => 'M10', 'account' => 'after', 'username' => '', 'first_name' => 'after', 'language' => 'zh', 'from' => 1]);
+        $this->assertSame(0, $this->service->dispatchDue()['sent']); $this->assertSame(0, $this->inbox->unread('user', 10)['count']);
+        $this->assertSame('系统', DB::table('operation_audits')->where('resource_id', $message['id'])->where('action', 'send')->value('operator_name'));
+    }
+
+    public function test_lightweight_search_language_filters_and_agent_permissions_do_not_leak_finance_or_other_kinds(): void
+    {
+        $users = $this->service->recipients('user', ['q' => 'member', 'limit' => 2]);
+        $this->assertSame(4, $users['total']); $this->assertCount(2, $users['data']);
+        $this->assertSame(['id', 'user_id', 'member_id', 'account', 'username', 'first_name', 'language'], array_keys((array) $users['data'][0]));
+        $this->assertSame(2, $this->service->recipients('user', ['q' => '102'])['data'][0]->id);
+        $this->send(); $this->send(['recipient_type' => 'all_agents']);
+        $this->assertSame(2, $this->service->listing(['language' => 'en'])['total']);
+        $this->assertSame(0, $this->service->listing(['language' => 'vi'])['total']);
+        $this->assertSame(0, $this->service->listing(['language' => 'en', 'title' => '中文'])['total']);
+        $this->assertSame(2, $this->service->logs(['language' => 'en'])['total']);
+        $this->request([], 2);
+        $this->assertSame(1, $this->service->listing([])['total']); $this->assertSame(1, $this->service->logs([])['total']);
+        foreach ([fn () => $this->service->recipients('agent', []), fn () => $this->send(['recipient_type' => 'all_agents'])] as $call) {
+            try { $call(); $this->fail('Agent management bypass'); } catch (AuthorizationException $e) { $this->assertNotEmpty($e->getMessage()); }
+        }
+    }
+
+    public function test_delivery_and_audit_failures_roll_back_everything_and_retry_once(): void
+    {
+        DB::statement("CREATE TRIGGER reject_receipts BEFORE INSERT ON bot_site_message_recipients BEGIN SELECT RAISE(ABORT, 'delivery unavailable'); END");
+        try { $this->send(); $this->fail('Expected failure'); } catch (\Illuminate\Database\QueryException $e) { $this->assertStringContainsString('delivery unavailable', $e->getMessage()); }
+        $this->assertSame(0, DB::table('site_messages')->count()); $this->assertSame(0, DB::table('site_message_translations')->count());
+        DB::statement('DROP TRIGGER reject_receipts');
+        $message = $this->send(['send_at' => '2026-10-26 11:00:00']);
+        DB::statement("CREATE TRIGGER reject_message_audit BEFORE INSERT ON bot_operation_audits BEGIN SELECT RAISE(ABORT, 'audit unavailable'); END");
+        Carbon::setTestNow(Carbon::parse('2026-10-26 11:00:00', 'Asia/Shanghai'));
+        $this->assertSame(1, $this->service->dispatchDue()['failed']);
+        $this->assertSame('scheduled', $this->service->detail($message['id'])['status']); $this->assertSame(0, DB::table('site_message_recipients')->count());
+        DB::statement('DROP TRIGGER reject_message_audit');
+        $this->assertSame(1, $this->service->dispatchDue()['sent']); $this->assertSame(2, DB::table('site_message_recipients')->count());
+    }
+
+    /** @dataProvider invalidInputs */
+    public function test_invalid_messages_never_leave_partial_rows(array $input): void
+    {
+        try { $this->send($input); $this->fail('Expected validation failure'); } catch (ValidationException $e) { $this->assertNotEmpty($e->errors()); }
+        $this->assertSame(0, DB::table('site_messages')->count()); $this->assertSame(0, DB::table('operation_audits')->count());
+    }
+    public static function invalidInputs(): array
+    {
+        return array_map(fn ($x) => [$x], [['translations' => []], ['translations' => ['zh' => ['content' => '']]],
+            ['translations' => ['de' => ['title' => 'x', 'content' => 'x']]], ['translations' => ['zh' => ['title' => '', 'content' => 'x']]],
+            ['translations' => ['en' => ['title' => str_repeat('a', 501), 'content' => 'x']]], ['translations' => ['vi' => ['title' => 'x', 'content' => str_repeat('a', 5001)]]],
+            ['recipient_type' => 'specified_users', 'recipient_ids' => []], ['recipient_type' => 'specified_users', 'recipient_ids' => [5]],
+            ['recipient_type' => 'specified_users', 'recipient_ids' => [8]], ['recipient_type' => 'all_users', 'recipient_ids' => [1]],
+            ['send_at' => '2026-02-30 10:00:00'], ['sender_id' => 99], ['request_id' => ''], ['category' => 'other']]);
+    }
+
+    public function test_agent_language_only_profile_update_keeps_real_name_and_all_new_routes_use_correct_auth(): void
+    {
+        DB::table('agents')->insert(['id' => 4, 'username' => 'new', 'real_name' => '保留姓名']);
+        $this->assertSame('zh', DB::table('agents')->where('id', 4)->value('language'));
+        $request = $this->request(['language' => 'en']); $request->attributes->set('agent', Agent::findOrFail(4));
+        $this->assertSame(0, (new ProfileController())->update($request)->getData(true)['code']);
+        $this->assertSame('保留姓名', DB::table('agents')->where('id', 4)->value('real_name'));
+        $this->assertSame('en', DB::table('agents')->where('id', 4)->value('language'));
+        $router = new Router(new Dispatcher($this->app), $this->app); $this->app->instance('router', $router);
+        $router->group(['prefix' => 'admin'], function () { require dirname(__DIR__, 2) . '/routes/admin.php'; });
+        $router->group(['prefix' => 'agent'], function () { require dirname(__DIR__, 2) . '/routes/agent.php'; });
+        foreach (['/admin/message', '/admin/message/recipients/users', '/admin/message/recipients/agents', '/admin/message/logs'] as $uri) {
+            $route = $router->getRoutes()->match(Request::create($uri)); $this->assertContains('admin.jwt', $route->middleware());
+        }
+        $this->assertContains('agent.auth', $router->getRoutes()->match(Request::create('/agent/messages'))->middleware());
+        $this->assertContains('agent.admin', $router->getRoutes()->match(Request::create('/admin/message/recipients/agents'))->middleware());
+        $this->request(['translations' => []]); $this->assertSame(-3, (new SiteMessage())->send($this->service)->getData(true)['code']);
+    }
+
+    public function test_no_matching_language_completes_with_zero_deliveries_and_all_agents_does_not_include_their_members(): void
+    {
+        $zero = $this->send(['recipient_type' => 'specified_users', 'recipient_ids' => [3]]);
+        $this->assertSame('sent', $zero['status']); $this->assertSame(0, $zero['recipient_count']); $this->assertSame(1, $zero['skipped_count']);
+        $agents = $this->send(['recipient_type' => 'all_agents', 'translations' => ['vi' => ['title' => 'Vi', 'content' => 'Chỉ đại lý']]]);
+        $this->assertSame(1, $agents['recipient_count']);
+        $receipt = DB::table('site_message_recipients')->where('message_id', $agents['id'])->first();
+        $this->assertSame('agent', $receipt->recipient_kind); $this->assertSame(3, $receipt->recipient_id);
+        $this->assertSame(0, $this->inbox->unread('user', 3)['count']);
+    }
+
+    public function test_agent_inbox_with_real_agent_token_middleware_cannot_read_member_messages(): void
+    {
+        Schema::create('agent_tokens', function (Blueprint $t) {
+            $t->id(); $t->unsignedBigInteger('agent_id'); $t->string('token_hash'); $t->dateTime('expires_at'); $t->dateTime('last_used_at')->nullable(); $t->timestamps();
+        });
+        DB::table('agent_tokens')->insert(['agent_id' => 1, 'token_hash' => hash('sha256', 'agent-one'), 'expires_at' => now()->addDay()]);
+        $member = $this->send();
+        $agent = $this->send(['recipient_type' => 'specified_agents', 'recipient_ids' => [1]]);
+        $request = Request::create('/agent/messages', 'GET', ['user_id' => 2]);
+        $request->headers->set('Authorization', 'Bearer agent-one'); $this->app->instance('request', $request);
+        $response = (new \App\Http\Middleware\AgentAuthMiddleware())->handle($request, fn () => (new \App\Http\Controllers\agent\MessageController())->index($this->inbox));
+        $body = $response->getData(true); $this->assertSame(0, $body['code']);
+        $this->assertSame([$agent['id']], array_column($body['data']['list'], 'id'));
+        $request = Request::create('/agent/messages/detail', 'GET', ['id' => $member['id']]);
+        $request->headers->set('Authorization', 'Bearer agent-one'); $this->app->instance('request', $request);
+        $denied = (new \App\Http\Middleware\AgentAuthMiddleware())->handle($request, fn () => (new \App\Http\Controllers\agent\MessageController())->detail($this->inbox));
+        $this->assertSame(-3, $denied->getData(true)['code']);
+        $request = Request::create('/agent/messages'); $this->app->instance('request', $request);
+        $this->assertSame(101011, (new \App\Http\Middleware\AgentAuthMiddleware())->handle($request, fn () => null)->getData(true)['code']);
+    }
+}

+ 52 - 0
tests/fixtures/sharing_reconcile_recovery_worker.php

@@ -0,0 +1,52 @@
+<?php
+
+// Isolated SQLite crash worker. Never boots application providers or loads .env.
+require dirname(__DIR__) . '/bootstrap-agent.php';
+
+use App\Services\Sharing\ShareSettlementService;
+use Carbon\Carbon;
+use Illuminate\Config\Repository;
+use Illuminate\Database\Capsule\Manager;
+use Illuminate\Foundation\Application;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Facade;
+
+$database=$argv[1] ?? '';
+$date=$argv[2] ?? '';
+$point=$argv[3] ?? '';
+$directory=realpath(dirname($database));
+if (!$directory || dirname($directory)!==realpath(sys_get_temp_dir())
+    || !preg_match('/^bot28-share-reconcile-[a-f0-9]{12}$/D',basename($directory))
+    || realpath($database)!==$directory.'/db.sqlite'
+    || !in_array($point,['before','after'],true) || !preg_match('/^\d{4}-\d{2}-\d{2}$/D',$date)
+    || !function_exists('posix_kill') || !defined('SIGKILL')) {
+    throw new RuntimeException('A dedicated temporary recovery database and POSIX signals are required');
+}
+
+Carbon::setTestNow(Carbon::parse('2026-10-27 12:00:00','Asia/Shanghai'));
+$app=new Application(dirname(__DIR__,2));
+$app->instance('config',new Repository(['app'=>['timezone'=>'Asia/Shanghai'],'third_game'=>['currency'=>'CNY']]));
+Facade::setFacadeApplication($app);
+$db=new Manager($app); $db->addConnection(['driver'=>'sqlite','database'=>$database,'prefix'=>'bot_']);
+$db->setAsGlobal(); $db->bootEloquent(); $app->instance('db',$db->getDatabaseManager());
+
+$service=new class($date,$point,$directory.'/interrupted.json') extends ShareSettlementService {
+    public function __construct(private string $date,private string $point,private string $marker) {}
+    public function settle(string $date,bool $write=true,bool $allowToday=false): array
+    {
+        if ($date===$this->date && $this->point==='before') $this->interrupt($date);
+        $result=parent::settle($date,$write,$allowToday);
+        if ($date===$this->date && $this->point==='after') $this->interrupt($date);
+        return $result;
+    }
+    private function interrupt(string $date): void
+    {
+        file_put_contents($this->marker,json_encode(['date'=>$date,'point'=>$this->point,
+            'pending'=>json_decode(DB::table('share_system_state')->where('key','facts_dirty_dates')->value('value'),true)],JSON_THROW_ON_ERROR));
+        if (!posix_kill(getmypid(),SIGKILL)) throw new RuntimeException('Could not terminate the recovery worker');
+        // Let the signal terminate us; a normal exit would not exercise crash recovery.
+        while (true) usleep(1000);
+    }
+};
+$service->reconcile();
+throw new RuntimeException('The expected interruption point was not reached');