doge 1 день назад
Родитель
Сommit
b5e93f4311

+ 10 - 2
app/Console/Commands/SyncPromotions.php

@@ -4,6 +4,7 @@ namespace App\Console\Commands;
 
 use App\Services\PromotionAdminService;
 use App\Services\PromotionService;
+use App\Support\SyncCommandLock;
 use Illuminate\Console\Command;
 use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\Schema;
@@ -13,7 +14,14 @@ class SyncPromotions extends Command
 {
     protected $signature = 'promotions:sync';
     protected $description = '到期活动自动下架,补记转盘任务次数并解冻满足流水的新活动奖金';
-    public function handle(PromotionService $service): int
+    public function handle(PromotionService $service, SyncCommandLock $lock): int
+    {
+        $exit = $lock->run('promotions:sync', fn () => $this->synchronize($service));
+        if ($exit === null) $this->info('活动同步已在运行,跳过本次执行');
+        return $exit ?? self::SUCCESS;
+    }
+
+    private function synchronize(PromotionService $service): int
     {
         if (!Schema::hasTable('promotion_accounts')) return self::SUCCESS;
         $errors = 0;
@@ -22,7 +30,7 @@ class SyncPromotions extends Command
         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); }
+                foreach ($rows as $row) try { $service->syncAccount($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) {

+ 9 - 4
app/Console/Commands/SyncReporting.php

@@ -3,16 +3,21 @@
 namespace App\Console\Commands;
 
 use App\Services\Reporting\ReportSync;
+use App\Support\SyncCommandLock;
 use Illuminate\Console\Command;
 
 class SyncReporting extends Command
 {
     protected $signature = 'reporting:sync {--full : 分批重新核对全部历史来源,不修改业务订单或钱包}';
     protected $description = '同步后台统一报表事实(默认增量,幂等,可重试)';
-    public function handle(ReportSync $service): int
+    public function handle(ReportSync $service, SyncCommandLock $lock): 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;
+        $exit = $lock->run('reporting:sync', function () use ($service) {
+            $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;
+        });
+        if ($exit === null) $this->info('报表同步已在运行,跳过本次执行');
+        return $exit ?? self::SUCCESS;
     }
 }

+ 90 - 8
app/Services/PromotionFacts.php

@@ -11,6 +11,24 @@ use Illuminate\Validation\ValidationException;
 /** Promotion eligibility is independent of commission accrual and never uses raw stake as effective stake. */
 class PromotionFacts
 {
+    private ?array $snapshotCache = null;
+
+    /** Share repeated reads only within one refresh; never retain eligibility across requests or accounts. */
+    public function snapshot(callable $work): mixed
+    {
+        if ($this->snapshotCache !== null) return $work();
+        $this->snapshotCache = [];
+        try { return $work(); }
+        finally { $this->snapshotCache = null; }
+    }
+
+    private function remember(string $method, array $arguments, callable $read): mixed
+    {
+        if ($this->snapshotCache === null) return $read();
+        $key = $method . ':' . json_encode($arguments, JSON_THROW_ON_ERROR);
+        return $this->snapshotCache[$key] ??= $read();
+    }
+
     public static function now(): string { return now('Asia/Shanghai')->format('Y-m-d H:i:s'); }
 
     public function policy(): array
@@ -24,6 +42,11 @@ class PromotionFacts
     }
 
     public function children(int $userId): array
+    {
+        return $this->remember(__FUNCTION__, func_get_args(), fn () => $this->readChildren($userId));
+    }
+
+    private function readChildren(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())
@@ -31,12 +54,23 @@ class PromotionFacts
     }
 
     public function deposits(array $members, string $from, ?string $until = null, int $cursor = 0): array
+    {
+        return $this->remember(__FUNCTION__, [$members, $from, $until, $cursor], fn () => $this->readDeposits($members, $from, $until, $cursor));
+    }
+
+    private function readDeposits(array $members, string $from, ?string $until, int $cursor): 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);
+        if ($this->usesDecimalAggregates()) {
+            foreach ($q->selectRaw('SUBSTR(created_at, 1, 10) AS day, SUM(amount) AS total')->groupByRaw('SUBSTR(created_at, 1, 10)')->get() as $row) {
+                $totals[$row->day] = bcadd((string) $row->total, '0', 10);
+            }
+            return $totals;
+        }
         foreach ($q->orderBy('id')->cursor() as $row) {
             $day = substr($row->created_at, 0, 10);
             $totals[$day] = bcadd($totals[$day] ?? '0', (string)$row->amount, 10);
@@ -54,6 +88,11 @@ class PromotionFacts
     }
 
     public function betting(array $ids, string $from, ?string $until = null, array $cursors = []): array
+    {
+        return $this->remember(__FUNCTION__, [$ids, $from, $until, $cursors], fn () => $this->readBetting($ids, $from, $until, $cursors));
+    }
+
+    private function readBetting(array $ids, string $from, ?string $until, array $cursors): array
     {
         $totals = ['effective' => [], 'profit' => '0'];
         if (!$ids) return $totals;
@@ -61,9 +100,22 @@ class PromotionFacts
         $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);
+        if (isset($cursors['third_game_orders'])) {
+            // One indexed semijoin replaces a separate original-order lookup for every fact.
+            $q->where(function ($q) use ($cursors) {
+                $q->where('source', '<>', 'third_game_orders')->orWhereExists(function ($origin) use ($cursors) {
+                    $origin->selectRaw('1')->from('third_game_orders as promotion_origin')
+                        ->whereColumn('promotion_origin.order_key', 'share_bet_facts.source_id')->where('promotion_origin.id', '>', $cursors['third_game_orders']);
+                });
+            });
+        }
+        if (!$cursors && $this->usesDecimalAggregates()) {
+            $this->aggregateBets($q, 'bet_at', 'bet_amount', 'net_profit', $totals);
+        } else {
+            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();
@@ -72,9 +124,13 @@ class PromotionFacts
             $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);
+            if ($this->usesDecimalAggregates()) {
+                $this->aggregateBets($q, 'created_at', 'amount', ($table === 'bets' ? 'profit' : 'win_amount') . ' - amount', $totals);
+            } else {
+                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;
@@ -84,8 +140,7 @@ class PromotionFacts
     {
         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'];
+            return true; // Already checked in the source query.
         }
         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);
@@ -98,6 +153,23 @@ class PromotionFacts
         $totals['profit'] = bcadd($totals['profit'], $profit, 10);
     }
 
