jisu_lottery_initialization.php 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153
  1. <?php
  2. namespace app\common\service {
  3. // Initialization must never enter either side-effecting dependency.
  4. final class WsClientService { public function __construct() { throw new \LogicException('Initialization attempted a network connection'); } }
  5. final class JisuSettlementService { public function __construct() { throw new \LogicException('Initialization attempted settlement'); } }
  6. }
  7. namespace {
  8. error_reporting(E_ALL & ~E_DEPRECATED);
  9. date_default_timezone_set('Asia/Shanghai');
  10. $repo = getenv('JISU_SITE_REPO') ?: dirname(__DIR__, 3) . '/melbet_sport-api';
  11. $stage = getenv('JISU_SITE_STAGE') ?: $repo;
  12. $loader = require $repo . '/vendor/autoload.php';
  13. $loader->setPsr4('app\\', [$stage . '/app/', $repo . '/app/']);
  14. $app = new \think\App($repo . '/');
  15. \think\Container::setInstance($app);
  16. $db = new \think\DbManager();
  17. $db->setConfig(['default' => 'isolated', 'connections' => ['isolated' => [
  18. 'type' => 'sqlite', 'database' => ':memory:', 'prefix' => '', 'fields_strict' => true, 'debug' => false,
  19. ]], 'auto_timestamp' => false]);
  20. $app->instance(\think\DbManager::class, $db);
  21. $app->instance('log', new \Psr\Log\NullLogger());
  22. $checks = 0;
  23. function check($actual, $expected, string $message): void {
  24. global $checks; ++$checks;
  25. if ($actual !== $expected) throw new \RuntimeException($message . ': actual=' . json_encode($actual) . ' expected=' . json_encode($expected));
  26. }
  27. function rejects(callable $action, string $text): void {
  28. global $checks; ++$checks;
  29. try { $action(); } catch (\Throwable $error) {
  30. if (!str_contains($error->getMessage(), $text)) throw $error;
  31. return;
  32. }
  33. throw new \RuntimeException('Expected rejection: ' . $text);
  34. }
  35. function period(array $extra = []): array {
  36. return $extra + ['type' => 1, 'issue' => '34122211', 'status' => 3,
  37. 'open_time' => 1787567760, 'next_open_time' => 1787567835, 'next_issue' => 34122212,
  38. 'open_code' => '1,2,3,4,5,6,7,8,9,10', 'advance_code' => 'private-existing-value', 'is_settlement' => 1,
  39. 'created_at' => '2026-08-24 18:34:46', 'updated_at' => '2026-08-24 18:34:46'];
  40. }
  41. function resetPeriods(array $rows = []): void {
  42. global $db;
  43. $db->execute('DROP TABLE IF EXISTS bot_jisu_lottery');
  44. $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))');
  45. foreach ($rows as $row) \think\facade\Db::table('bot_jisu_lottery')->insert($row);
  46. $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");
  47. $db->execute("CREATE TRIGGER protect_period_delete BEFORE DELETE ON bot_jisu_lottery BEGIN SELECT RAISE(ABORT,'history deleted'); END");
  48. }
  49. function snapshot(): array { return \think\facade\Db::table('bot_jisu_lottery')->order('id')->select()->toArray(); }
  50. function finances(): array {
  51. $result = [];
  52. foreach (['bot_wallets', 'bot_lhc_order', 'bot_balance_logs'] as $table) $result[$table] = \think\facade\Db::table($table)->order('id')->select()->toArray();
  53. return $result;
  54. }
  55. foreach (['bot_wallets', 'bot_lhc_order', 'bot_balance_logs'] as $table) {
  56. $db->execute('CREATE TABLE ' . $table . ' (id INTEGER PRIMARY KEY,amount TEXT)');
  57. \think\facade\Db::table($table)->insert(['id' => 1, 'amount' => '123.45']);
  58. 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");
  59. }
  60. $financialBefore = finances();
  61. $now = (new \DateTimeImmutable('2026-10-25 15:18:55'))->getTimestamp();
  62. $service = \app\common\service\JisuLotteryPeriodService::class;
  63. check(\app\api\command\JisuLottery::COUN_TDOWN, $service::INTERVAL, 'Initialization interval matches the ongoing draw loop');
  64. check(\app\api\command\JisuLottery::FENG_PAN, $service::CLOSE_BEFORE, 'Initialization closing window matches the ongoing draw loop');
  65. $rows = [];
  66. foreach ([1, 2, 3, 6] as $type) $rows[] = period(['type' => $type, 'is_settlement' => $type === 6 ? 0 : 1]);
  67. foreach ([4, 5, 7] as $type) $rows[] = period(['type' => $type, 'issue' => '20261025184', 'status' => 1, 'open_time' => $now + 300]);
  68. resetPeriods($rows);
  69. $before = snapshot();
  70. foreach ([1, 2, 3, 6] as $type) {
  71. $result = $service::ensure($type, $now);
  72. check($result['created'], true, 'One next period created');
  73. check($result['issue'], 34122212, 'Continue the observed issue instead of using the seed');
  74. check($result['open_time'], $now + 75, 'Long outage resumes a full future period');
  75. check($result['next_open_time'], $now + 150, 'Next period is 75 seconds later');
  76. check($result['next_issue'], 34122213, 'Next issue increments');
  77. check($result['rescheduled'], true, 'Expired schedule recovery is explicit');
  78. check($result['status'], 1, 'Future period opens normally');
  79. $stored = \think\facade\Db::table('bot_jisu_lottery')->where('type', $type)->where('issue', '34122212')->find();
  80. check($stored['open_time'], $now + 75, 'Timestamp saved as an integer');
  81. check($stored['open_code'], null, 'No draw result is invented');
  82. check($stored['advance_code'], null, 'Previous advance code is never copied');
  83. check($stored['is_settlement'], 0, 'New period is not settled');
  84. }
  85. $after = snapshot();
  86. check(count($after), count($before) + 4, 'Only four future local periods added');
  87. check(array_slice($after, 0, count($before)), $before, 'All closed history and SG rows stay byte-for-byte unchanged');
  88. $command = new \app\api\command\JisuLottery();
  89. $output = new \think\console\Output('buffer');
  90. (new \ReflectionProperty(\think\console\Command::class, 'output'))->setValue($command, $output);
  91. $command->init(); $command->init();
  92. check(snapshot(), $after, 'Repeated real command initialization is idempotent');
  93. check(str_contains($output->fetch(), '保留已有待开奖期次'), true, 'Initialization reports that existing periods are retained');
  94. check(finances(), $financialBefore, 'Recovery does not change wallets, orders or ledger');
  95. foreach ([$now + 150, (string)($now + 150), date('Y-m-d H:i:s', $now + 150)] as $future) {
  96. $plan = $service::plan(period(['next_open_time' => $future]), $now);
  97. check($plan['open_time'], $now + 150, 'Integer, numeric-string and datetime future schedules are preserved');
  98. check($plan['rescheduled'], false, 'Valid future schedule is not shifted');
  99. check($plan['issue'], 34122212, 'Future plan still increments issue');
  100. }
  101. foreach ([$now - 1, $now] as $expired) {
  102. $plan = $service::plan(period(['next_open_time' => $expired]), $now);
  103. check($plan['open_time'], $now + 75, 'Expired and exact-now schedules move to the future');
  104. check($plan['rescheduled'], true, 'Expired schedule is marked as resumed');
  105. }
  106. check($service::plan(period(['next_open_time' => $now + 15]), $now)['status'], 2, 'A retained future schedule inside the closing window starts closed');
  107. check($service::plan(period(['next_open_time' => $now + 16]), $now)['status'], 1, 'Future schedule outside closing window remains open');
  108. check($service::plan(null, $now)['issue'], 34038100, 'Seed is used only for a truly empty game');
  109. check($service::plan(null, $now)['open_time'], $now + 75, 'Empty game starts a full future period');
  110. foreach ([0, 1, 2] as $status) {
  111. resetPeriods([period(['status' => $status, 'open_time' => $now - 100])]);
  112. $pendingBefore = snapshot();
  113. check($service::ensure(1, $now)['created'], false, 'Existing pending status is retained: ' . $status);
  114. check(snapshot(), $pendingBefore, 'Pending rows are never moved or replaced');
  115. }
  116. resetPeriods([period(), period(['issue' => '34038100', 'status' => 1])]);
  117. $pendingBefore = snapshot();
  118. check($service::ensure(1, $now)['created'], false, 'Lower-number pending row also blocks creating another active period');
  119. check(snapshot(), $pendingBefore, 'Out-of-order pending rows remain untouched for audit');
  120. resetPeriods();
  121. $fresh = $service::ensure(1, $now);
  122. check($fresh['issue'], 34038100, 'Fresh database keeps original first issue');
  123. check($service::ensure(1, $now)['created'], false, 'Fresh initialization is also idempotent');
  124. foreach ([null, '', 'invalid-date', [], -1, 0] as $invalid) {
  125. resetPeriods([period(['next_open_time' => is_array($invalid) ? null : $invalid])]);
  126. $invalidBefore = snapshot();
  127. rejects(static fn() => $service::plan(period(['next_open_time' => $invalid]), $now), 'next_open_time');
  128. rejects(static fn() => $service::ensure(1, $now), 'next_open_time');
  129. check(snapshot(), $invalidBefore, 'Invalid timestamp never creates a guessed period');
  130. }
  131. foreach (['', 'unknown', '0', '-1', str_repeat('9', 50)] as $invalid) {
  132. resetPeriods([period(['issue' => $invalid])]);
  133. $invalidBefore = snapshot();
  134. rejects(static fn() => $service::ensure(1, $now), '期号无效');
  135. check(snapshot(), $invalidBefore, 'Invalid issue never falls back to the seed');
  136. }
  137. foreach ([0, 1, 2, null] as $status) rejects(static fn() => $service::plan(period(['status' => $status]), $now), '尚未确认开奖');
  138. foreach ([0, 4, 5, 7, 8] as $type) rejects(static fn() => $service::ensure($type, $now), '仅支持');
  139. resetPeriods([period()]);
  140. $failedBefore = snapshot();
  141. $db->execute("CREATE TRIGGER injected_insert_failure BEFORE INSERT ON bot_jisu_lottery BEGIN SELECT RAISE(ABORT,'injected period failure'); END");
  142. rejects(static fn() => $service::ensure(1, $now), 'injected period failure');
  143. check(snapshot(), $failedBefore, 'Failed insertion rolls back without altering history');
  144. $db->execute('DROP TRIGGER injected_insert_failure');
  145. check($service::ensure(1, $now)['created'], true, 'Initialization can retry after a rolled-back failure');
  146. check(finances(), $financialBefore, 'All validation, retry and failure cases preserve finances');
  147. echo 'PASS ' . $checks . " jisu initialization assertions (isolated SQLite, no settlement or network)\n";
  148. }