Simulation.php 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427
  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. 'members' => ['total' => DB::table('users')->where('user_type', MemberAudience::TYPE_SIMULATED)->count(),
  69. 'new' => DB::table('users')->where('user_type', MemberAudience::TYPE_SIMULATED)->where('created_at', '>=', $start)->where('created_at', '<', $end)->count(),
  70. 'online' => SimulationMemberService::onlineUserIds()->count()],
  71. 'funds' => ['recharge' => bcadd($logs(['充值', '三方充值']), '0', 2), 'manual_credit' => bcadd($logs(['人工充值']), '0', 2),
  72. 'manual_debit' => bcmul($logs(['人工扣款']), '-1', 2), // 提现被拒绝时退款记为正数的“三方提现”,一并计入才是净提现
  73. 'withdraw' => bcmul($logs(['提现', '人工提现', '三方提现']), '-1', 2),
  74. 'wallet_available' => bcadd((string) $wallet->a, '0', 2), 'wallet_frozen' => bcadd((string) $wallet->f, '0', 2)],
  75. 'bets' => ['bet_amount' => $bet, 'payout' => $payout, 'win_loss' => bcsub($bet, $payout, 2),
  76. 'unsettled' => DB::table('lhc_order')->whereIn('member_id', $members)->where('lottery_status', 0)->count()
  77. + SimulationBetService::sportQuery()->where('sport_order.settlement_status', 0)->where('sport_order.status', 1)->count()],
  78. 'runs' => $runs->map(fn ($run) => SimulationTaskService::present($run))->all(),
  79. ];
  80. });
  81. }
  82. // ---------- 会员 ----------
  83. public function members()
  84. {
  85. $response = $this->mirror(User::class, 'index');
  86. $body = $response->getData(true);
  87. if (($body['code'] ?? -1) !== 0 || empty($body['data']['data'])) return $response;
  88. $online = array_flip(SimulationMemberService::onlineUserIds()->map(fn ($id) => (string) $id)->all());
  89. foreach ($body['data']['data'] as &$row) $row['sim_online'] = isset($online[(string) ($row['user_id'] ?? '')]);
  90. return response()->json($body);
  91. }
  92. public function createMembers()
  93. {
  94. return $this->run(function () {
  95. $params = request()->validate(['count' => ['required', 'integer', 'min:1'], 'domain' => ['nullable', 'string', 'max:100'],
  96. 'initial_balance' => ['nullable', 'numeric', 'min:0']]);
  97. $created = SimulationMemberService::create((int) $params['count'], $params);
  98. $this->audit('member', null, 'create', ['count' => count($created), 'member_ids' => array_column($created, 'member_id')]);
  99. return ['created' => $created];
  100. });
  101. }
  102. public function memberDetail()
  103. {
  104. return $this->run(fn () => SimulationMemberService::detail(request()->validate(['member_id' => ['required', 'string']])['member_id']));
  105. }
  106. public function memberBanned()
  107. {
  108. return $this->run(function () {
  109. $params = request()->validate(['member_id' => ['required', 'string'], 'is_banned' => ['required', 'integer', 'in:0,1']]);
  110. SimulationMemberService::setBanned($params['member_id'], (int) $params['is_banned']);
  111. $this->audit('member', null, 'banned', $params);
  112. return [];
  113. });
  114. }
  115. public function memberLogin()
  116. {
  117. return $this->run(function () {
  118. $params = request()->validate(['member_id' => ['required', 'string'], 'platform' => ['nullable', 'string', 'max:16'], 'domain' => ['nullable', 'string', 'max:100']]);
  119. return SimulationMemberService::login($params['member_id'], $params['platform'] ?? 'H5', $params['domain'] ?? null);
  120. });
  121. }
  122. public function memberLogout()
  123. {
  124. return $this->run(fn () => ['closed' => SimulationMemberService::logout(request()->validate(['member_id' => ['required', 'string']])['member_id'])]);
  125. }
  126. public function loginLogs() { return $this->mirror(User::class, 'loginLog'); }
  127. // ---------- 资金 ----------
  128. private function orders(array $types)
  129. {
  130. $query = DB::table('payment_orders')->join('users', 'users.member_id', '=', 'payment_orders.member_id')
  131. ->where('users.user_type', MemberAudience::TYPE_SIMULATED)->whereIn('payment_orders.type', $types)
  132. ->select('payment_orders.id', 'payment_orders.order_no', 'payment_orders.member_id', 'users.account', 'users.first_name', 'payment_orders.type',
  133. 'payment_orders.amount', 'payment_orders.frozen_amount', 'payment_orders.channel', 'payment_orders.status', 'payment_orders.remark', 'payment_orders.created_at');
  134. if (request()->filled('member_id')) $query->where('payment_orders.member_id', (string) request()->input('member_id'));
  135. if (request()->filled('account')) $query->where('users.account', 'like', '%' . trim((string) request()->input('account')) . '%');
  136. if (request()->filled('status')) $query->where('payment_orders.status', (int) request()->input('status'));
  137. if (request()->filled('start_time')) $query->where('payment_orders.created_at', '>=', request()->input('start_time') . ' 00:00:00');
  138. if (request()->filled('end_time')) $query->where('payment_orders.created_at', '<=', request()->input('end_time') . ' 23:59:59');
  139. return $query;
  140. }
  141. public function recharges()
  142. {
  143. return $this->run(fn () => $this->page($this->orders([PaymentOrderService::TYPE_PAY, PaymentOrderService::TYPE_SELF_PAY]), 'payment_orders.id'));
  144. }
  145. public function recharge()
  146. {
  147. return $this->run(function () {
  148. $params = request()->validate(['member_id' => ['required', 'string'], 'amount' => ['required', 'numeric', 'min:0.01'],
  149. 'channel' => ['nullable', 'in:company,third'], 'result' => ['nullable', 'in:success,pending,fail']]);
  150. $order = SimulationFundService::recharge($params['member_id'], $params['amount'], $params['channel'] ?? 'third', $params['result'] ?? 'success');
  151. $this->audit('fund', $order->id, 'recharge', $params);
  152. return $order;
  153. });
  154. }
  155. public function withdrawals()
  156. {
  157. return $this->run(fn () => $this->page($this->orders([PaymentOrderService::TYPE_SELF_PAYOUT]), 'payment_orders.id'));
  158. }
  159. public function withdraw()
  160. {
  161. return $this->run(function () {
  162. $params = request()->validate(['member_id' => ['required', 'string'], 'amount' => ['required', 'numeric', 'min:0.01']]);
  163. $order = SimulationFundService::withdrawApply($params['member_id'], $params['amount']);
  164. $this->audit('fund', $order->id, 'withdraw_apply', $params);
  165. return $order;
  166. });
  167. }
  168. public function withdrawAudit()
  169. {
  170. return $this->run(function () {
  171. $params = request()->validate(['id' => ['required', 'integer', 'min:1'], 'approve' => ['required', 'integer', 'in:0,1'], 'remark' => ['nullable', 'string', 'max:120']]);
  172. $order = SimulationFundService::withdrawAudit((int) $params['id'], (int) $params['approve'] === 1, (string) ($params['remark'] ?? ''));
  173. $this->audit('fund', $order->id, (int) $params['approve'] === 1 ? 'withdraw_approve' : 'withdraw_reject', $params);
  174. return $order;
  175. });
  176. }
  177. public function adjust()
  178. {
  179. return $this->run(function () {
  180. $params = request()->validate(['member_id' => ['required', 'string'], 'amount' => ['required', 'numeric', 'min:0.01'],
  181. 'direction' => ['required', 'in:credit,debit'], 'remark' => ['required', 'string', 'max:120']]);
  182. $log = SimulationFundService::adjust($params['member_id'], $params['amount'], $params['direction'], $params['remark']);
  183. $this->audit('fund', $log->id, 'adjust_' . $params['direction'], $params);
  184. return $log;
  185. });
  186. }
  187. public function ledger() { return $this->mirror(Balance::class, 'log'); }
  188. public function freezes() { return $this->mirror(WalletFreeze::class, 'index'); }
  189. public function freeze() { return $this->applyFreeze(false); }
  190. public function unfreeze() { return $this->applyFreeze(true); }
  191. private function applyFreeze(bool $unfreeze)
  192. {
  193. return $this->run(function () use ($unfreeze) {
  194. $params = request()->validate(['member_id' => ['required', 'string'], 'amount' => ['required', 'numeric', 'min:0.01'], 'reason' => ['required', 'string', 'max:120']]);
  195. $record = SimulationFundService::freeze($params['member_id'], $params['amount'], $params['reason'], (int) (request()->user->id ?? 0), $unfreeze);
  196. $this->audit('fund', $record['id'] ?? null, $unfreeze ? 'unfreeze' : 'freeze', $params);
  197. return $record;
  198. });
  199. }
  200. // ---------- 注单 ----------
  201. public function betOptions() { return $this->run(fn () => SimulationBetService::options()); }
  202. public function lotteryBets() { return $this->mirror(LhcOrder::class, 'list'); }
  203. public function lotteryBet()
  204. {
  205. return $this->run(function () {
  206. $params = request()->validate(['member_id' => ['required', 'string'], 'keywords' => ['required', 'string', 'max:32'], 'amount' => ['required', 'numeric', 'min:0.01']]);
  207. $order = SimulationBetService::lottery($params['member_id'], $params['keywords'], $params['amount']);
  208. $this->audit('bet', $order->id, 'lottery_bet', $params);
  209. return $order;
  210. });
  211. }
  212. public function sportBets()
  213. {
  214. return $this->run(function () {
  215. $query = SimulationBetService::sportQuery()->select('sport_order.id', 'sport_order.order_id', 'sport_order.issue', 'users.member_id', 'users.account',
  216. 'users.first_name', 'sport_order.odd_name', 'sport_order.odd_value', 'sport_order.odd', 'sport_order.amount', 'sport_order.win_amount',
  217. 'sport_order.return_amount', 'sport_order.profit_and_loss', 'sport_order.is_win', 'sport_order.status', 'sport_order.settlement_status',
  218. 'sport_order.create_time', 'sport_order.settlement_time');
  219. if (request()->filled('member_id')) $query->where('users.member_id', (string) request()->input('member_id'));
  220. if (request()->filled('account')) $query->where('users.account', 'like', '%' . trim((string) request()->input('account')) . '%');
  221. if (request()->filled('settlement_status')) $query->where('sport_order.settlement_status', (int) request()->input('settlement_status'));
  222. if (request()->filled('start_time')) $query->where('sport_order.create_time', '>=', strtotime(request()->input('start_time') . ' 00:00:00'));
  223. if (request()->filled('end_time')) $query->where('sport_order.create_time', '<=', strtotime(request()->input('end_time') . ' 23:59:59'));
  224. return $this->page($query, 'sport_order.id');
  225. });
  226. }
  227. public function sportBet()
  228. {
  229. return $this->run(function () {
  230. $params = request()->validate(['member_id' => ['required', 'string'], 'amount' => ['required', 'numeric', 'min:0.01'], 'odd' => ['required', 'numeric'],
  231. 'title' => ['nullable', 'string', 'max:100'], 'selection' => ['nullable', 'string', 'max:100']]);
  232. $order = SimulationBetService::sport($params['member_id'], $params['amount'], $params['odd'], (string) ($params['title'] ?? ''), (string) ($params['selection'] ?? ''));
  233. $this->audit('bet', $order->id, 'sport_bet', $params);
  234. return $order;
  235. });
  236. }
  237. public function sportSettle()
  238. {
  239. return $this->run(function () {
  240. $params = request()->validate(['id' => ['required', 'integer', 'min:1'], 'result' => ['required', 'in:win,lose,draw,half']]);
  241. $order = SimulationBetService::settleSport((int) $params['id'], $params['result']);
  242. $this->audit('bet', $order->id, 'sport_settle', $params);
  243. return $order;
  244. });
  245. }
  246. public function sportCancel()
  247. {
  248. return $this->run(function () {
  249. $params = request()->validate(['id' => ['required', 'integer', 'min:1'], 'remark' => ['nullable', 'string', 'max:120']]);
  250. $order = SimulationBetService::cancelSport((int) $params['id'], (string) ($params['remark'] ?? ''));
  251. $this->audit('bet', $order->id, 'sport_cancel', $params);
  252. return $order;
  253. });
  254. }
  255. public function thirdBets() { return $this->mirror(ThirdGameOrder::class, 'index'); }
  256. // ---------- 自动任务 ----------
  257. public function templates()
  258. {
  259. return $this->run(function () {
  260. $page = $this->page(DB::table('simulation_tasks'));
  261. $page['data'] = $page['data']->map(fn ($row) => ['steps' => json_decode($row->steps, true)] + (array) $row)->all();
  262. return $page;
  263. });
  264. }
  265. public function saveTemplate()
  266. {
  267. return $this->run(function () {
  268. $id = SimulationTaskService::saveTemplate(request()->all(), request()->user->id ?? null);
  269. $this->audit('task', $id, 'template_save', ['name' => request()->input('name')]);
  270. return ['id' => $id];
  271. });
  272. }
  273. public function deleteTemplate()
  274. {
  275. return $this->run(function () {
  276. $id = (int) request()->validate(['id' => ['required', 'integer', 'min:1']])['id'];
  277. DB::table('simulation_tasks')->where('id', $id)->delete();
  278. $this->audit('task', $id, 'template_delete');
  279. return [];
  280. });
  281. }
  282. public function runs()
  283. {
  284. return $this->run(function () {
  285. $query = DB::table('simulation_task_runs');
  286. if (request()->filled('status')) $query->where('status', (string) request()->input('status'));
  287. if (request()->filled('name')) $query->where('name', 'like', '%' . trim((string) request()->input('name')) . '%');
  288. $page = $this->page($query);
  289. $page['data'] = $page['data']->map(fn ($run) => SimulationTaskService::present($run))->all();
  290. return $page;
  291. });
  292. }
  293. public function startRun()
  294. {
  295. return $this->run(function () {
  296. $id = SimulationTaskService::start(request()->all(), request()->user->id ?? null);
  297. $this->audit('task', $id, 'run_start', ['task_id' => request()->input('task_id'), 'name' => request()->input('name')]);
  298. return ['id' => $id];
  299. });
  300. }
  301. public function runStatus()
  302. {
  303. return $this->run(function () {
  304. $params = request()->validate(['id' => ['required', 'integer', 'min:1'], 'action' => ['required', 'in:pause,resume,stop']]);
  305. $run = SimulationTaskService::setStatus((int) $params['id'], $params['action']);
  306. $this->audit('task', $run->id, 'run_' . $params['action']);
  307. return SimulationTaskService::present($run);
  308. });
  309. }
  310. public function events()
  311. {
  312. return $this->run(function () {
  313. $query = DB::table('simulation_task_events');
  314. foreach (['run_id', 'status', 'action', 'member_id'] as $field) if (request()->filled($field)) $query->where($field, request()->input($field));
  315. return $this->page($query);
  316. });
  317. }
  318. // ---------- 报表:与正式报表同一套代码,只统计模拟会员 ----------
  319. public function reportUsers() { return $this->mirror(MemberDailyReport::class, 'index'); }
  320. public function reportFunds() { return $this->mirror(Funds::class, 'summary'); }
  321. public function reportHome() { return $this->mirror(Home::class, 'index'); }
  322. public function reportGames() { return $this->mirror(Reporting::class, 'summary'); }
  323. public function reportFundFlow() { return $this->mirror(Reporting::class, 'funds'); }
  324. public function reportRegistrations() { return $this->mirror(User::class, 'registerTrend'); }
  325. public function reportDomains() { return $this->mirror(User::class, 'registerDomain'); }
  326. /** 立即刷新报表依赖的中间数据(平时由定时任务每 1~10 分钟刷新一次)。 */
  327. public function reportRefresh()
  328. {
  329. return $this->run(function () {
  330. $date = request()->validate(['date' => ['nullable', 'date_format:Y-m-d']])['date'] ?? date('Y-m-d');
  331. $facts = app(BetFactSynchronizer::class)->sync($date, $date, true);
  332. $reporting = app(ReportSync::class)->sync();
  333. $daily = app(MemberDailyReportService::class)->refresh($date);
  334. return ['date' => $date, 'facts' => $facts, 'reporting' => $reporting, 'member_daily' => $daily];
  335. });
  336. }
  337. // ---------- 操作日志 ----------
  338. public function audits()
  339. {
  340. return $this->run(function () {
  341. $query = DB::table('operation_audits')->where('resource_type', 'like', 'simulation\_%');
  342. if (request()->filled('resource_type')) $query->where('resource_type', 'simulation_' . request()->input('resource_type'));
  343. if (request()->filled('operator')) $query->where('operator_name', 'like', '%' . trim((string) request()->input('operator')) . '%');
  344. if (request()->filled('start_time')) $query->where('created_at', '>=', request()->input('start_time') . ' 00:00:00');
  345. if (request()->filled('end_time')) $query->where('created_at', '<=', request()->input('end_time') . ' 23:59:59');
  346. $page = $this->page($query);
  347. $page['data'] = $page['data']->map(fn ($row) => ['changes' => json_decode((string) $row->changes, true)] + (array) $row)->all();
  348. return $page;
  349. });
  350. }
  351. // ---------- 设置与重置 ----------
  352. public function settings() { return $this->run(fn () => SimulationSettings::all()); }
  353. public function saveSettings()
  354. {
  355. return $this->run(function () {
  356. $change = SimulationSettings::save(request()->all());
  357. OperationAuditService::record('simulation_settings', null, 'save', $change['before'], $change['after']);
  358. return $change['after'];
  359. });
  360. }
  361. public function resetPreview() { return $this->run(fn () => SimulationResetService::preview()); }
  362. public function reset()
  363. {
  364. return $this->run(function () {
  365. $params = request()->validate(['confirm_token' => ['required', 'string'], 'confirm_text' => ['required', 'string']]);
  366. $deleted = SimulationResetService::reset($params['confirm_token'], $params['confirm_text']);
  367. $this->audit('reset', null, 'reset', $deleted);
  368. return ['deleted' => $deleted];
  369. });
  370. }
  371. }