AgentTestEnvironment.php 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189
  1. <?php
  2. namespace Tests\Support;
  3. use App\Models\Agent\Agent;
  4. use App\Models\Agent\AgentToken;
  5. use App\Services\Agent\AgentService;
  6. use Carbon\Carbon;
  7. use Illuminate\Config\Repository;
  8. use Illuminate\Database\Capsule\Manager;
  9. use Illuminate\Database\Schema\Blueprint;
  10. use Illuminate\Events\Dispatcher;
  11. use Illuminate\Foundation\Application;
  12. use Illuminate\Hashing\BcryptHasher;
  13. use Illuminate\Http\Request;
  14. use Illuminate\Support\Facades\DB;
  15. use Illuminate\Support\Facades\Facade;
  16. use Illuminate\Support\Facades\Schema;
  17. use Illuminate\Translation\ArrayLoader;
  18. use Illuminate\Translation\Translator;
  19. use Illuminate\Validation\Factory;
  20. /**
  21. * 代理模块集成测试环境:隔离 SQLite(表前缀 bot_),按生产顺序跑代理迁移,
  22. * 包括把旧的流水佣金/二级代理结构迁移到合营/入股结构的 2026_11_07 迁移。
  23. */
  24. trait AgentTestEnvironment
  25. {
  26. private Application $app;
  27. private AgentService $agents;
  28. protected function setUp(): void
  29. {
  30. parent::setUp();
  31. Carbon::setTestNow(Carbon::parse('2026-09-07 12:00:00'));
  32. Facade::clearResolvedInstances();
  33. $this->app = new Application(dirname(__DIR__, 2));
  34. $this->app->instance('config', new Repository(['app' => ['locale' => 'zh'], 'agent' => []]));
  35. Facade::setFacadeApplication($this->app);
  36. $db = new Manager($this->app);
  37. $db->addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => 'bot_']);
  38. $db->setEventDispatcher(new Dispatcher($this->app));
  39. $db->setAsGlobal();
  40. $db->bootEloquent();
  41. // 生产环境由 DatabaseServiceProvider 绑定;DB::afterCommit 依赖它。
  42. $this->app->instance('db.transactions', new \Illuminate\Database\DatabaseTransactionsManager());
  43. $this->app->instance('db', $db->getDatabaseManager());
  44. $this->app->bind('db.schema', fn () => $db->schema());
  45. $this->app->instance('hash', new BcryptHasher(['rounds' => 4]));
  46. $translator = new Translator(new ArrayLoader(), 'zh');
  47. $this->app->instance('translator', $translator);
  48. $validator = new Factory($translator, $this->app);
  49. $validator->setPresenceVerifier(new \Illuminate\Validation\DatabasePresenceVerifier($db->getDatabaseManager()));
  50. $this->app->instance('validator', $validator);
  51. $this->app->instance('request', Request::create('/'));
  52. Request::macro('validate', function (array $rules) {
  53. return app('validator')->make($this->all(), $rules)->validate();
  54. });
  55. $this->app->instance(\Illuminate\Contracts\Routing\ResponseFactory::class, new JsonResponseFactory());
  56. Schema::create('users', function (Blueprint $t) {
  57. $t->id(); $t->string('member_id'); $t->string('username'); $t->string('first_name')->default('');
  58. $t->integer('status')->default(1); $t->string('register_ip')->default('');
  59. $t->string('register_domain')->default(''); $t->timestamps();
  60. });
  61. // The profit migration inspects MySQL index metadata before creating indexes.
  62. DB::connection()->getPdo()->sqliteCreateFunction('DATABASE', static fn () => 'isolated');
  63. DB::statement("ATTACH DATABASE ':memory:' AS information_schema");
  64. DB::statement('CREATE TABLE information_schema.statistics (table_schema TEXT,table_name TEXT,index_name TEXT,column_name TEXT,non_unique INTEGER,seq_in_index INTEGER)');
  65. foreach (['2026_08_24_140000_create_agent_system_tables.php',
  66. '2026_08_28_120000_create_agent_profit_profiles_and_rebate_records.php'] as $file) {
  67. (require dirname(__DIR__, 2) . '/database/migrations/' . $file)->up();
  68. }
  69. // Pre-upgrade flow commission and owner columns (their MySQL-only migrations use information_schema).
  70. Schema::create('agent_flow_commission_profiles', function (Blueprint $t) {
  71. $t->id(); $t->string('name'); $t->integer('is_default')->default(0); $t->timestamps();
  72. });
  73. Schema::create('agent_flow_commission_assignments', function (Blueprint $t) {
  74. $t->id(); $t->unsignedBigInteger('agent_id'); $t->date('effective_from'); $t->timestamps();
  75. });
  76. Schema::table('agents', fn (Blueprint $t) => $t->unsignedBigInteger('flow_commission_profile_id')->nullable()->index());
  77. Schema::table('agents', fn (Blueprint $t) => $t->string('language', 8)->default('zh'));
  78. Schema::table('agents', fn (Blueprint $t) => $t->unsignedBigInteger('owner_user_id')->nullable());
  79. Schema::table('agent_profit_commission_profiles', fn (Blueprint $t) => $t->unsignedBigInteger('owner_agent_id')->nullable());
  80. Schema::create('share_system_state', function (Blueprint $t) {
  81. $t->string('key')->primary(); $t->text('value')->nullable(); $t->timestamps();
  82. });
  83. Schema::create('share_policies', function (Blueprint $t) {
  84. $t->id(); $t->date('effective_date'); $t->json('rules'); $t->timestamps();
  85. });
  86. DB::table('share_policies')->insert(['effective_date' => '1970-01-01',
  87. 'rules' => json_encode(['third:1' => [], 'third:3' => [], 'self:lottery' => []])]);
  88. (require dirname(__DIR__, 2) . '/database/migrations/2026_11_07_120000_agent_joint_venture_and_equity.php')->up();
  89. (require dirname(__DIR__, 2) . '/database/migrations/2026_11_08_180000_add_agent_equity_order_admin.php')->up();
  90. (require dirname(__DIR__, 2) . '/database/migrations/2026_11_08_190000_add_agent_recharge_locks.php')->up();
  91. foreach (['recharges', 'withdraws', 'payment_orders'] as $table) {
  92. Schema::create($table, function (Blueprint $t) {
  93. $t->id(); $t->string('member_id'); $t->integer('status'); $t->string('type')->nullable();
  94. $t->decimal('amount', 18, 4); $t->timestamps();
  95. });
  96. }
  97. Schema::create('wallets', function (Blueprint $t) {
  98. $t->id(); $t->string('member_id'); $t->decimal('available_balance', 18, 4)->default(0);
  99. $t->decimal('frozen_balance', 18, 4)->default(0);
  100. });
  101. Schema::create('balance_logs', function (Blueprint $t) {
  102. $t->id(); $t->string('member_id'); $t->string('change_type');
  103. $t->integer('related_id')->nullable(); $t->decimal('amount', 18, 4); $t->timestamps();
  104. });
  105. Schema::create('config', function (Blueprint $t) {
  106. $t->id(); $t->string('field'); $t->string('val')->nullable();
  107. });
  108. Schema::create('share_bet_facts', function (Blueprint $t) {
  109. $t->id(); $t->string('source', 32); $t->string('source_id', 64); $t->unsignedBigInteger('user_id');
  110. $t->string('member_id', 64)->default(''); $t->string('category', 32); $t->dateTime('bet_at');
  111. $t->date('bet_date'); $t->dateTime('settled_at')->nullable();
  112. foreach (['bet_amount', 'net_profit', 'effective_amount'] as $c) $t->decimal($c, 24, 10)->default(0);
  113. $t->boolean('settled')->default(false); $t->boolean('voided')->default(false); $t->timestamps();
  114. });
  115. $this->agents = new AgentService();
  116. }
  117. protected function tearDown(): void
  118. {
  119. DB::disconnect();
  120. Facade::clearResolvedInstances();
  121. Carbon::setTestNow();
  122. parent::tearDown();
  123. }
  124. /** 充值/提现通道(tests/fixtures 的线上快照)、支付公司开关,以及代理人民币充值、提现通道迁移。可重复调用。 */
  125. private function paymentChannels(): void
  126. {
  127. if (Schema::hasTable('recharge_channel')) return;
  128. Schema::create('recharge_channel', function (Blueprint $t) {
  129. $t->id(); $t->integer('from'); $t->integer('data_type'); $t->string('key');
  130. $t->string('name'); $t->string('type'); $t->decimal('rate', 8, 4);
  131. $t->decimal('min')->nullable(); $t->decimal('max')->nullable(); $t->string('fixed')->nullable();
  132. $t->integer('status'); $t->integer('sort'); $t->timestamps();
  133. });
  134. Schema::create('recharge_channel_group', function (Blueprint $t) {
  135. $t->id(); $t->string('name'); $t->text('recharge_type')->nullable();
  136. $t->text('withdraw_type')->nullable(); $t->text('activity_type')->nullable(); $t->timestamps();
  137. });
  138. $fixture = require dirname(__DIR__) . '/fixtures/payment_provider_channels.php';
  139. DB::table('recharge_channel')->insert($fixture['channels']);
  140. DB::table('recharge_channel_group')->insert($fixture['groups']);
  141. foreach (['2026_09_08_160000_add_payment_provider_status_and_channel_soft_deletes.php',
  142. '2026_09_08_170000_add_soft_deletes_to_recharge_channel_groups.php',
  143. '2026_10_28_150000_add_recharge_channel_logo.php', '2026_10_28_160000_create_recharge_method_logos.php',
  144. '2026_11_08_120000_create_agent_payment_orders.php', '2026_11_08_170000_add_agent_withdrawal_channels.php',
  145. '2026_11_09_120000_add_fee_snapshots_to_payment_orders.php', '2026_11_09_130000_add_fee_fixed_to_recharge_channel.php'] as $file) {
  146. (require dirname(__DIR__, 2) . '/database/migrations/' . $file)->up();
  147. }
  148. }
  149. /** 用 payment_orders 的完整字段替换简化表(三方下单/回调会按 order_no、pay_no 查会员订单)。 */
  150. private function fullPaymentOrders(): void
  151. {
  152. Schema::drop('payment_orders');
  153. Schema::create('payment_orders', function (Blueprint $t) {
  154. $t->id(); $t->tinyInteger('type'); $t->string('order_no')->unique(); $t->string('member_id');
  155. $t->decimal('amount', 18, 2); $t->decimal('fee', 18, 4)->default(0); $t->string('channel')->nullable();
  156. $t->decimal('fee_rate', 10, 6)->default(0); $t->decimal('fee_fixed', 18, 4)->default(0);
  157. $t->string('bank_name')->nullable(); $t->integer('payment_type')->nullable(); $t->tinyInteger('status')->default(0);
  158. $t->string('state')->default('0'); $t->string('callback_url')->nullable(); $t->text('callback_data')->nullable();
  159. $t->string('pay_no')->nullable(); $t->text('pay_url')->nullable(); $t->text('pay_data')->nullable();
  160. $t->string('image')->nullable(); $t->tinyInteger('is_send')->default(0); $t->string('remark')->nullable();
  161. $t->text('recharge_bonus_snapshot')->nullable(); $t->timestamps();
  162. });
  163. }
  164. private function agent(int $type = Agent::TYPE_JOINT): Agent
  165. {
  166. static $number = 0;
  167. return $this->agents->create([
  168. 'username' => 'agent' . ++$number, 'password' => 'old-password',
  169. 'withdraw_password' => 'withdraw-old', 'type' => $type,
  170. ]);
  171. }
  172. private function token(Agent $agent): string
  173. {
  174. $plain = 'test-token-' . $agent->id;
  175. AgentToken::query()->create(['agent_id' => $agent->id, 'token_hash' => hash('sha256', $plain),
  176. 'expires_at' => now()->addDay(), 'last_used_at' => now(), 'ip' => '', 'device' => 'pc']);
  177. return $plain;
  178. }
  179. }