ManualAuditService.php 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475
  1. <?php
  2. namespace App\Services;
  3. use App\Constants\HttpStatus;
  4. use App\Models\BalanceLog;
  5. use App\Models\Level;
  6. use App\Models\ManualAudit as ManualAuditRecord;
  7. use App\Models\User;
  8. use App\Models\Wallet;
  9. use Carbon\Carbon;
  10. use Exception;
  11. use Illuminate\Database\Eloquent\Builder;
  12. use Illuminate\Database\QueryException;
  13. use Illuminate\Support\Facades\DB;
  14. use Illuminate\Support\Facades\Log;
  15. class ManualAuditService
  16. {
  17. public const DEBIT_CHANGE_TYPE = '人工扣款';
  18. /** 上下分选择该充值类型时,按平台配置的提现打码倍数计算打码量 */
  19. public const PLATFORM_WAGER_TYPE = '人工充值';
  20. public const STATUS_FAILED = ManualAuditRecord::STATUS_FAILED;
  21. public const STATUS_SUCCESS = ManualAuditRecord::STATUS_SUCCESS;
  22. public static function list(array $params): array
  23. {
  24. $page = max(1, (int)($params['page'] ?? 1));
  25. $limit = min(200, max(1, (int)($params['limit'] ?? 20)));
  26. $query = self::query($params);
  27. $total = (clone $query)->count('id');
  28. $rows = $query
  29. ->orderByDesc('id')
  30. ->forPage($page, $limit)
  31. ->get();
  32. $accounts = self::accounts($rows->pluck('member_id')->all());
  33. // 会员当前打码量(所需/当前/剩余),便于上下分、冻结时参考
  34. $wallets = WageringService::enabled()
  35. ? Wallet::whereIn('member_id', $rows->pluck('member_id')->unique()->values()->all() ?: [''])->get()->keyBy('member_id')
  36. : collect();
  37. return [
  38. 'total' => $total,
  39. 'data' => $rows->map(static fn(ManualAuditRecord $row): array => self::format($row, $accounts)
  40. + ['member_wager' => WageringService::summary($wallets->get((string)$row->member_id))])->all(),
  41. 'options' => [
  42. 'transaction_types' => [
  43. ['label' => '人工上分', 'value' => 'credit'],
  44. ['label' => '人工下分', 'value' => 'debit'],
  45. ],
  46. 'credit_types' => array_map(static fn(string $type): array => [
  47. 'label' => $type,
  48. 'value' => $type,
  49. ], BalanceLogService::$manualRecharge),
  50. 'statuses' => [
  51. ['label' => '失败', 'value' => self::STATUS_FAILED],
  52. ['label' => '成功', 'value' => self::STATUS_SUCCESS],
  53. ],
  54. // 人工充值按平台提现打码倍数自动增减打码量,其他类型需填写 wager_amount
  55. 'platform_wager_type' => self::PLATFORM_WAGER_TYPE,
  56. 'withdraw_wager_multiple' => WageringService::multiple(),
  57. ],
  58. ];
  59. }
  60. public static function detail(int $id): array
  61. {
  62. $row = self::query(['id' => $id])->first();
  63. if (!$row) {
  64. throw new Exception('人工稽查记录不存在', HttpStatus::CUSTOM_ERROR);
  65. }
  66. return self::format($row, self::accounts([$row->member_id]));
  67. }
  68. /** 会员ID和账号可二选一,也可同时填写,但必须指向同一会员 */
  69. public static function resolveMemberId(?string $memberId, ?string $account): string
  70. {
  71. $memberId = trim((string)$memberId);
  72. $account = trim((string)$account);
  73. if ($account === '') {
  74. if ($memberId === '') throw new Exception('请填写会员ID或账号', HttpStatus::CUSTOM_ERROR);
  75. return $memberId;
  76. }
  77. $matches = User::where('account', $account)->limit(2)->pluck('member_id');
  78. if ($matches->isEmpty()) throw new Exception('账号不存在', HttpStatus::CUSTOM_ERROR);
  79. if ($matches->count() > 1) throw new Exception('该账号对应多个会员,请填写会员ID', HttpStatus::CUSTOM_ERROR);
  80. $resolved = (string)$matches->first();
  81. if ($memberId !== '' && $memberId !== $resolved) throw new Exception('会员ID与账号不一致', HttpStatus::CUSTOM_ERROR);
  82. return $resolved;
  83. }
  84. /** @return array<string,string> member_id => account */
  85. private static function accounts(array $memberIds): array
  86. {
  87. $memberIds = array_values(array_unique(array_filter(array_map('strval', $memberIds), 'strlen')));
  88. if (!$memberIds) return [];
  89. return User::whereIn('member_id', $memberIds)->pluck('account', 'member_id')
  90. ->map(static fn($account): string => (string)($account ?? ''))->all();
  91. }
  92. /**
  93. * @param string|null $wagerAmount 非“人工充值”类型时填写的打码量(为空视为0,可直接提现)
  94. */
  95. public static function credit(
  96. string $requestId,
  97. int $adminId,
  98. string $memberId,
  99. string $amount,
  100. string $changeType,
  101. string $remark,
  102. ?string $wagerAmount = null
  103. ): array {
  104. return self::adjust($requestId, $adminId, $memberId, $amount, $changeType, $remark, false, null, $wagerAmount);
  105. }
  106. /**
  107. * @param string|null $rechargeType 下分对应的充值类型:人工充值按平台倍数扣除打码量,其他类型扣除 $wagerAmount;为空不调整打码量
  108. */
  109. public static function debit(
  110. string $requestId,
  111. int $adminId,
  112. string $memberId,
  113. string $amount,
  114. string $remark,
  115. ?string $rechargeType = null,
  116. ?string $wagerAmount = null
  117. ): array {
  118. return self::adjust(
  119. $requestId,
  120. $adminId,
  121. $memberId,
  122. $amount,
  123. self::DEBIT_CHANGE_TYPE,
  124. $remark,
  125. true,
  126. $rechargeType,
  127. $wagerAmount
  128. );
  129. }
  130. /**
  131. * 本次上下分对打码量的有符号调整。上分“人工充值”由充值钩子自动按平台倍数增加,这里只返回展示值。
  132. * @return array{0:string,1:bool} [有符号打码量, 是否需要在此处手动写入]
  133. */
  134. public static function wagerDelta(bool $isDebit, string $amount, string $changeType, ?string $rechargeType, ?string $wagerAmount): array
  135. {
  136. $custom = bcadd((string)($wagerAmount ?? '0'), '0', 10);
  137. if (!$isDebit) {
  138. if ($changeType === self::PLATFORM_WAGER_TYPE) return [WageringService::platformRequirement($amount), false];
  139. return [$custom, true];
  140. }
  141. if ($rechargeType === null || $rechargeType === '') return ['0', false];
  142. $value = $rechargeType === self::PLATFORM_WAGER_TYPE ? WageringService::platformRequirement($amount) : $custom;
  143. return [bcsub('0', $value, 10), true];
  144. }
  145. private static function adjust(
  146. string $requestId,
  147. int $adminId,
  148. string $memberId,
  149. string $amount,
  150. string $changeType,
  151. string $remark,
  152. bool $isDebit,
  153. ?string $rechargeType = null,
  154. ?string $wagerAmount = null
  155. ): array {
  156. $signedAmount = bcadd($amount, '0', 10);
  157. if ($isDebit) {
  158. $signedAmount = bcmul($signedAmount, '-1', 10);
  159. }
  160. $transactionType = $isDebit ? 'debit' : 'credit';
  161. [$wagerDelta, $writeWager] = self::wagerDelta($isDebit, $amount, $changeType, $rechargeType, $wagerAmount);
  162. $requestHash = self::requestHash(
  163. $adminId,
  164. $memberId,
  165. $signedAmount,
  166. $transactionType,
  167. $changeType,
  168. $remark,
  169. // 只摘要请求原始参数,平台倍数调整后同一 request_id 重试仍能识别为同一请求
  170. $rechargeType === null && $wagerAmount === null ? null
  171. : ['recharge_type' => $rechargeType, 'wager_amount' => $wagerAmount === null ? null : bcadd($wagerAmount, '0', 10)]
  172. );
  173. $existing = ManualAuditRecord::where('request_id', $requestId)->first();
  174. if ($existing) {
  175. self::assertRequestMatches($existing, $requestHash);
  176. return self::format($existing, self::accounts([$memberId]));
  177. }
  178. $created = false;
  179. try {
  180. $audit = DB::transaction(function () use (
  181. $requestId,
  182. $requestHash,
  183. $adminId,
  184. $memberId,
  185. $signedAmount,
  186. $transactionType,
  187. $changeType,
  188. $remark,
  189. $wagerDelta,
  190. $writeWager
  191. ): ManualAuditRecord {
  192. $audit = ManualAuditRecord::create(self::withWager([
  193. 'request_id' => $requestId,
  194. 'request_hash' => $requestHash,
  195. 'admin_id' => $adminId,
  196. 'member_id' => $memberId,
  197. 'username' => '',
  198. 'first_name' => '',
  199. 'transaction_type' => $transactionType,
  200. 'change_type' => $changeType,
  201. 'amount' => $signedAmount,
  202. 'level_before' => 0,
  203. 'level_before_name' => '普通会员',
  204. 'level_after' => 0,
  205. 'level_after_name' => '普通会员',
  206. 'status' => self::STATUS_FAILED,
  207. 'remark' => $remark,
  208. 'failure_reason' => '',
  209. ], $wagerDelta));
  210. // Keep the same lock order as payment callbacks: wallet first, user second.
  211. $wallet = Wallet::where('member_id', $memberId)->lockForUpdate()->first();
  212. $user = User::where('member_id', $memberId)->lockForUpdate()->first();
  213. [$level, $levelName] = self::levelSnapshot($user);
  214. $audit->fill([
  215. 'username' => (string)($user->username ?? ''),
  216. 'first_name' => (string)($user->first_name ?? ''),
  217. 'level_before' => $level,
  218. 'level_before_name' => $levelName,
  219. 'level_after' => $level,
  220. 'level_after_name' => $levelName,
  221. ]);
  222. if (!$user) {
  223. return self::fail($audit, '用户不存在');
  224. }
  225. if (!$wallet) {
  226. return self::fail($audit, '用户钱包不存在');
  227. }
  228. $beforeBalance = bcadd((string)$wallet->available_balance, '0', 10);
  229. $afterBalance = bcadd($beforeBalance, $signedAmount, 10);
  230. $audit->before_balance = $beforeBalance;
  231. if (bccomp($afterBalance, '0', 10) < 0) {
  232. $audit->after_balance = $beforeBalance;
  233. return self::fail($audit, '可用余额不足');
  234. }
  235. // 先保存余额再写日志:充值钩子(首充奖励、即充即送、打码量)会在同一事务内重新锁定并更新钱包。
  236. $wallet->available_balance = $afterBalance;
  237. if (!$wallet->save()) {
  238. throw new Exception('钱包更新失败', HttpStatus::CUSTOM_ERROR);
  239. }
  240. $log = BalanceLogService::addLog(
  241. $memberId,
  242. $signedAmount,
  243. $beforeBalance,
  244. $afterBalance,
  245. $changeType,
  246. null,
  247. $remark
  248. );
  249. if ($writeWager) {
  250. $wallet = Wallet::where('member_id', $memberId)->lockForUpdate()->first();
  251. WageringService::addRequirement($wallet, $wagerDelta, bccomp($signedAmount, '0', 10) < 0 ? 'manual_debit' : 'manual_credit',
  252. $audit->id, $changeType . ($remark !== '' ? ';' . $remark : ''), $adminId);
  253. }
  254. $audit->balance_log_id = $log->id;
  255. $audit->after_balance = $afterBalance;
  256. $audit->status = self::STATUS_SUCCESS;
  257. $audit->failure_reason = '';
  258. $audit->save();
  259. return $audit;
  260. }, 3);
  261. $created = true;
  262. } catch (QueryException $e) {
  263. $audit = ManualAuditRecord::where('request_id', $requestId)->first();
  264. if (!$audit) {
  265. throw $e;
  266. }
  267. self::assertRequestMatches($audit, $requestHash);
  268. }
  269. if ($created && (int)$audit->status === self::STATUS_SUCCESS) {
  270. self::notifyUser($memberId, (string)$audit->amount, (string)$audit->after_balance);
  271. }
  272. return self::format($audit, self::accounts([$memberId]));
  273. }
  274. private static ?bool $hasWagerColumn = null;
  275. private static function withWager(array $data, string $wagerDelta): array
  276. {
  277. self::$hasWagerColumn ??= DB::connection()->getSchemaBuilder()->hasColumn('manual_audits', 'wager_amount');
  278. if (self::$hasWagerColumn) $data['wager_amount'] = $wagerDelta;
  279. return $data;
  280. }
  281. private static function fail(ManualAuditRecord $audit, string $reason): ManualAuditRecord
  282. {
  283. $audit->status = self::STATUS_FAILED;
  284. $audit->failure_reason = $reason;
  285. $audit->save();
  286. return $audit;
  287. }
  288. private static function query(array $params = []): Builder
  289. {
  290. $query = ManualAuditRecord::query();
  291. if (!empty($params['id'])) {
  292. $query->where('id', (int)$params['id']);
  293. }
  294. if (!empty($params['member_id'])) {
  295. $query->where('member_id', (string)$params['member_id']);
  296. }
  297. if (!empty($params['account'])) {
  298. $query->whereIn('member_id', User::select('member_id')
  299. ->where('account', 'like', '%' . trim((string)$params['account']) . '%'));
  300. }
  301. if (!empty($params['first_name'])) {
  302. $query->where('first_name', 'like', '%' . $params['first_name'] . '%');
  303. }
  304. if (!empty($params['transaction_type'])) {
  305. $query->where('transaction_type', (string)$params['transaction_type']);
  306. }
  307. if (!empty($params['change_type'])) {
  308. $query->where('change_type', (string)$params['change_type']);
  309. }
  310. if (array_key_exists('status', $params) && $params['status'] !== null && $params['status'] !== '') {
  311. $query->where('status', (int)$params['status']);
  312. }
  313. if (!empty($params['start_date']) && !empty($params['end_date'])) {
  314. [$start, $end] = self::dateRange($params['start_date'], $params['end_date']);
  315. $query->whereBetween('created_at', [$start, $end]);
  316. }
  317. return $query;
  318. }
  319. private static function format(ManualAuditRecord $row, array $accounts = []): array
  320. {
  321. $amount = (string)$row->amount;
  322. $status = (int)$row->status;
  323. return [
  324. 'id' => (int)$row->id,
  325. 'request_id' => (string)$row->request_id,
  326. 'admin_id' => $row->admin_id === null ? null : (int)$row->admin_id,
  327. 'balance_log_id' => $row->balance_log_id === null ? null : (int)$row->balance_log_id,
  328. 'member_id' => (string)$row->member_id,
  329. 'account' => $accounts[(string)$row->member_id] ?? '',
  330. 'username' => (string)($row->username ?? ''),
  331. 'first_name' => (string)($row->first_name ?? ''),
  332. 'transaction_type' => (string)$row->transaction_type,
  333. 'transaction_type_text' => $row->transaction_type === 'debit' ? '人工下分' : '人工上分',
  334. 'change_type' => (string)$row->change_type,
  335. 'level_before' => (int)$row->level_before,
  336. 'level_before_name' => (string)$row->level_before_name,
  337. 'level_after' => (int)$row->level_after,
  338. 'level_after_name' => (string)$row->level_after_name,
  339. 'amount' => self::money(abs((float)$amount)),
  340. 'signed_amount' => self::money((float)$amount),
  341. 'before_balance' => self::nullableMoney($row->before_balance),
  342. 'after_balance' => self::nullableMoney($row->after_balance),
  343. 'status' => $status,
  344. 'status_text' => $status === self::STATUS_SUCCESS ? '成功' : '失败',
  345. 'remark' => (string)($row->remark ?? ''),
  346. 'wager_amount' => self::nullableMoney($row->getAttribute('wager_amount')) ?? '0.00',
  347. 'failure_reason' => (string)($row->failure_reason ?? ''),
  348. 'created_at' => (string)$row->created_at,
  349. ];
  350. }
  351. /**
  352. * @return array{0:int,1:string}
  353. */
  354. private static function levelSnapshot(?User $user): array
  355. {
  356. if (!$user) {
  357. return [0, '普通会员'];
  358. }
  359. $level = (int)($user->level ?? 0);
  360. $levelName = (string)(Level::where('level', $level)->value('level_name') ?? '');
  361. if ($levelName === '') {
  362. $levelName = $level > 0 ? '等级' . $level : '普通会员';
  363. }
  364. return [$level, $levelName];
  365. }
  366. private static function requestHash(
  367. int $adminId,
  368. string $memberId,
  369. string $signedAmount,
  370. string $transactionType,
  371. string $changeType,
  372. string $remark,
  373. ?array $wager = null
  374. ): string {
  375. $data = [
  376. 'admin_id' => $adminId,
  377. 'member_id' => $memberId,
  378. 'amount' => $signedAmount,
  379. 'transaction_type' => $transactionType,
  380. 'change_type' => $changeType,
  381. 'remark' => $remark,
  382. ];
  383. // 未传打码量参数的请求保持旧摘要,避免升级前后重放同一 request_id 被判为冲突
  384. if ($wager !== null) $data['wager'] = $wager;
  385. $payload = json_encode($data, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
  386. return hash('sha256', $payload);
  387. }
  388. private static function assertRequestMatches(ManualAuditRecord $audit, string $requestHash): void
  389. {
  390. if (!hash_equals((string)$audit->request_hash, $requestHash)) {
  391. throw new Exception('request_id 已被其他人工操作使用', HttpStatus::CUSTOM_ERROR);
  392. }
  393. }
  394. private static function notifyUser(string $memberId, string $amount, string $afterBalance): void
  395. {
  396. try {
  397. $formattedAmount = self::money((float)$amount);
  398. $formattedBalance = self::money((float)$afterBalance);
  399. TopUpService::notifyTransferSuccess(
  400. $memberId,
  401. '您的账户余额更新:' . ((float)$amount > 0 ? '+' : '') . $formattedAmount
  402. . " \n总余额为:" . $formattedBalance
  403. );
  404. } catch (\Throwable $e) {
  405. Log::warning('manual_audit_notify_failed', [
  406. 'member_id' => $memberId,
  407. 'error' => $e->getMessage(),
  408. ]);
  409. }
  410. }
  411. private static function money(float $amount): string
  412. {
  413. return number_format($amount, 2, '.', '');
  414. }
  415. private static function nullableMoney($amount): ?string
  416. {
  417. return $amount === null || $amount === '' ? null : self::money((float)$amount);
  418. }
  419. /**
  420. * @return array{0: Carbon, 1: Carbon}
  421. */
  422. private static function dateRange(string $startDate, string $endDate): array
  423. {
  424. $timezone = config('app.timezone', 'Asia/Shanghai');
  425. return [
  426. Carbon::createFromFormat('Y-m-d', $startDate, $timezone)->startOfDay(),
  427. Carbon::createFromFormat('Y-m-d', $endDate, $timezone)->endOfDay(),
  428. ];
  429. }
  430. }