Simulation.php 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439
  1. <?php
  2. namespace App\Http\Controllers\admin;
  3. use App\Constants\HttpStatus;
  4. use App\Http\Controllers\Controller;
  5. use App\Services\MemberDailyReportService;
  6. use App\Services\OperationAuditService;
  7. use App\Services\PaymentOrderService;
  8. use App\Services\Reporting\ReportSync;
  9. use App\Services\Sharing\BetFactSynchronizer;
  10. use App\Services\Simulation\SimulationBetService;
  11. use App\Services\Simulation\SimulationFundService;
  12. use App\Services\Simulation\SimulationMemberService;
  13. use App\Services\Simulation\SimulationResetService;
  14. use App\Services\Simulation\SimulationSettings;
  15. use App\Services\Simulation\SimulationTaskService;
  16. use App\Support\MemberAudience;
  17. use Illuminate\Http\JsonResponse;
  18. use Illuminate\Support\Facades\DB;
  19. use Illuminate\Validation\ValidationException;
  20. /**
  21. * 模拟管理。只有这里能看到后台模拟会员(users.user_type = 2)的数据:
  22. * 列表和报表直接复用正式接口的代码,只是放在 MemberAudience::simulated() 里执行,所以行结构和统计口径与正式页面完全一致。
  23. */
  24. class Simulation extends Controller
  25. {
  26. /** 在“仅模拟会员”口径下调用正式控制器的方法。 */
  27. private function mirror(string $controller, string $method): JsonResponse
  28. {
  29. return MemberAudience::simulated(fn () => app()->call([app($controller), $method]));
  30. }
  31. private function run(callable $work): JsonResponse
  32. {
  33. try {
  34. return $this->success($work());
  35. } catch (ValidationException $e) {
  36. return $this->error(HttpStatus::CUSTOM_ERROR, $e->validator->errors()->first());
  37. } catch (\Throwable $e) {
  38. return $this->error(HttpStatus::CUSTOM_ERROR, $e->getMessage());
  39. }
  40. }
  41. private function audit(string $type, $id, string $action, array $after = []): void
  42. {
  43. OperationAuditService::record('simulation_' . $type, $id === null ? null : (int) $id, $action, [], $after);
  44. }
  45. private function page($query, string $order = 'id'): array
  46. {
  47. $page = max(1, (int) request()->input('page', 1));
  48. $limit = min(200, max(1, (int) request()->input('limit', 15)));
  49. return ['total' => (clone $query)->count(), 'data' => $query->orderByDesc($order)->forPage($page, $limit)->get()];
  50. }
  51. // ---------- 概览 ----------
  52. public function overview()
  53. {
  54. return $this->run(function () {
  55. $start = request()->input('start_date') ?: date('Y-m-d');
  56. $end = date('Y-m-d', strtotime((request()->input('end_date') ?: date('Y-m-d')) . ' +1 day'));
  57. $members = MemberAudience::simulatedMembers('member_id');
  58. $logs = fn (array $types) => (string) DB::table('balance_logs')->whereIn('member_id', $members)->whereIn('change_type', $types)
  59. ->where('created_at', '>=', $start)->where('created_at', '<', $end)->sum('amount');
  60. $like = fn (string $suffix) => (string) DB::table('balance_logs')->whereIn('member_id', $members)->where('change_type', 'like', '%' . $suffix)
  61. ->where('created_at', '>=', $start)->where('created_at', '<', $end)->sum('amount');
  62. $bet = bcmul($like('投注'), '-1', 2);
  63. $payout = bcadd(bcadd($like('中奖'), $like('退款'), 2), $like('退分'), 2);
  64. $wallet = DB::table('wallets')->whereIn('member_id', $members)->selectRaw('COALESCE(SUM(available_balance),0) a, COALESCE(SUM(frozen_balance),0) f')->first();
  65. $runs = DB::table('simulation_task_runs')->whereIn('status', ['queued', 'running', 'paused'])->orderByDesc('id')->limit(10)->get();
  66. return [
  67. 'enabled' => SimulationSettings::enabled(), 'migrated' => MemberAudience::enabled(), 'start_date' => $start,
  68. 'schedule' => ['enabled' => SimulationSettings::get('simulation_schedule_enabled') === '1', 'start' => SimulationSettings::get('simulation_schedule_start'),
  69. 'end' => SimulationSettings::get('simulation_schedule_end'), 'gap_minutes' => (int) SimulationSettings::get('simulation_schedule_gap_minutes')],
  70. 'members' => ['total' => DB::table('users')->where('user_type', MemberAudience::TYPE_SIMULATED)->count(),
  71. 'new' => DB::table('users')->where('user_type', MemberAudience::TYPE_SIMULATED)->where('created_at', '>=', $start)->where('created_at', '<', $end)->count(),
  72. 'online' => SimulationMemberService::onlineUserIds()->count()],
  73. 'funds' => ['recharge' => bcadd($logs(['充值', '三方充值']), '0', 2), 'manual_credit' => bcadd($logs(['人工充值']), '0', 2),
  74. 'manual_debit' => bcmul($logs(['人工扣款']), '-1', 2), // 提现被拒绝时退款记为正数的“三方提现”,一并计入才是净提现
  75. 'withdraw' => bcmul($logs(['提现', '人工提现', '三方提现']), '-1', 2),
  76. 'wallet_available' => bcadd((string) $wallet->a, '0', 2), 'wallet_frozen' => bcadd((string) $wallet->f, '0', 2)],
  77. 'bets' => ['bet_amount' => $bet, 'payout' => $payout, 'win_loss' => bcsub($bet, $payout, 2),
  78. 'unsettled' => DB::table('lhc_order')->whereIn('member_id', $members)->where('lottery_status', 0)->count()
  79. + SimulationBetService::sportQuery()->where('sport_order.settlement_status', 0)->where('sport_order.status', 1)->count()],
  80. 'runs' => $runs->map(fn ($run) => SimulationTaskService::present($run))->all(),
  81. ];
  82. });
  83. }
  84. // ---------- 会员 ----------
  85. public function members()
  86. {
  87. $response = $this->mirror(User::class, 'index');
  88. $body = $response->getData(true);
  89. if (($body['code'] ?? -1) !== 0 || empty($body['data']['data'])) return $response;
  90. $online = array_flip(SimulationMemberService::onlineUserIds()->map(fn ($id) => (string) $id)->all());
  91. foreach ($body['data']['data'] as &$row) $row['sim_online'] = isset($online[(string) ($row['user_id'] ?? '')]);
  92. return response()->json($body);
  93. }
  94. public function createMembers()
  95. {
  96. return $this->run(function () {
  97. $params = request()->validate(['count' => ['required', 'integer', 'min:1'], 'domain' => ['nullable', 'string', 'max:100'],
  98. 'initial_balance' => ['nullable', 'numeric', 'min:0']]);
  99. $created = SimulationMemberService::create((int) $params['count'], $params);
  100. $this->audit('member', null, 'create', ['count' => count($created), 'member_ids' => array_column($created, 'member_id')]);
  101. return ['created' => $created];
  102. });
  103. }
  104. public function memberDetail()
  105. {
  106. return $this->run(fn () => SimulationMemberService::detail(request()->validate(['member_id' => ['required', 'string']])['member_id']));
  107. }
  108. public function memberBanned()
  109. {
  110. return $this->run(function () {
  111. $params = request()->validate(['member_id' => ['required', 'string'], 'is_banned' => ['required', 'integer', 'in:0,1']]);
  112. SimulationMemberService::setBanned($params['member_id'], (int) $params['is_banned']);
  113. $this->audit('member', null, 'banned', $params);
  114. return [];
  115. });
  116. }
  117. public function memberLogin()
  118. {
  119. return $this->run(function () {
  120. $params = request()->validate(['member_id' => ['required', 'string'], 'platform' => ['nullable', 'string', 'max:16'], 'domain' => ['nullable', 'string', 'max:100']]);
  121. return SimulationMemberService::login($params['member_id'], $params['platform'] ?? 'H5', $params['domain'] ?? null);
  122. });
  123. }
  124. public function memberLogout()
  125. {
  126. return $this->run(fn () => ['closed' => SimulationMemberService::logout(request()->validate(['member_id' => ['required', 'string']])['member_id'])]);
  127. }
  128. public function loginLogs() { return $this->mirror(User::class, 'loginLog'); }
  129. // ---------- 资金 ----------
  130. private function orders(array $types)
  131. {
  132. $query = DB::table('payment_orders')->join('users', 'users.member_id', '=', 'payment_orders.member_id')
  133. ->where('users.user_type', MemberAudience::TYPE_SIMULATED)->whereIn('payment_orders.type', $types)
  134. ->select('payment_orders.id', 'payment_orders.order_no', 'payment_orders.member_id', 'users.account', 'users.first_name', 'payment_orders.type',
  135. 'payment_orders.amount', 'payment_orders.frozen_amount', 'payment_orders.channel', 'payment_orders.status', 'payment_orders.remark', 'payment_orders.created_at');
  136. if (request()->filled('member_id')) $query->where('payment_orders.member_id', (string) request()->input('member_id'));
  137. if (request()->filled('account')) $query->where('users.account', 'like', '%' . trim((string) request()->input('account')) . '%');
  138. if (request()->filled('status')) $query->where('payment_orders.status', (int) request()->input('status'));
  139. if (request()->filled('start_time')) $query->where('payment_orders.created_at', '>=', request()->input('start_time') . ' 00:00:00');
  140. if (request()->filled('end_time')) $query->where('payment_orders.created_at', '<=', request()->input('end_time') . ' 23:59:59');
  141. return $query;
  142. }
  143. public function recharges()
  144. {
  145. return $this->run(fn () => $this->page($this->orders([PaymentOrderService::TYPE_PAY, PaymentOrderService::TYPE_SELF_PAY]), 'payment_orders.id'));
  146. }
  147. public function recharge()
  148. {
  149. return $this->run(function () {
  150. $params = request()->validate(['member_id' => ['required', 'string'], 'amount' => ['required', 'numeric', 'min:0.01'],
  151. 'channel' => ['nullable', 'in:company,third'], 'result' => ['nullable', 'in:success,pending,fail']]);
  152. $order = SimulationFundService::recharge($params['member_id'], $params['amount'], $params['channel'] ?? 'third', $params['result'] ?? 'success');
  153. $this->audit('fund', $order->id, 'recharge', $params);
  154. return $order;
  155. });
  156. }
  157. public function withdrawals()
  158. {
  159. return $this->run(fn () => $this->page($this->orders([PaymentOrderService::TYPE_SELF_PAYOUT]), 'payment_orders.id'));
  160. }
  161. public function withdraw()
  162. {
  163. return $this->run(function () {
  164. $params = request()->validate(['member_id' => ['required', 'string'], 'amount' => ['required', 'numeric', 'min:0.01']]);
  165. $order = SimulationFundService::withdrawApply($params['member_id'], $params['amount']);
  166. $this->audit('fund', $order->id, 'withdraw_apply', $params);
  167. return $order;
  168. });
  169. }
  170. public function withdrawAudit()
  171. {
  172. return $this->run(function () {
  173. $params = request()->validate(['id' => ['required', 'integer', 'min:1'], 'approve' => ['required', 'integer', 'in:0,1'], 'remark' => ['nullable', 'string', 'max:120']]);
  174. $order = SimulationFundService::withdrawAudit((int) $params['id'], (int) $params['approve'] === 1, (string) ($params['remark'] ?? ''));
  175. $this->audit('fund', $order->id, (int) $params['approve'] === 1 ? 'withdraw_approve' : 'withdraw_reject', $params);
  176. return $order;
  177. });
  178. }
  179. public function adjust()
  180. {
  181. return $this->run(function () {
  182. $params = request()->validate(['member_id' => ['required', 'string'], 'amount' => ['required', 'numeric', 'min:0.01'],
  183. 'direction' => ['required', 'in:credit,debit'], 'remark' => ['required', 'string', 'max:120']]);
  184. $log = SimulationFundService::adjust($params['member_id'], $params['amount'], $params['direction'], $params['remark']);
  185. $this->audit('fund', $log->id, 'adjust_' . $params['direction'], $params);
  186. return $log;
  187. });
  188. }
  189. public function ledger() { return $this->mirror(Balance::class, 'log'); }
  190. public function freezes() { return $this->mirror(WalletFreeze::class, 'index'); }
  191. public function freeze() { return $this->applyFreeze(false); }
  192. public function unfreeze() { return $this->applyFreeze(true); }
  193. private function applyFreeze(bool $unfreeze)
  194. {
  195. return $this->run(function () use ($unfreeze) {
  196. $params = request()->validate(['member_id' => ['required', 'string'], 'amount' => ['required', 'numeric', 'min:0.01'], 'reason' => ['required', 'string', 'max:120']]);
  197. $record = SimulationFundService::freeze($params['member_id'], $params['amount'], $params['reason'], (int) (request()->user->id ?? 0), $unfreeze);
  198. $this->audit('fund', $record['id'] ?? null, $unfreeze ? 'unfreeze' : 'freeze', $params);
  199. return $record;
  200. });
  201. }
  202. // ---------- 注单 ----------
  203. public function betOptions() { return $this->run(fn () => SimulationBetService::options()); }
  204. public function lotteryBets() { return $this->mirror(LhcOrder::class, 'list'); }
  205. public function lotteryBet()
  206. {
  207. return $this->run(function () {
  208. $params = request()->validate(['member_id' => ['required', 'string'], 'keywords' => ['required', 'string', 'max:32'], 'amount' => ['required', 'numeric', 'min:0.01']]);
  209. $order = SimulationBetService::lottery($params['member_id'], $params['keywords'], $params['amount']);
  210. $this->audit('bet', $order->id, 'lottery_bet', $params);
  211. return $order;
  212. });
  213. }
  214. public function sportBets()
  215. {
  216. return $this->run(function () {
  217. $query = SimulationBetService::sportQuery()->select('sport_order.id', 'sport_order.order_id', 'sport_order.issue', 'users.member_id', 'users.account',
  218. 'users.first_name', 'sport_order.odd_name', 'sport_order.odd_value', 'sport_order.odd', 'sport_order.amount', 'sport_order.win_amount',
  219. 'sport_order.return_amount', 'sport_order.profit_and_loss', 'sport_order.is_win', 'sport_order.status', 'sport_order.settlement_status',
  220. 'sport_order.create_time', 'sport_order.settlement_time');
  221. if (request()->filled('member_id')) $query->where('users.member_id', (string) request()->input('member_id'));
  222. if (request()->filled('account')) $query->where('users.account', 'like', '%' . trim((string) request()->input('account')) . '%');
  223. if (request()->filled('settlement_status')) $query->where('sport_order.settlement_status', (int) request()->input('settlement_status'));
  224. if (request()->filled('start_time')) $query->where('sport_order.create_time', '>=', strtotime(request()->input('start_time') . ' 00:00:00'));
  225. if (request()->filled('end_time')) $query->where('sport_order.create_time', '<=', strtotime(request()->input('end_time') . ' 23:59:59'));
  226. return $this->page($query, 'sport_order.id');
  227. });
  228. }
  229. public function sportBet()
  230. {
  231. return $this->run(function () {
  232. $params = request()->validate(['member_id' => ['required', 'string'], 'amount' => ['required', 'numeric', 'min:0.01'], 'odd' => ['required', 'numeric'],
  233. 'title' => ['nullable', 'string', 'max:100'], 'selection' => ['nullable', 'string', 'max:100']]);
  234. $order = SimulationBetService::sport($params['member_id'], $params['amount'], $params['odd'], (string) ($params['title'] ?? ''), (string) ($params['selection'] ?? ''));
  235. $this->audit('bet', $order->id, 'sport_bet', $params);
  236. return $order;
  237. });
  238. }
  239. public function sportSettle()
  240. {
  241. return $this->run(function () {
  242. $params = request()->validate(['id' => ['required', 'integer', 'min:1'], 'result' => ['required', 'in:win,lose,draw,half']]);
  243. $order = SimulationBetService::settleSport((int) $params['id'], $params['result']);
  244. $this->audit('bet', $order->id, 'sport_settle', $params);
  245. return $order;
  246. });
  247. }
  248. public function sportCancel()
  249. {
  250. return $this->run(function () {
  251. $params = request()->validate(['id' => ['required', 'integer', 'min:1'], 'remark' => ['nullable', 'string', 'max:120']]);
  252. $order = SimulationBetService::cancelSport((int) $params['id'], (string) ($params['remark'] ?? ''));
  253. $this->audit('bet', $order->id, 'sport_cancel', $params);
  254. return $order;
  255. });
  256. }
  257. public function thirdBets() { return $this->mirror(ThirdGameOrder::class, 'index'); }
  258. // ---------- 自动任务 ----------
  259. public function templates()
  260. {
  261. return $this->run(function () {
  262. $page = $this->page(DB::table('simulation_tasks'));
  263. $page['data'] = $page['data']->map(fn ($row) => ['steps' => json_decode($row->steps, true)] + (array) $row)->all();
  264. return $page;
  265. });
  266. }
  267. public function saveTemplate()
  268. {
  269. return $this->run(function () {
  270. $id = SimulationTaskService::saveTemplate(request()->all(), request()->user->id ?? null);
  271. $this->audit('task', $id, 'template_save', ['name' => request()->input('name')]);
  272. return ['id' => $id];
  273. });
  274. }
  275. public function deleteTemplate()
  276. {
  277. return $this->run(function () {
  278. $id = (int) request()->validate(['id' => ['required', 'integer', 'min:1']])['id'];
  279. DB::table('simulation_tasks')->where('id', $id)->delete();
  280. $this->audit('task', $id, 'template_delete');
  281. return [];
  282. });
  283. }
  284. public function runs()
  285. {
  286. return $this->run(function () {
  287. $query = DB::table('simulation_task_runs');
  288. if (request()->filled('status')) $query->where('status', (string) request()->input('status'));
  289. if (request()->filled('name')) $query->where('name', 'like', '%' . trim((string) request()->input('name')) . '%');
  290. $page = $this->page($query);
  291. $page['data'] = $page['data']->map(fn ($run) => SimulationTaskService::present($run))->all();
  292. return $page;
  293. });
  294. }
  295. public function startRun()
  296. {
  297. return $this->run(function () {
  298. $id = SimulationTaskService::start(request()->all(), request()->user->id ?? null);
  299. $this->audit('task', $id, 'run_start', ['task_id' => request()->input('task_id'), 'name' => request()->input('name')]);
  300. return ['id' => $id];
  301. });
  302. }
  303. /** 一键启动:按模拟设置里的默认任务参数跑一遍完整流程。 */
  304. public function quickStart()
  305. {
  306. return $this->run(function () {
  307. $id = SimulationTaskService::quickStart(request()->user->id ?? null);
  308. $this->audit('task', $id, 'run_quick_start');
  309. return ['id' => $id];
  310. });
  311. }
  312. public function runStatus()
  313. {
  314. return $this->run(function () {
  315. $params = request()->validate(['id' => ['required', 'integer', 'min:1'], 'action' => ['required', 'in:pause,resume,stop']]);
  316. $run = SimulationTaskService::setStatus((int) $params['id'], $params['action']);
  317. $this->audit('task', $run->id, 'run_' . $params['action']);
  318. return SimulationTaskService::present($run);
  319. });
  320. }
  321. public function events()
  322. {
  323. return $this->run(function () {
  324. $query = DB::table('simulation_task_events');
  325. foreach (['run_id', 'status', 'action', 'member_id'] as $field) if (request()->filled($field)) $query->where($field, request()->input($field));
  326. return $this->page($query);
  327. });
  328. }
  329. // ---------- 报表:与正式报表同一套代码,只统计模拟会员 ----------
  330. public function reportUsers() { return $this->mirror(MemberDailyReport::class, 'index'); }
  331. public function reportFunds() { return $this->mirror(Funds::class, 'summary'); }
  332. public function reportHome() { return $this->mirror(Home::class, 'index'); }
  333. public function reportGames() { return $this->mirror(Reporting::class, 'summary'); }
  334. public function reportFundFlow() { return $this->mirror(Reporting::class, 'funds'); }
  335. public function reportRegistrations() { return $this->mirror(User::class, 'registerTrend'); }
  336. public function reportDomains() { return $this->mirror(User::class, 'registerDomain'); }
  337. /** 立即刷新报表依赖的中间数据(平时由定时任务每 1~10 分钟刷新一次)。 */
  338. public function reportRefresh()
  339. {
  340. return $this->run(function () {
  341. $date = request()->validate(['date' => ['nullable', 'date_format:Y-m-d']])['date'] ?? date('Y-m-d');
  342. $facts = app(BetFactSynchronizer::class)->sync($date, $date, true);
  343. $reporting = app(ReportSync::class)->sync();
  344. $daily = app(MemberDailyReportService::class)->refresh($date);
  345. return ['date' => $date, 'facts' => $facts, 'reporting' => $reporting, 'member_daily' => $daily];
  346. });
  347. }
  348. // ---------- 操作日志 ----------
  349. public function audits()
  350. {
  351. return $this->run(function () {
  352. $query = DB::table('operation_audits')->where('resource_type', 'like', 'simulation\_%');
  353. if (request()->filled('resource_type')) $query->where('resource_type', 'simulation_' . request()->input('resource_type'));
  354. if (request()->filled('operator')) $query->where('operator_name', 'like', '%' . trim((string) request()->input('operator')) . '%');
  355. if (request()->filled('start_time')) $query->where('created_at', '>=', request()->input('start_time') . ' 00:00:00');
  356. if (request()->filled('end_time')) $query->where('created_at', '<=', request()->input('end_time') . ' 23:59:59');
  357. $page = $this->page($query);
  358. $page['data'] = $page['data']->map(fn ($row) => ['changes' => json_decode((string) $row->changes, true)] + (array) $row)->all();
  359. return $page;
  360. });
  361. }
  362. // ---------- 设置与重置 ----------
  363. public function settings() { return $this->run(fn () => SimulationSettings::all()); }
  364. public function saveSettings()
  365. {
  366. return $this->run(function () {
  367. $change = SimulationSettings::save(request()->all());
  368. OperationAuditService::record('simulation_settings', null, 'save', $change['before'], $change['after']);
  369. return $change['after'];
  370. });
  371. }
  372. public function resetPreview() { return $this->run(fn () => SimulationResetService::preview()); }
  373. public function reset()
  374. {
  375. return $this->run(function () {
  376. $params = request()->validate(['confirm_token' => ['required', 'string'], 'confirm_text' => ['required', 'string']]);
  377. $deleted = SimulationResetService::reset($params['confirm_token'], $params['confirm_text']);
  378. $this->audit('reset', null, 'reset', $deleted);
  379. return ['deleted' => $deleted];
  380. });
  381. }
  382. }