+    private function usesDecimalAggregates(): bool
+    {
+        // SQLite SUM uses floating point; keep BCMath there so isolated tests preserve exact amounts.
+        return in_array(DB::connection()->getDriverName(), ['mysql', 'mariadb'], true);
+    }
+
+    private function aggregateBets($query, string $time, string $stake, string $profit, array &$totals): void
+    {
+        foreach ($query->selectRaw("SUBSTR($time, 1, 10) AS day, SUM(CASE WHEN $stake < ABS($profit) THEN $stake ELSE ABS($profit) END) AS effective_total, SUM($profit) AS profit_total, SUM(CASE WHEN $stake < 0 THEN 1 ELSE 0 END) AS invalid_stakes, SUM(CASE WHEN $stake IS NULL OR ($profit) IS NULL THEN 1 ELSE 0 END) AS invalid_amounts")
+            ->groupByRaw("SUBSTR($time, 1, 10)")->get() as $row) {
+            if ($row->invalid_amounts > 0) throw new \InvalidArgumentException('Expected a plain decimal amount');
+            if ($row->invalid_stakes > 0) throw new \InvalidArgumentException('Bet amount cannot be negative');
+            $totals['effective'][$row->day] = bcadd($totals['effective'][$row->day] ?? '0', (string) $row->effective_total, 10);
+            $totals['profit'] = bcadd($totals['profit'], (string) $row->profit_total, 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);
@@ -112,6 +184,11 @@ class PromotionFacts
     }
 
     public function logins(string $memberId, string $from): array
+    {
+        return $this->remember(__FUNCTION__, func_get_args(), fn () => $this->readLogins($memberId, $from));
+    }
+
+    private function readLogins(string $memberId, string $from): array
     {
         $days = [];
         foreach (DB::table('user_login')->where('user_id', $memberId)->where('status', 1)->where('created_at', '>=', $from)
@@ -125,6 +202,11 @@ class PromotionFacts
     }
 
     public function online(int $userId, string $from): array
+    {
+        return $this->remember(__FUNCTION__, func_get_args(), fn () => $this->readOnline($userId, $from));
+    }
+
+    private function readOnline(int $userId, string $from): array
     {
         $last = []; $intervals = []; $observations = [];
         foreach (['reporting_activity', 'promotion_presence'] as $table) {

+ 48 - 16
app/Services/PromotionService.php

@@ -133,15 +133,31 @@ class PromotionService
     public function progress(int $activityId, int $userId, ?string $platform = null, ?string $lang = null): array
     {
         return $this->mutate($activityId, $userId, function ($activity, $member) use ($platform, $lang) {
-            $start = $this->open($activity, $member, $platform); $account = $this->account($activity, $member); $round = null;
-            if ((int)$activity->type === Rules::WHEEL) {
-                $round = $this->round($account) ?? $this->newRound($account, $start);
-                if ($round->status === 'active') $this->refreshWheel($activity, $member, $account, $round);
-            }
-            return $this->state($activity, $member, $account, $round, $lang);
+            return $this->facts->snapshot(function () use ($activity, $member, $platform, $lang) {
+                [$account, $round] = $this->refreshProgress($activity, $member, $platform);
+                return $this->state($activity, $member, $account, $round, $lang);
+            });
         });
     }
 
+    /** Background synchronization needs task writes, without building member-facing daily metrics. */
+    public function syncAccount(int $activityId, int $userId): void
+    {
+        $this->mutate($activityId, $userId, fn ($activity, $member) => $this->facts->snapshot(
+            fn () => $this->refreshProgress($activity, $member)
+        ));
+    }
+
+    private function refreshProgress(PromotionActivity $activity, User $member, ?string $platform = null): array
+    {
+        $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 [$account, $round];
+    }
+
     public function apply(int $activityId, int $userId, ?string $platform = null, ?string $lang = null): array
     {
         return $this->mutate($activityId, $userId, function ($activity, $member) use ($platform, $lang) {
@@ -173,34 +189,49 @@ class PromotionService
         return $qualified;
     }
 
-    private function grantTask(object $account, object $round, string $task, string $event, array $evidence): void
+    private function grantTask(object $account, object $round, string $task, string $event, array $evidence, array &$known): bool
     {
+        $key = $task . ':' . $event;
+        if (isset($known[$key])) return false;
         $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()]);
+        $known[$key] = true;
         if ($inserted) { ++$account->spins; DB::table('promotion_accounts')->where('id', $account->id)->update(['spins' => $account->spins, 'updated_at' => PromotionFacts::now()]); }
+        return $inserted > 0;
     }
 
     private function refreshWheel(PromotionActivity $activity, User $member, object $account, object $round): void
+    {
+        $this->facts->snapshot(fn () => $this->refreshWheelTasks($activity, $member, $account, $round));
+    }
+
+    private function refreshWheelTasks(PromotionActivity $activity, User $member, object $account, object $round): void
     {
         $rules = $this->rules($activity); $policy = $this->facts->policy();
         $completed = $this->json($round->completed_tasks);
+        $grants = DB::table('promotion_task_grants')->where('account_id', $account->id)
+            ->where(fn ($q) => $q->where('round_no', $round->round_no)->orWhereIn('task', ['login', 'online']))->get();
+        $known = [];
+        foreach ($grants as $grant) $known[$grant->task . ':' . $grant->event_key] = true;
+        $roundGrants = $grants->where('round_no', $round->round_no)->groupBy('task');
         $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;
+            $this->grantTask($account, $round, 'login', $day, ['day' => $day], $known); $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;
+            $this->grantTask($account, $round, 'online', $day, ['day' => $day, 'seconds' => $seconds], $known); $completed['online'] = true;
         }
         $children = $this->facts->children($member->id);
+        $newInvite = false;
         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]);
+                $newInvite = $this->grantTask($account, $round, 'invite', (string)$child['id'], ['user_id' => $child['id'], 'policy' => $policy], $known) || $newInvite;
             }
         }
         // 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) {
+        $completed['invite'] = $newInvite; $childrenById = array_column($children, null, 'id');
+        foreach ($roundGrants->get('invite', []) as $grant) {
             $evidence = $this->json($grant->evidence); $child = $childrenById[$evidence['user_id']] ?? null;
             if ($child && $this->facts->qualified($child, $evidence['policy'])) $completed['invite'] = true;
         }
@@ -221,15 +252,16 @@ class PromotionService
             // 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;
+                $this->grantTask($account, $round, $task, $round->round_no . ':' . $day, ['day' => $day, 'amount' => $amount, 'target' => $rules[$task . '_amount']], $known); $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) {
+            foreach ($roundGrants->get($task, []) 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()]);
+        $encoded = json_encode($completed);
+        if ($encoded !== $round->completed_tasks) DB::table('promotion_rounds')->where('id', $round->id)->update(['completed_tasks' => $encoded, 'updated_at' => PromotionFacts::now()]);
+        $round->completed_tasks = $encoded;
     }
 
     private function reserve(PromotionActivity $activity, User $member, object $account, int $roundNo, string $amount, array $rules): object

