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

+ 1 - 1
app/Console/Kernel.php

@@ -30,7 +30,6 @@ class Kernel extends ConsoleKernel
         $schedule->command('messages:dispatch')->everyMinute()->withoutOverlapping(10);
         $schedule->command('reporting:sync')->everyMinute()->withoutOverlapping(30);
         $schedule->command('promotions:sync')->everyMinute()->withoutOverlapping(30);
-        $schedule->command('wagering:sync')->everyMinute()->withoutOverlapping(30);
         $schedule->command('third-game:sync-orders')
             ->everyMinute()
             ->withoutOverlapping(5);
@@ -42,6 +41,7 @@ class Kernel extends ConsoleKernel
             ->withoutOverlapping(180);
         $schedule->command('share:sync-facts --write')->everyMinute()->withoutOverlapping(30);
         $schedule->command('rewards:sync-bases')->everyMinute()->withoutOverlapping(30);
+        $schedule->command('wagering:sync')->everyMinute()->withoutOverlapping(30);
         $schedule->command('share:settle --reconcile')->everyFiveMinutes()->withoutOverlapping(120);
         $schedule->command('share:settle')->timezone('Asia/Shanghai')->dailyAt('00:10')->withoutOverlapping(180);
         $schedule->command('payment:pay-timeout')->everyMinute()->withoutOverlapping(10);

+ 26 - 6
app/Services/Sharing/BetFactSynchronizer.php

@@ -45,10 +45,20 @@ class BetFactSynchronizer
             $highestIdAtStart = $cursorEnabled ? (int) DB::table($table)->max('id') : 0;
             $errorCount = count($result['errors']);
             $query = DB::table($table);
-            if ($table === 'lhc_order' && ($legacyMaxId = (int) config('sharing.lottery_legacy_max_id', 0)) > 0) {
-                $query->where('id', '>', $legacyMaxId);
+            $legacyMaxId = $table === 'lhc_order' ? (int) config('sharing.lottery_legacy_max_id', 0) : 0;
+            $hasLotteryItems = $table === 'lhc_order' && Schema::hasTable('lhc_order_item');
+            $legacyBackfillMaxId = $hasLotteryItems ? $legacyMaxId : 0;
+            if ($legacyMaxId > 0) {
+                $query->where(function ($query) use ($legacyMaxId, $hasLotteryItems) {
+                    $query->where('id', '>', $legacyMaxId);
+                    if ($hasLotteryItems) {
+                        $query->orWhereExists(DB::table('lhc_order_item')->selectRaw('1')
+                            ->whereColumn('lhc_order_item.ordernum', 'lhc_order.ordernum'));
+                    }
+                });
             }
             $lastRun = null;
+            $reconciledLegacyId = 0;
             $scannedIds = [];
             $process = function (int $id) use ($table, $from, $to, $write, &$result) {
                 $result['scanned']++;
@@ -66,12 +76,17 @@ class BetFactSynchronizer
                 $lastRun = DB::table('share_system_state')->where('key', 'facts_cursor:' . $table)->value('value');
                 if ($lastRun) {
                     $cursor = json_decode($lastRun, true, 512, JSON_THROW_ON_ERROR);
+                    $reconciledLegacyId = (int) ($cursor['lottery_children_checked_through'] ?? 0);
                     $cutoff = EffectiveBet::time($cursor['updated_at'])->subMinutes(5);
                     $cutoffValue = in_array($table, ['lhc_order', 'sport_order'], true)
                         ? $cutoff->timestamp : $cutoff->setTimezone('Asia/Shanghai')->format('Y-m-d H:i:s');
-                    $query->where(function ($query) use ($cursorColumn, $cutoffValue, $cursor) {
+                    $query->where(function ($query) use ($cursorColumn, $cutoffValue, $cursor, $legacyBackfillMaxId, $reconciledLegacyId) {
                         $query->where($cursorColumn, '>=', $cutoffValue)->orWhereNull($cursorColumn)
                             ->orWhere('id', '>', (int) $cursor['last_id']);
+                        // Old cursors skipped this ID range even when child bets existed. Catch it up once.
+                        if ($legacyBackfillMaxId > $reconciledLegacyId) {
+                            $query->orWhereBetween('id', [$reconciledLegacyId + 1, $legacyBackfillMaxId]);
+                        }
                     });
                 }
             }
@@ -82,12 +97,18 @@ class BetFactSynchronizer
                 }
             });
             if ($cursorEnabled && count($result['errors']) === $errorCount) {
+                $cursor = ['updated_at' => $startedAt, 'last_id' => $highestIdAtStart];
+                if ($legacyBackfillMaxId > 0 || $reconciledLegacyId > 0) {
+                    $cursor['lottery_children_checked_through'] = max($legacyBackfillMaxId, $reconciledLegacyId);
+                }
                 DB::table('share_system_state')->updateOrInsert(['key' => 'facts_cursor:' . $table],
-                    ['value' => json_encode(['updated_at' => $startedAt, 'last_id' => $highestIdAtStart]),
+                    ['value' => json_encode($cursor),
                         'updated_at' => $startedAt, 'created_at' => $startedAt]);
             }
             // Runs after the cursor is saved: a source that keeps failing here must not hold the cursor back.
-            if ($lastRun) $this->repairFinishedOrders($table, $scannedIds, $process, $result);
+            if ($lastRun && ($table !== 'lhc_order' || $hasLotteryItems)) {
+                $this->repairFinishedOrders($table, $scannedIds, $process, $result);
+            }
         }
         $result['changed_dates'] = array_values($result['changed_dates']);
         sort($result['changed_dates']);
