ManualAuditService.php 13 KB

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