+ 92 - 31
app/Services/Reporting/ReportSync.php

@@ -13,49 +13,101 @@ 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 const RECHECK_BATCH = 200;
 
     public function sync(bool $full = false): array
     {
         $result = ['scanned' => 0, 'published' => 0, 'errors' => 0, 'sources' => []];
+        $lotteryParents = [];
         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')) {
+            $hasUpdate = Schema::hasColumn($source, $column);
+            $max = (int) DB::table($source)->max('id');
+            $scan = function ($query) use ($source, &$result, &$lotteryParents) {
+                $query->orderBy('id')->chunkById(200, function ($rows) use ($source, &$result, &$lotteryParents) {
+                    $this->processRows($source, $rows, $result, $lotteryParents);
+                });
+            };
+            $recheckId = 0;
+            if ($full || !$cursor) {
+                $scan(DB::table($source)->where('id', '<=', $max));
+            } else {
+                $lastId = min((int) $cursor['last_id'], $max);
+                // Keep the ID and time ranges separate so either can use its own index.
+                $scan(DB::table($source)->where('id', '>', $lastId)->where('id', '<=', $max));
                 $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 ($hasUpdate) $scan(DB::table($source)->where('id', '<=', $lastId)->where($column, '>=', $value));
+
+                // A bad row must not pin the whole source's high-water mark. Retry errors independently.
+                $retryIds = DB::table('reporting_errors')->where('source', $source)->orderBy('updated_at')->orderBy('id')
+                    ->limit(self::RECHECK_BATCH)->pluck('source_id')->all();
+                if ($retryIds) {
+                    // Missing source rows remain visible as errors, but cannot starve the rest of the retry queue.
+                    DB::table('reporting_errors')->where('source', $source)->whereIn('source_id', $retryIds)->update(['updated_at' => $start]);
+                    $retry = DB::table($source)->whereIn('id', $retryIds)->where('id', '<=', $lastId);
+                    if ($hasUpdate) $retry->where(fn ($q) => $q->whereNull($column)->orWhere($column, '<', $value));
+                    $scan($retry);
                 }
-            });
-            if (!$errors) DB::table('reporting_sync_state')->updateOrInsert(['source' => $source], ['state' => json_encode(['checked_at' => $start, 'last_id' => $max]), 'updated_at' => $start]);
+
+                // Missing timestamps still receive eventual correction checks, without a full scan each minute.
+                $recheck = DB::table($source)->where('id', '>', (int) ($cursor['recheck_id'] ?? 0))->where('id', '<=', $lastId);
+                if ($hasUpdate) $recheck->whereNull($column);
+                if ($retryIds) $recheck->whereNotIn('id', $retryIds);
+                $rows = $recheck->orderBy('id')->limit(self::RECHECK_BATCH)->get();
+                $this->processRows($source, $rows, $result, $lotteryParents);
+                $recheckId = $rows->count() === self::RECHECK_BATCH ? (int) $rows->last()->id : 0;
+            }
+            DB::table('reporting_sync_state')->updateOrInsert(['source' => $source], ['state' => json_encode([
+                'checked_at' => $start, 'last_id' => $max, 'recheck_id' => $recheckId,
+            ]), 'updated_at' => $start]);
+            $errors = DB::table('reporting_errors')->where('source', $source)->count();
+            $result['errors'] += $errors;
             $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;
     }
 
+    private function processRows(string $source, $rows, array &$result, array &$lotteryParents): void
+    {
+        if ($rows->isEmpty()) return;
+        $parents = $source === 'lhc_order_item'
+            ? DB::table('lhc_order')->whereIn('ordernum', $rows->pluck('ordernum')->unique()->all())->get(['id', 'ordernum'])->groupBy('ordernum') : null;
+        $successfulItems = [];
+        foreach ($rows as $row) {
+            ++$result['scanned'];
+            try {
+                $publishSource = $source; $id = (int) $row->id;
+                if ($source === 'lhc_order_item') {
+                    $matches = $parents->get($row->ordernum);
+                    if (!$matches || $matches->count() !== 1) throw new \RuntimeException('六合彩子单对应的父单不明确');
+                    $publishSource = 'lhc_order'; $id = (int) $matches->first()->id;
+                }
+                if ($publishSource === 'lhc_order' && array_key_exists($id, $lotteryParents)) {
+                    if ($lotteryParents[$id] !== null) throw new \RuntimeException($lotteryParents[$id]);
+                } else {
+                    try { $this->publish($publishSource, $id); }
+                    catch (\Throwable $e) {
+                        if ($publishSource === 'lhc_order') $lotteryParents[$id] = $e->getMessage();
+                        throw $e;
+                    }
+                    if ($publishSource === 'lhc_order') $lotteryParents[$id] = null;
+                    ++$result['published'];
+                }
+                if ($source === 'lhc_order_item') $successfulItems[] = (string) $row->id;
+            } catch (\Throwable $e) {
+                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 ($successfulItems) DB::table('reporting_errors')->where('source', $source)->whereIn('source_id', $successfulItems)->delete();
+    }
+
     public function publish(string $source, int $id): void
     {
         if (!in_array($source, array_merge(self::ORDER_SOURCES, self::MONEY_SOURCES), true)) throw new \InvalidArgumentException('不支持的数据来源');
@@ -120,9 +172,7 @@ class ReportSync
                 '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);
+            $this->writeProjection('reporting_orders', $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();
     }
@@ -219,11 +269,22 @@ class ReportSync
         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);
+            'customer_fee' => $customerFee, 'configured_fee' => $configuredFee, 'occurred_at' => $at, 'candidate_at' => $candidate];
+        $this->writeProjection('reporting_events', $key, $values);
+    }
+
+    private function writeProjection(string $table, array $key, array $values): void
+    {
+        $old = DB::table($table)->where($key)->first();
+        if (!$old) { DB::table($table)->insert($key + $values + ['created_at' => now(), 'updated_at' => now()]); return; }
+        $decimals = ['bet_amount', 'payout_amount', 'net_profit', 'refund_amount', 'amount', 'customer_fee', 'configured_fee'];
+        foreach ($values as $column => $value) {
+            $previous = $old->$column;
+            $same = $value === null || $previous === null ? $value === $previous
+                : (in_array($column, $decimals, true) ? bccomp((string) $value, (string) $previous, 10) === 0 : (string) $value === (string) $previous);
+            if (!$same) { DB::table($table)->where($key)->update($values + ['updated_at' => now()]); return; }
+        }
     }
 
     private function agentCategory(string $platform, int $type): ?string

+ 31 - 0
app/Support/SyncCommandLock.php

