doge 16 часов назад
Родитель
Сommit
58ed24fb1d

+ 31 - 0
app/Http/Controllers/admin/Funds.php

@@ -0,0 +1,31 @@
+<?php
+
+namespace App\Http\Controllers\admin;
+
+use App\Constants\HttpStatus;
+use App\Http\Controllers\Controller;
+use App\Services\FundsSummaryService;
+use Exception;
+use Illuminate\Validation\ValidationException;
+
+class Funds extends Controller
+{
+    public function summary()
+    {
+        try {
+            $params = request()->validate([
+                'start_date' => ['nullable', 'date_format:Y-m-d', 'required_with:end_date'],
+                'end_date' => ['nullable', 'date_format:Y-m-d', 'required_with:start_date', 'after_or_equal:start_date'],
+            ]);
+            $today = date('Y-m-d');
+            $startDate = $params['start_date'] ?? $today;
+            $endDate = $params['end_date'] ?? $today;
+
+            return $this->success(FundsSummaryService::summary($startDate, $endDate));
+        } catch (ValidationException $e) {
+            return $this->error(HttpStatus::CUSTOM_ERROR, $e->validator->errors()->first());
+        } catch (Exception $e) {
+            return $this->error(HttpStatus::CUSTOM_ERROR, $e->getMessage());
+        }
+    }
+}

+ 123 - 0
app/Http/Controllers/admin/ManualAudit.php

@@ -0,0 +1,123 @@
+<?php
+
+namespace App\Http\Controllers\admin;
+
+use App\Constants\HttpStatus;
+use App\Http\Controllers\Controller;
+use App\Services\BalanceLogService;
+use App\Services\ManualAuditService;
+use Exception;
+use Illuminate\Validation\ValidationException;
+
+class ManualAudit extends Controller
+{
+    private const AMOUNT_PATTERN = '/^\d{1,20}(?:\.\d{1,10})?$/';
+
+    public function index()
+    {
+        try {
+            $params = request()->validate([
+                'page' => ['nullable', 'integer', 'min:1'],
+                'limit' => ['nullable', 'integer', 'min:1', 'max:200'],
+                'member_id' => ['nullable', 'string', 'max:64'],
+                'first_name' => ['nullable', 'string', 'max:100'],
+                'transaction_type' => ['nullable', 'string', 'in:credit,debit'],
+                'change_type' => ['nullable', 'string', 'in:' . implode(',', array_merge(
+                    BalanceLogService::$manualRecharge,
+                    [ManualAuditService::DEBIT_CHANGE_TYPE]
+                ))],
+                'status' => ['nullable', 'integer', 'in:0,1'],
+                'start_date' => ['nullable', 'date_format:Y-m-d', 'required_with:end_date'],
+                'end_date' => ['nullable', 'date_format:Y-m-d', 'required_with:start_date', 'after_or_equal:start_date'],
+            ]);
+
+            return $this->success(ManualAuditService::list($params));
+        } catch (ValidationException $e) {
+            return $this->error(HttpStatus::CUSTOM_ERROR, $e->validator->errors()->first());
+        } catch (Exception $e) {
+            return $this->error(HttpStatus::CUSTOM_ERROR, $e->getMessage());
+        }
+    }
+
+    public function detail()
+    {
+        try {
+            $params = request()->validate([
+                'id' => ['required', 'integer', 'min:1'],
+            ]);
+
+            return $this->success(ManualAuditService::detail((int)$params['id']));
+        } catch (ValidationException $e) {
+            return $this->error(HttpStatus::CUSTOM_ERROR, $e->validator->errors()->first());
+        } catch (Exception $e) {
+            return $this->error(HttpStatus::CUSTOM_ERROR, $e->getMessage());
+        }
+    }
+
+    public function topUp()
+    {
+        try {
+            $params = request()->validate([
+                'request_id' => ['required', 'uuid'],
+                'member_id' => ['required', 'string', 'max:64'],
+                'change_type' => ['required', 'string', 'in:' . implode(',', BalanceLogService::$manualRecharge)],
+                'amount' => ['required', 'numeric', 'min:0.01', 'regex:' . self::AMOUNT_PATTERN],
+                'remark' => ['nullable', 'string', 'max:255'],
+            ]);
+            $remark = trim((string)($params['remark'] ?? ''));
+            $result = ManualAuditService::credit(
+                (string)$params['request_id'],
+                (int)request()->user->id,
+                (string)$params['member_id'],
+                (string)$params['amount'],
+                (string)$params['change_type'],
+                $remark
+            );
+
+            return $this->adjustmentResponse($result);
+        } catch (ValidationException $e) {
+            return $this->error(HttpStatus::CUSTOM_ERROR, $e->validator->errors()->first());
+        } catch (Exception $e) {
+            return $this->error(HttpStatus::CUSTOM_ERROR, $e->getMessage());
+        }
+    }
+
+    public function debit()
+    {
+        try {
+            $params = request()->validate([
+                'request_id' => ['required', 'uuid'],
+                'member_id' => ['required', 'string', 'max:64'],
+                'amount' => ['required', 'numeric', 'min:0.01', 'regex:' . self::AMOUNT_PATTERN],
+                'remark' => ['nullable', 'string', 'max:255'],
+            ]);
+            $remark = trim((string)($params['remark'] ?? ''));
+            $result = ManualAuditService::debit(
+                (string)$params['request_id'],
+                (int)request()->user->id,
+                (string)$params['member_id'],
+                (string)$params['amount'],
+                $remark
+            );
+
+            return $this->adjustmentResponse($result);
+        } catch (ValidationException $e) {
+            return $this->error(HttpStatus::CUSTOM_ERROR, $e->validator->errors()->first());
+        } catch (Exception $e) {
+            return $this->error(HttpStatus::CUSTOM_ERROR, $e->getMessage());
+        }
+    }
+
+    private function adjustmentResponse(array $result)
+    {
+        if ((int)($result['status'] ?? ManualAuditService::STATUS_FAILED) !== ManualAuditService::STATUS_SUCCESS) {
+            return $this->error(
+                HttpStatus::CUSTOM_ERROR,
+                (string)($result['failure_reason'] ?? '人工操作失败'),
+                $result
+            );
+        }
+
+        return $this->success($result);
+    }
+}

+ 57 - 0
app/Http/Controllers/admin/ThirdPartyDeposit.php

