| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377 |
- <?php
- namespace App\Services;
- use App\Constants\HttpStatus;
- use App\Models\BalanceLog;
- use App\Models\Level;
- use App\Models\ManualAudit as ManualAuditRecord;
- use App\Models\User;
- use App\Models\Wallet;
- use Carbon\Carbon;
- use Exception;
- use Illuminate\Database\Eloquent\Builder;
- use Illuminate\Database\QueryException;
- use Illuminate\Support\Facades\DB;
- use Illuminate\Support\Facades\Log;
- class ManualAuditService
- {
- public const DEBIT_CHANGE_TYPE = '人工扣款';
- public const STATUS_FAILED = ManualAuditRecord::STATUS_FAILED;
- public const STATUS_SUCCESS = ManualAuditRecord::STATUS_SUCCESS;
- public static function list(array $params): array
- {
- $page = max(1, (int)($params['page'] ?? 1));
- $limit = min(200, max(1, (int)($params['limit'] ?? 20)));
- $query = self::query($params);
- $total = (clone $query)->count('id');
- $rows = $query
- ->orderByDesc('id')
- ->forPage($page, $limit)
- ->get();
- return [
- 'total' => $total,
- 'data' => $rows->map(static fn(ManualAuditRecord $row): array => self::format($row))->all(),
- 'options' => [
- 'transaction_types' => [
- ['label' => '人工上分', 'value' => 'credit'],
- ['label' => '人工下分', 'value' => 'debit'],
- ],
- 'credit_types' => array_map(static fn(string $type): array => [
- 'label' => $type,
- 'value' => $type,
- ], BalanceLogService::$manualRecharge),
- 'statuses' => [
- ['label' => '失败', 'value' => self::STATUS_FAILED],
- ['label' => '成功', 'value' => self::STATUS_SUCCESS],
- ],
- ],
- ];
- }
- public static function detail(int $id): array
- {
- $row = self::query(['id' => $id])->first();
- if (!$row) {
- throw new Exception('人工稽查记录不存在', HttpStatus::CUSTOM_ERROR);
- }
- return self::format($row);
- }
- public static function credit(
- string $requestId,
- int $adminId,
- string $memberId,
- string $amount,
- string $changeType,
- string $remark
- ): array {
- return self::adjust($requestId, $adminId, $memberId, $amount, $changeType, $remark, false);
- }
- public static function debit(
- string $requestId,
- int $adminId,
- string $memberId,
- string $amount,
- string $remark
- ): array {
- return self::adjust(
- $requestId,
- $adminId,
- $memberId,
- $amount,
- self::DEBIT_CHANGE_TYPE,
- $remark,
- true
- );
- }
- private static function adjust(
- string $requestId,
- int $adminId,
- string $memberId,
- string $amount,
- string $changeType,
- string $remark,
- bool $isDebit
- ): array {
- $signedAmount = bcadd($amount, '0', 10);
- if ($isDebit) {
- $signedAmount = bcmul($signedAmount, '-1', 10);
- }
- $transactionType = $isDebit ? 'debit' : 'credit';
- $requestHash = self::requestHash(
- $adminId,
- $memberId,
- $signedAmount,
- $transactionType,
- $changeType,
- $remark
- );
- $existing = ManualAuditRecord::where('request_id', $requestId)->first();
- if ($existing) {
- self::assertRequestMatches($existing, $requestHash);
- return self::format($existing);
- }
- $created = false;
- try {
- $audit = DB::transaction(function () use (
- $requestId,
- $requestHash,
- $adminId,
- $memberId,
- $signedAmount,
- $transactionType,
- $changeType,
- $remark
- ): ManualAuditRecord {
- $audit = ManualAuditRecord::create([
- 'request_id' => $requestId,
- 'request_hash' => $requestHash,
- 'admin_id' => $adminId,
- 'member_id' => $memberId,
- 'username' => '',
- 'first_name' => '',
- 'transaction_type' => $transactionType,
- 'change_type' => $changeType,
- 'amount' => $signedAmount,
- 'level_before' => 0,
- 'level_before_name' => '普通会员',
- 'level_after' => 0,
- 'level_after_name' => '普通会员',
- 'status' => self::STATUS_FAILED,
- 'remark' => $remark,
- 'failure_reason' => '',
- ]);
- // Keep the same lock order as payment callbacks: wallet first, user second.
- $wallet = Wallet::where('member_id', $memberId)->lockForUpdate()->first();
- $user = User::where('member_id', $memberId)->lockForUpdate()->first();
- [$level, $levelName] = self::levelSnapshot($user);
- $audit->fill([
- 'username' => (string)($user->username ?? ''),
- 'first_name' => (string)($user->first_name ?? ''),
- 'level_before' => $level,
- 'level_before_name' => $levelName,
- 'level_after' => $level,
- 'level_after_name' => $levelName,
- ]);
- if (!$user) {
- return self::fail($audit, '用户不存在');
- }
- if (!$wallet) {
- return self::fail($audit, '用户钱包不存在');
- }
- $beforeBalance = bcadd((string)$wallet->available_balance, '0', 10);
- $afterBalance = bcadd($beforeBalance, $signedAmount, 10);
- $audit->before_balance = $beforeBalance;
- if (bccomp($afterBalance, '0', 10) < 0) {
- $audit->after_balance = $beforeBalance;
- return self::fail($audit, '可用余额不足');
- }
- $log = BalanceLogService::addLog(
- $memberId,
- $signedAmount,
- $beforeBalance,
- $afterBalance,
- $changeType,
- null,
- $remark
- );
- $wallet->available_balance = $afterBalance;
- if (!$wallet->save()) {
- throw new Exception('钱包更新失败', HttpStatus::CUSTOM_ERROR);
- }
- $audit->balance_log_id = $log->id;
- $audit->after_balance = $afterBalance;
- $audit->status = self::STATUS_SUCCESS;
- $audit->failure_reason = '';
- $audit->save();
- return $audit;
- }, 3);
- $created = true;
- } catch (QueryException $e) {
- $audit = ManualAuditRecord::where('request_id', $requestId)->first();
- if (!$audit) {
- throw $e;
- }
- self::assertRequestMatches($audit, $requestHash);
- }
- if ($created && (int)$audit->status === self::STATUS_SUCCESS) {
- self::notifyUser($memberId, (string)$audit->amount, (string)$audit->after_balance);
- }
- return self::format($audit);
- }
- private static function fail(ManualAuditRecord $audit, string $reason): ManualAuditRecord
- {
- $audit->status = self::STATUS_FAILED;
- $audit->failure_reason = $reason;
- $audit->save();
- return $audit;
- }
- private static function query(array $params = []): Builder
- {
- $query = ManualAuditRecord::query();
- if (!empty($params['id'])) {
- $query->where('id', (int)$params['id']);
- }
- if (!empty($params['member_id'])) {
- $query->where('member_id', (string)$params['member_id']);
- }
- if (!empty($params['first_name'])) {
- $query->where('first_name', 'like', '%' . $params['first_name'] . '%');
- }
- if (!empty($params['transaction_type'])) {
- $query->where('transaction_type', (string)$params['transaction_type']);
- }
- if (!empty($params['change_type'])) {
- $query->where('change_type', (string)$params['change_type']);
- }
- if (array_key_exists('status', $params) && $params['status'] !== null && $params['status'] !== '') {
- $query->where('status', (int)$params['status']);
- }
- if (!empty($params['start_date']) && !empty($params['end_date'])) {
- [$start, $end] = self::dateRange($params['start_date'], $params['end_date']);
- $query->whereBetween('created_at', [$start, $end]);
- }
- return $query;
- }
- private static function format(ManualAuditRecord $row): array
- {
- $amount = (string)$row->amount;
- $status = (int)$row->status;
- return [
- 'id' => (int)$row->id,
- 'request_id' => (string)$row->request_id,
- 'admin_id' => $row->admin_id === null ? null : (int)$row->admin_id,
- 'balance_log_id' => $row->balance_log_id === null ? null : (int)$row->balance_log_id,
- 'member_id' => (string)$row->member_id,
- 'username' => (string)($row->username ?? ''),
- 'first_name' => (string)($row->first_name ?? ''),
- 'transaction_type' => (string)$row->transaction_type,
- 'transaction_type_text' => $row->transaction_type === 'debit' ? '人工下分' : '人工上分',
- 'change_type' => (string)$row->change_type,
- 'level_before' => (int)$row->level_before,
- 'level_before_name' => (string)$row->level_before_name,
- 'level_after' => (int)$row->level_after,
- 'level_after_name' => (string)$row->level_after_name,
- 'amount' => self::money(abs((float)$amount)),
- 'signed_amount' => self::money((float)$amount),
- 'before_balance' => self::nullableMoney($row->before_balance),
- 'after_balance' => self::nullableMoney($row->after_balance),
- 'status' => $status,
- 'status_text' => $status === self::STATUS_SUCCESS ? '成功' : '失败',
- 'remark' => (string)($row->remark ?? ''),
- 'failure_reason' => (string)($row->failure_reason ?? ''),
- 'created_at' => (string)$row->created_at,
- ];
- }
- /**
- * @return array{0:int,1:string}
- */
- private static function levelSnapshot(?User $user): array
- {
- if (!$user) {
- return [0, '普通会员'];
- }
- $level = (int)($user->level ?? 0);
- $levelName = (string)(Level::where('level', $level)->value('level_name') ?? '');
- if ($levelName === '') {
- $levelName = $level > 0 ? '等级' . $level : '普通会员';
- }
- return [$level, $levelName];
- }
- private static function requestHash(
- int $adminId,
- string $memberId,
- string $signedAmount,
- string $transactionType,
- string $changeType,
- string $remark
- ): string {
- $payload = json_encode([
- 'admin_id' => $adminId,
- 'member_id' => $memberId,
- 'amount' => $signedAmount,
- 'transaction_type' => $transactionType,
- 'change_type' => $changeType,
- 'remark' => $remark,
- ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
- return hash('sha256', $payload);
- }
- private static function assertRequestMatches(ManualAuditRecord $audit, string $requestHash): void
- {
- if (!hash_equals((string)$audit->request_hash, $requestHash)) {
- throw new Exception('request_id 已被其他人工操作使用', HttpStatus::CUSTOM_ERROR);
- }
- }
- private static function notifyUser(string $memberId, string $amount, string $afterBalance): void
- {
- try {
- $formattedAmount = self::money((float)$amount);
- $formattedBalance = self::money((float)$afterBalance);
- TopUpService::notifyTransferSuccess(
- $memberId,
- '您的账户余额更新:' . ((float)$amount > 0 ? '+' : '') . $formattedAmount
- . " \n总余额为:" . $formattedBalance
- );
- } catch (\Throwable $e) {
- Log::warning('manual_audit_notify_failed', [
- 'member_id' => $memberId,
- 'error' => $e->getMessage(),
- ]);
- }
- }
- private static function money(float $amount): string
- {
- return number_format($amount, 2, '.', '');
- }
- private static function nullableMoney($amount): ?string
- {
- return $amount === null || $amount === '' ? null : self::money((float)$amount);
- }
- /**
- * @return array{0: Carbon, 1: Carbon}
- */
- private static function dateRange(string $startDate, string $endDate): array
- {
- $timezone = config('app.timezone', 'Asia/Shanghai');
- return [
- Carbon::createFromFormat('Y-m-d', $startDate, $timezone)->startOfDay(),
- Carbon::createFromFormat('Y-m-d', $endDate, $timezone)->endOfDay(),
- ];
- }
- }
|