@@ -0,0 +1,31 @@
+<?php
+
+namespace App\Support;
+
+use Illuminate\Support\Facades\DB;
+use RuntimeException;
+
+/** A command-wide lock also covers direct cron invocations and separate deployments sharing MySQL. */
+class SyncCommandLock
+{
+    public function run(string $command, callable $work): ?int
+    {
+        $connection = DB::connection();
+        $name = 'bot-sync:' . substr(hash('sha256', $connection->getDatabaseName() . ':' . $connection->getTablePrefix() . ':' . $command), 0, 48);
+        if (in_array($connection->getDriverName(), ['mysql', 'mariadb'], true)) {
+            $acquired = $connection->selectOne('SELECT GET_LOCK(?, 0) AS acquired', [$name], false)->acquired;
+            if ($acquired === null) throw new RuntimeException('Unable to acquire synchronization lock');
+            if ((int) $acquired !== 1) return null;
+            try { return $work(); }
+            finally { $connection->selectOne('SELECT RELEASE_LOCK(?) AS released', [$name], false); }
+        }
+
+        $handle = fopen(sys_get_temp_dir() . '/' . $name . '.lock', 'c');
+        if ($handle === false) throw new RuntimeException('Unable to open synchronization lock');
+        try {
+            if (!flock($handle, LOCK_EX | LOCK_NB)) return null;
+            try { return $work(); }
+            finally { flock($handle, LOCK_UN); }
+        } finally { fclose($handle); }
+    }
+}

+ 44 - 0
database/migrations/2026_11_03_130000_add_sync_performance_indexes.php

@@ -0,0 +1,44 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration {
+    private function indexes(): array
+    {
+        $indexes = [
+            ['reporting_orders', ['parent_source', 'parent_id'], 'reporting_orders_parent_lookup'],
+            ['reporting_errors', ['source', 'updated_at', 'id'], 'reporting_errors_retry_lookup'],
+            ['share_bet_facts', ['user_id', 'currency', 'settled', 'voided', 'bet_at'], 'promotion_bet_facts_lookup'],
+            ['reporting_activity', ['user_id', 'observed_at'], 'promotion_reporting_activity_lookup'],
+            ['promotion_rewards', ['status', 'user_id'], 'promotion_rewards_pending_lookup'],
+            ['balance_logs', ['member_id', 'type', 'created_at'], 'promotion_deposits_lookup'],
+            ['user_login', ['user_id', 'status', 'created_at'], 'promotion_logins_lookup'],
+            ['bets', ['member_id', 'status', 'created_at'], 'promotion_legacy_bets_lookup'],
+            ['jisu_game_order', ['member_id', 'status', 'created_at'], 'promotion_legacy_jisu_lookup'],
+            ['lhc_order', ['ordernum'], 'reporting_lottery_parent_lookup'],
+            ['lhc_order_item', ['ordernum'], 'reporting_lottery_items_lookup'],
+        ];
+        foreach (['lhc_order', 'lhc_order_item', 'sport_game_order', 'sport_order', 'third_game_orders', 'bets', 'jisu_game_order',
+            'balance_logs', 'withdraws', 'payment_orders', 'agent_withdrawals', 'agent_rebate_records'] as $source) {
+            $indexes[] = [$source, [$source === 'sport_order' ? 'update_time' : 'updated_at', 'id'], 'reporting_' . $source . '_changes'];
+        }
+        return $indexes;
+    }
+
+    public function up(): void
+    {
+        foreach ($this->indexes() as [$table, $columns, $name]) {
+            if (!Schema::hasTable($table) || !Schema::hasColumns($table, $columns) || Schema::hasIndex($table, $columns)) continue;
+            Schema::table($table, fn (Blueprint $t) => $t->index($columns, $name));
+        }
+    }
+
+    public function down(): void
+    {
+        foreach (array_reverse($this->indexes()) as [$table, $columns, $name]) {
+            if (Schema::hasTable($table) && Schema::hasIndex($table, $name)) Schema::table($table, fn (Blueprint $t) => $t->dropIndex($name));
+        }
+    }
+};

+ 62 - 0
docs/后端/同步任务性能修正与部署.md

@@ -0,0 +1,62 @@
+# 同步任务性能修正与部署
+
+本次调整 `reporting:sync`、`promotions:sync` 的读取、投影写入及互斥执行,不修改统计金额口径或奖励规则。
+
+## 报表同步
+
+- 同一轮运行内,父单扫描和多个子单变化共用一次彩票父单发布;子单按200条分批,父单关系批量查询。子单单独更正仍能触发父单发布。
+- 新ID、最近5分钟重叠窗口、异常重试分开查询。正常扫描结束后推进来源游标;单条异常保留在 `reporting_errors`,不再让全部正常记录反复扫描。
+- 异常每个来源每轮最多重试200条,按上次重试时间轮转。存在未修复异常时,来源仍为 `errors`、运行仍不完整,命令返回失败。
+- 空更新时间、缺更新时间列的历史来源,每轮最多核对200条,通过 `reporting_sync_state.state.recheck_id` 轮转。历史更正可能需要等待一轮覆盖;需要立即核对全部历史时使用 `reporting:sync --full`。按分钟运行时,N条这类历史记录一轮约需 `ceil(N / 200) + 1` 分钟(不含执行耗时及异常重试)。
+- 已有投影所有业务字段相同时,不更新 `reporting_orders` / `reporting_events`,保留其更新时间。金额比较使用BCMath,避免数据库DECIMAL格式差异造成重复写入。
+- `published` 现在表示实际完成的发布次数,去重的彩票子单不重复计入;`scanned` 仍表示读取的来源记录次数;`errors` 为对应来源尚未解决的异常数。
+
+## 活动同步
+
+- 定时命令使用 `PromotionService::syncAccount`,不再构造会员页面数据。
+- 单次账户刷新内部复用相同条件的历史读数,结束后立即释放,不跨账户、跨请求缓存资格。后续退款、金额更正和政策变化仍会重新核对。
+- 已发放任务批量读取,避免按历史日期反复尝试插入和逐任务查询。跨轮次每日奖励、邀请证据及更正后的资格保持原规则。
+- MySQL/MariaDB充值与无投注游标的统计按日使用DECIMAL聚合;SQLite隔离测试继续使用BCMath,避免其SUM浮点计算影响金额精度。
+- 第三方投注的原始订单ID游标通过索引关联条件核对,不再逐条查询原订单。
+
+## 命令互斥
+
+两个命令分别增加命令级互斥,直接计划任务、手动命令和框架调度均受保护。`reporting:sync` 与 `reporting:sync --full` 共用一把锁。
+
+MySQL/MariaDB通过写连接的会话命名锁互斥;共享数据库和表前缀的不同部署共用锁,运行结束或抛出异常时释放,不依赖30分钟缓存过期。其他数据库使用本机文件锁。锁被占用时输出“已在运行,跳过本次执行”,返回成功。
+
+不同类型任务可以并行运行。框架原有 `withoutOverlapping` 保留。部署不会主动终止已经启动的旧代码进程;上线前应确认旧同步进程已结束,避免它们继续按旧方式执行。
+
+## 部署
+
+新增迁移只建立索引,覆盖报表父单清理、异常重试、来源更新时间及活动会员/时间查询。已有同列索引会跳过,缺少的来源表或列也会跳过。
+
+在既有报表及活动表已上线的环境,执行本次迁移:
+
+```bash
+php artisan migrate --path=database/migrations/2026_11_03_130000_add_sync_performance_indexes.php --force
+```
+
+大表建立索引的耗时和锁等待取决于实际MySQL版本、表大小及并发写入,应在部署窗口执行。然后发布本次代码,正常运行每分钟调度。无需因本次优化重新执行全量回填。
+
+回滚代码前先等待同步结束;本次迁移的 `down` 仅移除其命名索引,不删除业务或投影数据。新增游标字段可被旧代码忽略。
+
+## 验证
+
+- 隔离SQLite:覆盖跨批次彩票父单去重、子单单独更正、异常独立重试、空/缺更新时间轮转、无变化不写投影、后台任务不构造页面、资格更正、索引往返及文件锁释放。
+- 隔离MySQL:覆盖DECIMAL聚合与BCMath结果一致、大额10位小数、异常金额、第三方游标关联、索引往返、真实来源更正,以及两个连接之间的命名锁互斥。
+- 同一组隔离数据中,10个子单一次普通同步由110次报表更新/671条SQL降为10次更新/157条SQL;未变化且仅1名下级的转盘账户,后台刷新由87条SQL降为44条。实际线上耗时仍需结合来源规模、慢SQL及任务运行时长核对。
+
+基础回归:
+
+```bash
+XDEBUG_MODE=off vendor/bin/phpunit --bootstrap tests/bootstrap-agent.php --do-not-cache-result tests/Integration/ReportingTest.php tests/Integration/PromotionActivitiesTest.php
+```
+
+MySQL回归显式指定隔离测试实例的本地socket,不加载项目`.env`,会创建并删除自己的随机测试数据库:
+
+```bash
+SYNC_TEST_MYSQL_SOCKET=/path/to/isolated/mysql.sock XDEBUG_MODE=off vendor/bin/phpunit --bootstrap tests/bootstrap-agent.php --do-not-cache-result tests/Integration/SyncPerformanceMysqlTest.php
+```
+
+可选测试账号通过 `SYNC_TEST_MYSQL_USER` / `SYNC_TEST_MYSQL_PASSWORD` 提供,需具有创建、删除测试数据库的权限。

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