@@ -146,7 +167,6 @@ class BetFactSynchronizer
                 if ($table === 'lhc_order') {
                     $query = DB::table('lhc_order')->where('lottery_status', '<>', 0)
                         ->whereIn('ordernum', DB::table('lhc_order_item')->whereIn('id', $sourceIds)->distinct()->pluck('ordernum')->all());
-                    if (($legacyMaxId = (int) config('sharing.lottery_legacy_max_id', 0)) > 0) $query->where('id', '>', $legacyMaxId);
                 } elseif ($table === 'sport_order') {
                     $query = DB::table('sport_order')->whereIn('id', $sourceIds)
                         ->where(fn ($q) => $q->where('settlement_status', 1)->orWhere('status', 2)->orWhere('return_status', 2));

+ 1 - 1
config/sharing.php

@@ -1,6 +1,6 @@
 <?php
 
 return [
-    // Lottery orders up to this id predate the sharing system and have no child bets; never import them.
+    // Legacy lottery orders at or below this ID can be skipped if they have no child bets.
     'lottery_legacy_max_id' => (int) env('SHARE_LOTTERY_LEGACY_MAX_ID', 2095),
 ];

+ 1 - 1
docs/后端/笔笔返与回水分类规则-部署说明.md

@@ -22,6 +22,6 @@
 ## 说明
 
 - 注单数据来自分享返佣的注单事实表 `share_bet_facts`(由 `share:sync-facts` 同步)。
-- 早于 `SHARE_LOTTERY_LEGACY_MAX_ID` 的旧 PC28 订单不在事实表中。这类未发放的旧记录仍按原来存的有效投注,用「F1彩票」(`self:lottery`)分类的规则发放。
+- `SHARE_LOTTERY_LEGACY_MAX_ID` 范围内仅跳过没有子注的旧彩票订单。有子注的低编号订单正常同步,已被旧规则跳过的订单会在首次增量同步时补扫。没有注单事实的旧笔笔返记录仍按已存的有效投注,用「F1彩票」(`self:lottery`)分类的规则发放。
 - `huishui_restriction` 已不再参与计算,仍保留在配置表中,确认后可手动删除。
 - `BetService` 不再在下注/结算时累加 `rebates`;充值/提现仍记录到 `backflow.recharge_amount / withdrawal_amount`,仅用于展示。

+ 22 - 2
tests/Integration/AutomaticRewardGrantTest.php

@@ -178,7 +178,7 @@ class AutomaticRewardGrantTest extends TestCase
 
     private function runRewards(): array { return (new AutomaticRewardGrantService())->run(); }
 
-    public function test_console_scheduler_registers_the_automatic_payout_command_every_minute_with_overlap_protection(): void
+    private function scheduledEvents(): array
     {
         $this->app['config']->set('cache', ['default' => 'array', 'stores' => ['array' => ['driver' => 'array']]]);
         $this->app->register(\Illuminate\Cache\CacheServiceProvider::class);
@@ -187,13 +187,33 @@ class AutomaticRewardGrantTest extends TestCase
         $method = new \ReflectionMethod($kernel, 'schedule');
         $method->setAccessible(true);
         $method->invoke($kernel, $schedule);
-        $events = array_values(array_filter($schedule->events(), fn ($event) => str_contains($event->command ?? '', 'rewards:auto-grant')));
+        return $schedule->events();
+    }
+
+    public function test_console_scheduler_registers_the_automatic_payout_command_every_minute_with_overlap_protection(): void
+    {
+        $events = array_values(array_filter($this->scheduledEvents(), fn ($event) => str_contains($event->command ?? '', 'rewards:auto-grant')));
         $this->assertCount(1, $events);
         $this->assertSame('* * * * *', $events[0]->expression);
         $this->assertTrue($events[0]->withoutOverlapping);
         $this->assertSame(60, $events[0]->expiresAt);
     }
 
+    public function test_console_scheduler_refreshes_reward_bases_and_wagers_after_bet_facts(): void
+    {
+        $commands = ['share:sync-facts --write', 'rewards:sync-bases', 'wagering:sync'];
+        $scheduled = [];
+        foreach ($this->scheduledEvents() as $event) {
+            foreach ($commands as $command) {
+                if (!str_ends_with($event->command ?? '', ' ' . $command)) continue;
+                $this->assertSame('* * * * *', $event->expression);
+                $this->assertTrue($event->withoutOverlapping);
+                $scheduled[] = $command;
+            }
+        }
+        $this->assertSame($commands, $scheduled);
+    }
+
     public function test_migration_defaults_to_manual_and_both_settings_routes_keep_admin_and_existing_grant_permissions(): void
     {
         $id = $this->rebate(); $this->backflow();

+ 35 - 0
tests/Integration/SharingBetFactsTest.php

@@ -3,6 +3,8 @@
 namespace Tests\Integration;
 
 use App\Console\Commands\RecalculateShareRebates;
+use App\Services\PromotionFacts;
+use App\Services\RewardBasisService;
 use App\Services\Sharing\BetFactSynchronizer;
 use Carbon\Carbon;
 use Illuminate\Config\Repository;
@@ -283,6 +285,39 @@ class SharingBetFactsTest extends TestCase
         $this->assertNotNull(DB::table('share_system_state')->where('key', 'facts_cursor:lhc_order')->value('value'));
     }
 
+    public function test_existing_cursor_recovers_low_id_racing_orders_and_their_late_settlement(): void
+    {
+        app('config')->set('sharing.lottery_legacy_max_id', 2095);
+        $this->lottery(0);
+        DB::table('lhc_order')->where('id', 10)->update(['type' => 7]);
+        DB::table('lhc_order_item')->where('ordernum', 'current-lottery')->update(['lottery_status' => 0]);
+        DB::table('share_system_state')->insert(['key' => 'facts_cursor:lhc_order',
+            'value' => json_encode(['updated_at' => '2026-10-24 11:59:00', 'last_id' => 10])]);
+
+        $sync = new BetFactSynchronizer();
+        Schema::rename('lhc_order_item', 'unavailable_lottery_items');
+        $unavailable = $sync->sync(null, null, true);
+        $this->assertSame([], $unavailable['errors']);
+        $this->assertSame(0, $unavailable['scanned']);
+        Schema::rename('unavailable_lottery_items', 'lhc_order_item');
+
+        $result = $sync->sync(null, null, true);
+        $this->assertSame([], $result['errors']);
+        $this->assertSame(2, $result['created']);
+        $this->assertSame(0, $sync->sync(null, null, true)['scanned']);
+
+        DB::table('lhc_order')->where('id', 10)->update(['lottery_status' => 2]);
+        DB::table('lhc_order_item')->where('id', 101)->update(['lottery_status' => 2]);
+        DB::table('lhc_order_item')->where('id', 102)->update(['lottery_status' => 1]);
+        $result = $sync->sync(null, null, true);
+        $this->assertSame([], $result['errors']);
+        $this->assertSame(2, $result['updated']);
+        $basis = RewardBasisService::rebateBasis((object) ['id' => 1], '2026-10-23');
+        $this->assertSame('150.00', $basis['self:lottery']['effective_amount']);
+        $wager = (new PromotionFacts())->betting([1], '2026-10-24 00:00:00');
+        $this->assertSame('150.0000000000', $wager['effective']['2026-10-24']);
+    }
+
     public function test_second_child_failure_rolls_back_all_facts_dirty_dates_and_source_cursor(): void
     {
         $this->lottery();