SimulationTest.php 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454
  1. <?php
  2. namespace Tests\Integration;
  3. use App\Http\Controllers\admin\Simulation as SimulationController;
  4. use App\Models\User;
  5. use App\Services\LotteryOdds;
  6. use App\Services\Simulation\SimulationBetService;
  7. use App\Services\Simulation\SimulationFundService;
  8. use App\Services\Simulation\SimulationMemberService;
  9. use App\Services\Simulation\SimulationResetService;
  10. use App\Services\Simulation\SimulationRunner;
  11. use App\Services\Simulation\SimulationSettings;
  12. use App\Services\Simulation\SimulationTaskService;
  13. use App\Services\UserService;
  14. use App\Support\MemberAudience;
  15. use App\Support\StatsScope;
  16. use Illuminate\Config\Repository;
  17. use Illuminate\Database\Capsule\Manager;
  18. use Illuminate\Database\Schema\Blueprint;
  19. use Illuminate\Events\Dispatcher;
  20. use Illuminate\Foundation\Application;
  21. use Illuminate\Http\Request;
  22. use Illuminate\Support\Facades\DB;
  23. use Illuminate\Support\Facades\Facade;
  24. use Illuminate\Support\Facades\Schema;
  25. use PHPUnit\Framework\TestCase;
  26. /**
  27. * 模拟管理:模拟会员的充值、下注、结算、提现写真实业务表,但正式口径的统计和列表完全看不到;
  28. * 模拟口径只看到模拟会员;自动任务能跑完并可重复调度;重置只删模拟数据。
  29. */
  30. class SimulationTest extends TestCase
  31. {
  32. private Application $app;
  33. protected function setUp(): void
  34. {
  35. parent::setUp();
  36. Facade::clearResolvedInstances();
  37. $this->app = $app = new Application(dirname(__DIR__, 2));
  38. $app->instance('config', new Repository(['app' => ['timezone' => 'Asia/Shanghai', 'locale' => 'zh'], 'admin_realtime' => ['enabled' => false],
  39. 'cache' => ['default' => 'array', 'stores' => ['array' => ['driver' => 'array']], 'prefix' => '']]));
  40. $app->instance('files', new \Illuminate\Filesystem\Filesystem());
  41. $app->instance('path.lang', dirname(__DIR__, 2) . '/lang');
  42. $app->register(\Illuminate\Translation\TranslationServiceProvider::class);
  43. $app->register(\Illuminate\Validation\ValidationServiceProvider::class);
  44. $app->register(\Illuminate\Cache\CacheServiceProvider::class);
  45. $app->instance('log', new \Psr\Log\NullLogger());
  46. $app->instance(\Illuminate\Contracts\Routing\ResponseFactory::class, (new \ReflectionClass(\Illuminate\Routing\ResponseFactory::class))->newInstanceWithoutConstructor());
  47. Request::macro('validate', function (array $rules) { return validator()->validate($this->all(), $rules); });
  48. Facade::setFacadeApplication($app);
  49. $db = new Manager($app);
  50. $db->addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => 'bot_']);
  51. $db->setEventDispatcher(new Dispatcher($app));
  52. $db->setAsGlobal();
  53. $db->bootEloquent();
  54. $app->instance('db.transactions', new \Illuminate\Database\DatabaseTransactionsManager());
  55. $app->instance('db', $db->getDatabaseManager());
  56. $app->bind('db.schema', fn () => $db->schema());
  57. $this->request();
  58. $this->schema();
  59. $this->seedRealMember();
  60. }
  61. protected function tearDown(): void
  62. {
  63. DB::disconnect();
  64. Facade::clearResolvedInstances();
  65. parent::tearDown();
  66. }
  67. private function request(array $input = [], string $method = 'GET'): void
  68. {
  69. $request = Request::create('/admin/simulation', $method, $input);
  70. $request->user = (object) ['id' => 1, 'username' => 'root'];
  71. $this->app->instance('request', $request);
  72. }
  73. private function schema(): void
  74. {
  75. Schema::create('users', function (Blueprint $t) {
  76. $t->id(); $t->string('member_id')->nullable(); $t->string('user_id')->nullable(); $t->string('account')->nullable();
  77. $t->string('first_name')->nullable(); $t->string('username')->default(''); $t->string('from', 20)->nullable()->default('-1');
  78. $t->unsignedBigInteger('agent_id')->nullable(); $t->string('agent_user_code')->nullable(); $t->string('user_code')->nullable();
  79. $t->integer('is_banned')->default(0); $t->integer('status')->default(0); $t->integer('level')->default(0);
  80. $t->string('register_ip')->default(''); $t->string('register_domain')->default(''); $t->integer('last_active_time')->default(0);
  81. $t->string('language')->default('zh'); $t->integer('recharge_channel_group_id')->default(1); $t->string('admin_note')->default('');
  82. $t->timestamps();
  83. });
  84. (require dirname(__DIR__, 2) . '/database/migrations/2026_11_11_120000_add_user_type_to_users.php')->up();
  85. Schema::create('wallets', function (Blueprint $t) {
  86. $t->id(); $t->unsignedBigInteger('user_id')->default(0); $t->string('member_id'); $t->string('coin')->nullable(); $t->string('net')->nullable();
  87. $t->decimal('available_balance', 30, 10)->default(0); $t->decimal('frozen_balance', 30, 10)->default(0); $t->timestamps();
  88. });
  89. Schema::create('balance_logs', function (Blueprint $t) {
  90. $t->id(); $t->string('member_id'); $t->decimal('amount', 30, 10); $t->decimal('before_balance', 30, 10)->default(0);
  91. $t->decimal('after_balance', 30, 10)->default(0); $t->string('change_type'); $t->string('related_id')->nullable(); $t->string('remark')->nullable();
  92. $t->integer('type')->default(1); $t->string('room_id')->nullable(); $t->timestamps();
  93. });
  94. Schema::create('payment_orders', function (Blueprint $t) {
  95. $t->id(); $t->integer('type'); $t->string('order_no')->nullable(); $t->string('member_id'); $t->decimal('amount', 24, 10);
  96. $t->string('channel')->nullable(); $t->string('bank_name')->nullable(); $t->string('account')->nullable(); $t->string('card_no')->nullable();
  97. $t->integer('status'); $t->integer('state')->nullable(); $t->text('callback_data')->nullable(); $t->string('remark')->nullable();
  98. $t->decimal('fee', 24, 8)->default(0); $t->decimal('fee_rate', 12, 6)->default(0); $t->decimal('fee_fixed', 12, 4)->default(0);
  99. $t->decimal('frozen_amount', 24, 10)->default(0); $t->text('recharge_bonus_snapshot')->nullable(); $t->timestamps();
  100. });
  101. Schema::create('level', function (Blueprint $t) { $t->id(); $t->integer('level'); $t->decimal('recharge', 20, 2); });
  102. Schema::create('config', function (Blueprint $t) { $t->id(); $t->string('field'); $t->string('val')->nullable(); });
  103. foreach (['issues', 'pc_issues'] as $table) {
  104. Schema::create($table, function (Blueprint $t) {
  105. $t->id(); $t->string('issue_no'); $t->dateTime('start_time'); $t->dateTime('end_time'); $t->integer('status'); $t->timestamps();
  106. });
  107. }
  108. Schema::create('gameplay_rules', function (Blueprint $t) {
  109. $t->id(); $t->string('keywords'); $t->string('groups'); $t->decimal('odds', 10, 2); $t->decimal('mininum', 12, 2); $t->decimal('maxinum', 12, 2); $t->timestamps();
  110. });
  111. foreach (['jisu_gameplay', 'lhc_number'] as $table) {
  112. Schema::create($table, function (Blueprint $t) {
  113. $t->id(); $t->integer('type')->nullable(); $t->string('odds'); $t->bigInteger('mininum'); $t->bigInteger('maxinum');
  114. });
  115. }
  116. Schema::create('lhc_order', function (Blueprint $t) {
  117. $t->id(); $t->string('issue'); $t->string('ordernum'); $t->string('member_id'); $t->decimal('odds', 10, 2)->default(0);
  118. $t->decimal('amount', 22, 2); $t->decimal('total_amount', 22, 2)->default(0); $t->integer('lottery_status')->default(0);
  119. $t->decimal('win_amount', 22, 2)->default(0); $t->decimal('profit_and_loss', 22, 2)->nullable(); $t->integer('type')->default(5);
  120. $t->integer('is_faker')->default(0); $t->string('bet_source', 16)->nullable(); $t->integer('created_at')->nullable(); $t->integer('updated_at')->nullable();
  121. });
  122. Schema::create('lhc_order_item', function (Blueprint $t) {
  123. $t->id(); $t->string('ordernum'); $t->string('member_id'); $t->string('number'); $t->integer('lottery_status')->default(0);
  124. $t->timestamps();
  125. });
  126. Schema::create('sport_order', function (Blueprint $t) {
  127. $t->id(); $t->string('user_id'); $t->string('order_id'); $t->string('issue'); $t->text('detail')->nullable(); $t->integer('is_roll')->default(0);
  128. $t->decimal('amount', 22, 2); $t->decimal('cny_rate', 22, 10)->nullable(); $t->decimal('win_amount', 22, 2)->default(0);
  129. $t->decimal('return_amount', 22, 2)->default(0); $t->decimal('profit_and_loss', 22, 2)->default(0); $t->string('game_result')->nullable();
  130. $t->integer('is_faker')->default(0); $t->integer('is_win')->default(0); $t->integer('status')->default(0); $t->integer('settlement_status')->default(0);
  131. $t->integer('pay_status')->default(0); $t->integer('pay_time')->nullable(); $t->string('odd_name')->nullable(); $t->decimal('odd', 12, 6)->nullable();
  132. $t->string('odd_value')->nullable(); $t->string('remark')->nullable(); $t->integer('create_time')->default(0); $t->integer('update_time')->default(0);
  133. $t->integer('settlement_time')->nullable();
  134. });
  135. Schema::create('third_game_orders', function (Blueprint $t) {
  136. $t->id(); $t->string('order_key', 64)->unique(); $t->unsignedBigInteger('user_id')->nullable(); $t->string('member_id')->nullable();
  137. $t->string('username')->default(''); $t->string('first_name')->default(''); $t->string('player_id'); $t->string('platform');
  138. $t->string('currency')->default(''); $t->integer('game_type'); $t->string('game_name')->default(''); $t->string('round_no')->default('');
  139. $t->string('table_no')->default(''); $t->string('seat')->default(''); $t->decimal('bet_amount', 30, 10)->default(0);
  140. $t->decimal('valid_amount', 30, 10)->default(0); $t->decimal('settled_amount', 30, 10)->default(0); $t->text('bet_content')->nullable();
  141. $t->integer('status'); $t->string('game_order_id'); $t->dateTime('bet_time')->nullable(); $t->dateTime('last_update_time')->nullable(); $t->timestamps();
  142. });
  143. Schema::create('user_login', function (Blueprint $t) {
  144. $t->id(); $t->string('user_id')->nullable(); $t->string('login_account')->default(''); $t->string('login_ip')->default('');
  145. $t->string('login_domain')->default(''); $t->string('platform')->default(''); $t->integer('status')->default(1);
  146. $t->dateTime('logout_time')->nullable(); $t->integer('online_duration')->default(0); $t->timestamps();
  147. });
  148. (require dirname(__DIR__, 2) . '/database/migrations/2026_11_11_130000_create_simulation_tables.php')->up();
  149. DB::table('config')->insert([['field' => 'pc28_switch', 'val' => '0'], ['field' => 'recharge_rate', 'val' => '0']]);
  150. DB::table('config')->where('field', 'simulation_enabled')->update(['val' => '1']);
  151. DB::table('issues')->insert(['issue_no' => '3300001', 'start_time' => now(), 'end_time' => now()->addMinutes(5), 'status' => 1]);
  152. DB::table('gameplay_rules')->insert(['keywords' => '大', 'groups' => '大小单双', 'odds' => 2, 'mininum' => 1, 'maxinum' => 100000]);
  153. (require dirname(__DIR__, 2) . '/database/migrations/2026_11_11_120000_create_lottery_odds_templates.php')->up();
  154. (require dirname(__DIR__, 2) . '/database/migrations/2026_11_11_160000_add_sort_to_lottery_odds_templates.php')->up();
  155. }
  156. /** 一个真实会员和一个前台游客,各有充值、投注、订单。 */
  157. private function seedRealMember(): void
  158. {
  159. foreach ([['r1', '1', 0], ['g1', '2', 1]] as $i => [$member, $from, $type]) {
  160. DB::table('users')->insert(['member_id' => $member, 'user_id' => $member, 'account' => $member, 'first_name' => $member, 'from' => $from,
  161. 'user_type' => $type, 'created_at' => now(), 'updated_at' => now()]);
  162. DB::table('wallets')->insert(['user_id' => $i + 1, 'member_id' => $member, 'available_balance' => 90]);
  163. DB::table('balance_logs')->insert([
  164. ['member_id' => $member, 'amount' => 100, 'change_type' => '三方充值', 'created_at' => now(), 'updated_at' => now()],
  165. ['member_id' => $member, 'amount' => -10, 'change_type' => '投注', 'created_at' => now(), 'updated_at' => now()]]);
  166. DB::table('payment_orders')->insert(['type' => 1, 'member_id' => $member, 'amount' => 100, 'status' => 2, 'created_at' => now(), 'updated_at' => now()]);
  167. DB::table('lhc_order')->insert(['issue' => '3300001', 'ordernum' => 'o' . $member, 'member_id' => $member, 'amount' => 10, 'total_amount' => 10]);
  168. }
  169. }
  170. /** 正式口径下各处看到的数字。模拟数据进来前后必须完全一样。 */
  171. private function realView(): array
  172. {
  173. return [
  174. 'stats_deposit' => (float) StatsScope::excludeTestMembers(DB::table('balance_logs'))->where('change_type', '三方充值')->sum('amount'),
  175. 'stats_bet' => (float) StatsScope::excludeTestMembers(DB::table('balance_logs'))->where('change_type', 'like', '%投注')->sum('amount'),
  176. 'stats_wallet' => (float) StatsScope::excludeTestMembers(DB::table('wallets'))->sum('available_balance'),
  177. 'stats_orders' => StatsScope::excludeTestMembers(DB::table('payment_orders'))->count(),
  178. 'real_users' => StatsScope::realUsers(DB::table('users'))->pluck('member_id')->all(),
  179. 'guest_users' => StatsScope::guestUsers(DB::table('users'))->pluck('member_id')->all(),
  180. 'raw_users' => array_column(DB::select('SELECT u.member_id FROM bot_users u WHERE 1 = 1' . StatsScope::rawExcludeTestUsers('u')), 'member_id'),
  181. 'list_users' => MemberAudience::visibleUsers(DB::table('users'))->orderBy('id')->pluck('member_id')->all(),
  182. 'list_ledger' => MemberAudience::visibleUsers(DB::table('balance_logs')->join('users', 'users.member_id', '=', 'balance_logs.member_id'))->count(),
  183. 'list_orders' => MemberAudience::visibleMembers(DB::table('payment_orders'))->count(),
  184. 'list_lottery' => MemberAudience::visibleMembers(DB::table('lhc_order'), 'lhc_order.member_id')->count(),
  185. 'list_sport' => MemberAudience::visibleMembers(DB::table('sport_order'), 'user_id', 'user_id')->count(),
  186. 'list_logins' => MemberAudience::visibleMembers(DB::table('user_login'), 'user_id', 'user_id')->count(),
  187. 'list_third' => MemberAudience::visibleMembers(DB::table('third_game_orders'), 'third_game_orders.user_id', 'id')->count(),
  188. ];
  189. }
  190. private function wallet(string $memberId): array
  191. {
  192. $w = DB::table('wallets')->where('member_id', $memberId)->first();
  193. return [(float) $w->available_balance, (float) $w->frozen_balance];
  194. }
  195. public function test_lottery_options_and_simulated_bets_use_the_active_games_default_template(): void
  196. {
  197. [$member] = array_column(SimulationMemberService::create(1, ['initial_balance' => 1000]), 'member_id');
  198. $ledgerCount = DB::table('balance_logs')->count();
  199. LotteryOdds::setOdds('canada28', null, ['id' => 1, 'odds' => '0', 'mininum' => 1, 'maxinum' => 100]);
  200. $this->assertSame([], SimulationBetService::options()['lottery']['rules']->all());
  201. try {
  202. SimulationBetService::lottery($member, '大', 10);
  203. $this->fail('The active default template has disabled this play');
  204. } catch (\Exception $e) {
  205. $this->assertStringContainsString('禁止投注', $e->getMessage());
  206. }
  207. $this->assertSame([1000.0, 0.0], $this->wallet($member));
  208. $this->assertSame($ledgerCount, DB::table('balance_logs')->count());
  209. $this->assertSame(0, DB::table('lhc_order')->where('member_id', $member)->count());
  210. DB::table('config')->where('field', 'pc28_switch')->update(['val' => '1']);
  211. DB::table('pc_issues')->insert(['issue_no' => '2200001', 'start_time' => now(), 'end_time' => now()->addMinutes(5), 'status' => 1]);
  212. LotteryOdds::setOdds('jisu28', null, ['id' => 1, 'odds' => '3.25', 'mininum' => 10, 'maxinum' => 20]);
  213. $rules = SimulationBetService::options()['lottery']['rules'];
  214. $this->assertSame(['keywords' => '大', 'odds' => '3.25', 'mininum' => 10, 'maxinum' => 20], $rules->first());
  215. try {
  216. SimulationBetService::lottery($member, '大', 21);
  217. $this->fail('The selected template caps the stake at 20');
  218. } catch (\Exception $e) {
  219. $this->assertStringContainsString('超过该玩法最大金额', $e->getMessage());
  220. }
  221. $order = SimulationBetService::lottery($member, '大', 20);
  222. $template = LotteryOdds::template('jisu28');
  223. $this->assertEquals(['2200001', 6, '3.25', $template->id, $template->revision],
  224. [$order->issue, $order->type, $order->odds, $order->odds_template_id, $order->odds_template_revision]);
  225. $this->assertSame([980.0, 0.0], $this->wallet($member));
  226. }
  227. public function test_simulated_money_and_bets_use_real_tables_but_stay_out_of_the_real_view(): void
  228. {
  229. $before = $this->realView();
  230. $this->assertSame(100.0, $before['stats_deposit'], 'guest deposits are not counted either');
  231. $this->assertSame(['r1'], $before['real_users']);
  232. $this->assertSame(['g1'], $before['guest_users']);
  233. $this->assertSame(['r1', 'g1'], $before['list_users'], 'lists still show guests');
  234. [$a, $b] = array_column(SimulationMemberService::create(2, ['initial_balance' => 1000]), 'member_id');
  235. $user = DB::table('users')->where('member_id', $a)->first();
  236. $this->assertSame(['2', 2, null], [(string) $user->from, (int) $user->user_type, $user->agent_id]);
  237. $this->assertSame([1000.0, 0.0], $this->wallet($a));
  238. // 充值:三方成功、公司入款成功、失败的不入账
  239. $paid = SimulationFundService::recharge($a, 500, 'third');
  240. $this->assertSame([2, 1], [(int) $paid->status, $paid->state], 'state is an integer column in production');
  241. $this->assertSame(2, (int) SimulationFundService::recharge($a, 200, 'company')->status);
  242. $this->assertSame(3, (int) SimulationFundService::recharge($a, 999, 'third', 'fail')->status);
  243. $this->assertSame(1, (int) SimulationFundService::recharge($a, 50, 'third', 'pending')->status);
  244. $this->assertSame(1700.0, $this->wallet($a)[0]);
  245. // 彩票下在真实期号上;体育按给定结果结算
  246. $lottery = SimulationBetService::lottery($a, '大', 100);
  247. $this->assertSame(['3300001', 'simulation', 0], [$lottery->issue, $lottery->bet_source, (int) $lottery->fresh()->lottery_status]);
  248. $win = SimulationBetService::sport($a, 50, 2);
  249. $this->assertSame('100.00', SimulationBetService::settleSport($win->id, 'win')->win_amount);
  250. $half = SimulationBetService::sport($a, 40, 1.9);
  251. $settled = SimulationBetService::settleSport($half->id, 'half');
  252. $this->assertSame(['20.00', '-20.00'], [$settled->return_amount, $settled->profit_and_loss]);
  253. try { SimulationBetService::settleSport($half->id, 'win'); $this->fail('an order settles once'); }
  254. catch (\Exception $e) { $this->assertStringContainsString('已结算', $e->getMessage()); }
  255. $this->assertSame(1630.0, $this->wallet($a)[0], '1700 - 100 - 50 + 100 - 40 + 20');
  256. $this->assertSame($before, $this->realView());
  257. }
  258. public function test_full_flow_isolation_runner_and_reset(): void
  259. {
  260. $before = $this->realView();
  261. [$a] = array_column(SimulationMemberService::create(1, ['initial_balance' => 1000]), 'member_id');
  262. SimulationFundService::recharge($a, 500, 'third');
  263. SimulationFundService::recharge($a, 200, 'company');
  264. SimulationBetService::lottery($a, '大', 100);
  265. $win = SimulationBetService::sport($a, 50, 2);
  266. SimulationBetService::settleSport($win->id, 'win');
  267. $draw = SimulationBetService::sport($a, 30, 2);
  268. SimulationBetService::settleSport($draw->id, 'draw');
  269. $lose = SimulationBetService::sport($a, 20, 2);
  270. SimulationBetService::settleSport($lose->id, 'lose');
  271. $cancelled = SimulationBetService::sport($a, 10, 2);
  272. SimulationBetService::cancelSport($cancelled->id);
  273. $approved = SimulationFundService::withdrawApply($a, 300);
  274. SimulationFundService::withdrawAudit($approved->id, true);
  275. $rejected = SimulationFundService::withdrawApply($a, 70);
  276. SimulationFundService::withdrawAudit($rejected->id, false, '测试拒绝');
  277. SimulationFundService::adjust($a, 5, 'debit', '扣款');
  278. SimulationMemberService::login($a);
  279. // 1000 + 500 + 200 - 100 - 50 + 100 - 30 + 30 - 20 - 10 + 10 - 300 - 70 + 70 - 5
  280. $this->assertSame([1325.0, 0.0], $this->wallet($a));
  281. $this->assertSame([2, 3], [(int) $approved->fresh()->status, (int) $rejected->fresh()->status]);
  282. $this->assertSame($before, $this->realView(), 'nothing the real back office reads has changed');
  283. $sim = MemberAudience::simulated(fn () => [
  284. 'deposit' => (float) StatsScope::excludeTestMembers(DB::table('balance_logs'))->whereIn('change_type', ['三方充值', '充值'])->sum('amount'),
  285. 'bet' => (float) StatsScope::excludeTestMembers(DB::table('balance_logs'))->where('change_type', 'like', '%投注')->sum('amount'),
  286. 'users' => StatsScope::realUsers(DB::table('users'))->pluck('member_id')->all(),
  287. 'raw' => array_column(DB::select('SELECT u.member_id FROM bot_users u WHERE 1 = 1' . StatsScope::rawExcludeTestUsers('u')), 'member_id'),
  288. 'guests' => StatsScope::guestUsers(DB::table('users'))->count(),
  289. 'list' => MemberAudience::visibleUsers(DB::table('users'))->pluck('member_id')->all(),
  290. 'sport' => MemberAudience::visibleMembers(DB::table('sport_order'), 'user_id', 'user_id')->count(),
  291. ]);
  292. $this->assertSame(['deposit' => 700.0, 'bet' => -210.0, 'users' => [$a], 'raw' => [$a], 'guests' => 0, 'list' => [$a], 'sport' => 4], $sim);
  293. $this->assertFalse(MemberAudience::isSimulated(), 'audience is restored');
  294. // 概览接口与手算一致
  295. $this->request();
  296. $overview = (new SimulationController())->overview()->getData(true);
  297. $this->assertSame(0, $overview['code'], $overview['msg']);
  298. $this->assertSame(['1', '1'], [(string) $overview['data']['members']['total'], (string) $overview['data']['members']['online']]);
  299. $this->assertSame(['700.00', '1000.00', '5.00', '300.00', '1325.00'], [$overview['data']['funds']['recharge'], $overview['data']['funds']['manual_credit'],
  300. $overview['data']['funds']['manual_debit'], $overview['data']['funds']['withdraw'], $overview['data']['funds']['wallet_available']]);
  301. $this->assertSame(['210.00', '140.00', 1], [$overview['data']['bets']['bet_amount'], $overview['data']['bets']['payout'], $overview['data']['bets']['unsettled']]);
  302. // 自动任务:注册 2 人 → 登录 → 充值 → 体育下注 2 次 → 结算 → 提现 → 退出
  303. $steps = [['type' => 'register', 'interval' => 0], ['type' => 'login', 'interval' => 0], ['type' => 'recharge', 'amount' => 100, 'interval' => 0],
  304. ['type' => 'bet', 'category' => 'sport', 'amount' => 10, 'count' => 2, 'interval' => 0], ['type' => 'settle', 'settle_mode' => 'lose', 'interval' => 0],
  305. ['type' => 'withdraw', 'amount' => 30, 'interval' => 0], ['type' => 'logout', 'interval' => 0]];
  306. $runId = SimulationTaskService::start(['name' => '完整流程', 'steps' => $steps, 'member_count' => 2], 1);
  307. $paused = SimulationTaskService::setStatus($runId, 'pause');
  308. $this->assertSame('paused', $paused->status);
  309. $this->assertSame(0, SimulationRunner::tick(), 'a paused run does nothing');
  310. SimulationTaskService::setStatus($runId, 'resume');
  311. for ($i = 0; $i < 60 && DB::table('simulation_task_runs')->where('id', $runId)->value('status') === 'running'; $i++) SimulationRunner::tick();
  312. $run = DB::table('simulation_task_runs')->where('id', $runId)->first();
  313. $this->assertSame(['completed', 16, 16, 0], [$run->status, (int) $run->total, (int) $run->completed, (int) $run->failed], (string) $run->last_error);
  314. $this->assertSame(16, DB::table('simulation_task_events')->where('run_id', $runId)->where('status', 'ok')->count());
  315. $this->assertSame(0, SimulationRunner::tick(), 'a finished run is not executed again');
  316. foreach (json_decode($run->member_ids, true) as $memberId) $this->assertSame([50.0, 0.0], $this->wallet($memberId), '100 - 10 - 10 - 30');
  317. $this->assertSame(3, DB::table('users')->where('user_type', 2)->count());
  318. $this->assertSame($before, $this->realView(), 'the task run did not leak either');
  319. // 第三方游戏:只在本地生成已完成的注单,输赢净额进出钱包
  320. $balance = $this->wallet($a)[0];
  321. $lost = SimulationBetService::third($a, 40, 2, 'lose');
  322. $this->assertSame([1, 2, 'SIM', '-40.0000000000', 64], [(int) $lost->status, (int) $lost->game_type, $lost->platform, (string) $lost->settled_amount, strlen($lost->order_key)]);
  323. $won = SimulationBetService::third($a, 40, null, 'win');
  324. $this->assertGreaterThanOrEqual(20, (float) $won->settled_amount);
  325. $this->assertEqualsWithDelta($balance - 40 + (float) $won->settled_amount, $this->wallet($a)[0], 0.001);
  326. $this->assertSame(2, DB::table('balance_logs')->where('member_id', $a)->where('change_type', '资产转移')->count());
  327. try { SimulationBetService::third('r1', 10); $this->fail('real members are refused'); }
  328. catch (\Exception $e) { $this->assertSame('只能操作模拟会员', $e->getMessage()); }
  329. $this->assertSame($before, $this->realView());
  330. // 混合下注:体育、彩票、第三方游戏都会下
  331. $mixId = SimulationTaskService::start(['name' => '混合', 'member_ids' => [$a], 'steps' => [['type' => 'bet', 'category' => 'mixed', 'amount' => 1, 'count' => 30, 'interval' => 0]]], 1);
  332. for ($i = 0; $i < 80 && in_array(DB::table('simulation_task_runs')->where('id', $mixId)->value('status'), ['queued', 'running'], true); $i++) SimulationRunner::tick();
  333. $mix = DB::table('simulation_task_runs')->where('id', $mixId)->first();
  334. $this->assertSame(['completed', 30, 0], [$mix->status, (int) $mix->completed, (int) $mix->failed], (string) $mix->last_error);
  335. $kinds = DB::table('simulation_task_events')->where('run_id', $mixId)->distinct()->orderBy('ref_type')->pluck('ref_type')->all();
  336. $this->assertSame(['lhc_order', 'sport_order', 'third_game_orders'], $kinds);
  337. // 彩票没有可下注期号时,混合下注改下其他分类而不是失败
  338. DB::table('issues')->update(['status' => 2]);
  339. $closedId = SimulationTaskService::start(['name' => '封盘', 'member_ids' => [$a], 'steps' => [['type' => 'bet', 'category' => 'mixed', 'amount' => 1, 'count' => 12, 'interval' => 0]]], 1);
  340. for ($i = 0; $i < 40 && in_array(DB::table('simulation_task_runs')->where('id', $closedId)->value('status'), ['queued', 'running'], true); $i++) SimulationRunner::tick();
  341. $this->assertSame([12, 0, 0], [(int) DB::table('simulation_task_runs')->where('id', $closedId)->value('completed'), (int) DB::table('simulation_task_runs')->where('id', $closedId)->value('failed'),
  342. DB::table('simulation_task_events')->where('run_id', $closedId)->where('ref_type', 'lhc_order')->count()]);
  343. DB::table('issues')->update(['status' => 1]);
  344. $this->assertSame($before, $this->realView());
  345. // 一键启动:按模拟设置的默认任务参数跑完整流程
  346. $this->assertSame('mixed', SimulationSettings::get('simulation_task_bet_category'), 'the default task bets on every category');
  347. // 这里用体育:彩票要等真实开奖,测试里不会开奖
  348. SimulationSettings::save(['simulation_task_bet_category' => 'sport', 'simulation_task_members' => 2, 'simulation_task_recharge' => 500, 'simulation_task_bet_amount' => 50,
  349. 'simulation_task_bet_count' => 2, 'simulation_task_withdraw' => 100, 'simulation_task_interval' => 0]);
  350. $quickId = SimulationTaskService::quickStart(1);
  351. for ($i = 0; $i < 60 && in_array(DB::table('simulation_task_runs')->where('id', $quickId)->value('status'), ['queued', 'running'], true); $i++) SimulationRunner::tick();
  352. $quick = DB::table('simulation_task_runs')->where('id', $quickId)->first();
  353. $this->assertSame(['completed', 16, 16, 0], [$quick->status, (int) $quick->total, (int) $quick->completed, (int) $quick->failed], (string) $quick->last_error);
  354. $this->assertSame(['register', 'login', 'recharge', 'bet', 'settle', 'withdraw', 'logout'], array_column(json_decode($quick->steps, true), 'type'));
  355. SimulationSettings::save(['simulation_task_recharge' => 20000]);
  356. try { SimulationTaskService::quickStart(1); $this->fail('defaults above the amount cap are refused'); }
  357. catch (\Exception $e) { $this->assertStringContainsString('单次金额上限', $e->getMessage()); }
  358. $this->assertSame($before, $this->realView());
  359. // 持续运行:日期区间内一轮接一轮;区间外、间隔未到、上一轮没结束时都不启动
  360. SimulationSettings::save(['simulation_task_recharge' => 500]);
  361. try { SimulationSettings::save(['simulation_schedule_enabled' => 1, 'simulation_schedule_start' => '', 'simulation_schedule_end' => '']); $this->fail('dates are required'); }
  362. catch (\Exception $e) { $this->assertStringContainsString('开始和结束日期', $e->getMessage()); }
  363. $this->assertSame('0', SimulationSettings::get('simulation_schedule_enabled'), 'a refused save changes nothing');
  364. $today = now()->toDateString();
  365. SimulationSettings::save(['simulation_schedule_enabled' => 1, 'simulation_schedule_start' => now()->addDay()->toDateString(), 'simulation_schedule_end' => now()->addDays(30)->toDateString(), 'simulation_schedule_gap_minutes' => 10]);
  366. $this->assertNull(SimulationTaskService::scheduleNext(), 'not started before the range');
  367. SimulationSettings::save(['simulation_schedule_start' => $today]);
  368. $runsBefore = DB::table('simulation_task_runs')->count();
  369. SimulationRunner::tick();
  370. $first = DB::table('simulation_task_runs')->orderByDesc('id')->first();
  371. $this->assertSame([$runsBefore + 1, true], [DB::table('simulation_task_runs')->count(), str_starts_with($first->name, '持续任务 ')]);
  372. $this->assertNull(SimulationTaskService::scheduleNext(), 'one round at a time');
  373. for ($i = 0; $i < 60 && DB::table('simulation_task_runs')->where('id', $first->id)->value('status') === 'running'; $i++) SimulationRunner::tick();
  374. $this->assertSame('completed', DB::table('simulation_task_runs')->where('id', $first->id)->value('status'));
  375. $this->assertNull(SimulationTaskService::scheduleNext(), 'waits for the gap after a round');
  376. \Carbon\Carbon::setTestNow(now()->addMinutes(11));
  377. // 会员数到上限后不再注册,改用已有模拟会员
  378. SimulationSettings::save(['simulation_max_members_total' => DB::table('users')->where('user_type', 2)->count()]);
  379. $second = SimulationTaskService::scheduleNext();
  380. $this->assertNotNull($second);
  381. $secondRun = DB::table('simulation_task_runs')->where('id', $second)->first();
  382. $this->assertNotContains('register', array_column(json_decode($secondRun->steps, true), 'type'));
  383. $this->assertCount(2, json_decode($secondRun->member_ids, true));
  384. \Carbon\Carbon::setTestNow(now()->addDays(40));
  385. SimulationTaskService::setStatus($second, 'stop');
  386. $this->assertNull(SimulationTaskService::scheduleNext(), 'not started after the range');
  387. \Carbon\Carbon::setTestNow();
  388. SimulationSettings::save(['simulation_schedule_enabled' => 0]);
  389. $this->assertSame($before, $this->realView());
  390. $simulatedMembers = DB::table('users')->where('user_type', 2)->count();
  391. // 重置:只删模拟数据
  392. $preview = SimulationResetService::preview();
  393. $this->assertSame([7, 7], [$simulatedMembers, $preview['counts']['users']]);
  394. try { SimulationResetService::reset('wrong', SimulationResetService::CONFIRM_TEXT); $this->fail('wrong token must be refused'); }
  395. catch (\Exception $e) { $this->assertStringContainsString('失效', $e->getMessage()); }
  396. $preview = SimulationResetService::preview();
  397. $deleted = SimulationResetService::reset($preview['confirm_token'], $preview['confirm_text']);
  398. $this->assertSame(7, $deleted['users']);
  399. $this->assertSame(0, DB::table('users')->where('user_type', 2)->count());
  400. $this->assertSame([2, 2, 4, 2, 2], [DB::table('users')->count(), DB::table('wallets')->count(), DB::table('balance_logs')->count(),
  401. DB::table('payment_orders')->count(), DB::table('lhc_order')->count()], 'real and guest rows are untouched');
  402. $this->assertSame([0, 0], [DB::table('sport_order')->count(), DB::table('user_login')->count()]);
  403. $this->assertSame($before, $this->realView());
  404. }
  405. public function test_guards_refuse_real_members_and_a_closed_switch(): void
  406. {
  407. foreach ([fn () => SimulationFundService::recharge('r1', 10), fn () => SimulationFundService::adjust('g1', 10, 'credit', 'x'),
  408. fn () => SimulationBetService::sport('r1', 10, 2), fn () => SimulationFundService::withdrawApply('r1', 10)] as $call) {
  409. try { $call(); $this->fail('real members and guests must be refused'); }
  410. catch (\Exception $e) { $this->assertSame('只能操作模拟会员', $e->getMessage()); }
  411. }
  412. $this->assertSame(90.0, $this->wallet('r1')[0]);
  413. $order = \App\Models\PaymentOrder::create(['type' => 1, 'member_id' => 'r1', 'amount' => 10, 'status' => 1]);
  414. try { \App\Services\PaymentOrderService::simulatePaySuccess($order); $this->fail('a real order must not be credited'); }
  415. catch (\Exception $e) { $this->assertStringContainsString('模拟会员', $e->getMessage()); }
  416. SimulationSettings::save(['simulation_enabled' => 0]);
  417. try { SimulationMemberService::create(1); $this->fail('closed switch'); }
  418. catch (\Exception $e) { $this->assertStringContainsString('未开启', $e->getMessage()); }
  419. $this->assertSame(0, SimulationRunner::tick());
  420. $this->assertSame(0, User::where('user_type', 2)->count());
  421. $this->assertNotNull(UserService::class);
  422. }
  423. }