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