ManualAuditService.php 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406
  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. public const STATUS_FAILED = ManualAuditRecord::STATUS_FAILED;
  19. public const STATUS_SUCCESS = ManualAuditRecord::STATUS_SUCCESS;
  20. public static function list(array $params): array
  21. {
  22. $page = max(1, (int)($params['page'] ?? 1));
  23. $limit = min(200, max(1, (int)($params['limit'] ?? 20)));
  24. $query = self::query($params);
  25. $total = (clone $query)->count('id');
  26. $rows = $query
  27. ->orderByDesc('id')
  28. ->forPage($page, $limit)
  29. ->get();
  30. $accounts = self::accounts($rows->pluck('member_id')->all());
  31. return [
  32. 'total' => $total,
  33. 'data' => $rows->map(static fn(ManualAuditRecord $row): array => self::format($row, $accounts))->all(),
  34. 'options' => [
  35. 'transaction_types' => [
  36. ['label' => '人工上分', 'value' => 'credit'],
  37. ['label' => '人工下分', 'value' => 'debit'],
  38. ],
  39. 'credit_types' => array_map(static fn(string $type): array => [
  40. 'label' => $type,
  41. 'value' => $type,
  42. ], BalanceLogService::$manualRecharge),
  43. 'statuses' => [
  44. ['label' => '失败', 'value' => self::STATUS_FAILED],
  45. ['label' => '成功', 'value' => self::STATUS_SUCCESS],
  46. ],
  47. ],
  48. ];
  49. }
  50. public static function detail(int $id): array
  51. {
  52. $row = self::query(['id' => $id])->first();
  53. if (!$row) {
  54. throw new Exception('人工稽查记录不存在', HttpStatus::CUSTOM_ERROR);
  55. }
  56. return self::format($row, self::accounts([$row->member_id]));
  57. }
  58. /** 会员ID和账号可二选一,也可同时填写,但必须指向同一会员 */
  59. public static function resolveMemberId(?string $memberId, ?string $account): string
  60. {
  61. $memberId = trim((string)$memberId);
  62. $account = trim((string)$account);
  63. if ($account === '') {
  64. if ($memberId === '') throw new Exception('请填写会员ID或账号', HttpStatus::CUSTOM_ERROR);
  65. return $memberId;
  66. }
  67. $matches = User::where('account', $account)->limit(2)->pluck('member_id');
  68. if ($matches->isEmpty()) throw new Exception('账号不存在', HttpStatus::CUSTOM_ERROR);
  69. if ($matches->count() > 1) throw new Exception('该账号对应多个会员,请填写会员ID', HttpStatus::CUSTOM_ERROR);
  70. $resolved = (string)$matches->first();
  71. if ($memberId !== '' && $memberId !== $resolved) throw new Exception('会员ID与账号不一致', HttpStatus::CUSTOM_ERROR);
  72. return $resolved;
  73. }
  74. /** @return array<string,string> member_id => account */
  75. private static function accounts(array $memberIds): array
  76. {
  77. $memberIds = array_values(array_unique(array_filter(array_map('strval', $memberIds), 'strlen')));
  78. if (!$memberIds) return [];
  79. return User::whereIn('member_id', $memberIds)->pluck('account', 'member_id')
  80. ->map(static fn($account): string => (string)($account ?? ''))->all();
  81. }
  82. public static function credit(
  83. string $requestId,
  84. int $adminId,
  85. string $memberId,
  86. string $amount,
  87. string $changeType,
  88. string $remark
  89. ): array {
  90. return self::adjust($requestId, $adminId, $memberId, $amount, $changeType, $remark, false);
  91. }
  92. public static function debit(
  93. string $requestId,
  94. int $adminId,
  95. string $memberId,
  96. string $amount,
  97. string $remark
  98. ): array {
  99. return self::adjust(
  100. $requestId,
  101. $adminId,
  102. $memberId,
  103. $amount,
  104. self::DEBIT_CHANGE_TYPE,
  105. $remark,
  106. true
  107. );
  108. }
  109. private static function adjust(
  110. string $requestId,
  111. int $adminId,
  112. string $memberId,
  113. string $amount,
  114. string $changeType,
  115. string $remark,
  116. bool $isDebit
  117. ): array {
  118. $signedAmount = bcadd($amount, '0', 10);
  119. if ($isDebit) {
  120. $signedAmount = bcmul($signedAmount, '-1', 10);
  121. }
  122. $transactionType = $isDebit ? 'debit' : 'credit';
  123. $requestHash = self::requestHash(
  124. $adminId,
  125. $memberId,
  126. $signedAmount,
  127. $transactionType,
  128. $changeType,
  129. $remark
  130. );
  131. $existing = ManualAuditRecord::where('request_id', $requestId)->first();
  132. if ($existing) {
  133. self::assertRequestMatches($existing, $requestHash);
  134. return self::format($existing, self::accounts([$memberId]));
  135. }
  136. $created = false;
  137. try {
  138. $audit = DB::transaction(function () use (
  139. $requestId,
  140. $requestHash,
  141. $adminId,
  142. $memberId,
  143. $signedAmount,
  144. $transactionType,
  145. $changeType,
  146. $remark
  147. ): ManualAuditRecord {
  148. $audit = ManualAuditRecord::create([
  149. 'request_id' => $requestId,
  150. 'request_hash' => $requestHash,
  151. 'admin_id' => $adminId,
  152. 'member_id' => $memberId,
  153. 'username' => '',
  154. 'first_name' => '',
  155. 'transaction_type' => $transactionType,
  156. 'change_type' => $changeType,
  157. 'amount' => $signedAmount,
  158. 'level_before' => 0,
  159. 'level_before_name' => '普通会员',
  160. 'level_after' => 0,
  161. 'level_after_name' => '普通会员',
  162. 'status' => self::STATUS_FAILED,
  163. 'remark' => $remark,
  164. 'failure_reason' => '',
  165. ]);
  166. // Keep the same lock order as payment callbacks: wallet first, user second.
  167. $wallet = Wallet::where('member_id', $memberId)->lockForUpdate()->first();
  168. $user = User::where('member_id', $memberId)->lockForUpdate()->first();
  169. [$level, $levelName] = self::levelSnapshot($user);
  170. $audit->fill([
  171. 'username' => (string)($user->username ?? ''),
  172. 'first_name' => (string)($user->first_name ?? ''),
  173. 'level_before' => $level,
  174. 'level_before_name' => $levelName,
  175. 'level_after' => $level,
  176. 'level_after_name' => $levelName,
  177. ]);
  178. if (!$user) {
  179. return self::fail($audit, '用户不存在');
  180. }
  181. if (!$wallet) {
  182. return self::fail($audit, '用户钱包不存在');
  183. }
  184. $beforeBalance = bcadd((string)$wallet->available_balance, '0', 10);
  185. $afterBalance = bcadd($beforeBalance, $signedAmount, 10);
  186. $audit->before_balance = $beforeBalance;
  187. if (bccomp($afterBalance, '0', 10) < 0) {
  188. $audit->after_balance = $beforeBalance;
  189. return self::fail($audit, '可用余额不足');
  190. }
  191. $log = BalanceLogService::addLog(
  192. $memberId,
  193. $signedAmount,
  194. $beforeBalance,
  195. $afterBalance,
  196. $changeType,
  197. null,
  198. $remark
  199. );
  200. $wallet->available_balance = $afterBalance;
  201. if (!$wallet->save()) {
  202. throw new Exception('钱包更新失败', HttpStatus::CUSTOM_ERROR);
  203. }
  204. $audit->balance_log_id = $log->id;
  205. $audit->after_balance = $afterBalance;
  206. $audit->status = self::STATUS_SUCCESS;
  207. $audit->failure_reason = '';
  208. $audit->save();
  209. return $audit;
  210. }, 3);
  211. $created = true;
  212. } catch (QueryException $e) {
  213. $audit = ManualAuditRecord::where('request_id', $requestId)->first();
  214. if (!$audit) {
  215. throw $e;
  216. }
  217. self::assertRequestMatches($audit, $requestHash);
  218. }
  219. if ($created && (int)$audit->status === self::STATUS_SUCCESS) {
  220. self::notifyUser($memberId, (string)$audit->amount, (string)$audit->after_balance);
  221. }
  222. return self::format($audit, self::accounts([$memberId]));
  223. }
  224. private static function fail(ManualAuditRecord $audit, string $reason): ManualAuditRecord
  225. {
  226. $audit->status = self::STATUS_FAILED;
  227. $audit->failure_reason = $reason;
  228. $audit->save();
  229. return $audit;
  230. }
  231. private static function query(array $params = []): Builder
  232. {
  233. $query = ManualAuditRecord::query();
  234. if (!empty($params['id'])) {
  235. $query->where('id', (int)$params['id']);
  236. }
  237. if (!empty($params['member_id'])) {
  238. $query->where('member_id', (string)$params['member_id']);
  239. }
  240. if (!empty($params['first_name'])) {
  241. $query->where('first_name', 'like', '%' . $params['first_name'] . '%');
  242. }
  243. if (!empty($params['transaction_type'])) {
  244. $query->where('transaction_type', (string)$params['transaction_type']);
  245. }
  246. if (!empty($params['change_type'])) {
  247. $query->where('change_type', (string)$params['change_type']);
  248. }
  249. if (array_key_exists('status', $params) && $params['status'] !== null && $params['status'] !== '') {
  250. $query->where('status', (int)$params['status']);
  251. }
  252. if (!empty($params['start_date']) && !empty($params['end_date'])) {
  253. [$start, $end] = self::dateRange($params['start_date'], $params['end_date']);
  254. $query->whereBetween('created_at', [$start, $end]);
  255. }
  256. return $query;
  257. }
  258. private static function format(ManualAuditRecord $row, array $accounts = []): array
  259. {
  260. $amount = (string)$row->amount;
  261. $status = (int)$row->status;
  262. return [
  263. 'id' => (int)$row->id,
  264. 'request_id' => (string)$row->request_id,
  265. 'admin_id' => $row->admin_id === null ? null : (int)$row->admin_id,
  266. 'balance_log_id' => $row->balance_log_id === null ? null : (int)$row->balance_log_id,
  267. 'member_id' => (string)$row->member_id,
  268. 'account' => $accounts[(string)$row->member_id] ?? '',
  269. 'username' => (string)($row->username ?? ''),
  270. 'first_name' => (string)($row->first_name ?? ''),
  271. 'transaction_type' => (string)$row->transaction_type,
  272. 'transaction_type_text' => $row->transaction_type === 'debit' ? '人工下分' : '人工上分',
  273. 'change_type' => (string)$row->change_type,
  274. 'level_before' => (int)$row->level_before,
  275. 'level_before_name' => (string)$row->level_before_name,
  276. 'level_after' => (int)$row->level_after,
  277. 'level_after_name' => (string)$row->level_after_name,
  278. 'amount' => self::money(abs((float)$amount)),
  279. 'signed_amount' => self::money((float)$amount),
  280. 'before_balance' => self::nullableMoney($row->before_balance),
  281. 'after_balance' => self::nullableMoney($row->after_balance),
  282. 'status' => $status,
  283. 'status_text' => $status === self::STATUS_SUCCESS ? '成功' : '失败',
  284. 'remark' => (string)($row->remark ?? ''),
  285. 'failure_reason' => (string)($row->failure_reason ?? ''),
  286. 'created_at' => (string)$row->created_at,
  287. ];
  288. }
  289. /**
  290. * @return array{0:int,1:string}
  291. */
  292. private static function levelSnapshot(?User $user): array
  293. {
  294. if (!$user) {
  295. return [0, '普通会员'];
  296. }
  297. $level = (int)($user->level ?? 0);
  298. $levelName = (string)(Level::where('level', $level)->value('level_name') ?? '');
  299. if ($levelName === '') {
  300. $levelName = $level > 0 ? '等级' . $level : '普通会员';
  301. }
  302. return [$level, $levelName];
  303. }
  304. private static function requestHash(
  305. int $adminId,
  306. string $memberId,
  307. string $signedAmount,
  308. string $transactionType,
  309. string $changeType,
  310. string $remark
  311. ): string {
  312. $payload = json_encode([
  313. 'admin_id' => $adminId,
  314. 'member_id' => $memberId,
  315. 'amount' => $signedAmount,
  316. 'transaction_type' => $transactionType,
  317. 'change_type' => $changeType,
  318. 'remark' => $remark,
  319. ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
  320. return hash('sha256', $payload);
  321. }
  322. private static function assertRequestMatches(ManualAuditRecord $audit, string $requestHash): void
  323. {
  324. if (!hash_equals((string)$audit->request_hash, $requestHash)) {
  325. throw new Exception('request_id 已被其他人工操作使用', HttpStatus::CUSTOM_ERROR);
  326. }
  327. }
  328. private static function notifyUser(string $memberId, string $amount, string $afterBalance): void
  329. {
  330. try {
  331. $formattedAmount = self::money((float)$amount);
  332. $formattedBalance = self::money((float)$afterBalance);
  333. TopUpService::notifyTransferSuccess(
  334. $memberId,
  335. '您的账户余额更新:' . ((float)$amount > 0 ? '+' : '') . $formattedAmount
  336. . " \n总余额为:" . $formattedBalance
  337. );
  338. } catch (\Throwable $e) {
  339. Log::warning('manual_audit_notify_failed', [
  340. 'member_id' => $memberId,
  341. 'error' => $e->getMessage(),
  342. ]);
  343. }
  344. }
  345. private static function money(float $amount): string
  346. {
  347. return number_format($amount, 2, '.', '');
  348. }
  349. private static function nullableMoney($amount): ?string
  350. {
  351. return $amount === null || $amount === '' ? null : self::money((float)$amount);
  352. }
  353. /**
  354. * @return array{0: Carbon, 1: Carbon}
  355. */
  356. private static function dateRange(string $startDate, string $endDate): array
  357. {
  358. $timezone = config('app.timezone', 'Asia/Shanghai');
  359. return [
  360. Carbon::createFromFormat('Y-m-d', $startDate, $timezone)->startOfDay(),
  361. Carbon::createFromFormat('Y-m-d', $endDate, $timezone)->endOfDay(),
  362. ];
  363. }
  364. }