ShareAdminManagementTest.php 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494
  1. <?php
  2. namespace Tests\Integration;
  3. use App\Console\Commands\BackfillShareCommissions;
  4. use App\Http\Controllers\admin\ShareEarning;
  5. use App\Http\Controllers\admin\SharingManagement;
  6. use App\Models\BalanceLog;
  7. use App\Models\ShareSetting;
  8. use App\Services\BalanceLogService;
  9. use App\Services\ShareCommissionDiagnostics;
  10. use App\Services\Sharing\ShareAdminService;
  11. use App\Services\Sharing\ShareClaimAuditService;
  12. use Carbon\Carbon;
  13. use Illuminate\Config\Repository;
  14. use Illuminate\Database\Capsule\Manager;
  15. use Illuminate\Database\Schema\Blueprint;
  16. use Illuminate\Foundation\Application;
  17. use Illuminate\Http\JsonResponse;
  18. use Illuminate\Http\Request;
  19. use Illuminate\Support\Facades\DB;
  20. use Illuminate\Support\Facades\Facade;
  21. use Illuminate\Translation\ArrayLoader;
  22. use Illuminate\Translation\Translator;
  23. use Illuminate\Validation\Factory;
  24. use Illuminate\Validation\ValidationException;
  25. use PHPUnit\Framework\TestCase;
  26. use Symfony\Component\Console\Tester\CommandTester;
  27. /** Only an isolated, prefixed SQLite database; never boots project providers or reads .env. */
  28. class ShareAdminManagementTest extends TestCase
  29. {
  30. private Application $app;
  31. private ShareAdminService $admin;
  32. private ShareClaimAuditService $claims;
  33. protected function setUp(): void
  34. {
  35. Facade::clearResolvedInstances();
  36. Carbon::setTestNow(Carbon::parse('2026-10-24 18:00:00', 'Asia/Shanghai'));
  37. $this->app = new Application(dirname(__DIR__, 2));
  38. $this->app->instance('config', new Repository(['app' => ['locale' => 'zh', 'timezone' => 'Asia/Shanghai']]));
  39. Facade::setFacadeApplication($this->app);
  40. $db = new Manager($this->app);
  41. $db->addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => 'bot_']);
  42. $db->setAsGlobal(); $db->bootEloquent();
  43. $this->app->instance('db', $db->getDatabaseManager());
  44. $this->app->bind('db.schema', fn () => $db->schema());
  45. $translator = new Translator(new ArrayLoader(), 'zh');
  46. $this->app->instance('translator', $translator);
  47. $this->app->instance('validator', new Factory($translator, $this->app));
  48. $this->app->instance(\Illuminate\Contracts\Routing\ResponseFactory::class, new \Tests\Support\JsonResponseFactory());
  49. Request::macro('validate', function (array $rules) { return app('validator')->make($this->all(), $rules)->validate(); });
  50. $db->schema()->create('share_settings', function (Blueprint $t) {
  51. $t->id(); $t->integer('enabled')->default(0); $t->integer('benefit_enabled')->default(0);
  52. $t->string('share_domain')->default(''); $t->decimal('valid_member_min_deposit', 20, 2)->default(20);
  53. $t->decimal('non_seller_threshold', 24, 10)->default(200); $t->decimal('tier_one_rate', 8, 4)->default(4);
  54. $t->decimal('tier_two_rate', 8, 4)->default(6); $t->integer('operator_id')->nullable(); $t->string('operator_name')->default(''); $t->timestamps();
  55. });
  56. $db->schema()->create('users', function (Blueprint $t) {
  57. $t->id(); $t->string('member_id')->unique(); $t->string('account'); $t->string('first_name'); $t->timestamps();
  58. });
  59. $db->schema()->create('wallets', function (Blueprint $t) {
  60. $t->id(); $t->string('member_id')->unique(); $t->decimal('available_balance', 24, 10)->default(0); $t->timestamps();
  61. });
  62. $db->schema()->create('balance_logs', function (Blueprint $t) {
  63. $t->id(); $t->string('member_id'); $t->string('change_type'); $t->integer('type')->default(1);
  64. foreach (['amount', 'before_balance', 'after_balance'] as $field) $t->decimal($field, 24, 10);
  65. $t->integer('related_id')->nullable(); $t->text('remark')->nullable(); $t->timestamps();
  66. });
  67. $db->schema()->create('operation_audits', function (Blueprint $t) {
  68. $t->id(); $t->string('resource_type'); $t->integer('resource_id')->nullable(); $t->string('action');
  69. $t->json('changes'); $t->integer('operator_id')->nullable(); $t->string('operator_name'); $t->timestamp('created_at')->useCurrent();
  70. });
  71. (require dirname(__DIR__, 2) . '/database/migrations/2026_10_24_120000_create_member_sharing_system.php')->up();
  72. $request = Request::create('/admin/shareEarning/settings', 'GET');
  73. $request->user = (object)['id' => 8, 'username' => 'admin'];
  74. $this->app->instance('request', $request);
  75. $this->admin = new ShareAdminService(); $this->claims = new ShareClaimAuditService();
  76. }
  77. protected function tearDown(): void
  78. {
  79. BalanceLogService::$MODEL = BalanceLog::class;
  80. Carbon::setTestNow(); DB::disconnect(); Facade::clearResolvedInstances(); parent::tearDown();
  81. }
  82. private function config(array $extra = []): array
  83. {
  84. return array_replace(['enabled' => 1, 'share_domain' => 'https://PC.example.com/',
  85. 'valid_member_min_deposit' => '20.00', 'valid_member_min_effective_bet' => '100.00'], $extra);
  86. }
  87. private function initialized(): void
  88. {
  89. DB::table('share_system_state')->insert(['key' => 'initialized_at', 'value' => '2026-10-24 18:00:00']);
  90. }
  91. private function fails(callable $callback, string $contains): void
  92. {
  93. try { $callback(); $this->fail('Expected validation rejection'); }
  94. catch (ValidationException $e) { $this->assertStringContainsString($contains, $e->validator->errors()->first()); }
  95. }
  96. private function assertNoCommissionSwitch(array $settings): void
  97. {
  98. $this->assertArrayNotHasKey('benefit_enabled', $settings);
  99. $this->assertArrayNotHasKey('activation_date', $settings);
  100. foreach (['current_policy', 'pending_policy'] as $key) {
  101. if ($settings[$key] !== null) $this->assertArrayNotHasKey('benefit_enabled', $settings[$key]);
  102. }
  103. }
  104. public function test_explicit_setup_and_initializer_gate_enablement(): void
  105. {
  106. $initial = $this->admin->settings();
  107. $this->assertArrayNotHasKey('activation_date', $initial);
  108. $this->assertNoCommissionSwitch($initial);
  109. $this->fails(fn () => $this->admin->saveSettings($this->config()), '初始化');
  110. $this->fails(fn () => $this->admin->saveSettings($this->config(['benefit_enabled' => 0])), '初始化');
  111. $this->assertSame(0, DB::table('share_settings')->count());
  112. $this->initialized();
  113. $incomplete = $this->config(); unset($incomplete['valid_member_min_effective_bet']);
  114. $this->fails(fn () => $this->admin->saveSettings($incomplete), 'required');
  115. $this->fails(fn () => $this->admin->saveSettings($this->config(['tier_one_rate' => 4])), '已移除');
  116. $this->fails(fn () => $this->admin->saveSettings($this->config(['share_domain' => 'https://pc.example/a'])), '源地址');
  117. $data = $this->admin->saveSettings($this->config());
  118. $this->assertArrayNotHasKey('activation_date', $data);
  119. $this->assertSame('https://pc.example.com', $data['share_domain']);
  120. $this->assertNoCommissionSwitch($data);
  121. $this->assertSame(0, (int) DB::table('share_policies')->where('id', $data['current_policy']['id'])->value('benefit_enabled'));
  122. $this->assertSame(0, (int) DB::table('share_policies')->where('id', $data['pending_policy']['id'])->value('benefit_enabled'));
  123. $this->assertSame(0, (int) DB::table('share_settings')->value('benefit_enabled'));
  124. $this->assertArrayNotHasKey('tier_one_rate', $data);
  125. $this->assertArrayNotHasKey('indirect_bonus_rate', $data);
  126. $this->assertArrayNotHasKey('indirect_bonus_rate', $data['current_policy']);
  127. $this->assertArrayNotHasKey('indirect_bonus_rate', $data['pending_policy']);
  128. $this->assertSame(12, count($data['pending_policy']['rules']['third:1']));
  129. }
  130. public function test_pending_policy_edits_preserve_past_policy_without_retired_activation_state(): void
  131. {
  132. $this->initialized();
  133. $first = $this->admin->saveSettings($this->config());
  134. $pendingId = $first['pending_policy']['id'];
  135. $this->admin->saveRateRules('third:1', [['min_valid_members' => 1, 'min_performance' => '1000000', 'rate' => '0.25']]);
  136. $again = $this->admin->saveSettings($this->config(['valid_member_min_effective_bet' => '250']));
  137. $this->assertSame($pendingId, $again['pending_policy']['id']);
  138. $this->assertSame('0.2500', $again['pending_policy']['rules']['third:1'][0]['rate']);
  139. Carbon::setTestNow(Carbon::parse('2026-10-25 01:00:00', 'Asia/Shanghai'));
  140. $before = (array)DB::table('share_policies')->where('id', $pendingId)->first();
  141. $this->admin->saveSettings($this->config(['benefit_enabled' => 0]));
  142. $this->admin->saveRateRules('third:2', [['min_valid_members' => 0, 'min_performance' => 10, 'rate' => '0.50']]);
  143. $this->assertSame($before, (array)DB::table('share_policies')->where('id', $pendingId)->first());
  144. $settings = $this->admin->settings();
  145. $this->assertNoCommissionSwitch($settings);
  146. $this->assertSame(0, (int) DB::table('share_policies')->where('id', $settings['pending_policy']['id'])->value('benefit_enabled'));
  147. $this->assertSame(0, (int) DB::table('share_policies')->where('id', $settings['current_policy']['id'])->value('benefit_enabled'));
  148. $this->admin->saveSettings($this->config());
  149. $this->assertArrayNotHasKey('activation_date', $this->admin->settings());
  150. $this->assertSame(3, DB::table('share_policies')->count());
  151. }
  152. public function test_both_member_thresholds_are_required_and_invalid_values_cannot_create_configuration(): void
  153. {
  154. $this->initialized();
  155. foreach (['valid_member_min_deposit', 'valid_member_min_effective_bet'] as $field) {
  156. foreach ([null, ''] as $value) {
  157. $input = $this->config([$field => $value, 'benefit_enabled' => 0]);
  158. try { $this->admin->saveSettings($input); $this->fail('Expected a required member threshold'); }
  159. catch (ValidationException $e) {
  160. $this->assertArrayHasKey('Required', $e->validator->failed()[$field]);
  161. $this->assertArrayNotHasKey('RequiredIf', $e->validator->failed()[$field]);
  162. }
  163. }
  164. $input = $this->config(['benefit_enabled' => 0]); unset($input[$field]);
  165. try { $this->admin->saveSettings($input); $this->fail('Expected a missing member threshold rejection'); }
  166. catch (ValidationException $e) { $this->assertArrayHasKey('Required', $e->validator->failed()[$field]); }
  167. foreach ([-1, 'invalid', '20.001'] as $value) {
  168. $this->fails(fn () => $this->admin->saveSettings($this->config([$field => $value, 'benefit_enabled' => 0])), 'regex');
  169. }
  170. }
  171. $this->assertSame(0, DB::table('share_settings')->count());
  172. $this->assertSame(1, DB::table('share_policies')->count());
  173. $this->assertSame(0, DB::table('operation_audits')->count());
  174. }
  175. public function test_legacy_switch_input_is_ignored_and_cannot_disable_complete_configuration(): void
  176. {
  177. $this->initialized();
  178. $past = (array) DB::table('share_policies')->where('effective_date', '1970-01-01')->first();
  179. $legacyInputs = [[]];
  180. foreach ([0, 1, '0', '1', null, 'obsolete', []] as $value) $legacyInputs[] = ['benefit_enabled' => $value];
  181. foreach ([null, '2099-01-01', 'invalid', []] as $value) $legacyInputs[] = ['activation_date' => $value];
  182. foreach ($legacyInputs as $legacyInput) {
  183. $request = Request::create('/admin/shareEarning/settings', 'POST', $this->config($legacyInput + ['enabled' => 0]));
  184. $request->user = (object) ['id' => 8, 'username' => 'admin'];
  185. $this->app->instance('request', $request);
  186. $body = (new ShareEarning())->saveSettings($this->admin)->getData(true);
  187. $this->assertSame(0, $body['code'], $body['msg']);
  188. $this->assertNoCommissionSwitch($body['data']);
  189. $this->assertSame(0, $body['data']['enabled']);
  190. $this->assertArrayNotHasKey('activation_date', $body['data']);
  191. $this->assertSame(0, (int) DB::table('share_settings')->value('benefit_enabled'));
  192. $this->assertSame(0, (int) DB::table('share_policies')->where('effective_date', '2026-10-25')->value('benefit_enabled'));
  193. }
  194. $this->assertSame(2, DB::table('share_policies')->count());
  195. $this->assertSame($past, (array) DB::table('share_policies')->where('id', $past['id'])->first());
  196. $this->assertArrayNotHasKey('benefit_enabled', ShareSetting::query()->firstOrFail()->toArray());
  197. Carbon::setTestNow(Carbon::parse('2026-10-24 23:59:59', 'Asia/Shanghai'));
  198. $this->assertSame($past['id'], $this->admin->settings()['current_policy']['id']);
  199. Carbon::setTestNow(Carbon::parse('2026-10-25 00:00:00', 'Asia/Shanghai'));
  200. $current = $this->admin->settings();
  201. $this->assertNoCommissionSwitch($current);
  202. $this->assertSame('2026-10-25', $current['current_policy']['effective_date']);
  203. $this->assertSame(0, (int) DB::table('share_policies')->where('id', $current['current_policy']['id'])->value('benefit_enabled'));
  204. $this->assertNull($current['pending_policy']);
  205. }
  206. public function test_existing_disabled_configuration_remains_unchanged_until_a_complete_save(): void
  207. {
  208. $id = DB::table('share_settings')->insertGetId(['benefit_enabled' => 0, 'share_domain' => 'https://legacy.example.test']);
  209. $before = (array) DB::table('share_settings')->where('id', $id)->first();
  210. $this->assertNoCommissionSwitch($this->admin->settings());
  211. $this->assertArrayNotHasKey('benefit_enabled', ShareSetting::query()->findOrFail($id)->toArray());
  212. $this->assertSame($before, (array) DB::table('share_settings')->where('id', $id)->first());
  213. $this->fails(fn () => $this->admin->saveSettings($this->config(['benefit_enabled' => 1])), '初始化');
  214. $this->assertSame($before, (array) DB::table('share_settings')->where('id', $id)->first());
  215. $this->initialized();
  216. $saved = $this->admin->saveSettings($this->config(['benefit_enabled' => 0]));
  217. $this->assertNoCommissionSwitch($saved);
  218. $this->assertSame(0, (int) DB::table('share_settings')->where('id', $id)->value('benefit_enabled'));
  219. $this->assertArrayNotHasKey('activation_date', $saved);
  220. $this->assertSame(0, (int) DB::table('share_policies')->where('id', $saved['current_policy']['id'])->value('benefit_enabled'));
  221. }
  222. public function test_retired_indirect_coefficient_is_rejected_even_when_null_without_saving(): void
  223. {
  224. $this->initialized();
  225. foreach ([null, 0, '30.0000'] as $value) {
  226. $this->fails(fn () => $this->admin->saveSettings($this->config(['indirect_bonus_rate' => $value])), '已移除');
  227. }
  228. $this->assertSame(0, DB::table('share_settings')->count());
  229. $this->assertSame(1, DB::table('share_policies')->count());
  230. $this->assertSame(0, DB::table('operation_audits')->count());
  231. $request = Request::create('/admin/shareEarning/settings', 'POST', $this->config(['indirect_bonus_rate' => '30']));
  232. $this->app->instance('request', $request);
  233. $body = (new ShareEarning())->saveSettings($this->admin)->getData(true);
  234. $this->assertSame(-3, $body['code']);
  235. $this->assertStringContainsString('已移除', $body['msg']);
  236. $this->assertSame(0, DB::table('share_settings')->count());
  237. }
  238. public function test_rule_edits_need_no_coefficient_and_preserve_archived_policy_values(): void
  239. {
  240. $this->initialized();
  241. DB::table('share_settings')->insert(['indirect_bonus_rate' => '25.0000']);
  242. DB::table('share_policies')->update(['indirect_bonus_rate' => '30.0000']);
  243. $archived = (array) DB::table('share_policies')->where('effective_date', '1970-01-01')->first();
  244. $settings = $this->admin->saveSettings($this->config());
  245. $this->assertArrayNotHasKey('indirect_bonus_rate', $settings);
  246. $this->assertArrayNotHasKey('indirect_bonus_rate', $settings['current_policy']);
  247. $this->assertArrayNotHasKey('indirect_bonus_rate', $settings['pending_policy']);
  248. $this->assertNull(DB::table('share_policies')->where('id', $settings['pending_policy']['id'])->value('indirect_bonus_rate'));
  249. $this->assertSame('25.0000', bcadd((string) DB::table('share_settings')->value('indirect_bonus_rate'), '0', 4));
  250. $model = ShareSetting::query()->firstOrFail();
  251. $this->assertArrayNotHasKey('indirect_bonus_rate', $model->getCasts());
  252. $this->assertArrayNotHasKey('indirect_bonus_rate', $model->toArray());
  253. $this->assertArrayNotHasKey('benefit_enabled', $model->toArray());
  254. $rules = $this->admin->saveRateRules('third:1', [['min_valid_members' => 0, 'min_performance' => 0, 'rate' => '10']]);
  255. $this->assertSame('10.0000', $rules['categories'][0]['pending_rules'][0]['rate']);
  256. $this->admin->saveSettings($this->config(['valid_member_min_effective_bet' => '250']));
  257. $this->admin->saveLevel(['level' => 2, 'name' => '二级', 'image' => '', 'incremental_commission' => '10']);
  258. $settings = $this->admin->settings();
  259. $this->assertSame('10.0000', $settings['pending_policy']['rules']['third:1'][0]['rate']);
  260. $this->assertSame('250.00', $settings['pending_policy']['valid_member_min_effective_bet']);
  261. $this->assertArrayNotHasKey('indirect_bonus_rate', $settings['pending_policy']);
  262. $this->assertNull(DB::table('share_policies')->where('id', $settings['pending_policy']['id'])->value('indirect_bonus_rate'));
  263. $this->assertSame($archived, (array) DB::table('share_policies')->where('id', $archived['id'])->first());
  264. Carbon::setTestNow(Carbon::parse('2026-10-25 01:00:00', 'Asia/Shanghai'));
  265. $this->assertSame('10.0000', $this->admin->rateRules('third:1')['categories'][0]['current_rules'][0]['rate']);
  266. $this->assertArrayNotHasKey('indirect_bonus_rate', $this->admin->settings()['current_policy']);
  267. }
  268. public function test_diagnostic_and_backfill_output_hide_archived_coefficient_without_writes(): void
  269. {
  270. $schema = DB::connection()->getSchemaBuilder();
  271. $schema->table('users', function (Blueprint $t) {
  272. $t->string('user_code')->default(''); $t->string('agent_user_code')->nullable(); $t->integer('from')->default(1);
  273. });
  274. $schema->create('config', function (Blueprint $t) {
  275. $t->id(); $t->string('field'); $t->string('val');
  276. });
  277. $schema->create('share_commission_records', function (Blueprint $t) {
  278. $t->id(); $t->date('stat_date'); $t->integer('status');
  279. });
  280. DB::table('config')->insert(['field' => 'recharge_rate', 'val' => '2.5000']);
  281. DB::table('share_settings')->insert(['benefit_enabled' => 1, 'indirect_bonus_rate' => '30', 'valid_member_min_effective_bet' => '100']);
  282. $before = (array) DB::table('share_settings')->first();
  283. $data = (new ShareCommissionDiagnostics())->inspect(null);
  284. $this->assertArrayNotHasKey('indirect_bonus_rate', $data);
  285. $this->assertArrayNotHasKey('benefit_enabled', $data);
  286. $this->assertEquals('100', $data['valid_member_min_effective_bet']);
  287. $command = new BackfillShareCommissions();
  288. $command->setLaravel($this->app);
  289. $tester = new CommandTester($command);
  290. $tester->execute(['--diagnose' => true, '--all' => true]);
  291. $this->assertSame(0, $tester->getStatusCode(), $tester->getDisplay());
  292. $this->assertStringNotContainsString('indirect_bonus_rate', $tester->getDisplay());
  293. $this->assertStringNotContainsString('benefit_enabled', $tester->getDisplay());
  294. $this->assertStringContainsString('valid_member_min_effective_bet: 100', $tester->getDisplay());
  295. $this->assertSame($before, (array) DB::table('share_settings')->first());
  296. $this->assertSame(0, DB::table('share_commission_records')->count());
  297. $this->assertSame(0, DB::table('balance_logs')->count());
  298. }
  299. public function test_deposit_precision_and_display_switch_do_not_silently_change_money_configuration(): void
  300. {
  301. $this->initialized();
  302. $this->fails(fn () => $this->admin->saveSettings($this->config(['valid_member_min_deposit' => '20.123'])), 'regex');
  303. $this->assertSame(0, DB::table('share_settings')->count());
  304. $this->assertSame(1, DB::table('share_policies')->count());
  305. $saved = $this->admin->saveSettings($this->config(['enabled' => 0, 'valid_member_min_deposit' => '20.12']));
  306. $this->assertSame(0, $saved['enabled']);
  307. $this->assertNoCommissionSwitch($saved);
  308. $this->assertSame(0, (int) DB::table('share_settings')->value('benefit_enabled'));
  309. $this->assertSame(0, (int) DB::table('share_policies')->where('id', $saved['pending_policy']['id'])->value('benefit_enabled'));
  310. $this->assertSame('20.12', $saved['pending_policy']['valid_member_min_deposit']);
  311. $this->assertSame('20.12', $saved['valid_member_min_deposit']);
  312. $this->assertSame('20.1200000000', bcadd((string)DB::table('share_policies')->where('effective_date', '2026-10-25')->value('valid_member_min_deposit'), '0', 10));
  313. }
  314. public function test_rules_are_ordered_by_threshold_and_invalid_ladders_are_rejected_without_pending_writes(): void
  315. {
  316. $this->admin->saveRateRules('third:1', [
  317. ['min_valid_members' => 2, 'min_performance' => '100', 'rate' => '0.30'],
  318. ['min_valid_members' => 1, 'min_performance' => '100', 'rate' => '0.20'],
  319. ['min_valid_members' => 0, 'min_performance' => '10', 'rate' => '0.10'],
  320. ]);
  321. $rules = $this->admin->rateRules('third:1')['categories'][0]['pending_rules'];
  322. $this->assertSame([0, 1, 2], array_column($rules, 'min_valid_members'));
  323. $this->assertSame(['10.00', '100.00', '100.00'], array_column($rules, 'min_performance'));
  324. $before = (array)DB::table('share_policies')->where('effective_date', '2026-10-25')->first();
  325. $this->fails(fn () => $this->admin->saveRateRules('third:1', [
  326. ['min_valid_members' => 1, 'min_performance' => '100.0', 'rate' => '0.20'],
  327. ['min_valid_members' => 1, 'min_performance' => '100.00', 'rate' => '0.30'],
  328. ]), '不可重复');
  329. $this->fails(fn () => $this->admin->saveRateRules('third:1', [
  330. ['min_valid_members' => 2, 'min_performance' => '100', 'rate' => '0.20'],
  331. ['min_valid_members' => 1, 'min_performance' => '200', 'rate' => '0.30'],
  332. ]), '人数门槛不可降低');
  333. $this->fails(fn () => $this->admin->saveRateRules('third:1', [
  334. ['min_valid_members' => 1, 'min_performance' => '100', 'rate' => '0.30'],
  335. ['min_valid_members' => 2, 'min_performance' => '200', 'rate' => '0.20'],
  336. ]), '返佣比例不可降低');
  337. $this->assertSame($before, (array)DB::table('share_policies')->where('effective_date', '2026-10-25')->first());
  338. $this->assertSame(1, DB::table('operation_audits')->count());
  339. }
  340. public function test_levels_sum_increments_and_never_downgrade_members(): void
  341. {
  342. DB::table('share_members')->insert(['user_id' => 7, 'level' => 1, 'bound_at' => '2026-10-24 00:00:00']);
  343. DB::table('share_accounts')->insert(['user_id' => 7, 'earned_amount' => 25]);
  344. $this->admin->saveLevel(['level' => 2, 'name' => '二级', 'image' => '', 'incremental_commission' => 10]);
  345. $this->admin->saveLevel(['level' => 3, 'name' => '三级', 'image' => '/three.png', 'incremental_commission' => 20]);
  346. $this->assertSame(2, (int)DB::table('share_members')->value('level'));
  347. $levels = $this->admin->levels()['data'];
  348. $this->assertSame('30.00', $levels[2]['cumulative_commission']);
  349. $this->admin->saveLevel(['id' => $levels[1]['id'], 'level' => 2, 'name' => '二级', 'image' => '', 'incremental_commission' => 100]);
  350. $this->assertSame(2, (int)DB::table('share_members')->value('level'));
  351. $this->fails(fn () => $this->admin->deleteLevel($levels[1]['id']), '已有会员');
  352. $this->admin->deleteLevel($levels[2]['id']);
  353. $this->assertCount(2, $this->admin->levels()['data']);
  354. }
  355. private function pending(string $earned = '10.019', string $amount = '10.01'): int
  356. {
  357. DB::table('users')->insert(['id' => 7, 'member_id' => 'M7', 'account' => 'member7', 'first_name' => '会员']);
  358. DB::table('wallets')->insert(['member_id' => 'M7', 'available_balance' => 20]);
  359. DB::table('share_accounts')->insert(['user_id' => 7, 'earned_amount' => $earned, 'paid_amount' => 0, 'reserved_amount' => $amount]);
  360. return DB::table('share_claims')->insertGetId(['user_id' => 7, 'request_id' => 'req-1', 'active_user_id' => 7,
  361. 'amount' => $amount, 'status' => 'pending', 'created_at' => '2026-10-24 12:00:00', 'updated_at' => '2026-10-24 12:00:00']);
  362. }
  363. public function test_approval_credits_member_wallet_once_and_retains_fractional_remainder(): void
  364. {
  365. $id = $this->pending();
  366. $approved = $this->claims->audit($id, 'approve', 8, '已核对');
  367. $this->assertSame('approved', $approved['status']);
  368. $this->assertSame('0.00', $approved['commission_account']['spendable_amount']);
  369. $this->assertSame('10.01', $approved['commission_account']['paid_amount']);
  370. $this->assertEquals('30.01', DB::table('wallets')->value('available_balance'));
  371. $this->assertEquals('0.0090000000', bcsub((string)DB::table('share_accounts')->value('earned_amount'), (string)DB::table('share_accounts')->value('paid_amount'), 10));
  372. $this->assertNull(DB::table('share_claims')->value('active_user_id'));
  373. $this->claims->audit($id, 'approve', 8, '重复');
  374. $this->assertSame(1, DB::table('balance_logs')->count());
  375. $this->assertSame(1, DB::table('operation_audits')->count());
  376. $this->assertSame('分享佣金领取', DB::table('balance_logs')->value('change_type'));
  377. $this->assertSame(8, (int)DB::table('operation_audits')->value('operator_id'));
  378. $this->fails(fn () => $this->claims->audit($id, 'reject', 8), '不可更改');
  379. }
  380. public function test_recomputed_insufficiency_blocks_approval_and_rejection_releases_full_reservation(): void
  381. {
  382. $id = $this->pending('5.00', '10.01');
  383. $this->assertFalse($this->claims->detail($id)['can_approve']);
  384. $this->fails(fn () => $this->claims->audit($id, 'approve', 8), '重新申请');
  385. $this->assertSame('pending', DB::table('share_claims')->value('status'));
  386. $this->assertEquals(20, DB::table('wallets')->value('available_balance'));
  387. $rejected = $this->claims->audit($id, 'reject', 8, '请重新申请');
  388. $this->assertSame('5.00', $rejected['commission_account']['spendable_amount']);
  389. $this->assertSame(0, DB::table('balance_logs')->count());
  390. $this->claims->audit($id, 'reject', 8);
  391. $this->assertSame(1, DB::table('operation_audits')->count());
  392. }
  393. public function test_audit_failure_rolls_back_wallet_log_account_and_claim(): void
  394. {
  395. $id = $this->pending();
  396. DB::statement("CREATE TRIGGER block_operation_audit BEFORE INSERT ON bot_operation_audits BEGIN SELECT RAISE(ABORT, 'audit offline'); END");
  397. try { $this->claims->audit($id, 'approve', 8); $this->fail('Expected audit write failure'); }
  398. catch (\Illuminate\Database\QueryException $e) { $this->assertStringContainsString('audit offline', $e->getMessage()); }
  399. $this->assertEquals(20, DB::table('wallets')->value('available_balance'));
  400. $this->assertSame('pending', DB::table('share_claims')->value('status'));
  401. $this->assertEquals(0, DB::table('share_accounts')->value('paid_amount'));
  402. $this->assertEquals('10.01', DB::table('share_accounts')->value('reserved_amount'));
  403. $this->assertSame(0, DB::table('balance_logs')->count());
  404. }
  405. public function test_claim_audit_type_options_and_filter_preserve_existing_filters(): void
  406. {
  407. $id = $this->pending();
  408. $this->assertSame([['value' => 'agent_commission', 'label' => '代理佣金']], $this->claims->options()['audit_types']);
  409. $all = $this->claims->list([]);
  410. $filtered = $this->claims->list(['audit_type' => 'agent_commission', 'status' => 'pending', 'account' => 'member7', 'limit' => 1]);
  411. $this->assertSame($all['total'], $filtered['total']);
  412. $this->assertSame($id, $filtered['data'][0]['id']);
  413. $this->assertSame('agent_commission', $filtered['data'][0]['audit_type']);
  414. $this->assertSame(0, $this->claims->list(['audit_type' => 'agent_commission', 'status' => 'approved'])['total']);
  415. $this->assertSame($all['total'], $this->claims->list(['audit_type' => ''])['total']);
  416. foreach (['invalid', 'pending', ['agent_commission']] as $type) {
  417. try { $this->claims->list(['audit_type' => $type]); $this->fail('Invalid audit type accepted'); }
  418. catch (ValidationException $e) { $this->assertArrayHasKey('audit_type', $e->errors()); }
  419. }
  420. $this->assertSame(0, (new SharingManagement())->claimOptions($this->claims)->getData(true)['code']);
  421. $router = new \Illuminate\Routing\Router(new \Illuminate\Events\Dispatcher($this->app), $this->app);
  422. $this->app->instance('router', $router);
  423. $router->group(['prefix' => 'admin'], function () { require dirname(__DIR__, 2) . '/routes/admin.php'; });
  424. $route = $router->getRoutes()->match(Request::create('/admin/shareEarning/claims/options'));
  425. $this->assertSame('admin/shareEarning/claims', $route->defaults['permission_uri']);
  426. $this->assertContains('operations.admin', $route->middleware());
  427. $this->assertContains('admin.jwt', $route->middleware());
  428. }
  429. public function test_admin_controller_envelopes_and_filtered_lists(): void
  430. {
  431. $id = $this->pending();
  432. $this->assertSame('代理佣金', $this->claims->list(['status' => 'pending'])['data'][0]['activity_name']);
  433. $this->assertSame('代理佣金', $this->claims->detail($id)['activity_name']);
  434. $this->assertSame(0, (new ShareEarning())->settings($this->admin)->getData(true)['code']);
  435. $request = Request::create('/admin/shareEarning/claims/audit', 'POST', ['id' => $id, 'action' => 'approve']);
  436. $request->user = (object)['id' => 8, 'username' => 'admin'];
  437. $this->app->instance('request', $request);
  438. $body = (new SharingManagement())->auditClaim($this->claims)->getData(true);
  439. $this->assertSame(0, $body['code']); $this->assertSame('approved', $body['data']['status']);
  440. $this->assertSame('代理佣金', $body['data']['activity_name']);
  441. $list = $this->claims->list(['status' => 'approved', 'account' => 'member7', 'start_date' => '2026-10-24', 'end_date' => '2026-10-24']);
  442. $this->assertSame(1, $list['total']); $this->assertSame('10.01', $list['data'][0]['amount']);
  443. $this->assertSame('代理佣金', $list['data'][0]['activity_name']);
  444. $this->assertSame(0, $this->claims->list(['status' => 'pending'])['total']);
  445. }
  446. public function test_database_errors_do_not_expose_sql_in_admin_responses(): void
  447. {
  448. $this->app->instance('log', new \Psr\Log\NullLogger());
  449. DB::statement('DROP TABLE bot_share_levels');
  450. $body = (new SharingManagement())->levels($this->admin)->getData(true);
  451. $this->assertSame(-3, $body['code']);
  452. $this->assertStringContainsString('暂不可用', $body['msg']);
  453. $this->assertStringNotContainsString('SQLSTATE', $body['msg']);
  454. $this->assertStringNotContainsString('share_levels', $body['msg']);
  455. }
  456. public function test_money_configuration_cannot_lose_subcent_values_during_frontend_roundtrip(): void
  457. {
  458. $this->initialized();
  459. $this->fails(fn () => $this->admin->saveSettings($this->config(['valid_member_min_effective_bet'=>'0.001'])), 'regex');
  460. $this->fails(fn () => $this->admin->saveRateRules('third:2', [['min_valid_members'=>0,'min_performance'=>'0.001','rate'=>'0.1']]), 'regex');
  461. $this->fails(fn () => $this->admin->saveLevel(['level'=>2,'name'=>'二级','image'=>'','incremental_commission'=>'0.001']), 'regex');
  462. $this->assertSame(1, DB::table('share_policies')->count());
  463. $this->assertSame(1, DB::table('share_levels')->count());
  464. }
  465. }