@@ -0,0 +1,57 @@
+<?php
+
+namespace App\Http\Controllers\admin;
+
+use App\Constants\HttpStatus;
+use App\Http\Controllers\Controller;
+use App\Services\ThirdPartyDepositService;
+use Exception;
+use Illuminate\Validation\ValidationException;
+
+class ThirdPartyDeposit extends Controller
+{
+    public function index()
+    {
+        try {
+            $params = request()->validate([
+                'page' => ['nullable', 'integer', 'min:1'],
+                'limit' => ['nullable', 'integer', 'min:1', 'max:200'],
+                'member_id' => ['nullable', 'string', 'max:64'],
+                'payment_company' => ['nullable', 'string', 'in:sanjin,jd,no,zimu'],
+                'payment_method' => ['nullable', 'string', 'max:100'],
+                'channel' => ['nullable', 'string', 'max:50'],
+                'status' => ['nullable', 'integer', 'in:0,1,2,3'],
+                'platform' => ['nullable', 'string', 'max:32'],
+                'submit_start_date' => ['nullable', 'date_format:Y-m-d', 'required_with:submit_end_date'],
+                'submit_end_date' => [
+                    'nullable', 'date_format:Y-m-d', 'required_with:submit_start_date', 'after_or_equal:submit_start_date',
+                ],
+                'credit_start_date' => ['nullable', 'date_format:Y-m-d', 'required_with:credit_end_date'],
+                'credit_end_date' => [
+                    'nullable', 'date_format:Y-m-d', 'required_with:credit_start_date', 'after_or_equal:credit_start_date',
+                ],
+            ]);
+
+            return $this->success(ThirdPartyDepositService::list($params));
+        } catch (ValidationException $e) {
+            return $this->error(HttpStatus::CUSTOM_ERROR, $e->validator->errors()->first());
+        } catch (Exception $e) {
+            return $this->error(HttpStatus::CUSTOM_ERROR, $e->getMessage());
+        }
+    }
+
+    public function detail()
+    {
+        try {
+            $params = request()->validate([
+                'id' => ['required', 'integer', 'min:1'],
+            ]);
+
+            return $this->success(ThirdPartyDepositService::detail((int)$params['id']));
+        } catch (ValidationException $e) {
+            return $this->error(HttpStatus::CUSTOM_ERROR, $e->validator->errors()->first());
+        } catch (Exception $e) {
+            return $this->error(HttpStatus::CUSTOM_ERROR, $e->getMessage());
+        }
+    }
+}

+ 46 - 0
app/Models/ManualAudit.php

@@ -0,0 +1,46 @@
+<?php
+
+namespace App\Models;
+
+class ManualAudit extends BaseModel
+{
+    public const STATUS_FAILED = 0;
+    public const STATUS_SUCCESS = 1;
+
+    protected $table = 'manual_audits';
+
+    protected $hidden = [];
+
+    protected $fillable = [
+        'request_id',
+        'request_hash',
+        'admin_id',
+        'balance_log_id',
+        'member_id',
+        'username',
+        'first_name',
+        'transaction_type',
+        'change_type',
+        'amount',
+        'before_balance',
+        'after_balance',
+        'level_before',
+        'level_before_name',
+        'level_after',
+        'level_after_name',
+        'status',
+        'remark',
+        'failure_reason',
+    ];
+
+    protected $casts = [
+        'admin_id' => 'integer',
+        'balance_log_id' => 'integer',
+        'amount' => 'decimal:10',
+        'before_balance' => 'decimal:10',
+        'after_balance' => 'decimal:10',
+        'level_before' => 'integer',
+        'level_after' => 'integer',
+        'status' => 'integer',
+    ];
+}

+ 277 - 0
app/Services/FundsSummaryService.php