@@ -78,7 +78,7 @@ php artisan reporting:sync --full
 php artisan reporting:sync
 ```
 
-4. 原始数据按200条分批;source+source_id保持幂等,六合彩子单变化也会重算父单。游标重叠5分钟处理迟到更新;有错误不推进对应来源游标,错误解决后重跑会清除异常。
+4. 原始数据按200条分批;source+source_id保持幂等,六合彩子单变化也会重算父单,同一轮同步只发布一次父单。游标重叠5分钟处理迟到更新;异常记录独立重试,不阻止来源游标推进,修正后重跑会清除异常。缺少更新时间的历史记录按游标每轮核对最多200条,循环覆盖;详见[同步任务性能修正与部署](同步任务性能修正与部署.md)。
 5. 首次同步未完成或存在异常时不提供最终净值。命令输出各来源ready/absent/errors及数量;reporting_errors保留具体来源、ID和原因。上线前核对这些异常,完成实际费用与真实时间补录后再开放对应菜单。
 6. 新业务采集失败只记录日志,不阻止原支付、投注等主业务;未捕获的信息由统计缺失提示和凭据核对处理。投影读取失败不会自动进行结算或移动资金。
 

+ 42 - 0
tests/Integration/PromotionActivitiesTest.php

@@ -108,6 +108,48 @@ class PromotionActivitiesTest extends TestCase
         $this->travel('2026-10-26 14:00:00'); return $this->service->progress($activity, 1, 'H5');
     }
 
+    public function test_background_sync_skips_page_metrics_and_duplicate_task_writes_but_observes_corrections(): void
+    {
+        $id = $this->activity(9); $before = $this->complete($id);
+        $this->assertTrue($before['conditions_complete']);
+        DB::enableQueryLog(); DB::flushQueryLog();
+        try {
+            $this->service->syncAccount($id, 1);
+            $queries = DB::getQueryLog();
+            $this->assertLessThanOrEqual(50, count($queries));
+            $this->assertCount(0, array_filter($queries, fn ($q) => str_contains($q['query'], 'from "bot_promotion_rewards"')));
+            $this->assertCount(0, array_filter($queries, fn ($q) => preg_match('/^(insert.*"bot_promotion_task_grants"|update "bot_promotion_rounds")/', $q['query'])));
+            $this->assertLessThanOrEqual(4, count(array_filter($queries, fn ($q) => str_contains($q['query'], 'from "bot_share_bet_facts"'))));
+        } finally { DB::disableQueryLog(); DB::flushQueryLog(); }
+        $after = $this->service->detail($id, 1);
+        $this->assertSame($before['completed_tasks'], $after['completed_tasks']);
+        $this->assertSame($before['spins'], $after['spins']);
+        DB::table('share_bet_facts')->where('user_id', 2)->update(['net_profit' => '0']);
+        $this->service->syncAccount($id, 1);
+        $corrected = $this->service->detail($id, 1);
+        $this->assertFalse($corrected['completed_tasks']['invite']);
+        $this->assertFalse($corrected['completed_tasks']['team_bet']);
+        $this->assertSame(7, DB::table('promotion_task_grants')->count());
+    }
+
+    public function test_third_party_cursor_uses_one_query_and_original_order_ids(): void
+    {
+        DB::statement('CREATE TABLE bot_third_game_orders (id INTEGER PRIMARY KEY,order_key TEXT UNIQUE)');
+        foreach (range(1, 20) as $id) {
+            $key = str_pad(dechex($id), 64, '0', STR_PAD_LEFT);
+            DB::table('third_game_orders')->insert(['id' => $id, 'order_key' => $key]);
+            $this->bet(1, '10', '-10', '2026-10-26 11:00:00', ['source' => 'third_game_orders', 'source_id' => $key]);
+        }
+        $this->bet(1, '999', '-999', '2026-10-26 11:00:00', ['source' => 'third_game_orders', 'source_id' => str_repeat('f', 64)]);
+        DB::enableQueryLog(); DB::flushQueryLog();
+        try {
+            $result = (new PromotionFacts())->betting([1], '2026-10-01 00:00:00', null, ['third_game_orders' => 10]);
+            $this->assertLessThanOrEqual(6, count(DB::getQueryLog()));
+        } finally { DB::disableQueryLog(); DB::flushQueryLog(); }
+        $this->assertSame('100.0000000000', $result['effective']['2026-10-26']);
+        $this->assertSame('-100.0000000000', $result['profit']);
+    }
+
     public function test_images_are_only_configurable_for_first_deposit_not_fixed_loss_or_wheel_campaigns(): void
     {
         $admin = new PromotionAdminService();

+ 128 - 0
tests/Integration/ReportingTest.php

@@ -426,6 +426,134 @@ class ReportingTest extends TestCase
         $this->assertEquals($before, DB::table('balance_logs')->get()->toArray());
     }
 
+    public function test_lottery_parent_is_published_once_across_child_chunks_and_parent_scan(): void
+    {
+        $this->f1LotteryWithoutCompletionContext();
+        for ($id = 3; $id <= 230; ++$id) DB::table('lhc_order_item')->insert([
+            'id' => $id, 'member_id' => '200', 'ordernum' => 'F1-live', 'lottery_status' => 1, 'win_amount' => '0', 'updated_at' => now()->timestamp,
+        ]);
+        DB::table('lhc_order')->where('id', 1)->update(['total_amount' => '11500']);
+        $result = $this->sync->sync();
+        $this->assertSame(0, $result['errors']);
+        $this->assertSame(1, $result['published']);
+        $this->assertSame(230, DB::table('reporting_orders')->count());
+        Carbon::setTestNow(now('Asia/Shanghai')->addMinutes(10));
+        DB::table('lhc_order_item')->where('id', 230)->update(['win_amount' => '68', 'lottery_status' => 2, 'updated_at' => now()->timestamp]);
+        $this->assertSame(1, $this->sync->sync()['published']);
+        $this->assertSame('68', (string) DB::table('reporting_orders')->where('source_id', '230')->value('payout_amount'));
+    }
+
+    public function test_source_cursor_advances_past_errors_and_failed_rows_retry_without_replaying_good_history(): void
+    {
+        foreach (range(1, 4) as $id) DB::table('bets')->insert(['id' => $id, 'member_id' => $id === 4 ? 'missing' : '200',
+            'amount' => '100', 'profit' => '60', 'status' => 2, 'created_at' => '2026-10-01 09:00:00', 'updated_at' => '2026-10-01 10:00:00']);
+        $this->assertSame(1, $this->sync->sync()['errors']);
+        $cursor = json_decode(DB::table('reporting_sync_state')->where('source', 'bets')->value('state'), true);
+        $this->assertSame(4, $cursor['last_id']);
+        Carbon::setTestNow(now('Asia/Shanghai')->addHour());
+        DB::table('bets')->insert(['id' => 5, 'member_id' => '200', 'amount' => '100', 'profit' => '60', 'status' => 2,
+            'created_at' => '2026-10-01 09:00:00', 'updated_at' => '2026-10-01 10:00:00']);
+        $result = $this->sync->sync();
+        $this->assertSame(2, $result['scanned']);
+        $this->assertSame(1, $result['errors']);
+        $this->assertSame('errors', $result['sources']['bets']);
+        $this->assertSame(4, DB::table('reporting_orders')->count());
+        DB::table('bets')->where('id', 4)->update(['member_id' => '200']);
+        Carbon::setTestNow(now('Asia/Shanghai')->addMinute());
+        $result = $this->sync->sync();
+        $this->assertSame(1, $result['scanned']);
+        $this->assertSame(0, $result['errors']);
+        $this->assertSame(5, DB::table('reporting_orders')->count());
+        $this->assertSame(0, DB::table('reporting_errors')->count());
+    }
+
+    public function test_missing_timestamps_use_bounded_rotating_checks_and_still_apply_old_corrections(): void
+    {
+        DB::statement('CREATE TABLE bot_jisu_game_order (id INTEGER PRIMARY KEY,member_id TEXT,amount TEXT,win_amount TEXT,status INTEGER,created_at TEXT)');
+        foreach (range(1, 405) as $id) {
+            DB::table('bets')->insert(['id' => $id, 'member_id' => '200', 'amount' => '100', 'profit' => '60', 'status' => 2,
+                'created_at' => '2026-10-01 09:00:00', 'updated_at' => null]);
+            DB::table('jisu_game_order')->insert(['id' => $id, 'member_id' => '200', 'amount' => '100', 'win_amount' => '60', 'status' => 2, 'created_at' => '2026-10-01 09:00:00']);
+        }
+        $this->assertSame(810, $this->sync->sync()['scanned']);
+        DB::table('bets')->where('id', 405)->update(['profit' => '25']);
+        DB::table('jisu_game_order')->where('id', 405)->update(['win_amount' => '35']);
+        foreach ([400, 400, 10, 400] as $expected) {
+            Carbon::setTestNow(now('Asia/Shanghai')->addMinute());
+            $result = $this->sync->sync();
+            $this->assertSame($expected, $result['scanned']);
+            $this->assertSame(0, $result['errors']);
+        }
+        $this->assertSame('25', (string) DB::table('reporting_orders')->where('source', 'bets')->where('source_id', '405')->value('payout_amount'));
+        $this->assertSame('35', (string) DB::table('reporting_orders')->where('source', 'jisu_game_order')->where('source_id', '405')->value('payout_amount'));
+    }
+
+    public function test_missing_source_errors_do_not_starve_later_retries_and_keep_run_incomplete(): void
+    {
+        DB::table('bets')->insert(['id' => 205, 'member_id' => '200', 'amount' => '100', 'profit' => '60', 'status' => 2,
+            'created_at' => '2026-10-01 09:00:00', 'updated_at' => '2026-10-01 10:00:00']);
+        DB::table('reporting_sync_state')->insert(['source' => 'bets', 'state' => json_encode(['last_id' => 205, 'checked_at' => now()->toDateTimeString()]), 'updated_at' => now()]);
+        foreach (range(1, 205) as $id) DB::table('reporting_errors')->insert(['source' => 'bets', 'source_id' => (string) $id,
+            'reason' => 'previous error', 'created_at' => '2026-10-01 10:00:00', 'updated_at' => '2026-10-01 10:00:00']);
+        $first = $this->sync->sync();
+        $this->assertSame(0, $first['published']);
+        $this->assertSame(205, $first['errors']);
+        Carbon::setTestNow(now('Asia/Shanghai')->addMinute());
+        $second = $this->sync->sync();
+        $this->assertSame(1, $second['published']);
+        $this->assertSame(204, $second['errors']);
+        $this->assertSame('errors', $second['sources']['bets']);
+        $state = json_decode(DB::table('reporting_sync_state')->where('source', '__run__')->value('state'), true);
+        $this->assertFalse($state['complete']);
+        $this->assertSame(1, DB::table('reporting_orders')->count());
+    }
+
+    public function test_unchanged_projections_keep_timestamps_and_skip_updates(): void
+    {
+        $this->bet(); $this->deposit();
+        $beforeOrders = DB::table('reporting_orders')->get()->toArray();
+        $beforeEvents = DB::table('reporting_events')->get()->toArray();
+        Carbon::setTestNow(now('Asia/Shanghai')->addMinutes(10));
+        DB::table('bets')->where('id', 1)->update(['amount' => '100.0000000000']);
+        DB::enableQueryLog(); DB::flushQueryLog();
+        try {
+            $this->assertSame(0, $this->sync->sync(true)['errors']);
+            $updates = array_filter(DB::getQueryLog(), fn ($q) => preg_match('/^update "bot_reporting_(orders|events)"/', $q['query']));
+            $this->assertSame([], array_values($updates));
+        } finally { DB::disableQueryLog(); DB::flushQueryLog(); }
+        $this->assertEquals($beforeOrders, DB::table('reporting_orders')->get()->toArray());
+        $this->assertEquals($beforeEvents, DB::table('reporting_events')->get()->toArray());
+    }
+
+    public function test_sync_index_migration_is_repeatable_and_preserves_existing_indexes_and_data(): void
+    {
+        $this->bet();
+        Schema::table('bets', fn (Blueprint $t) => $t->index(['updated_at', 'id'], 'existing_bets_update_index'));
+        $migration = require dirname(__DIR__, 2) . '/database/migrations/2026_11_03_130000_add_sync_performance_indexes.php';
+        $migration->up(); $migration->up();
+        $plan = DB::select('EXPLAIN QUERY PLAN DELETE FROM bot_reporting_orders WHERE parent_source = ? AND parent_id = ?', ['lhc_order', '1']);
+        $this->assertStringContainsString('reporting_orders_parent_lookup', json_encode($plan));
+        $this->assertTrue(Schema::hasIndex('reporting_errors', 'reporting_errors_retry_lookup'));
+        $migration->down();
+        $this->assertTrue(Schema::hasIndex('bets', 'existing_bets_update_index'));
+        $this->assertFalse(Schema::hasIndex('reporting_orders', 'reporting_orders_parent_lookup'));
+        $this->assertSame(1, DB::table('reporting_orders')->count());
+    }
+
+    public function test_command_lock_skips_overlap_and_releases_after_failure(): void
+    {
+        $lock = new \App\Support\SyncCommandLock();
+        $name = 'test:' . bin2hex(random_bytes(8));
+        $this->assertSame(7, $lock->run($name, function () use ($lock, $name) {
+            $this->assertNull($lock->run($name, function () { $this->fail('Overlapping synchronization ran'); }));
+            $this->assertSame(8, $lock->run($name . ':other', fn () => 8));
+            return 7;
+        }));
+        try { $lock->run($name, fn () => throw new \RuntimeException('expected failure')); }
+        catch (\RuntimeException $e) { $this->assertSame('expected failure', $e->getMessage()); }
+        $this->assertSame(9, $lock->run($name, fn () => 9));
+    }
+
     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,

+ 156 - 0
tests/Integration/SyncPerformanceMysqlTest.php

@@ -0,0 +1,156 @@
+<?php
+
+namespace Tests\Integration;
+
+use App\Services\PromotionFacts;
+use App\Services\Reporting\ReportSync;
+use App\Support\SyncCommandLock;
+use Carbon\Carbon;
+use Illuminate\Config\Repository;
+use Illuminate\Database\Capsule\Manager;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Foundation\Application;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Facade;
+use Illuminate\Support\Facades\Schema;
+use PDO;
+use PHPUnit\Framework\TestCase;
+
+/** Explicit opt-in only; creates and drops its own database, never loads project .env/providers. */
+class SyncPerformanceMysqlTest extends TestCase
+{
+    private ?PDO $control = null;
+    private string $database;
+    private array $connection;
+
+    protected function setUp(): void
+    {
+        $socket = getenv('SYNC_TEST_MYSQL_SOCKET');
+        if (!$socket) $this->markTestSkipped('Set SYNC_TEST_MYSQL_SOCKET to an isolated test MySQL socket.');
+        $this->control = new PDO('mysql:unix_socket=' . $socket, getenv('SYNC_TEST_MYSQL_USER') ?: 'root', getenv('SYNC_TEST_MYSQL_PASSWORD') ?: '', [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
+        $this->database = 'bot28_sync_test_' . bin2hex(random_bytes(8));
+        $this->control->exec('CREATE DATABASE `' . $this->database . '` CHARACTER SET utf8mb4');
+        Facade::clearResolvedInstances();
+        $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);
+        $this->connection = ['driver' => 'mysql', 'unix_socket' => $socket, 'database' => $this->database,
+            'username' => getenv('SYNC_TEST_MYSQL_USER') ?: 'root', 'password' => getenv('SYNC_TEST_MYSQL_PASSWORD') ?: '',
+            'charset' => 'utf8mb4', 'collation' => 'utf8mb4_unicode_ci', 'prefix' => 'bot_'];
+        $db->addConnection($this->connection); $db->setAsGlobal(); $db->bootEloquent();
+        $app->instance('db', $db->getDatabaseManager()); $app->bind('db.schema', fn () => $db->schema());
+        Carbon::setTestNow(Carbon::parse('2026-11-02 16:00:00', 'Asia/Shanghai'));
+        Schema::create('users', function (Blueprint $t) {
+            $t->id(); $t->string('member_id', 64); $t->integer('from'); $t->unsignedBigInteger('agent_id')->nullable();
+        });
+        DB::table('users')->insert(['id' => 1, 'member_id' => 'm1', 'from' => 1]);
+        Schema::create('balance_logs', function (Blueprint $t) {
+            $t->id(); $t->string('member_id', 64); $t->integer('type'); $t->string('change_type', 50);
+            $t->decimal('amount', 24, 10); $t->timestamps();
+        });
+        Schema::create('share_bet_facts', function (Blueprint $t) {
+            $t->id(); $t->unsignedBigInteger('user_id'); $t->string('source', 32); $t->string('source_id', 64);
+            $t->unique(['source', 'source_id']); $t->dateTime('bet_at'); $t->decimal('bet_amount', 24, 10); $t->decimal('net_profit', 24, 10);
+            $t->string('currency', 8); $t->boolean('settled'); $t->boolean('voided');
+        });
+        foreach (['bets', 'jisu_game_order'] as $source) Schema::create($source, function (Blueprint $t) use ($source) {
+            $t->id(); $t->string('member_id', 64); $t->decimal('amount', 24, 10);
+            $t->decimal($source === 'bets' ? 'profit' : 'win_amount', 24, 10)->nullable(); $t->integer('status'); $t->timestamps();
+        });
+    }
+
+    protected function tearDown(): void
+    {
+        if ($this->control) {
+            DB::disconnect(); DB::disconnect('contender');
+            $this->control->exec('DROP DATABASE `' . $this->database . '`');
+            $this->control = null;
+            Facade::clearResolvedInstances(); Carbon::setTestNow();
+        }
+        parent::tearDown();
+    }
+
+    public function test_mysql_daily_aggregates_match_bcmath_including_legacy_and_large_decimal_amounts(): void
+    {
+        foreach ([['9000000000000.0000000001', '-9000000000000.0000000001'], ['10.1234567891', '2.1234567891'],
+            ['50', '100'], ['100', '-25.0000000001'], ['99', '0']] as $index => [$bet, $profit]) {
+            DB::table('share_bet_facts')->insert(['user_id' => 1, 'source' => 'sport_order', 'source_id' => (string) ($index + 1),
+                'bet_at' => '2026-11-02 10:00:00', 'bet_amount' => $bet, 'net_profit' => $profit, 'currency' => 'CNY', 'settled' => 1, 'voided' => 0]);
+        }
+        foreach (['bets' => 'profit', 'jisu_game_order' => 'win_amount'] as $source => $payout) {
+            DB::table($source)->insert(['member_id' => 'm1', 'amount' => '50.0000000001', $payout => '32.1234567891', 'status' => 2, 'created_at' => '2026-11-02 10:00:00']);
+        }
+        $facts = new PromotionFacts();
+        $aggregate = $facts->betting([1], '2026-11-01 00:00:00');
+        $bcmath = $facts->betting([1], '2026-11-01 00:00:00', null, ['share_bet_facts' => 0]);
+        $this->assertSame($bcmath, $aggregate);
+        foreach (['9000000000000.0000000001', '0.1234567891'] as $amount) DB::table('balance_logs')->insert([
+            'member_id' => 'm1', 'type' => 1, 'change_type' => '充值', 'amount' => $amount, 'created_at' => '2026-11-02 10:00:00',
+        ]);
+        $this->assertSame(['2026-11-02' => '9000000000000.1234567892'], $facts->deposits(['m1'], '2026-11-01 00:00:00'));
+        DB::table('share_bet_facts')->where('id', 1)->update(['bet_amount' => '-1']);
+        $this->expectException(\InvalidArgumentException::class);
+        $facts->betting([1], '2026-11-01 00:00:00');
+    }
+
+    public function test_mysql_indexes_projection_idempotence_and_recent_corrections(): void
+    {
+        (require dirname(__DIR__, 2) . '/database/migrations/2026_10_26_140000_create_reporting_tables.php')->up();
+        $migration = require dirname(__DIR__, 2) . '/database/migrations/2026_11_03_130000_add_sync_performance_indexes.php';
+        $migration->up(); $migration->up();
+        $this->assertTrue(Schema::hasIndex('reporting_orders', 'reporting_orders_parent_lookup'));
+        DB::table('bets')->insert(['id' => 1, 'member_id' => 'm1', 'amount' => '100.0000000001', 'profit' => '80', 'status' => 1,
+            'created_at' => '2026-11-02 10:00:00', 'updated_at' => '2026-11-02 10:00:00']);
+        $sync = new ReportSync(); $this->assertSame(0, $sync->sync()['errors']);
+        $before = DB::table('reporting_orders')->first();
+        Carbon::setTestNow(now('Asia/Shanghai')->addMinutes(10));
+        $this->assertSame(0, $sync->sync(true)['errors']);
+        $this->assertEquals($before, DB::table('reporting_orders')->first());
+        DB::table('bets')->where('id', 1)->update(['status' => 2, 'updated_at' => now('Asia/Shanghai')->toDateTimeString()]);
+        $this->assertSame(0, $sync->sync()['errors']);
+        $this->assertSame(1, (int) DB::table('reporting_orders')->value('settled'));
+        $this->assertSame('80.0000000000', DB::table('reporting_orders')->value('payout_amount'));
+        $migration->down();
+        $this->assertSame(1, DB::table('reporting_orders')->count());
+    }
+
+    public function test_mysql_third_party_cursor_filters_original_ids_in_the_fact_query(): void
+    {
+        Schema::create('third_game_orders', function (Blueprint $t) { $t->id(); $t->string('order_key', 64)->unique(); });
+        foreach ([1, 2] as $id) {
+            $key = str_repeat((string) $id, 64);
+            DB::table('third_game_orders')->insert(['id' => $id, 'order_key' => $key]);
+            DB::table('share_bet_facts')->insert(['user_id' => 1, 'source' => 'third_game_orders', 'source_id' => $key,
+                'bet_at' => '2026-11-02 10:00:00', 'bet_amount' => '10', 'net_profit' => '-10', 'currency' => 'CNY', 'settled' => 1, 'voided' => 0]);
+        }
+        $result = (new PromotionFacts())->betting([1], '2026-11-01 00:00:00', null, ['third_game_orders' => 1]);
+        $this->assertSame('10.0000000000', $result['effective']['2026-11-02']);
+    }
+
+    public function test_mysql_aggregation_rejects_missing_payouts_instead_of_ignoring_bad_rows(): void
+    {
+        foreach (['5', null] as $payout) DB::table('bets')->insert(['member_id' => 'm1', 'amount' => '10', 'profit' => $payout,
+            'status' => 2, 'created_at' => '2026-11-02 10:00:00']);
+        $this->expectException(\InvalidArgumentException::class);
+        (new PromotionFacts())->betting([1], '2026-11-01 00:00:00');
+    }
+
+    public function test_mysql_command_lock_excludes_other_connections_and_releases_on_exception(): void
+    {
+        app('config')->set('database.connections.contender', $this->connection);
+        $contender = DB::connection('contender');
+        $name = 'bot-sync:' . substr(hash('sha256', $this->database . ':bot_:reporting:sync'), 0, 48);
+        $lock = new SyncCommandLock();
+        try {
+            $lock->run('reporting:sync', function () use ($contender, $name) {
+                $this->assertSame(0, (int) $contender->selectOne('SELECT GET_LOCK(?, 0) AS acquired', [$name])->acquired);
+                throw new \RuntimeException('expected failure');
+            });
+            $this->fail('Expected callback failure');
+        } catch (\RuntimeException $e) { $this->assertSame('expected failure', $e->getMessage()); }
+        $this->assertSame(1, (int) $contender->selectOne('SELECT GET_LOCK(?, 0) AS acquired', [$name])->acquired);
+        $contender->selectOne('SELECT RELEASE_LOCK(?)', [$name]);
+        $this->assertSame(0, $lock->run('reporting:sync', fn () => 0));
+    }
+}