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

+ 12 - 2
tests/Regression/jisu_lottery_diagnostics.php

@@ -72,8 +72,10 @@ namespace {
     contains($text, 'last_inserted: id=2 issue=34038100 status=1');
     contains($text, '最新插入期号小于最大期号');
     contains($text, '没有未开奖期次');
-    contains($text, '固定期号 34038100');
-    contains($text, 'next_open_time 是原始时间戳');
+    contains($text, '[RECOVERY PLAN] issue=34040001');
+    contains($text, 'mode=resume_future');
+    check(str_contains($text, '当前初始化使用固定期号'), false, 'Diagnostics do not report a bug that has been fixed');
+    check(str_contains($text, '当前初始化使用 strtotime'), false, 'Diagnostics use the corrected raw-timestamp plan');
     contains($text, '此类型没有任何期次数据');
     contains($text, '检查结束');
     check($GLOBALS['jisu_diagnostic_http_calls'], [], 'Database diagnostics never contact upstream');
@@ -145,6 +147,14 @@ namespace {
     check($command->run($input, $output), 1, 'Network flag alone cannot start the mutating worker');
     contains($output->fetch(), '请同时传入 --diagnose');
 
+    $db->execute('DROP TRIGGER forbid_bot_jisu_lottery_UPDATE');
+    \think\facade\Db::table('bot_jisu_lottery')->where('type', 2)->update(['next_open_time' => null]);
+    $blockedBefore = snapshot();
+    $output = new \think\console\Output('buffer');
+    check($service->run($output), 1, 'Invalid archived schedule blocks the recovery preview with a nonzero result');
+    contains($output->fetch(), '[RECOVERY BLOCKED]');
+    check(snapshot(), $blockedBefore, 'A blocked recovery preview never edits the invalid data');
+
     $db->execute('DROP TABLE bot_jisu_lottery');
     $output = new \think\console\Output('buffer');
     check($service->run($output), 1, 'Database errors are reported as failures');

+ 153 - 0
tests/Regression/jisu_lottery_initialization.php

@@ -0,0 +1,153 @@
+<?php
+
+namespace app\common\service {
+    // Initialization must never enter either side-effecting dependency.
+    final class WsClientService { public function __construct() { throw new \LogicException('Initialization attempted a network connection'); } }
+    final class JisuSettlementService { public function __construct() { throw new \LogicException('Initialization attempted settlement'); } }
+}
+
+namespace {
+    error_reporting(E_ALL & ~E_DEPRECATED);
+    date_default_timezone_set('Asia/Shanghai');
+    $repo = getenv('JISU_SITE_REPO') ?: dirname(__DIR__, 3) . '/melbet_sport-api';
+    $stage = getenv('JISU_SITE_STAGE') ?: $repo;
+    $loader = require $repo . '/vendor/autoload.php';
+    $loader->setPsr4('app\\', [$stage . '/app/', $repo . '/app/']);
+    $app = new \think\App($repo . '/');
+    \think\Container::setInstance($app);
+    $db = new \think\DbManager();
+    $db->setConfig(['default' => 'isolated', 'connections' => ['isolated' => [
+        'type' => 'sqlite', 'database' => ':memory:', 'prefix' => '', 'fields_strict' => true, 'debug' => false,
+    ]], 'auto_timestamp' => false]);
+    $app->instance(\think\DbManager::class, $db);
+    $app->instance('log', new \Psr\Log\NullLogger());
+    $checks = 0;
+    function check($actual, $expected, string $message): void {
+        global $checks; ++$checks;
+        if ($actual !== $expected) throw new \RuntimeException($message . ': actual=' . json_encode($actual) . ' expected=' . json_encode($expected));
+    }
+    function rejects(callable $action, string $text): void {
+        global $checks; ++$checks;
+        try { $action(); } catch (\Throwable $error) {
+            if (!str_contains($error->getMessage(), $text)) throw $error;
+            return;
+        }
+        throw new \RuntimeException('Expected rejection: ' . $text);
+    }
+    function period(array $extra = []): array {
+        return $extra + ['type' => 1, 'issue' => '34122211', 'status' => 3,
+            'open_time' => 1787567760, 'next_open_time' => 1787567835, 'next_issue' => 34122212,
+            'open_code' => '1,2,3,4,5,6,7,8,9,10', 'advance_code' => 'private-existing-value', 'is_settlement' => 1,
+            'created_at' => '2026-08-24 18:34:46', 'updated_at' => '2026-08-24 18:34:46'];
+    }
+    function resetPeriods(array $rows = []): void {
+        global $db;
+        $db->execute('DROP TABLE IF EXISTS bot_jisu_lottery');
+        $db->execute('CREATE TABLE bot_jisu_lottery (id INTEGER PRIMARY KEY AUTOINCREMENT,type INTEGER,issue TEXT,status INTEGER,open_time INTEGER,next_open_time INTEGER,next_issue INTEGER,open_code TEXT,advance_code TEXT,is_settlement INTEGER DEFAULT 0,created_at TEXT,updated_at TEXT,UNIQUE(issue,type))');
+        foreach ($rows as $row) \think\facade\Db::table('bot_jisu_lottery')->insert($row);
+        $db->execute("CREATE TRIGGER protect_closed_update BEFORE UPDATE ON bot_jisu_lottery WHEN OLD.status = 3 BEGIN SELECT RAISE(ABORT,'closed history changed'); END");
+        $db->execute("CREATE TRIGGER protect_period_delete BEFORE DELETE ON bot_jisu_lottery BEGIN SELECT RAISE(ABORT,'history deleted'); END");
+    }
+    function snapshot(): array { return \think\facade\Db::table('bot_jisu_lottery')->order('id')->select()->toArray(); }
+    function finances(): array {
+        $result = [];
+        foreach (['bot_wallets', 'bot_lhc_order', 'bot_balance_logs'] as $table) $result[$table] = \think\facade\Db::table($table)->order('id')->select()->toArray();
+        return $result;
+    }
+    foreach (['bot_wallets', 'bot_lhc_order', 'bot_balance_logs'] as $table) {
+        $db->execute('CREATE TABLE ' . $table . ' (id INTEGER PRIMARY KEY,amount TEXT)');
+        \think\facade\Db::table($table)->insert(['id' => 1, 'amount' => '123.45']);
+        foreach (['INSERT', 'UPDATE', 'DELETE'] as $action) $db->execute('CREATE TRIGGER forbid_' . $table . '_' . $action . ' BEFORE ' . $action . ' ON ' . $table . " BEGIN SELECT RAISE(ABORT,'initialization changed finances'); END");
+    }
+    $financialBefore = finances();
+    $now = (new \DateTimeImmutable('2026-10-25 15:18:55'))->getTimestamp();
+    $service = \app\common\service\JisuLotteryPeriodService::class;
+    check(\app\api\command\JisuLottery::COUN_TDOWN, $service::INTERVAL, 'Initialization interval matches the ongoing draw loop');
+    check(\app\api\command\JisuLottery::FENG_PAN, $service::CLOSE_BEFORE, 'Initialization closing window matches the ongoing draw loop');
+    $rows = [];
+    foreach ([1, 2, 3, 6] as $type) $rows[] = period(['type' => $type, 'is_settlement' => $type === 6 ? 0 : 1]);
+    foreach ([4, 5, 7] as $type) $rows[] = period(['type' => $type, 'issue' => '20261025184', 'status' => 1, 'open_time' => $now + 300]);
+    resetPeriods($rows);
+    $before = snapshot();
+    foreach ([1, 2, 3, 6] as $type) {
+        $result = $service::ensure($type, $now);
+        check($result['created'], true, 'One next period created');
+        check($result['issue'], 34122212, 'Continue the observed issue instead of using the seed');
+        check($result['open_time'], $now + 75, 'Long outage resumes a full future period');
+        check($result['next_open_time'], $now + 150, 'Next period is 75 seconds later');
+        check($result['next_issue'], 34122213, 'Next issue increments');
+        check($result['rescheduled'], true, 'Expired schedule recovery is explicit');
+        check($result['status'], 1, 'Future period opens normally');
+        $stored = \think\facade\Db::table('bot_jisu_lottery')->where('type', $type)->where('issue', '34122212')->find();
+        check($stored['open_time'], $now + 75, 'Timestamp saved as an integer');
+        check($stored['open_code'], null, 'No draw result is invented');
+        check($stored['advance_code'], null, 'Previous advance code is never copied');
+        check($stored['is_settlement'], 0, 'New period is not settled');
+    }
+    $after = snapshot();
+    check(count($after), count($before) + 4, 'Only four future local periods added');
+    check(array_slice($after, 0, count($before)), $before, 'All closed history and SG rows stay byte-for-byte unchanged');
+    $command = new \app\api\command\JisuLottery();
+    $output = new \think\console\Output('buffer');
+    (new \ReflectionProperty(\think\console\Command::class, 'output'))->setValue($command, $output);
+    $command->init(); $command->init();
+    check(snapshot(), $after, 'Repeated real command initialization is idempotent');
+    check(str_contains($output->fetch(), '保留已有待开奖期次'), true, 'Initialization reports that existing periods are retained');
+    check(finances(), $financialBefore, 'Recovery does not change wallets, orders or ledger');
+
+    foreach ([$now + 150, (string)($now + 150), date('Y-m-d H:i:s', $now + 150)] as $future) {
+        $plan = $service::plan(period(['next_open_time' => $future]), $now);
+        check($plan['open_time'], $now + 150, 'Integer, numeric-string and datetime future schedules are preserved');
+        check($plan['rescheduled'], false, 'Valid future schedule is not shifted');
+        check($plan['issue'], 34122212, 'Future plan still increments issue');
+    }
+    foreach ([$now - 1, $now] as $expired) {
+        $plan = $service::plan(period(['next_open_time' => $expired]), $now);
+        check($plan['open_time'], $now + 75, 'Expired and exact-now schedules move to the future');
+        check($plan['rescheduled'], true, 'Expired schedule is marked as resumed');
+    }
+    check($service::plan(period(['next_open_time' => $now + 15]), $now)['status'], 2, 'A retained future schedule inside the closing window starts closed');
+    check($service::plan(period(['next_open_time' => $now + 16]), $now)['status'], 1, 'Future schedule outside closing window remains open');
+    check($service::plan(null, $now)['issue'], 34038100, 'Seed is used only for a truly empty game');
+    check($service::plan(null, $now)['open_time'], $now + 75, 'Empty game starts a full future period');
+
+    foreach ([0, 1, 2] as $status) {
+        resetPeriods([period(['status' => $status, 'open_time' => $now - 100])]);
+        $pendingBefore = snapshot();
+        check($service::ensure(1, $now)['created'], false, 'Existing pending status is retained: ' . $status);
+        check(snapshot(), $pendingBefore, 'Pending rows are never moved or replaced');
+    }
+    resetPeriods([period(), period(['issue' => '34038100', 'status' => 1])]);
+    $pendingBefore = snapshot();
+    check($service::ensure(1, $now)['created'], false, 'Lower-number pending row also blocks creating another active period');
+    check(snapshot(), $pendingBefore, 'Out-of-order pending rows remain untouched for audit');
+    resetPeriods();
+    $fresh = $service::ensure(1, $now);
+    check($fresh['issue'], 34038100, 'Fresh database keeps original first issue');
+    check($service::ensure(1, $now)['created'], false, 'Fresh initialization is also idempotent');
+
+    foreach ([null, '', 'invalid-date', [], -1, 0] as $invalid) {
+        resetPeriods([period(['next_open_time' => is_array($invalid) ? null : $invalid])]);
+        $invalidBefore = snapshot();
+        rejects(static fn() => $service::plan(period(['next_open_time' => $invalid]), $now), 'next_open_time');
+        rejects(static fn() => $service::ensure(1, $now), 'next_open_time');
+        check(snapshot(), $invalidBefore, 'Invalid timestamp never creates a guessed period');
+    }
+    foreach (['', 'unknown', '0', '-1', str_repeat('9', 50)] as $invalid) {
+        resetPeriods([period(['issue' => $invalid])]);
+        $invalidBefore = snapshot();
+        rejects(static fn() => $service::ensure(1, $now), '期号无效');
+        check(snapshot(), $invalidBefore, 'Invalid issue never falls back to the seed');
+    }
+    foreach ([0, 1, 2, null] as $status) rejects(static fn() => $service::plan(period(['status' => $status]), $now), '尚未确认开奖');
+    foreach ([0, 4, 5, 7, 8] as $type) rejects(static fn() => $service::ensure($type, $now), '仅支持');
+    resetPeriods([period()]);
+    $failedBefore = snapshot();
+    $db->execute("CREATE TRIGGER injected_insert_failure BEFORE INSERT ON bot_jisu_lottery BEGIN SELECT RAISE(ABORT,'injected period failure'); END");
+    rejects(static fn() => $service::ensure(1, $now), 'injected period failure');
+    check(snapshot(), $failedBefore, 'Failed insertion rolls back without altering history');
+    $db->execute('DROP TRIGGER injected_insert_failure');
+    check($service::ensure(1, $now)['created'], true, 'Initialization can retry after a rolled-back failure');
+    check(finances(), $financialBefore, 'All validation, retry and failure cases preserve finances');
+    echo 'PASS ' . $checks . " jisu initialization assertions (isolated SQLite, no settlement or network)\n";
+}