@@ -0,0 +1,277 @@
+<?php
+
+namespace App\Services;
+
+use Carbon\Carbon;
+use Illuminate\Support\Facades\DB;
+
+class FundsSummaryService
+{
+    public static function summary(string $startDate, string $endDate): array
+    {
+        [$start, $end] = self::dateRange($startDate, $endDate);
+        $balance = self::balanceAggregates($start, $end);
+        $withdraw = self::withdrawAggregates($start, $end);
+        $paymentOrder = self::paymentOrderAggregates($start, $end);
+        $combinedPeople = self::combinedPeople($start, $end);
+
+        $income = [
+            self::item('company_deposit', '公司入款', $balance->company_deposit_amount, $balance->company_deposit_people),
+            self::item(
+                'third_party_deposit',
+                '第三方支付(入款)',
+                $balance->third_party_deposit_amount,
+                $balance->third_party_deposit_people
+            ),
+            self::item('manual_credit', '人工上分', $balance->manual_credit_amount, $balance->manual_credit_people),
+            self::item(
+                'member_withdraw_deduction',
+                '会员出款扣除',
+                (float)$withdraw->member_withdraw_deduction_amount
+                    + (float)$paymentOrder->manual_withdrawal_fee_amount,
+                $combinedPeople['member_withdraw_deduction']
+            ),
+        ];
+
+        $expense = [
+            self::item(
+                'member_withdrawal',
+                '会员出款',
+                $withdraw->member_withdrawal_amount,
+                $withdraw->member_withdrawal_people
+            ),
+            self::item(
+                'manual_withdrawal',
+                '人工出款',
+                (float)$balance->manual_debit_amount + (float)$paymentOrder->manual_withdrawal_amount,
+                $combinedPeople['manual_withdrawal']
+            ),
+            self::item('vip_discount', '给予优惠(VIP 折扣)', 0, 0),
+            self::item('rebate', '给予返点', $balance->rebate_amount, $balance->rebate_people),
+            self::item('bonus', '优惠金', $balance->bonus_amount, $balance->bonus_people),
+            self::item(
+                'third_party_fee',
+                '第三方手续费',
+                $paymentOrder->third_party_fee_amount,
+                $paymentOrder->third_party_fee_people
+            ),
+            self::item(
+                'third_party_auto_withdrawal',
+                '第三方支付出款(自动出款)',
+                $paymentOrder->auto_withdrawal_amount,
+                $paymentOrder->auto_withdrawal_people
+            ),
+            self::item(
+                'third_party_auto_withdrawal_fee',
+                '第三方支付自动出款手续费',
+                $paymentOrder->auto_withdrawal_fee_amount,
+                $paymentOrder->auto_withdrawal_fee_people
+            ),
+        ];
+
+        return [
+            'start_date' => $startDate,
+            'end_date' => $endDate,
+            'income' => $income,
+            'expense' => $expense,
+            'income_total' => self::sumItems($income),
+            'expense_total' => self::sumItems($expense),
+        ];
+    }
+
+    private static function balanceAggregates(Carbon $start, Carbon $end): object
+    {
+        return DB::table('balance_logs')
+            ->whereBetween('created_at', [$start, $end])
+            ->whereIn('change_type', [
+                '充值', '三方充值', '人工充值', '人工扣款',
+                '比比返', '笔笔返', '返水', '回水',
+                '注册赠送', '优惠活动', '即充即送', '充值返现', '老用户回归',
+            ])
+            ->selectRaw(<<<'SQL'
+                COALESCE(SUM(CASE
+                    WHEN change_type = '充值' AND amount > 0 THEN amount ELSE 0 END), 0
+                ) AS company_deposit_amount,
+                COUNT(DISTINCT CASE
+                    WHEN change_type = '充值' AND amount > 0 THEN member_id END
+                ) AS company_deposit_people,
+                COALESCE(SUM(CASE
+                    WHEN change_type = '三方充值' AND amount > 0 THEN amount ELSE 0 END), 0
+                ) AS third_party_deposit_amount,
+                COUNT(DISTINCT CASE
+                    WHEN change_type = '三方充值' AND amount > 0 THEN member_id END
+                ) AS third_party_deposit_people,
+                COALESCE(SUM(CASE
+                    WHEN change_type = '人工充值' AND related_id IS NULL AND amount > 0 THEN amount ELSE 0 END), 0
+                ) AS manual_credit_amount,
+                COUNT(DISTINCT CASE
+                    WHEN change_type = '人工充值' AND related_id IS NULL AND amount > 0 THEN member_id END
+                ) AS manual_credit_people,
+                COALESCE(SUM(CASE
+                    WHEN change_type = '人工扣款' AND amount < 0 THEN ABS(amount) ELSE 0 END), 0
+                ) AS manual_debit_amount,
+                COALESCE(SUM(CASE
+                    WHEN change_type IN ('比比返', '笔笔返', '返水', '回水') AND amount > 0 THEN amount ELSE 0 END), 0
+                ) AS rebate_amount,
+                COUNT(DISTINCT CASE
+                    WHEN change_type IN ('比比返', '笔笔返', '返水', '回水') AND amount > 0 THEN member_id END
+                ) AS rebate_people,
+                COALESCE(SUM(CASE
+                    WHEN (
+                        change_type IN ('注册赠送', '优惠活动', '即充即送', '充值返现', '老用户回归')
+                        OR (change_type = '人工充值' AND related_id = 0)
+                    ) AND amount > 0 THEN amount ELSE 0 END), 0
+                ) AS bonus_amount,
+                COUNT(DISTINCT CASE
+                    WHEN (
+                        change_type IN ('注册赠送', '优惠活动', '即充即送', '充值返现', '老用户回归')
+                        OR (change_type = '人工充值' AND related_id = 0)
+                    ) AND amount > 0 THEN member_id END
+                ) AS bonus_people
+            SQL)
+            ->first();
+    }
+
+    private static function withdrawAggregates(Carbon $start, Carbon $end): object
+    {
+        return self::successfulWithdrawLogs($start, $end)
+            ->selectRaw(<<<'SQL'
+                COALESCE(SUM(ABS(bl.amount)), 0) AS member_withdrawal_amount,
+                COUNT(DISTINCT bl.member_id) AS member_withdrawal_people,
+                COALESCE(SUM(CASE
+                    WHEN w.service_charge > 0
+                    THEN ABS(bl.amount) * w.service_charge / NULLIF(w.amount, 0)
+                    ELSE 0 END), 0
+                ) AS member_withdraw_deduction_amount
+            SQL)
+            ->first();
+    }
+
+    private static function paymentOrderAggregates(Carbon $start, Carbon $end): object
+    {
+        $pay = PaymentOrderService::TYPE_PAY;
+        $payout = PaymentOrderService::TYPE_PAYOUT;
+        $manualPayout = PaymentOrderService::TYPE_SELF_PAYOUT;
+
+        return DB::table('payment_orders')
+            ->where('status', PaymentOrderService::STATUS_SUCCESS)
+            ->whereIn('type', [$pay, $payout, $manualPayout])
+            ->whereBetween('created_at', [$start, $end])
+            ->selectRaw(<<<SQL
+                COALESCE(SUM(CASE WHEN type = {$pay} AND fee > 0 THEN fee ELSE 0 END), 0)
+                    AS third_party_fee_amount,
+                COUNT(DISTINCT CASE WHEN type = {$pay} AND fee > 0 THEN member_id END)
+                    AS third_party_fee_people,
+                COALESCE(SUM(CASE WHEN type = {$payout} THEN amount ELSE 0 END), 0)
+                    AS auto_withdrawal_amount,
+                COUNT(DISTINCT CASE WHEN type = {$payout} THEN member_id END)
+                    AS auto_withdrawal_people,
+                COALESCE(SUM(CASE WHEN type = {$payout} AND fee > 0 THEN fee ELSE 0 END), 0)
+                    AS auto_withdrawal_fee_amount,
+                COUNT(DISTINCT CASE WHEN type = {$payout} AND fee > 0 THEN member_id END)
+                    AS auto_withdrawal_fee_people,
+                COALESCE(SUM(CASE WHEN type = {$manualPayout} THEN amount ELSE 0 END), 0)
+                    AS manual_withdrawal_amount,
+                COALESCE(SUM(CASE WHEN type = {$manualPayout} AND fee > 0 THEN fee ELSE 0 END), 0)
+                    AS manual_withdrawal_fee_amount
+            SQL)
+            ->first();
+    }
+
+    /**
+     * Count unique members across categories backed by more than one table.
+     *
+     * @return array{member_withdraw_deduction:int, manual_withdrawal:int}
+     */
+    private static function combinedPeople(Carbon $start, Carbon $end): array
+    {
+        $withdrawFees = self::successfulWithdrawLogs($start, $end)
+            ->where('w.service_charge', '>', 0)
+            ->selectRaw("'member_withdraw_deduction' AS category, bl.member_id AS member_id");
+        $manualPayoutFees = DB::table('payment_orders as po')
+            ->where('po.type', PaymentOrderService::TYPE_SELF_PAYOUT)
+            ->where('po.status', PaymentOrderService::STATUS_SUCCESS)
+            ->where('po.fee', '>', 0)
+            ->whereBetween('po.created_at', [$start, $end])
+            ->selectRaw("'member_withdraw_deduction' AS category, po.member_id AS member_id");
+        $manualDebits = DB::table('balance_logs as bl')
+            ->where('bl.change_type', '人工扣款')
+            ->where('bl.amount', '<', 0)
+            ->whereBetween('bl.created_at', [$start, $end])
+            ->selectRaw("'manual_withdrawal' AS category, bl.member_id AS member_id");
+        $manualPayouts = DB::table('payment_orders as po')
+            ->where('po.type', PaymentOrderService::TYPE_SELF_PAYOUT)
+            ->where('po.status', PaymentOrderService::STATUS_SUCCESS)
+            ->whereBetween('po.created_at', [$start, $end])
+            ->selectRaw("'manual_withdrawal' AS category, po.member_id AS member_id");
+
+        $union = $withdrawFees
+            ->unionAll($manualPayoutFees)
+            ->unionAll($manualDebits)
+            ->unionAll($manualPayouts);
+        $rows = DB::query()
+            ->fromSub($union, 'finance_members')
+            ->whereNotNull('member_id')
+            ->select('category')
+            ->selectRaw('COUNT(DISTINCT member_id) AS people')
+            ->groupBy('category')
+            ->get();
+
+        $result = [
+            'member_withdraw_deduction' => 0,
+            'manual_withdrawal' => 0,
+        ];
+        foreach ($rows as $row) {
+            $result[(string)$row->category] = (int)$row->people;
+        }
+
+        return $result;
+    }
+
+    private static function successfulWithdrawLogs(Carbon $start, Carbon $end)
+    {
+        return DB::table('balance_logs as bl')
+            ->join('withdraws as w', 'w.id', '=', 'bl.related_id')
+            ->where('w.status', 1)
+            ->where('bl.change_type', '提现')
+            ->where('bl.amount', '<', 0)
+            ->whereBetween('bl.created_at', [$start, $end]);
+    }
+
+    private static function item(string $key, string $label, $amount, $people): array
+    {
+        return [
+            'key' => $key,
+            'label' => $label,
+            'amount' => self::money((float)$amount),
+            'people' => (int)$people,
+        ];
+    }
+
+    private static function sumItems(array $items): string
+    {
+        $total = array_reduce($items, static function (float $sum, array $item): float {
+            return $sum + (float)$item['amount'];
+        }, 0.0);
+
+        return self::money($total);
+    }
+
+    private static function money(float $amount): string
+    {
+        return number_format($amount, 2, '.', '');
+    }
+
+    /**
+     * @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(),
+        ];
+    }
+}

+ 377 - 0
app/Services/ManualAuditService.php

@@ -0,0 +1,377 @@
+<?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(),
+        ];
+    }
+}

+ 465 - 0
app/Services/ThirdPartyDepositService.php

@@ -0,0 +1,465 @@
+<?php
+
+namespace App\Services;
+
+use App\Constants\HttpStatus;
+use App\Models\BalanceLog;
+use App\Models\PaymentOrder;
+use App\Models\UserLogin;
+use App\Services\Payment\JdPayService;
+use App\Services\Payment\NoPayService;
+use App\Services\Payment\ZimuPayService;
+use Carbon\Carbon;
+use Exception;
+use Illuminate\Database\Eloquent\Builder;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+
+class ThirdPartyDepositService
+{
+    private const COMPANIES = [
+        'sanjin' => '三斤支付',
+        'jd' => 'JD支付',
+        'no' => 'NO支付',
+        'zimu' => '808支付',
+    ];
+
+    private const STATUSES = [
+        PaymentOrderService::STATUS_STAY => '待处理',
+        PaymentOrderService::STATUS_PROCESS => '处理中',
+        PaymentOrderService::STATUS_SUCCESS => '成功',
+        PaymentOrderService::STATUS_FAIL => '失败',
+    ];
+
+    private static ?bool $hasPlatform = null;
+
+    private static ?bool $hasLoginStatus = null;
+
+    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('payment_orders.id');
+        $rows = $query
+            ->orderByDesc('payment_orders.id')
+            ->forPage($page, $limit)
+            ->get();
+        $creditLogs = self::creditLogs($rows->pluck('id')->all());
+
+        return [
+            'total' => $total,
+            'data' => $rows->map(static fn(PaymentOrder $row): array => self::format(
+                $row,
+                false,
+                $creditLogs[(int)$row->id] ?? null
+            ))->all(),
+            'options' => self::options(),
+        ];
+    }
+
+    public static function detail(int $id): array
+    {
+        $row = self::query(['id' => $id], true)->first();
+        if (!$row) {
+            throw new Exception('第三方入款记录不存在', HttpStatus::CUSTOM_ERROR);
+        }
+
+        $creditLogs = self::creditLogs([$id]);
+
+        return self::format($row, true, $creditLogs[$id] ?? null);
+    }
+
+    public static function paymentCompany(?string $channel): array
+    {
+        if (ZimuPayService::isRechargeChannel($channel)) {
+            $code = 'zimu';
+        } elseif (NoPayService::isRechargeChannel($channel)) {
+            $code = 'no';
+        } elseif (JdPayService::isChannel($channel)) {
+            $code = 'jd';
+        } else {
+            $code = 'sanjin';
+        }
+
+        return [
+            'code' => $code,
+            'name' => self::COMPANIES[$code],
+        ];
+    }
+
+    public static function statusText(int $status): string
+    {
+        return self::STATUSES[$status] ?? '未知';
+    }
+
+    private static function query(array $params = [], bool $withRawData = false): Builder
+    {
+        $query = PaymentOrder::query()
+            ->join('users', 'users.member_id', '=', 'payment_orders.member_id')
+            ->where('payment_orders.type', PaymentOrderService::TYPE_PAY)
+            ->select([
+                'payment_orders.id',
+                'payment_orders.member_id',
+                'payment_orders.order_no',
+                'payment_orders.pay_no',
+                'payment_orders.amount',
+                'payment_orders.fee',
+                'payment_orders.channel',
+                'payment_orders.bank_name',
+                'payment_orders.status',
+                'payment_orders.created_at as submitted_at',
+                'users.user_id',
+                'users.username',
+                'users.first_name',
+            ]);
+
+        if ($withRawData) {
+            $query->addSelect([
+                'payment_orders.pay_data',
+                'payment_orders.callback_data',
+                'payment_orders.remark',
+                'payment_orders.admin_note',
+            ]);
+        }
+
+        self::selectPlatform($query);
+        self::applyFilters($query, $params);
+
+        return $query;
+    }
+
+    /**
+     * @return array<int, BalanceLog>
+     */
+    private static function creditLogs(array $orderIds): array
+    {
+        if ($orderIds === []) {
+            return [];
+        }
+
+        $logs = [];
+        $rows = BalanceLog::query()
+            ->select(['id', 'related_id', 'before_balance', 'after_balance', 'created_at'])
+            ->whereIn('related_id', $orderIds)
+            ->where('balance_logs.change_type', '三方充值')
+            ->orderBy('balance_logs.id')
+            ->get();
+
+        foreach ($rows as $row) {
+            $logs[(int)$row->related_id] = $row;
+        }
+
+        return $logs;
+    }
+
+    private static function selectPlatform(Builder $query): void
+    {
+        if (!self::hasPlatformColumn()) {
+            $query->selectRaw("'' AS platform");
+            return;
+        }
+
+        $platformSql = self::platformSql();
+        $query->selectRaw("({$platformSql}) AS platform");
+    }
+
+    private static function applyFilters(Builder $query, array $params): void
+    {
+        if (!empty($params['id'])) {
+            $query->where('payment_orders.id', (int)$params['id']);
+        }
+        if (!empty($params['member_id'])) {
+            $query->where('payment_orders.member_id', (string)$params['member_id']);
+        }
+        if (!empty($params['payment_company'])) {
+            self::filterPaymentCompany($query, (string)$params['payment_company']);
+        }
+        if (!empty($params['payment_method'])) {
+            $query->where('payment_orders.bank_name', (string)$params['payment_method']);
+        }
+        if (!empty($params['channel'])) {
+            $query->where('payment_orders.channel', (string)$params['channel']);
+        }
+        if (array_key_exists('status', $params) && $params['status'] !== null && $params['status'] !== '') {
+            $query->where('payment_orders.status', (int)$params['status']);
+        }
+        if (!empty($params['submit_start_date']) && !empty($params['submit_end_date'])) {
+            [$start, $end] = self::dateRange($params['submit_start_date'], $params['submit_end_date']);
+            $query->whereBetween('payment_orders.created_at', [$start, $end]);
+        }
+        if (!empty($params['credit_start_date']) && !empty($params['credit_end_date'])) {
+            [$start, $end] = self::dateRange($params['credit_start_date'], $params['credit_end_date']);
+            $query->whereIn('payment_orders.id', function ($subquery) use ($start, $end) {
+                $subquery->select('related_id')
+                    ->from('balance_logs')
+                    ->where('balance_logs.change_type', '三方充值')
+                    ->whereBetween('balance_logs.created_at', [$start, $end]);
+            });
+        }
+        if (!empty($params['platform'])) {
+            if (!self::hasPlatformColumn()) {
+                $query->whereRaw('1 = 0');
+            } else {
+                $query->whereRaw('(' . self::platformSql() . ') = ?', [(string)$params['platform']]);
+            }
+        }
+    }
+
+    private static function filterPaymentCompany(Builder $query, string $company): void
+    {
+        $zimuChannels = [
+            ZimuPayService::CHANNEL_RECHARGE,
+            '808pay',
+            '808支付',
+        ];
+        $noChannels = [
+            NoPayService::CHANNEL_SCAN,
+            NoPayService::CHANNEL_BALANCE,
+        ];
+        $jdChannels = [
+            JdPayService::CHANNEL,
+            'JD钱包',
+        ];
+        $knownChannels = array_merge($zimuChannels, $noChannels, $jdChannels);
+
+        if ($company === 'zimu') {
+            $query->whereIn('payment_orders.channel', $zimuChannels);
+        } elseif ($company === 'no') {
+            $query->whereIn('payment_orders.channel', $noChannels);
+        } elseif ($company === 'jd') {
+            $query->whereIn('payment_orders.channel', $jdChannels);
+        } else {
+            $query->where(function (Builder $companyQuery) use ($knownChannels) {
+                $companyQuery->whereNull('payment_orders.channel')
+                    ->orWhereNotIn('payment_orders.channel', $knownChannels);
+            });
+        }
+    }
+
+    private static function format(PaymentOrder $row, bool $withRawData, ?BalanceLog $creditLog): array
+    {
+        $company = self::paymentCompany((string)$row->channel);
+        $callbackData = self::decodeJson($row->callback_data ?? null);
+        $payData = self::decodeJson($row->pay_data ?? null);
+
+        $data = [
+            'id' => (int)$row->id,
+            'member_id' => (string)$row->member_id,
+            'username' => (string)($row->username ?? ''),
+            'first_name' => (string)($row->first_name ?? ''),
+            'payment_company' => $company['code'],
+            'payment_company_name' => $company['name'],
+            'payment_method' => (string)($row->bank_name ?? ''),
+            'channel_code' => (string)($row->channel ?? ''),
+            'amount' => self::money($row->amount),
+            'fee' => self::money($row->fee),
+            'before_balance' => self::nullableMoney($creditLog?->before_balance),
+            'after_balance' => self::nullableMoney($creditLog?->after_balance),
+            'order_no' => (string)($row->order_no ?? ''),
+            'gateway_order_no' => (string)($row->pay_no ?? ''),
+            'gateway_response' => self::gatewayReference($callbackData, $payData, (string)($row->pay_no ?? '')),
+            'submitted_at' => self::formatTime($row->submitted_at ?? null),
+            'credited_at' => self::formatTime($creditLog?->created_at),
+            'platform' => (string)($row->platform ?? ''),
+            'status' => (int)$row->status,
+            'status_text' => self::statusText((int)$row->status),
+        ];
+
+        if ($withRawData) {
+            $data['remark'] = (string)($row->remark ?? '');
+            $data['admin_note'] = (string)($row->admin_note ?? '');
+            $data['pay_data'] = self::sanitizePayload($payData);
+            $data['callback_data'] = self::sanitizePayload($callbackData);
+        }
+
+        return $data;
+    }
+
+    private static function options(): array
+    {
+        $paymentMethods = PaymentOrder::query()
+            ->where('type', PaymentOrderService::TYPE_PAY)
+            ->whereNotNull('bank_name')
+            ->where('bank_name', '<>', '')
+            ->distinct()
+            ->orderBy('bank_name')
+            ->pluck('bank_name')
+            ->map(static fn(string $name): array => ['label' => $name, 'value' => $name])
+            ->values()
+            ->all();
+
+        $platforms = [];
+        if (self::hasPlatformColumn()) {
+            $platforms = UserLogin::query()
+                ->whereNotNull('platform')
+                ->where('platform', '<>', '')
+                ->distinct()
+                ->orderBy('platform')
+                ->pluck('platform')
+                ->map(static fn(string $platform): array => ['label' => $platform, 'value' => $platform])
+                ->values()
+                ->all();
+        }
+
+        return [
+            'payment_companies' => array_map(static fn(string $code, string $name): array => [
+                'label' => $name,
+                'value' => $code,
+            ], array_keys(self::COMPANIES), array_values(self::COMPANIES)),
+            'payment_methods' => $paymentMethods,
+            'statuses' => array_map(static fn(int $value, string $label): array => [
+                'label' => $label,
+                'value' => $value,
+            ], array_keys(self::STATUSES), array_values(self::STATUSES)),
+            'platforms' => $platforms,
+        ];
+    }
+
+    private static function platformSql(): string
+    {
+        $prefix = DB::getTablePrefix();
+        $login = $prefix . 'user_login';
+        $users = $prefix . 'users';
+        $paymentOrders = $prefix . 'payment_orders';
+        $statusCondition = self::hasLoginStatusColumn()
+            ? 'AND ul.status = ' . (int)UserLogin::STATUS_SUCCESS
+            : '';
+
+        return "SELECT ul.platform
+            FROM `{$login}` ul
+            WHERE ul.user_id COLLATE utf8mb4_general_ci = `{$users}`.`user_id` COLLATE utf8mb4_general_ci
+              {$statusCondition}
+              AND ul.created_at <= `{$paymentOrders}`.`created_at`
+            ORDER BY ul.id DESC
+            LIMIT 1";
+    }
+
+    private static function hasPlatformColumn(): bool
+    {
+        if (self::$hasPlatform === null) {
+            self::$hasPlatform = Schema::hasTable('user_login')
+                && Schema::hasColumn('user_login', 'platform');
+        }
+
+        return self::$hasPlatform;
+    }
+
+    private static function hasLoginStatusColumn(): bool
+    {
+        if (self::$hasLoginStatus === null) {
+            self::$hasLoginStatus = Schema::hasTable('user_login')
+                && Schema::hasColumn('user_login', 'status');
+        }
+
+        return self::$hasLoginStatus;
+    }
+
+    private static function decodeJson($value)
+    {
+        if (is_array($value) || $value === null || $value === '') {
+            return $value ?: null;
+        }
+
+        $decoded = json_decode((string)$value, true);
+        return json_last_error() === JSON_ERROR_NONE ? $decoded : (string)$value;
+    }
+
+    private static function gatewayReference($callbackData, $payData, string $fallback): string
+    {
+        if ($fallback !== '') {
+            return $fallback;
+        }
+
+        $keys = [
+            'tradeNo', 'trade_no', 'transactionId', 'transaction_id', 'txid',
+            'gatewayOrderNo', 'gateway_order_no', 'platformOrderNo', 'platform_order_no',
+            'orderNo', 'order_no', 'orderId', 'order_id',
+        ];
+
+        foreach ([$callbackData, $payData] as $payload) {
+            $reference = self::findValueByKeys($payload, $keys);
+            if ($reference !== '') {
+                return $reference;
+            }
+        }
+
+        return '';
+    }
+
+    private static function findValueByKeys($payload, array $keys): string
+    {
+        if (!is_array($payload)) {
+            return '';
+        }
+
+        foreach ($keys as $key) {
+            if (isset($payload[$key]) && is_scalar($payload[$key]) && (string)$payload[$key] !== '') {
+                return (string)$payload[$key];
+            }
+        }
+        foreach ($payload as $value) {
+            $reference = self::findValueByKeys($value, $keys);
+            if ($reference !== '') {
+                return $reference;
+            }
+        }
+
+        return '';
+    }
+
+    private static function sanitizePayload($payload)
+    {
+        if (!is_array($payload)) {
+            return $payload;
+        }
+
+        $sensitiveKeys = ['sign', 'signature', 'key', 'secret', 'password'];
+        foreach ($payload as $key => $value) {
+            if (in_array(strtolower((string)$key), $sensitiveKeys, true)) {
+                unset($payload[$key]);
+                continue;
+            }
+            if (is_array($value)) {
+                $payload[$key] = self::sanitizePayload($value);
+            }
+        }
+
+        return $payload;
+    }
+
+    private static function money($amount): string
+    {
+        return number_format((float)$amount, 2, '.', '');
+    }
+
+    private static function nullableMoney($amount): ?string
+    {
+        return $amount === null || $amount === '' ? null : self::money($amount);
+    }
+
+    private static function formatTime($value): ?string
+    {
+        if (empty($value)) {
+            return null;
+        }
+
+        return Carbon::parse($value)
+            ->setTimezone(config('app.timezone', 'Asia/Shanghai'))
+            ->format('Y-m-d H:i:s');
+    }
+
+    /**
+     * @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(),
+        ];
+    }
+}

+ 180 - 0
database/migrations/2026_08_11_120000_add_admin_finance_support.sql

@@ -0,0 +1,180 @@
+-- Execute this file directly against the target database after taking a backup.
+-- Do not run `php artisan migrate` for this deployment.
+-- This script contains no DROP, TRUNCATE, DELETE, or UPDATE statements.
+
+CREATE TABLE IF NOT EXISTS `bot_manual_audits` (
+  `id` bigint unsigned NOT NULL AUTO_INCREMENT,
+  `request_id` varchar(64) NOT NULL,
+  `request_hash` char(64) NOT NULL,
+  `admin_id` bigint unsigned NULL DEFAULT NULL,
+  `balance_log_id` bigint unsigned NULL DEFAULT NULL,
+  `member_id` varchar(64) NOT NULL,
+  `username` varchar(128) NOT NULL DEFAULT '',
+  `first_name` varchar(128) NOT NULL DEFAULT '',
+  `transaction_type` varchar(16) NOT NULL,
+  `change_type` varchar(50) NOT NULL,
+  `amount` decimal(30, 10) NOT NULL,
+  `before_balance` decimal(30, 10) NULL DEFAULT NULL,
+  `after_balance` decimal(30, 10) NULL DEFAULT NULL,
+  `level_before` int NOT NULL DEFAULT 0,
+  `level_before_name` varchar(100) NOT NULL DEFAULT '',
+  `level_after` int NOT NULL DEFAULT 0,
+  `level_after_name` varchar(100) NOT NULL DEFAULT '',
+  `status` tinyint unsigned NOT NULL DEFAULT 0 COMMENT '0=failed, 1=success',
+  `remark` varchar(255) NOT NULL DEFAULT '',
+  `failure_reason` varchar(255) NOT NULL DEFAULT '',
+  `created_at` datetime NULL DEFAULT NULL,
+  `updated_at` datetime NULL DEFAULT NULL,
+  PRIMARY KEY (`id`),
+  UNIQUE KEY `uniq_manual_audits_request_id` (`request_id`),
+  UNIQUE KEY `uniq_manual_audits_balance_log_id` (`balance_log_id`),
+  KEY `idx_manual_audits_member_created` (`member_id`, `created_at`),
+  KEY `idx_manual_audits_type_created` (`transaction_type`, `created_at`),
+  KEY `idx_manual_audits_status_created` (`status`, `created_at`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
+
+-- Import successful historical admin adjustments. Automatic rewards use a
+-- non-NULL related_id and are intentionally excluded.
+INSERT IGNORE INTO `bot_manual_audits` (
+  `request_id`,
+  `request_hash`,
+  `admin_id`,
+  `balance_log_id`,
+  `member_id`,
+  `username`,
+  `first_name`,
+  `transaction_type`,
+  `change_type`,
+  `amount`,
+  `before_balance`,
+  `after_balance`,
+  `level_before`,
+  `level_before_name`,
+  `level_after`,
+  `level_after_name`,
+  `status`,
+  `remark`,
+  `failure_reason`,
+  `created_at`,
+  `updated_at`
+)
+SELECT
+  CONCAT('legacy-', bl.`id`),
+  SHA2(CONCAT('legacy-', bl.`id`), 256),
+  NULL,
+  bl.`id`,
+  bl.`member_id`,
+  COALESCE(u.`username`, ''),
+  COALESCE(u.`first_name`, ''),
+  CASE WHEN bl.`amount` < 0 THEN 'debit' ELSE 'credit' END,
+  bl.`change_type`,
+  bl.`amount`,
+  bl.`before_balance`,
+  bl.`after_balance`,
+  COALESCE(u.`level`, 0),
+  COALESCE(
+    NULLIF(l.`level_name`, ''),
+    CASE WHEN COALESCE(u.`level`, 0) > 0 THEN CONCAT('等级', u.`level`) ELSE '普通会员' END
+  ),
+  COALESCE(u.`level`, 0),
+  COALESCE(
+    NULLIF(l.`level_name`, ''),
+    CASE WHEN COALESCE(u.`level`, 0) > 0 THEN CONCAT('等级', u.`level`) ELSE '普通会员' END
+  ),
+  1,
+  COALESCE(bl.`remark`, ''),
+  '',
+  COALESCE(bl.`created_at`, NOW()),
+  COALESCE(bl.`updated_at`, bl.`created_at`, NOW())
+FROM `bot_balance_logs` AS bl
+LEFT JOIN `bot_users` AS u ON u.`member_id` = bl.`member_id`
+LEFT JOIN `bot_level` AS l ON l.`level` = u.`level`
+WHERE bl.`member_id` IS NOT NULL
+  AND bl.`member_id` <> ''
+  AND bl.`related_id` IS NULL
+  AND (
+    (bl.`change_type` IN ('人工充值', '注册赠送', '优惠活动') AND bl.`amount` > 0)
+    OR (bl.`change_type` = '人工扣款' AND bl.`amount` < 0)
+  );
+
+-- Indexes used by the finance summary and third-party deposit queries.
+SET @index_exists = (
+  SELECT COUNT(*)
+  FROM information_schema.statistics
+  WHERE table_schema = DATABASE()
+    AND table_name = 'bot_balance_logs'
+    AND index_name = 'idx_balance_logs_finance_type_created'
+);
+SET @ddl = IF(
+  @index_exists = 0,
+  'ALTER TABLE `bot_balance_logs` ADD INDEX `idx_balance_logs_finance_type_created` (`change_type`, `created_at`, `related_id`, `member_id`)',
+  'SELECT 1'
+);
+PREPARE admin_finance_stmt FROM @ddl;
+EXECUTE admin_finance_stmt;
+DEALLOCATE PREPARE admin_finance_stmt;
+
+SET @index_exists = (
+  SELECT COUNT(*)
+  FROM information_schema.statistics
+  WHERE table_schema = DATABASE()
+    AND table_name = 'bot_balance_logs'
+    AND index_name = 'idx_balance_logs_finance_related_type'
+);
+SET @ddl = IF(
+  @index_exists = 0,
+  'ALTER TABLE `bot_balance_logs` ADD INDEX `idx_balance_logs_finance_related_type` (`related_id`, `change_type`, `id`)',
+  'SELECT 1'
+);
+PREPARE admin_finance_stmt FROM @ddl;
+EXECUTE admin_finance_stmt;
+DEALLOCATE PREPARE admin_finance_stmt;
+
+SET @index_exists = (
+  SELECT COUNT(*)
+  FROM information_schema.statistics
+  WHERE table_schema = DATABASE()
+    AND table_name = 'bot_payment_orders'
+    AND index_name = 'idx_payment_orders_finance_status_type_created'
+);
+SET @ddl = IF(
+  @index_exists = 0,
+  'ALTER TABLE `bot_payment_orders` ADD INDEX `idx_payment_orders_finance_status_type_created` (`status`, `type`, `created_at`, `member_id`)',
+  'SELECT 1'
+);
+PREPARE admin_finance_stmt FROM @ddl;
+EXECUTE admin_finance_stmt;
+DEALLOCATE PREPARE admin_finance_stmt;
+
+SET @index_exists = (
+  SELECT COUNT(*)
+  FROM information_schema.statistics
+  WHERE table_schema = DATABASE()
+    AND table_name = 'bot_user_login'
+    AND index_name = 'idx_user_login_finance_user_status_created'
+);
+SET @login_table_exists = (
+  SELECT COUNT(*)
+  FROM information_schema.tables
+  WHERE table_schema = DATABASE()
+    AND table_name = 'bot_user_login'
+);
+SET @status_column_exists = (
+  SELECT COUNT(*)
+  FROM information_schema.columns
+  WHERE table_schema = DATABASE()
+    AND table_name = 'bot_user_login'
+    AND column_name = 'status'
+);
+SET @ddl = IF(
+  @login_table_exists = 0 OR @index_exists > 0,
+  'SELECT 1',
+  IF(
+    @status_column_exists > 0,
+    'ALTER TABLE `bot_user_login` ADD INDEX `idx_user_login_finance_user_status_created` (`user_id`, `status`, `created_at`, `id`)',
+    'ALTER TABLE `bot_user_login` ADD INDEX `idx_user_login_finance_user_status_created` (`user_id`, `created_at`, `id`)'
+  )
+);
+PREPARE admin_finance_stmt FROM @ddl;
+EXECUTE admin_finance_stmt;
+DEALLOCATE PREPARE admin_finance_stmt;

+ 209 - 0
docs/admin-finance-api.md

@@ -0,0 +1,209 @@
+# 后台财务接口
+
+所有接口需要后台登录令牌:
+
+```http
+Authorization: Bearer <token>
+```
+
+POST 请求使用 `Content-Type: application/json`。统一响应:`code = 0` 表示成功,业务数据在 `data`;失败时 `code != 0`,原因在 `msg`。金额字段均为保留两位小数的字符串。
+
+## 1. 出入款汇总
+
+### GET `/admin/funds/summary`
+
+参数:
+
+| 参数 | 必填 | 说明 |
+| --- | --- | --- |
+| `start_date` | 否 | 开始日期,`Y-m-d`;与 `end_date` 同时传,不传时默认今天 |
+| `end_date` | 否 | 结束日期,`Y-m-d`;与 `start_date` 同时传,不传时默认今天 |
+
+返回:
+
+```json
+{
+  "code": 0,
+  "data": {
+    "start_date": "2026-08-11",
+    "end_date": "2026-08-11",
+    "income": [
+      {"key": "company_deposit", "label": "公司入款", "amount": "0.00", "people": 0},
+      {"key": "third_party_deposit", "label": "第三方支付(入款)", "amount": "1000.00", "people": 1},
+      {"key": "manual_credit", "label": "人工上分", "amount": "2000.00", "people": 1},
+      {"key": "member_withdraw_deduction", "label": "会员出款扣除", "amount": "0.00", "people": 0}
+    ],
+    "expense": [
+      {"key": "member_withdrawal", "label": "会员出款", "amount": "0.00", "people": 0},
+      {"key": "manual_withdrawal", "label": "人工出款", "amount": "0.00", "people": 0},
+      {"key": "vip_discount", "label": "给予优惠(VIP 折扣)", "amount": "0.00", "people": 0},
+      {"key": "rebate", "label": "给予返点", "amount": "0.00", "people": 0},
+      {"key": "bonus", "label": "优惠金", "amount": "0.00", "people": 0},
+      {"key": "third_party_fee", "label": "第三方手续费", "amount": "0.00", "people": 0},
+      {"key": "third_party_auto_withdrawal", "label": "第三方支付出款(自动出款)", "amount": "0.00", "people": 0},
+      {"key": "third_party_auto_withdrawal_fee", "label": "第三方支付自动出款手续费", "amount": "0.00", "people": 0}
+    ],
+    "income_total": "3000.00",
+    "expense_total": "0.00"
+  }
+}
+```
+
+前端直接遍历 `income`、`expense`。每项固定返回 `key`、`label`、`amount`、`people`。
+
+汇总金额统一按钱包余额单位统计;USDT 会员出款使用订单实际扣减的钱包金额。
+
+当前库没有独立的 VIP 折扣流水,因此 `vip_discount` 固定返回 `0.00 / 0人`。
+
+## 2. 人工稽查
+
+### GET `/admin/manualAudit`
+
+参数:
+
+| 参数 | 必填 | 说明 |
+| --- | --- | --- |
+| `page` | 否 | 默认 `1` |
+| `limit` | 否 | 默认 `20`,最大 `200` |
+| `member_id` | 否 | 会员 ID |
+| `first_name` | 否 | 会员昵称,模糊匹配 |
+| `transaction_type` | 否 | `credit` 人工上分;`debit` 人工下分 |
+| `change_type` | 否 | 上分类型或 `人工扣款` |
+| `status` | 否 | `0` 失败;`1` 成功 |
+| `start_date` | 否 | 交易开始日期,`Y-m-d` |
+| `end_date` | 否 | 交易结束日期,`Y-m-d` |
+
+返回 `data.total`、`data.data` 和筛选/表单选项 `data.options`。
+
+列表项主要字段:
+
+| 字段 | 说明 |
+| --- | --- |
+| `id` | 稽查记录 ID,查看详情时使用 |
+| `request_id` | 本次人工操作的幂等请求 ID |
+| `admin_id` | 操作管理员 ID;历史导入记录可能为 `null` |
+| `balance_log_id` | 成功落账后的资金流水 ID;失败记录为 `null` |
+| `member_id` / `username` / `first_name` | 会员 ID、账号、昵称 |
+| `transaction_type` / `transaction_type_text` | `credit/debit`、人工上分/人工下分 |
+| `change_type` | 充值类型或人工扣款 |
+| `level_before_name` / `level_after_name` | 操作当时保存的会员等级名称 |
+| `amount` | 显示金额,始终为正数 |
+| `signed_amount` | 带正负号的实际变动金额 |
+| `before_balance` / `after_balance` | 操作前后余额;用户或钱包不存在时可能为 `null` |
+| `status` / `status_text` | `0/失败`、`1/成功` |
+| `remark` / `failure_reason` / `created_at` | 说明、失败原因、交易时间 |
+
+`data.options.credit_types` 用于人工上分弹窗的充值类型下拉框。
+
+列表同时返回成功和失败记录,`data.options.statuses` 可直接用于状态筛选。
+
+### POST `/admin/manualAudit/topUp`
+
+```json
+{
+  "request_id": "7fd7a3cb-95f9-4acd-84e2-1ccbd211af92",
+  "member_id": "10001",
+  "change_type": "人工充值",
+  "amount": 2000,
+  "remark": "人工上分"
+}
+```
+
+`request_id` 必填,前端每次确认操作时生成一个 UUID(可使用 `crypto.randomUUID()`)。网络超时重试必须沿用同一个 UUID;新操作必须生成新 UUID。
+
+`change_type` 可选值由列表接口的 `options.credit_types` 提供。`remark` 可不传。成功时 `data` 返回完整稽查记录。
+
+人工上分不会触发前台充值活动赠送。
+
+### POST `/admin/manualAudit/debit`
+
+```json
+{
+  "request_id": "10c2c632-92ee-419a-b550-e459454a2802",
+  "member_id": "10001",
+  "amount": 1000,
+  "remark": "人工下分"
+}
+```
+
+`request_id` 规则与人工上分相同。余额不足、用户不存在或钱包不存在时返回非零 `code`,同时 `data` 返回已保存的失败稽查记录;成功时 `data` 返回完整稽查记录。
+
+### GET `/admin/manualAudit/detail?id=1`
+
+返回字段与列表项相同,供“查看”弹窗使用。
+
+## 3. 第三方入款
+
+### GET `/admin/thirdPartyDeposit`
+
+参数:
+
+| 参数 | 必填 | 说明 |
+| --- | --- | --- |
+| `page` / `limit` | 否 | 默认 `1/20`,`limit` 最大 `200` |
+| `member_id` | 否 | 会员 ID |
+| `payment_company` | 否 | `sanjin`、`jd`、`no`、`zimu` |
+| `payment_method` | 否 | 支付方式,使用返回的选项值 |
+| `channel` | 否 | 通道编号 |
+| `status` | 否 | `0` 待处理、`1` 处理中、`2` 成功、`3` 失败 |
+| `platform` | 否 | PC、Android、iOS、WeChat 等,使用返回的选项值 |
+| `submit_start_date` / `submit_end_date` | 否 | 提交日期范围,`Y-m-d` |
+| `credit_start_date` / `credit_end_date` | 否 | 到账日期范围,`Y-m-d` |
+
+返回 `data.total`、`data.data` 和全部筛选项 `data.options`:
+
+```json
+{
+  "code": 0,
+  "data": {
+    "total": 1,
+    "data": [
+      {
+        "id": 1,
+        "member_id": "10001",
+        "username": "test01",
+        "first_name": "test01",
+        "payment_company": "zimu",
+        "payment_company_name": "808支付",
+        "payment_method": "微信",
+        "channel_code": "ZIMUpay",
+        "amount": "1000.00",
+        "fee": "20.00",
+        "before_balance": "5000.00",
+        "after_balance": "6000.00",
+        "order_no": "order_001",
+        "gateway_order_no": "gateway_001",
+        "gateway_response": "gateway_001",
+        "submitted_at": "2026-08-05 10:20:30",
+        "credited_at": "2026-08-05 10:25:10",
+        "platform": "PC",
+        "status": 2,
+        "status_text": "成功"
+      }
+    ],
+    "options": {
+      "payment_companies": [],
+      "payment_methods": [],
+      "statuses": [],
+      "platforms": []
+    }
+  }
+}
+```
+
+未到账订单的 `before_balance`、`after_balance`、`credited_at` 为 `null`。
+
+`platform` 取该会员提交订单前最近一次成功登录的平台;没有平台登录记录时返回空字符串。
+
+### GET `/admin/thirdPartyDeposit/detail?id=1`
+
+返回列表项全部字段,另外包含:
+
+| 字段 | 说明 |
+| --- | --- |
+| `remark` | 订单备注 |
+| `admin_note` | 后台备注 |
+| `pay_data` | 发起支付时的三方响应,敏感签名字段已移除 |
+| `callback_data` | 三方回调内容,敏感签名字段已移除 |
+
+`gateway_response` 优先返回订单表保存的三方单号;该字段为空时才从三方响应中提取交易编号,不会返回本系统商户订单号。

+ 19 - 0
routes/admin.php

@@ -45,6 +45,9 @@ use App\Http\Controllers\admin\JisuGameOrder;
 use App\Http\Controllers\admin\JisuLottery;
 use App\Http\Controllers\admin\Egame;
 use App\Http\Controllers\admin\Online;
+use App\Http\Controllers\admin\Funds;
+use App\Http\Controllers\admin\ManualAudit;
+use App\Http\Controllers\admin\ThirdPartyDeposit;
 
 Route::post('/login', [Admin::class, 'login']);
 Route::get('/test', [Wallet::class, 'test']);
@@ -199,6 +202,22 @@ Route::middleware(['admin.jwt'])->group(function () {
             Route::get('/log', [Balance::class, 'log']);
         });
 
+        Route::prefix('/funds')->group(function () {
+            Route::get('/summary', [Funds::class, 'summary']);
+        });
+
+        Route::prefix('/manualAudit')->group(function () {
+            Route::get('/', [ManualAudit::class, 'index']);
+            Route::get('/detail', [ManualAudit::class, 'detail']);
+            Route::post('/topUp', [ManualAudit::class, 'topUp']);
+            Route::post('/debit', [ManualAudit::class, 'debit']);
+        });
+
+        Route::prefix('/thirdPartyDeposit')->group(function () {
+            Route::get('/', [ThirdPartyDeposit::class, 'index']);
+            Route::get('/detail', [ThirdPartyDeposit::class, 'detail']);
+        });
+
         Route::prefix('/wallet')->group(function () {
             Route::get('/', [Wallet::class, 'index']);
             Route::post('/topUp', [Wallet::class, 'topUp']);