doge 2 дней назад
Родитель
Сommit
594b8ce414

+ 35 - 0
app/Console/Commands/AgentReleaseManualCredits.php

@@ -0,0 +1,35 @@
+<?php
+
+namespace App\Console\Commands;
+
+use App\Models\Agent\AgentManualCreditLock;
+use App\Services\Agent\AgentWalletService;
+use Illuminate\Console\Command;
+
+class AgentReleaseManualCredits extends Command
+{
+    protected $signature = 'agent:release-manual-credits';
+
+    protected $description = '解锁入股代理到期的人工加款';
+
+    public function handle(AgentWalletService $wallets): int
+    {
+        $at = now();
+        $released = 0;
+        $failed = 0;
+        AgentManualCreditLock::query()->pending()->where('unlock_at', '<=', $at)
+            ->select('agent_id')->distinct()->chunkById(200, function ($agents) use ($wallets, $at, &$released, &$failed) {
+                foreach ($agents as $agent) {
+                    try {
+                        $released += $wallets->releaseManualCredits((int) $agent->agent_id, $at);
+                    } catch (\Throwable $e) {
+                        ++$failed;
+                        report($e);
+                    }
+                }
+            }, 'agent_id');
+
+        $this->info("已解锁 {$released} 笔,失败 {$failed} 个账户");
+        return $failed === 0 ? self::SUCCESS : self::FAILURE;
+    }
+}

+ 1 - 0
app/Console/Kernel.php

@@ -41,6 +41,7 @@ class Kernel extends ConsoleKernel
             ->withoutOverlapping(180);
         $schedule->command('share:sync-facts --write')->everyMinute()->withoutOverlapping(30);
         $schedule->command('agent:profit-sync')->everyMinute()->withoutOverlapping(30);
+        $schedule->command('agent:release-manual-credits')->everyMinute()->withoutOverlapping(10);
         $schedule->command('third-game:refresh-merchant-balance')->everyMinute()->withoutOverlapping(2)->runInBackground();
         $schedule->command('report:member-daily')->everyTenMinutes()->withoutOverlapping(30);
         $schedule->command('report:member-daily ' . now()->subDay()->toDateString() . ' --days=3')

+ 42 - 5
app/Http/Controllers/admin/Agent.php

@@ -4,12 +4,15 @@ namespace App\Http\Controllers\admin;
 
 use App\Http\Controllers\AgentApiController;
 use App\Models\Agent\Agent as AgentModel;
+use App\Models\Agent\AgentBalanceLog;
 use App\Models\Agent\AgentLoginLog;
+use App\Models\Agent\AgentManualCreditLock;
 use App\Models\Agent\AgentWithdrawal;
 use App\Models\Agent\AgentRebateRecord;
 use App\Models\EgameItem;
 use App\Services\Agent\AgentDomainService;
 use App\Services\Agent\AgentEquityAllocation;
+use App\Services\Agent\AgentEquityService;
 use App\Services\Agent\AgentReportService;
 use App\Services\Agent\AgentService;
 use App\Services\Agent\AgentWalletService;
@@ -113,18 +116,27 @@ class Agent extends AgentApiController
         });
     }
 
-    public function adjust(Request $request, AgentWalletService $wallets)
+    public function adjust(Request $request, AgentWalletService $wallets, AgentEquityService $equity)
     {
-        return $this->execute(function () use ($request, $wallets) {
+        return $this->execute(function () use ($request, $wallets, $equity) {
             $data = $request->validate([
                 'agent_id' => ['required', 'integer', 'exists:agents,id'], 'type' => ['required', 'in:credit,debit,margin_credit,margin_refund'],
                 'amount' => ['required', 'numeric', 'min:0.0001', new DecimalNumber()], 'remark' => ['required', 'string', 'max:500'],
                 'request_id' => ['required', 'string', 'max:64'],
             ]);
-            return $wallets->adjust(
+            $log = $wallets->adjust(
                 (int) $data['agent_id'], (string) $data['amount'], $data['type'], $data['remark'],
-                'admin:' . request()->user->id . ':' . $data['request_id'], 'admin', (int) request()->user->id
+                'admin:' . request()->user->id . ':' . $data['request_id'], 'admin', (int) request()->user->id,
+                freezeEquityCredit: true,
             );
+            if ($log->wasRecentlyCreated && $log->reference_type === AgentManualCreditLock::REFERENCE_TYPE) {
+                try {
+                    $equity->fulfillPending((int) $data['agent_id']);
+                } catch (\Throwable $e) {
+                    report($e);
+                }
+            }
+            return $log;
         });
     }
 
@@ -178,6 +190,31 @@ class Agent extends AgentApiController
         });
     }
 
+    public function balanceLogs(Request $request)
+    {
+        return $this->execute(function () use ($request) {
+            $data = $request->validate([
+                'agent_id' => ['required', 'integer', 'min:1'],
+                'page' => ['nullable', 'integer', 'min:1'],
+                'limit' => ['nullable', 'integer', 'min:1', 'max:100'],
+                'type' => ['nullable', 'string', 'max:32'],
+                'start_date' => ['nullable', 'date_format:Y-m-d'],
+                'end_date' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:start_date'],
+            ]);
+            $query = AgentBalanceLog::query()->where('agent_id', $data['agent_id']);
+            if (!empty($data['type'])) {
+                $query->where('type', $data['type']);
+            }
+            $result = $this->paginate($query, $data);
+            $types = AgentWalletService::TYPE_NAMES;
+            $result['list']->each(fn (AgentBalanceLog $log) => $log->setAttribute('type_text', $types[$log->type] ?? $log->type));
+            $result['types'] = array_map(fn (string $type, string $label) => [
+                'value' => $type, 'label' => $label,
+            ], array_keys($types), array_values($types));
+            return $result;
+        });
+    }
+
     public function withdrawals(Request $request)
     {
         return $this->execute(function () use ($request) {
@@ -187,7 +224,7 @@ class Agent extends AgentApiController
                 'status' => ['nullable', 'in:pending,approved,processing,rejected,paid,failed'], 'channel' => ['nullable', 'string', 'max:32'],
                 'start_date' => ['nullable', 'date_format:Y-m-d'], 'end_date' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:start_date'],
             ]);
-            $query = AgentWithdrawal::query()->with('agent:id,username,real_name');
+            $query = AgentWithdrawal::query()->with('agent:id,username,real_name,type');
             foreach (['agent_id', 'order_no', 'status'] as $field) if (!empty($data[$field])) $query->where($field, $data[$field]);
             if (!empty($data['channel'])) $query->where('account_type', $data['channel']);
             $result = $this->paginate($query, $data);

+ 21 - 0
app/Models/Agent/AgentManualCreditLock.php

@@ -0,0 +1,21 @@
+<?php
+
+namespace App\Models\Agent;
+
+use App\Models\BaseModel;
+use Illuminate\Database\Eloquent\Builder;
+
+class AgentManualCreditLock extends BaseModel
+{
+    public const REFERENCE_TYPE = 'agent_manual_credit_lock';
+    public const LOCK_DAYS = 7;
+
+    protected $table = 'agent_manual_credit_locks';
+    protected $fillable = ['agent_id', 'amount', 'remaining', 'unlock_at', 'released_at'];
+    protected $casts = ['amount' => 'decimal:4', 'remaining' => 'decimal:4', 'unlock_at' => 'datetime', 'released_at' => 'datetime'];
+
+    public function scopePending(Builder $query): Builder
+    {
+        return $query->whereNull('released_at')->where('remaining', '>', 0);
+    }
+}

+ 3 - 3
app/Services/Agent/AgentEquityService.php

@@ -38,7 +38,7 @@ class AgentEquityService
             'max_percent' => (string) $setting->equity_max_percent,
             'equity_percent' => $held,
             'remaining_percent' => bccomp($remaining, '0', 4) > 0 ? $remaining : '0.0000',
-            'balance' => (string) $agent->balance,
+            'balance' => $this->wallets->purchaseBalance($agent),
             'pending_order' => $pending,
         ];
     }
@@ -154,7 +154,7 @@ class AgentEquityService
             if ($orders->isEmpty()) return 0;
             $available = $agent->is_test ? null : AgentEquityAllocation::available(AgentSetting::lockCurrent()->company_equity_percent, false);
             foreach ($orders as $order) {
-                if (bccomp((string) $agent->balance, (string) $order->amount, 4) < 0) break;
+                if (bccomp($this->wallets->purchaseBalance($agent), $order->amount, 4) < 0) break;
                 if ($available !== null && bccomp($order->percent, $available, 4) > 0) break;
                 $this->wallets->adjust($agentId, (string) $order->amount, 'equity_purchase', '购买股份 ' . $order->percent . '%',
                     'equity-order:' . $order->id, 'agent', $agentId, 'agent_equity_order', (int) $order->id);
@@ -168,6 +168,6 @@ class AgentEquityService
                 ++$done;
             }
             return $done;
-        });
+        }, 3);
     }
 }

+ 111 - 11
app/Services/Agent/AgentWalletService.php

@@ -4,10 +4,12 @@ namespace App\Services\Agent;
 
 use App\Models\Agent\Agent;
 use App\Models\Agent\AgentBalanceLog;
+use App\Models\Agent\AgentManualCreditLock;
 use App\Models\Agent\AgentProfitCommissionProfile;
 use App\Models\Agent\AgentProfitEntry;
 use App\Models\Agent\AgentRechargeLock;
 use App\Models\Agent\AgentSetting;
+use DateTimeInterface;
 use Illuminate\Support\Facades\DB;
 
 /**
@@ -36,6 +38,7 @@ class AgentWalletService
         'credit' => '人工加款', 'debit' => '人工扣款', 'recharge' => '充值', 'withdraw_refund' => '提现退回',
         'withdraw_freeze' => '提现冻结', 'equity_purchase' => '购买股份', 'margin_credit' => '人工增加保证金',
         'margin_refund' => '退还保证金', 'profit_share' => '合营分成', 'commission' => '佣金(旧)',
+        'credit_unlock' => '人工加款解锁',
     ];
 
     public function adjust(
@@ -47,7 +50,8 @@ class AgentWalletService
         string $operatorType,
         ?int $operatorId,
         string $referenceType = '',
-        ?int $referenceId = null
+        ?int $referenceId = null,
+        bool $freezeEquityCredit = false,
     ): AgentBalanceLog {
         if (!isset(self::DIRECTIONS[$type])) {
             throw new \InvalidArgumentException('资金变动类型错误');
@@ -56,7 +60,7 @@ class AgentWalletService
             throw new \InvalidArgumentException('金额必须大于0');
         }
 
-        return DB::transaction(function () use ($agentId, $amount, $type, $remark, $idempotencyKey, $operatorType, $operatorId, $referenceType, $referenceId) {
+        return DB::transaction(function () use ($agentId, $amount, $type, $remark, $idempotencyKey, $operatorType, $operatorId, $referenceType, $referenceId, $freezeEquityCredit) {
             $agent = Agent::query()->lockForUpdate()->findOrFail($agentId);
             $existing = AgentBalanceLog::query()->where('idempotency_key', $idempotencyKey)->lockForUpdate()->first();
             if ($existing) {
@@ -64,15 +68,27 @@ class AgentWalletService
                 return $existing;
             }
             $signed = self::DIRECTIONS[$type] > 0 ? bcadd($amount, '0', 4) : bcsub('0', $amount, 4);
+            $freezeCredit = $freezeEquityCredit && $type === 'credit' && $agent->isEquity();
+            $purchaseFromFrozen = $type === 'equity_purchase' ? $this->consumeManualCredits($agent, $amount) : '0.0000';
+            $balanceChange = $freezeCredit ? '0.0000' : bcadd($signed, $purchaseFromFrozen, 4);
             $before = bcadd((string) $agent->balance, '0', 4);
-            $after = bcadd($before, $signed, 4);
-            $realtime = bcadd((string) $agent->realtime_balance, $signed, 4);
+            $after = bcadd($before, $balanceChange, 4);
+            $realtime = bcadd((string) $agent->realtime_balance, $balanceChange, 4);
             // 扣款后,待入账分成按结算日依次入账时余额也不能为负(同时保证实时余额不为负)。
             if (self::DIRECTIONS[$type] < 0 && bccomp(bcadd($after, $this->pendingFloor($agent), 4), '0', 4) < 0) {
                 throw new \RuntimeException('代理余额不足');
             }
             $agent->balance = $after;
             $agent->realtime_balance = $realtime;
+            if ($freezeCredit) {
+                $lock = AgentManualCreditLock::query()->create([
+                    'agent_id' => $agentId, 'amount' => $signed, 'remaining' => $signed,
+                    'unlock_at' => now()->addDays(AgentManualCreditLock::LOCK_DAYS),
+                ]);
+                $agent->frozen_balance = bcadd((string) $agent->frozen_balance, $signed, 4);
+                $referenceType = AgentManualCreditLock::REFERENCE_TYPE;
+                $referenceId = (int) $lock->id;
+            }
             if (($type === 'recharge' && $agent->isJoint()) || $type === 'margin_credit') {
                 $agent->margin_amount = bcadd((string) $agent->margin_amount, $amount, 4);
             } elseif ($type === 'margin_refund') {
@@ -92,13 +108,96 @@ class AgentWalletService
             if ($type === 'recharge' && $agent->isEquity()) {
                 $this->lockRecharge($agent, $log, $amount, $referenceType, $referenceId);
             } elseif ($type === 'equity_purchase') {
-                $this->consumeLocks((int) $agent->id, $amount);
+                $this->consumeLocks($agentId, bcsub($amount, $purchaseFromFrozen, 4));
+            } elseif ($agent->isEquity() && ($type === 'debit' || ($type === 'credit' && !$freezeCredit))) {
+                // 人工扣款先用可提现金额。加款前按旧余额清理历史超额锁定,避免锁住新到账的钱。
+                $this->capRechargeLocks($agent, $type === 'debit' ? $after : $before, now());
             }
             $this->evaluateActivation($agent);
             return $log;
         });
     }
 
+    private function manualCreditBalance(Agent $agent): string
+    {
+        if (!$agent->isEquity()) return '0.0000';
+        return bcadd((string) AgentManualCreditLock::query()->pending()->where('agent_id', $agent->id)->sum('remaining'), '0', 4);
+    }
+
+    public function purchaseBalance(Agent $agent): string
+    {
+        return bcadd((string) $agent->balance, $this->manualCreditBalance($agent), 4);
+    }
+
+    public function withdrawalFrozenBalance(Agent $agent): string
+    {
+        return bcsub((string) $agent->frozen_balance, $this->manualCreditBalance($agent), 4);
+    }
+
+    /** 购股先扣人工加款冻结额,再使用余额中的充值锁定额和可提现金额。调用方已锁住代理。 */
+    private function consumeManualCredits(Agent $agent, string $amount): string
+    {
+        $left = $amount;
+        $locks = AgentManualCreditLock::query()->pending()->where('agent_id', $agent->id)
+            ->orderBy('unlock_at')->orderBy('id')->lockForUpdate()->get();
+        if ($locks->isEmpty()) return '0.0000';
+        if (bccomp($this->withdrawalFrozenBalance($agent), '0', 4) < 0) {
+            throw new \RuntimeException('代理冻结余额异常');
+        }
+        foreach ($locks as $lock) {
+            if (bccomp($left, '0', 4) <= 0) break;
+            $take = bccomp($lock->remaining, $left, 4) < 0 ? $lock->remaining : $left;
+            $lock->remaining = bcsub($lock->remaining, $take, 4);
+            $lock->save();
+            $left = bcsub($left, $take, 4);
+        }
+        $spent = bcsub($amount, $left, 4);
+        $agent->frozen_balance = bcsub((string) $agent->frozen_balance, $spent, 4);
+        return $spent;
+    }
+
+    public function releaseManualCredits(int $agentId, DateTimeInterface $at): int
+    {
+        return DB::transaction(function () use ($agentId, $at) {
+            $agent = Agent::query()->lockForUpdate()->findOrFail($agentId);
+            $due = AgentManualCreditLock::query()->pending()->where('agent_id', $agentId)
+                ->where('unlock_at', '<=', $at)->orderBy('unlock_at')->orderBy('id')->lockForUpdate()->get();
+            if ($due->isEmpty()) return 0;
+            if (bccomp($this->withdrawalFrozenBalance($agent), '0', 4) < 0) {
+                throw new \RuntimeException('代理冻结余额异常');
+            }
+
+            $this->capRechargeLocks($agent, (string) $agent->balance, $at);
+            foreach ($due as $lock) {
+                $amount = $lock->remaining;
+                $before = $agent->balance;
+                $agent->balance = bcadd($before, $amount, 4);
+                $agent->realtime_balance = bcadd((string) $agent->realtime_balance, $amount, 4);
+                $agent->frozen_balance = bcsub((string) $agent->frozen_balance, $amount, 4);
+                AgentBalanceLog::query()->create([
+                    'agent_id' => $agentId, 'type' => 'credit_unlock', 'amount' => $amount,
+                    'before_balance' => $before, 'after_balance' => $agent->balance,
+                    'operator_type' => 'system', 'idempotency_key' => 'agent-credit-unlock:' . $lock->id,
+                    'reference_type' => AgentManualCreditLock::REFERENCE_TYPE, 'reference_id' => $lock->id,
+                    'remark' => '人工加款冻结期满',
+                ]);
+                $lock->remaining = '0.0000';
+                $lock->released_at = $at;
+                $lock->save();
+            }
+            $agent->save();
+            return $due->count();
+        }, 3);
+    }
+
+    private function capRechargeLocks(Agent $agent, string $balance, DateTimeInterface $at): void
+    {
+        $excess = bcsub($this->lockedAmount($agent, $at), $balance, 4);
+        if (bccomp($excess, '0', 4) > 0) {
+            $this->consumeLocks((int) $agent->id, $excess, $at);
+        }
+    }
+
     /** 入股代理的充值锁定 N 天。调用方已在事务内锁住代理。 */
     private function lockRecharge(Agent $agent, AgentBalanceLog $log, string $amount, string $referenceType, ?int $referenceId): void
     {
@@ -111,11 +210,12 @@ class AgentWalletService
         ]);
     }
 
-    /** 购买股份先用锁定中的充值(先到期的先用),用完再用可提现余额。 */
-    private function consumeLocks(int $agentId, string $amount): void
+    /** 扣减仍在锁定期的充值,先到期的先扣。 */
+    private function consumeLocks(int $agentId, string $amount, ?DateTimeInterface $at = null): void
     {
         $left = bcadd($amount, '0', 4);
-        $locks = AgentRechargeLock::query()->where('agent_id', $agentId)->where('unlock_at', '>', now())
+        if (bccomp($left, '0', 4) <= 0) return;
+        $locks = AgentRechargeLock::query()->where('agent_id', $agentId)->where('unlock_at', '>', $at ?? now())
             ->where('remaining', '>', 0)->orderBy('unlock_at')->orderBy('id')->lockForUpdate()->get();
         foreach ($locks as $lock) {
             if (bccomp($left, '0', 4) <= 0) break;
@@ -127,10 +227,10 @@ class AgentWalletService
     }
 
     /** 入股代理仍在锁定期内的充值金额。 */
-    public function lockedAmount(Agent $agent): string
+    public function lockedAmount(Agent $agent, ?DateTimeInterface $at = null): string
     {
         if (!$agent->isEquity()) return '0.0000';
-        $sum = AgentRechargeLock::query()->where('agent_id', $agent->id)->where('unlock_at', '>', now())->sum('remaining');
+        $sum = AgentRechargeLock::query()->where('agent_id', $agent->id)->where('unlock_at', '>', $at ?? now())->sum('remaining');
         return bcadd((string) $sum, '0', 4);
     }
 
@@ -238,7 +338,7 @@ class AgentWalletService
             ];
         } else {
             $data['equity_percent'] = bcadd((string) $agent->equity_percent, '0', 4);
-            // 锁定金额最多展示到当前余额(人工扣款等可能让余额低于锁定合计)
+            // 兼容历史记录,锁定金额最多展示到当前余额。
             $locked = $this->lockedAmount($agent);
             $shown = bccomp($locked, $balance, 4) > 0 ? $balance : $locked;
             $data['locked_amount'] = bccomp($shown, '0', 4) > 0 ? $shown : '0.0000';

+ 2 - 2
app/Services/Agent/AgentWithdrawalService.php

@@ -321,7 +321,7 @@ class AgentWithdrawalService
 
     private function settle(AgentWithdrawal $withdrawal, Agent $agent): void
     {
-        if (bccomp((string) $agent->frozen_balance, (string) $withdrawal->amount, 4) < 0) {
+        if (bccomp($this->wallets->withdrawalFrozenBalance($agent), (string) $withdrawal->amount, 4) < 0) {
             throw new \RuntimeException('代理冻结余额异常');
         }
         $agent->frozen_balance = bcsub((string) $agent->frozen_balance, (string) $withdrawal->amount, 4);
@@ -333,7 +333,7 @@ class AgentWithdrawalService
     private function refund(AgentWithdrawal $withdrawal, Agent $agent, string $operatorType, ?int $operatorId, string $remark): void
     {
         $before = bcadd((string) $agent->balance, '0', 4);
-        if (bccomp((string) $agent->frozen_balance, (string) $withdrawal->amount, 4) < 0) {
+        if (bccomp($this->wallets->withdrawalFrozenBalance($agent), (string) $withdrawal->amount, 4) < 0) {
             throw new \RuntimeException('代理冻结余额异常');
         }
         $agent->balance = bcadd($before, (string) $withdrawal->amount, 4);

+ 1 - 0
app/Services/RewardBasisService.php

@@ -50,6 +50,7 @@ class RewardBasisService
             AND MIN($type) BETWEEN 1 AND 13 THEN MIN($type) END");
 
         $facts = self::factsFor($user)
+            ->where('category', '<>', 'third:3')
             ->whereBetween('bet_date', [$start->toDateString(), $start->copy()->endOfMonth()->toDateString()])
             ->select('category', 'bet_amount', 'effective_amount')
             ->selectRaw('CASE WHEN category = ? AND settled = 0 THEN 0 ELSE net_profit END AS net_profit', ['self:lottery'])

+ 17 - 6
app/Services/RewardRuleService.php

@@ -13,12 +13,13 @@ use InvalidArgumentException;
 class RewardRuleService
 {
     private const TABLES = ['rebate' => 'rebate_rules', 'backflow' => 'backflow_rules'];
+    private const BACKFLOW_EXCLUDED_RULE_CATEGORIES = ['self:lottery', 'third:3'];
 
     public static function categories(string $type = 'rebate'): array
     {
         $categories = ShareDefaults::categories();
         if ($type === 'backflow') {
-            unset($categories['self:lottery']);
+            $categories = array_diff_key($categories, array_flip(self::BACKFLOW_EXCLUDED_RULE_CATEGORIES));
             foreach (ConfigService::sortedList('game_list') as $item) {
                 if (!is_string($item['game'] ?? null)) continue;
                 $category = MaintenancePolicy::lotterySubcategory($item);
@@ -33,11 +34,21 @@ class RewardRuleService
     public static function get(string $type): array
     {
         $table = self::table($type);
-        $categories = $rules = [];
-        foreach (self::categories($type) as $value => $label) {
-            $categories[] = ['value' => $value, 'label' => $label];
-            $rules[$value] = [];
+        $labels = self::categories($type);
+        $categories = $lotteries = [];
+        foreach ($labels as $value => $label) {
+            $option = ['value' => $value, 'label' => $label];
+            if ($type === 'backflow' && MaintenancePolicy::parentScope($value) === 'self:lottery') {
+                $lotteries[] = $option;
+            } else {
+                $categories[] = $option;
+            }
         }
+        if ($type === 'backflow') {
+            $categories[] = ['value' => 'self:lottery', 'label' => ShareDefaults::categories()['self:lottery'], 'children' => $lotteries];
+        }
+
+        $rules = array_fill_keys(array_keys($labels), []);
         $query = DB::table($table)->orderBy('category');
         if ($type === 'rebate') $query->orderBy('level')->orderBy('min_effective_amount');
         else $query->orderBy('min_loss_amount');
@@ -109,7 +120,7 @@ class RewardRuleService
         $tiers = [];
         $threshold = $type === 'rebate' ? 'min_effective_amount' : 'min_loss_amount';
         $query = DB::table(self::table($type))->orderByDesc($threshold);
-        if ($type === 'backflow') $query->where('category', '<>', 'self:lottery');
+        if ($type === 'backflow') $query->whereNotIn('category', self::BACKFLOW_EXCLUDED_RULE_CATEGORIES);
         foreach ($query->get() as $row) {
             $tiers[$row->category][] = self::present($type, $row);
         }

+ 32 - 0
database/migrations/2026_11_11_130000_create_agent_manual_credit_locks.php

@@ -0,0 +1,32 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    public function up(): void
+    {
+        Schema::create('agent_manual_credit_locks', function (Blueprint $table) {
+            $table->id();
+            $table->unsignedBigInteger('agent_id');
+            $table->decimal('amount', 18, 4);
+            $table->decimal('remaining', 18, 4);
+            $table->timestamp('unlock_at');
+            $table->timestamp('released_at')->nullable();
+            $table->timestamps();
+            $table->index(['released_at', 'unlock_at'], 'agent_manual_credits_due');
+            $table->index(['agent_id', 'released_at', 'unlock_at'], 'agent_manual_credits_by_agent');
+        });
+    }
+
+    public function down(): void
+    {
+        if (DB::table('agent_manual_credit_locks')->whereNull('released_at')->where('remaining', '>', 0)->exists()) {
+            throw new \RuntimeException('还有未解锁的入股代理人工加款');
+        }
+        Schema::dropIfExists('agent_manual_credit_locks');
+    }
+};

+ 1 - 1
docs/代理/代理充值-前端接入.md

@@ -36,7 +36,7 @@
 - 人民币充值(在线、人工)按**订单金额**入账,和会员一样不扣手续费,没有充值优惠。
 - USDT 充值按充值汇率折算成余额:入账金额 = USDT 数量 × `exchange_rate`。
 - **合营代理**:充值全部计入保证金,不能提现(只有超出保证金的盈利可以提现)。
-- **入股代理**:有待付款的入股订单时,充值到账后自动扣款开通股份。充值到账的金额计入余额,可以立即用来购买股份,但**充值后 7 天内不能提现**(天数由总后台「入股配置」设置);购买股份优先使用锁定中的充值。分红、提现退回等非充值的余额不锁定;充值成功页可以提示「充值 N 天后可提现」(N 为 summary 的 `recharge_lock_days`)。
+- **入股代理**:有待付款的入股订单时,充值到账后自动扣款开通股份。充值到账的金额计入余额,可以立即买股,提现须等充值锁定期届满(天数取 `recharge_lock_days`)。购股依次使用冻结的人工加款、锁定的充值和可提现余额。人工加款(含分红)从加款时起冻结 7 天。
 
 ## 2. 充值页流程
 

+ 4 - 2
docs/代理/代理后台调整-前端接入.md

@@ -25,7 +25,7 @@
   - 充值的金额**全部计入保证金,不能提现**。总余额 = 保证金 + 盈亏;盈亏为负时从保证金扣除,之后的盈利先补足保证金,**只有超出保证金的盈利可以提现**。
   - 方案要求的保证金交足才开始分成。保证金被亏到方案要求的一定比例以下(默认 10%)时暂停分成,需要再充值到方案保证金的 100% 才恢复。只展示「分成中/已暂停」,不展示实时保证金的具体数值。
   - 分成逐笔实时计算,每天 04:10 入账前一天的分成。当天未入账的亏损会预留,不能提现。
-- **入股**:按比例购买股份。余额够就立即开通;不够就生成待付款订单,充值到账后自动扣款开通。分红每月由平台人工发放,到账后计入余额,可以提现。
+- **入股**:按比例购买股份。购股资金足够时立即开通;待付款订单可在充值或人工加款到账后自动开通。总后台人工加款(含分红)先进入提现冻结,7 天内可买股,到期剩余金额转入余额并可提现。
   - 充值到账的金额计入余额,可以立即用来购买股份,但**充值后 7 天内不能提现**(天数由总后台「入股配置」设置);购买股份优先使用锁定中的充值。分红、提现退回等非充值的余额不锁定。
 - 余额单位和会员钱包相同(人民币)。人民币充值按订单金额入账;USDT 充值按 `exchange_rate_rmb` 折算进余额;提现按「汇率 + 汇率差」把余额折算成 USDT。
 
@@ -41,7 +41,7 @@
 | `type` / `type_text` | `1` 合营 / `2` 入股 |
 | `domains` | **推广链接**:总后台给代理绑定的全部域名(不分 PC / H5,原样返回),没分配时为空数组 |
 | `balance` | 总余额 |
-| `frozen_balance` | 提现冻结中 |
+| `frozen_balance` | 提现冻结金额,入股代理同时包含仍在 7 天锁定期内的人工加款 |
 | `withdrawable_amount` | 当前可提现 |
 | `withdraw_fee_rate` | 提现手续费 % |
 | `has_withdraw_password` | 是否已设置提现密码 |
@@ -116,6 +116,7 @@
 | `withdraw_freeze` | 提现冻结 |
 | `withdraw_refund` | 提现退回 |
 | `credit` | 人工加款(含入股分红) |
+| `credit_unlock` | 人工加款冻结期满,剩余金额转入余额 |
 | `debit` | 人工扣款 |
 | `margin_credit` | 人工增加保证金 |
 | `margin_refund` | 退还保证金 |
@@ -212,6 +213,7 @@
 
 - 应付金额 = 购买比例 × `price_per_percent`。
 - `remaining_percent` 是还能买的比例,受个人持股上限和平台可购买股份限制。
+- 入股代理的 `balance` 是可用于购股的金额,包含尚未解锁的人工加款。购股时先用这部分加款,再用充值锁定金额和可提现余额。
 - `pending_order` 是待付款的订单,页面上要提示「充值到账后自动开通」。
 
 ### `POST /agent/equity/orders`

+ 1 - 1
docs/前端交付/入股代理充值冻结-前端修正单.md

@@ -20,7 +20,7 @@
 | `balance` | 总余额 | 全部余额,可以用来买股份 | — |
 | `locked_amount` | **充值冻结** | 充值未满 N 天的金额:不能提现,可以买股份;到期自动变成可提现 | **包含** |
 | `withdrawable_amount` | 可提现 | `balance − locked_amount` | 包含 |
-| `frozen_balance` | 提现冻结 | 已提交、还在审核或打款中的提现金额 | **不包含**(提交提现时已从余额扣出) |
+| `frozen_balance` | 提现冻结 | 处理中提现金额和入股代理人工加款的冻结余额。人工加款可买股,7 天后未使用的金额转入余额 | **不包含** |
 
 **不要把 `locked_amount` 加到 `frozen_balance` 里显示**,否则会让人以为这笔钱不在余额里。
 

+ 6 - 0
docs/总后台/代理列表.md

@@ -285,6 +285,8 @@ GET /admin/agent/show?id=12
 
 相同操作重试必须复用相同 `request_id`;如果同一 `request_id` 被用于不同代理、类型或金额,接口会拒绝。扣款超过可用余额时返回业务错误。
 
+入股代理的 `credit` 进入 `frozen_balance`,可用于购股。7 天后,尚未使用的金额转入余额并可提现。成功响应中,`reference_type` 为 `agent_manual_credit_lock`,`before_balance` 和 `after_balance` 均为加款前的余额。
+
 成功时 `data` 返回资金流水:
 
 ```json
@@ -310,6 +312,10 @@ GET /admin/agent/show?id=12
 }
 ```
 
+## 代理资金流水
+
+[代理资金流水接入文档](代理资金流水.md)
+
 ## 7. 额度管理
 
 ### `GET /admin/agent/quota`

+ 3 - 3
docs/总后台/代理合营与入股-前端接入.md

@@ -47,7 +47,7 @@
 |---|---|
 | `type` / `type_text` | `1` 合营 / `2` 入股 |
 | `balance` | 总余额 |
-| `frozen_balance` | 提现冻结中 |
+| `frozen_balance` | 提现冻结金额,入股代理同时包含仍在 7 天锁定期内的人工加款 |
 | `withdrawable_amount` | 当前可提现 |
 | `margin_amount` | 合营:累计保证金本金(充值合计) |
 | `margin_balance` | 合营:当前保证金余额 = min(本金, 总余额) |
@@ -90,7 +90,7 @@
 
 | type | 说明 |
 |---|---|
-| `credit` | 人工加款。入股分红也用这个 |
+| `credit` | 人工加款。入股代理的加款(含分红)计入 `frozen_balance`,7 天内可买股,到期剩余金额自动转入余额 |
 | `debit` | 人工扣款 |
 | `margin_credit` | 人工增加保证金:总余额和保证金本金同时增加 |
 | `margin_refund` | 退还保证金:总余额和保证金本金同时减少,不能超过本金 |
@@ -349,4 +349,4 @@
 - `win_loss` 是会员视角,正为会员赢,按结算时间归日。
 - `agent_share` 是当天合营代理从该会员拿到的分成,正为代理收入。
 - 充值、提现、人工加扣款、优惠、返水的口径和代理报表一致。
-- 注单来自返佣用的注单事实表,不含旧 PC28 和极速游戏。
+- 注单来自返佣用的注单事实表,不含旧 PC28 和极速游戏。

+ 3 - 1
docs/总后台/代理提现列表.md

@@ -55,7 +55,7 @@ GET /admin/agent/withdrawals?page=1&limit=20&status=pending&start_date=2026-08-0
         "paid_at": null,
         "created_at": "2026-08-24 18:00:00",
         "updated_at": "2026-08-24 18:00:00",
-        "agent": {"id":12,"username":"agent001","real_name":"张三"}
+        "agent": {"id":12,"username":"agent001","real_name":"张三","type":2,"type_text":"入股"}
       }
     ]
   }
@@ -64,6 +64,8 @@ GET /admin/agent/withdrawals?page=1&limit=20&status=pending&start_date=2026-08-0
 
 `account_snapshot` 是申请时的收款资料快照,即使代理之后修改账户,本订单仍显示原资料。
 
+`agent.type` 为代理当前类型,`1` 合营、`2` 入股。`agent.type_text` 为类型名称。
+
 ## 2. 审核、驳回、确认付款
 
 ### `POST /admin/agent/withdrawals/audit`

+ 74 - 0
docs/总后台/代理资金流水.md

@@ -0,0 +1,74 @@
+# 总后台代理资金流水
+
+## `GET /admin/agent/balance-logs`
+
+查询指定代理的资金流水,包含历史人工扣款。沿用总后台登录和代理管理权限,请求头为 `Authorization: Bearer <admin_token>`。
+
+| 参数 | 类型 | 必填 | 说明 |
+|---|---|---|---|
+| `agent_id` | integer | 是 | 代理 ID,大于 0 |
+| `type` | string | 否 | 流水类型,最长 32 字符。省略时查询全部,`debit` 为人工扣款 |
+| `page` | integer | 否 | 页码,从 1 开始,默认 1 |
+| `limit` | integer | 否 | 每页 1~100 条,默认 20 |
+| `start_date` | string | 否 | 流水开始日期,`YYYY-MM-DD`,包含当天 |
+| `end_date` | string | 否 | 流水结束日期,`YYYY-MM-DD`,包含当天,不能早于开始日期 |
+
+```http
+GET /admin/agent/balance-logs?agent_id=12&type=debit&page=1&limit=20
+```
+
+成功时 `code` 为 `0`,失败原因见 `msg`。流水按记录 ID 倒序返回。
+
+| 返回字段 | 说明 |
+|---|---|
+| `data.total`、`data.page`、`data.limit` | 符合条件的总记录数、当前页码、每页条数 |
+| `data.list[].id`、`data.list[].agent_id` | 流水 ID、代理 ID |
+| `data.list[].type`、`data.list[].type_text` | 流水类型和名称,例如 `debit`、`人工扣款` |
+| `data.list[].amount` | 带符号的变动金额,四位小数字符串。人工扣款为负数 |
+| `data.list[].before_balance`、`data.list[].after_balance` | 变动前、变动后的账户余额,四位小数字符串,不含提现冻结余额 |
+| `data.list[].remark`、`data.list[].created_at` | 备注、流水时间 |
+| `data.types[]` | 类型筛选选项。每项包含 `value`(类型编码)和 `label`(类型名称),与当前筛选条件无关 |
+
+```json
+{
+  "code": 0,
+  "timestamp": 1794382260,
+  "msg": "OK",
+  "data": {
+    "total": 1,
+    "page": 1,
+    "limit": 20,
+    "list": [
+      {
+        "id": 101,
+        "agent_id": 12,
+        "type": "debit",
+        "type_text": "人工扣款",
+        "amount": "-25.1250",
+        "before_balance": "100.0000",
+        "after_balance": "74.8750",
+        "reference_type": "",
+        "reference_id": null,
+        "operator_type": "admin",
+        "operator_id": 1,
+        "remark": "人工扣款",
+        "created_at": "2026-11-11 15:30:00",
+        "updated_at": "2026-11-11 15:30:00"
+      }
+    ],
+    "types": [
+      {"value": "credit", "label": "人工加款"},
+      {"value": "debit", "label": "人工扣款"},
+      {"value": "recharge", "label": "充值"},
+      {"value": "withdraw_refund", "label": "提现退回"},
+      {"value": "withdraw_freeze", "label": "提现冻结"},
+      {"value": "equity_purchase", "label": "购买股份"},
+      {"value": "margin_credit", "label": "人工增加保证金"},
+      {"value": "margin_refund", "label": "退还保证金"},
+      {"value": "profit_share", "label": "合营分成"},
+      {"value": "commission", "label": "佣金(旧)"},
+      {"value": "credit_unlock", "label": "人工加款解锁"}
+    ]
+  }
+}
+```

+ 14 - 8
docs/总后台/回水规则配置.md

@@ -21,12 +21,18 @@ GET /admin/backflow/rules
   "data": {
     "type": "backflow",
     "categories": [
-      {"value": "third:3", "label": "彩票"},
-      {"value": "self:lottery:pc28", "label": "加拿大28"},
-      {"value": "self:lottery:jisu28", "label": "极速28"}
+      {"value": "third:1", "label": "真人"},
+      {
+        "value": "self:lottery",
+        "label": "F1彩票",
+        "children": [
+          {"value": "self:lottery:pc28", "label": "加拿大28"},
+          {"value": "self:lottery:jisu28", "label": "极速28"}
+        ]
+      }
     ],
     "rules": {
-      "third:3": [],
+      "third:1": [],
       "self:lottery:pc28": [
         {"id": 1, "category": "self:lottery:pc28", "min_loss_amount": "1000.00", "reward_amount": "20.00", "turnover_multiple": "0.00"}
       ],
@@ -38,10 +44,10 @@ GET /admin/backflow/rules
 
 | 返回字段 | 用途 |
 | --- | --- |
-| `categories` | 配置页签选项,`value` 用于保存,`label` 用于显示 |
-| `rules` | 以选项的 `value` 为键,每个选项都有对应数组,未配置时为空数组 |
+| `categories` | 一级页签。F1 彩票的彩种放在 `children`,空列表时显示「暂无可配置彩种」 |
+| `rules` | 以可配置项的 `value` 为键,未配置时为空数组。F1 彩票使用子项的 `value` |
 
-F1 彩票选项沿用 `/admin/config/gameList` 的名称和排序。加拿大 28 用 `self:lottery:pc28`,极速 28 用 `self:lottery:jisu28`,六合彩用 `self:lottery:lhc:<params.type>`,极速系列用 `self:lottery:jisu:<params.type>`。第三方彩票使用 `third:3`。保存接口以 `categories` 中的选项为准,提交旧的 `self:lottery` 会返回校验错误。
+F1 彩票子项沿用 `/admin/config/gameList` 的名称和排序。加拿大 28 用 `self:lottery:pc28`,极速 28 用 `self:lottery:jisu28`,六合彩用 `self:lottery:lhc:<params.type>`,极速系列用 `self:lottery:jisu:<params.type>`。提交 F1 彩票父级 `self:lottery` 或第三方彩票 `third:3` 会返回校验错误。
 
 ## 保存规则
 
@@ -62,7 +68,7 @@ Content-Type: application/json
 
 | 字段 | 含义 | 校验 |
 | --- | --- | --- |
-| `category` | 游戏分类或彩种,取 `categories[].value` | 必填 |
+| `category` | 游戏分类取一级 `value`,F1 彩票取彩种子项的 `value` | 必填 |
 | `min_loss_amount` | 亏损金额门槛 | ≥0,最多 2 位小数 |
 | `reward_amount` | 奖励金额(固定金额) | ≥0,最多 2 位小数 |
 | `turnover_multiple` | 需要打码量倍数,`0` 表示没有 | ≥0,最多 2 位小数 |

+ 1 - 0
routes/admin.php

@@ -161,6 +161,7 @@ Route::middleware(['admin.jwt'])->group(function () {
             Route::post('/update', [AgentAdmin::class, 'update']);
             Route::post('/domains', [AgentAdmin::class, 'domains']);
             Route::post('/adjust', [AgentAdmin::class, 'adjust']);
+            Route::get('/balance-logs', [AgentAdmin::class, 'balanceLogs'])->defaults('permission_uri', 'admin/agent');
             Route::get('/quota', [AgentAdmin::class, 'quota']);
             Route::get('/login-logs', [AgentAdmin::class, 'loginLogs']);
             Route::get('/withdrawals', [AgentAdmin::class, 'withdrawals']);

+ 39 - 0
tests/Integration/AgentApiRegressionTest.php

@@ -104,6 +104,45 @@ class AgentApiRegressionTest extends TestCase
         $this->assertSame(1, $controller->loginLogs($request)->getData(true)['data']['total']);
     }
 
+    public function test_admin_balance_logs_include_signed_debits_for_the_selected_agent(): void
+    {
+        $agent = $this->agent(Agent::TYPE_EQUITY);
+        $other = $this->agent();
+        $wallets = new AgentWalletService();
+        foreach ([$agent, $other] as $owner) {
+            $wallets->adjust($owner->id, '100', 'credit', '初始额度', 'credit-' . $owner->id, 'admin', 1);
+            $wallets->adjust($owner->id, '25.1250', 'debit', '人工扣款', 'debit-' . $owner->id, 'admin', 1);
+        }
+
+        $controller = new AdminAgent();
+        $query = ['agent_id' => $agent->id];
+        $all = $controller->balanceLogs(Request::create('/admin/agent/balance-logs', 'GET', $query))->getData(true);
+        $this->assertSame(0, $all['code']);
+        $this->assertSame(['debit', 'credit'], array_column($all['data']['list'], 'type'));
+        $this->assertContains(['value' => 'debit', 'label' => '人工扣款'], $all['data']['types']);
+        $filtered = $controller->balanceLogs(Request::create('/admin/agent/balance-logs', 'GET', $query + ['type' => 'debit']))->getData(true)['data'];
+        $this->assertSame(1, $filtered['total']);
+        $row = $filtered['list'][0];
+        $this->assertSame(['人工扣款', '-25.1250', '100.0000', '74.8750'], [$row['type_text'], $row['amount'], $row['before_balance'], $row['after_balance']]);
+        $this->assertArrayNotHasKey('idempotency_key', $row);
+    }
+
+    public function test_admin_withdrawal_list_includes_the_current_agent_type_and_label(): void
+    {
+        $joint = $this->agent();
+        $equity = $this->agent(Agent::TYPE_EQUITY);
+        foreach ([$joint, $equity] as $agent) {
+            $account = $this->fundedAccount($agent);
+            (new AgentWithdrawalService())->request($agent, $account->id, '10', 'withdraw-old', 'type-list');
+        }
+
+        $response = (new AdminAgent())->withdrawals(Request::create('/admin/agent/withdrawals'))->getData(true);
+        $this->assertSame(0, $response['code']);
+        $agents = array_column($response['data']['list'], 'agent', 'agent_id');
+        $this->assertSame([1, '合营'], [$agents[$joint->id]['type'], $agents[$joint->id]['type_text']]);
+        $this->assertSame([2, '入股'], [$agents[$equity->id]['type'], $agents[$equity->id]['type_text']]);
+    }
+
     private function fundedAccount(Agent $agent): AgentWithdrawAccount
     {
         $this->paymentChannels(); // 提现按总后台通道组合校验(组合 1 含 usdt)

+ 126 - 2
tests/Integration/AgentEquityRechargeLockTest.php

@@ -2,7 +2,11 @@
 
 namespace Tests\Integration;
 
+use App\Console\Commands\AgentReleaseManualCredits;
+use App\Http\Controllers\admin\Agent as AdminAgent;
 use App\Models\Agent\Agent;
+use App\Models\Agent\AgentBalanceLog;
+use App\Models\Agent\AgentManualCreditLock;
 use App\Models\Agent\AgentRechargeLock;
 use App\Models\Agent\AgentSetting;
 use App\Models\Agent\AgentWithdrawAccount;
@@ -10,8 +14,10 @@ use App\Services\Agent\AgentEquityService;
 use App\Services\Agent\AgentWalletService;
 use App\Services\Agent\AgentWithdrawalService;
 use Carbon\Carbon;
+use Illuminate\Http\Request;
 use Illuminate\Support\Facades\DB;
 use PHPUnit\Framework\TestCase;
+use Symfony\Component\Console\Tester\CommandTester;
 use Tests\Support\AgentTestEnvironment;
 
 /** 入股代理充值后 N 天内不能提现,可以立即购买股份(优先消耗锁定中的充值)。 */
@@ -62,7 +68,7 @@ class AgentEquityRechargeLockTest extends TestCase
     {
         $this->setUpEquity();
         $agent = $this->agent(Agent::TYPE_EQUITY);
-        $this->wallets->adjust($agent->id, '500', 'credit', '分红', 'c1', 'admin', 1); // 人工加款不锁定
+        $this->wallets->adjust($agent->id, '500', 'credit', '初始额度', 'c1', 'admin', 1);
         $this->recharge($agent, '1000', 'r1');
         $this->assertSame('500.0000', $this->wallets->withdrawable($agent->fresh()));
 
@@ -71,7 +77,125 @@ class AgentEquityRechargeLockTest extends TestCase
         $agent = $agent->fresh();
         $this->assertSame('900.0000', $agent->balance);
         $this->assertSame('400.0000', $this->wallets->lockedAmount($agent));
-        $this->assertSame('500.0000', $this->wallets->withdrawable($agent), 'the unlocked dividend stays withdrawable');
+        $this->assertSame('500.0000', $this->wallets->withdrawable($agent), 'the unlocked funds stay withdrawable');
+    }
+
+    public function test_admin_credit_is_frozen_can_buy_shares_and_unlocks_only_the_remainder_at_seven_days(): void
+    {
+        $this->setUpEquity(0);
+        $this->paymentChannels();
+        $agent = $this->agent(Agent::TYPE_EQUITY);
+        $equity = new AgentEquityService($this->wallets);
+        $log = $this->adminCredit($agent, '1000', 'c1');
+        $retry = $this->adminCredit($agent, '1000', 'c1');
+        $this->assertSame($log['id'], $retry['id']);
+        $this->assertSame(AgentManualCreditLock::REFERENCE_TYPE, $log['reference_type']);
+        $this->assertSame('0.0000', $log['after_balance']);
+
+        $agent->refresh();
+        $this->assertSame('1000.0000', $agent->frozen_balance);
+        $this->assertSame('0.0000', $this->wallets->withdrawable($agent));
+        $this->assertSame('1000.0000', $equity->overview($agent)['balance']);
+        $this->assertSame('completed', $equity->order($agent, '0.6')->status);
+        $agent->refresh();
+        $this->assertSame('400.0000', $agent->frozen_balance);
+        $this->assertSame('0.0000', $agent->balance);
+
+        $command = new AgentReleaseManualCredits();
+        $command->setLaravel($this->app);
+        $runner = new CommandTester($command);
+        Carbon::setTestNow(Carbon::parse('2026-09-14 11:59:59'));
+        $this->assertSame(0, $runner->execute([]));
+        $this->assertSame('400.0000', $agent->fresh()->frozen_balance);
+        Carbon::setTestNow(Carbon::parse('2026-09-14 12:00:00'));
+        $this->assertSame(0, $runner->execute([]));
+        $this->assertSame(0, $runner->execute([]));
+        $agent->refresh();
+        $this->assertSame('400.0000', $agent->balance);
+        $this->assertSame('400.0000', $this->wallets->withdrawable($agent));
+        $this->assertSame('0.0000', $agent->frozen_balance);
+        $this->assertSame('400.0000', AgentBalanceLog::query()->where('type', 'credit_unlock')->sole()->amount);
+    }
+
+    public function test_admin_credit_can_complete_a_pending_purchase_and_keeps_withdrawal_funds_separate(): void
+    {
+        $this->setUpEquity();
+        $this->paymentChannels();
+        $agent = $this->agent(Agent::TYPE_EQUITY);
+        $equity = new AgentEquityService($this->wallets);
+        $this->recharge($agent, '200', 'r1');
+        $order = $equity->order($agent, '0.5');
+        $this->assertSame('pending_payment', $order->status);
+        $this->adminCredit($agent, '300', 'c1');
+        $this->assertSame('completed', $order->fresh()->status);
+        $this->assertSame('0.0000', $agent->fresh()->balance);
+        $this->assertSame('0.0000', $this->wallets->lockedAmount($agent));
+        $this->assertSame('0.0000', $agent->fresh()->frozen_balance);
+        $this->adminCredit($agent, '300', 'c2');
+
+        $this->wallets->adjust($agent->id, '1000', 'credit', '初始额度', 'initial', 'admin', 1);
+        $withdrawals = new AgentWithdrawalService($this->wallets);
+        $account = AgentWithdrawAccount::query()->create(['agent_id' => $agent->id, 'type' => 'usdt', 'network' => 'TRC20',
+            'account' => 'TQn9Y2khEsLJW1ChVWFMSMeRDow5KcbLSE', 'status' => 1]);
+        $withdrawal = $withdrawals->request($agent, $account->id, '20', 'withdraw-old', 'withdraw');
+        $this->assertSame($withdrawal->amount, $this->wallets->withdrawalFrozenBalance($agent->fresh()));
+        $withdrawals->audit($withdrawal->id, 'reject', 1);
+        $this->assertSame('300.0000', $agent->fresh()->frozen_balance);
+        $this->assertSame('1000.0000', $agent->fresh()->balance);
+
+        $joint = $this->agent();
+        $this->adminCredit($joint, '100', 'c3');
+        $this->assertSame('100.0000', $joint->fresh()->balance);
+        $this->assertSame('0.0000', $joint->fresh()->frozen_balance);
+    }
+
+    private function adminCredit(Agent $agent, string $amount, string $requestId): array
+    {
+        $request = new class([], ['agent_id' => $agent->id, 'type' => 'credit', 'amount' => $amount,
+            'remark' => '测试加款', 'request_id' => $requestId]) extends Request {
+            public object $user;
+        };
+        $request->setMethod('POST');
+        $request->user = (object) ['id' => 1];
+        $this->app->instance('request', $request);
+        $result = (new AdminAgent())->adjust($request, $this->wallets, new AgentEquityService($this->wallets))->getData(true);
+        $this->assertSame(0, $result['code'], $result['msg']);
+        return $result['data'];
+    }
+
+    public function test_admin_debit_uses_free_balance_first_and_reduces_the_lock_only_for_spent_recharge(): void
+    {
+        $this->setUpEquity();
+        $agent = $this->agent(Agent::TYPE_EQUITY);
+        $this->recharge($agent, '1000', 'r1');
+        $this->wallets->adjust($agent->id, '200', 'credit', '初始额度', 'c1', 'admin', 1);
+
+        $this->wallets->adjust($agent->id, '100', 'debit', '扣款', 'd1', 'admin', 1);
+        $this->assertSame('1000.0000', $this->wallets->lockedAmount($agent));
+        $this->assertSame('100.0000', $this->wallets->withdrawable($agent->fresh()));
+
+        $this->wallets->adjust($agent->id, '700', 'debit', '扣款', 'd2', 'admin', 1);
+        $this->assertSame('400.0000', $this->wallets->lockedAmount($agent));
+        $this->assertSame('0.0000', $this->wallets->withdrawable($agent->fresh()));
+    }
+
+    public function test_released_admin_credit_stays_withdrawable_when_a_legacy_lock_exceeds_balance(): void
+    {
+        $this->setUpEquity();
+        $agent = $this->agent(Agent::TYPE_EQUITY);
+        $this->adminCredit($agent, '300', 'c1');
+        Carbon::setTestNow(Carbon::parse('2026-09-08 12:00:00'));
+        $this->recharge($agent, '1000', 'r1');
+        // 模拟旧版人工扣款只扣余额、未扣锁定额。
+        Agent::query()->whereKey($agent->id)->update(['balance' => 400, 'realtime_balance' => 400]);
+
+        Carbon::setTestNow(Carbon::parse('2026-09-14 12:00:00'));
+        $this->assertSame(1, $this->wallets->releaseManualCredits($agent->id, now()));
+        $funds = $this->wallets->funds($agent->fresh());
+        $this->assertSame('700.0000', $funds['balance']);
+        $this->assertSame('0.0000', $funds['frozen_balance']);
+        $this->assertSame('400.0000', $funds['locked_amount']);
+        $this->assertSame('300.0000', $funds['withdrawable_amount']);
     }
 
     public function test_joint_agents_and_zero_days_are_not_locked(): void

+ 16 - 10
tests/Integration/AutomaticRewardGrantTest.php

@@ -648,28 +648,33 @@ class AutomaticRewardGrantTest extends TestCase
         ]);
         $rule = ['min_loss_amount' => 10, 'reward_amount' => 20, 'turnover_multiple' => 2];
         DB::table('backflow_rules')->insert($rule + ['category' => 'self:lottery']);
+        DB::table('backflow_rules')->insert($rule + ['category' => 'third:3']);
 
         $controller = new RewardRules();
         $gameList = (new ConfigController())->gameList()->getData(true)['data'];
         $this->request('/admin/backflow/rules');
         $data = $controller->backflow()->getData(true)['data'];
-        $categories = array_column($data['categories'], 'label', 'value');
-        $this->assertSame('彩票', $categories['third:3']);
+        $categories = array_column($data['categories'], null, 'value');
+        $this->assertSame(['third:1', 'third:2', 'third:4', 'third:5', 'third:6', 'third:7', 'self:sport', 'self:lottery'], array_keys($categories));
         $this->assertArrayNotHasKey('self:lottery', $data['rules']);
-        $this->assertSame(array_column($gameList, 'name'), array_slice(array_values($categories), 8));
+        $this->assertArrayNotHasKey('third:3', $data['rules']);
+        $children = $categories['self:lottery']['children'];
+        $this->assertSame('F1彩票', $categories['self:lottery']['label']);
+        $this->assertSame(array_column($gameList, 'name'), array_column($children, 'label'));
         $this->assertSame(['self:lottery:jisu28', 'self:lottery:lhc:3', 'self:lottery:jisu:7', 'self:lottery:pc28'],
-            array_slice(array_keys($categories), 8));
+            array_column($children, 'value'));
 
         $this->request('/admin/backflow/rules', ['category' => 'self:lottery:jisu28', 'rules' => [$rule]], 'POST');
         $saved = $controller->saveBackflow()->getData(true);
         $this->assertSame(0, $saved['code']);
+        $this->assertSame($data['categories'], $saved['data']['categories']);
         $this->assertSame('20.00', $saved['data']['rules']['self:lottery:jisu28'][0]['reward_amount']);
         $this->assertSame('35.00', $saved['data']['rules']['self:lottery:pc28'][0]['reward_amount']);
         $this->request('/admin/backflow/rules', ['category' => 'self:lottery:pc28', 'rules' => []], 'POST');
         $cleared = $controller->saveBackflow()->getData(true);
         $this->assertSame([], $cleared['data']['rules']['self:lottery:pc28']);
         $this->assertSame($saved['data']['rules']['self:lottery:jisu28'], $cleared['data']['rules']['self:lottery:jisu28']);
-        foreach (['self:lottery', 'self:lottery:jisu:6'] as $category) {
+        foreach (['self:lottery', 'self:lottery:jisu:6', 'third:3'] as $category) {
             $this->request('/admin/backflow/rules', ['category' => $category, 'rules' => [$rule]], 'POST');
             $this->assertSame(-3, $controller->saveBackflow()->getData(true)['code']);
         }
@@ -704,7 +709,7 @@ class AutomaticRewardGrantTest extends TestCase
     #[PreserveGlobalState(false)]
     public function test_backflow_recalculates_an_unpaid_month_by_lottery_and_keeps_the_paid_snapshot(): void
     {
-        $longLabel = str_repeat('澳门六合彩', 30);
+        $longLabel = str_repeat('澳门六合彩', 35);
         $this->setGameList([
             ['game' => 'canada28', 'name' => '加拿大28'],
             ['game' => 'jisu28', 'name' => '极速28'],
@@ -745,8 +750,9 @@ class AutomaticRewardGrantTest extends TestCase
         $list = (new BackflowController())->index()->getData(true);
         $this->assertSame(0, $list['code'], $list['msg']);
         $preview = $list['data']['data'][0];
-        $this->assertSame(['72.00', '1705.00', '143.00'], [$preview['amount'], $preview['loss_amount'], $preview['need_flow']]);
+        $this->assertSame(['68.00', '1605.00', '143.00'], [$preview['amount'], $preview['loss_amount'], $preview['need_flow']]);
         $details = array_column($preview['details'], null, 'category');
+        $this->assertArrayNotHasKey('third:3', $details);
         $this->assertSame('160.00', $details['self:lottery:pc28']['loss_amount']);
         $this->assertSame('1140.00', $details['self:lottery:pc28']['bet_amount']);
         $this->assertSame('0.00', $details['self:lottery:jisu28']['amount']);
@@ -759,16 +765,16 @@ class AutomaticRewardGrantTest extends TestCase
 
         $this->assertSame(2, (new RewardBaseSyncService())->run()['backflow']);
         $row = DB::table('backflow')->where('id', $id)->first();
-        $this->assertEquals(72, $row->amount);
+        $this->assertEquals(68, $row->amount);
         $this->assertSame($preview['details'], json_decode($row->details, true));
         DB::statement("CREATE TRIGGER backflow_remark_limit BEFORE INSERT ON bot_balance_logs
             WHEN length(NEW.remark) > 255 BEGIN SELECT RAISE(ABORT, 'remark too long'); END");
         $this->enable('backflow');
         $this->assertSame(1, $this->runRewards()['backflow']['granted']);
         $this->assertSame(0, $this->runRewards()['backflow']['granted']);
-        $this->assertEquals(92, DB::table('wallets')->value('available_balance'));
+        $this->assertEquals(88, DB::table('wallets')->value('available_balance'));
         $this->assertEquals(143, DB::table('wallet_bonus')->value('need_flow'));
-        $this->assertSame('日期:2026-09;亏损:1705.00;档位明细见回水记录;本次发放:72.00;累计发放:72.00', DB::table('balance_logs')->value('remark'));
+        $this->assertSame('日期:2026-09;亏损:1605.00;档位明细见回水记录;本次发放:68.00;累计发放:68.00', DB::table('balance_logs')->value('remark'));
         $this->assertEquals($paidSnapshot, DB::table('backflow')->where('id', $paid)->first());
     }
 

+ 1 - 0
tests/Support/AgentTestEnvironment.php

@@ -96,6 +96,7 @@ trait AgentTestEnvironment
         (require dirname(__DIR__, 2) . '/database/migrations/2026_11_09_140000_merge_agent_domain_types.php')->up();
         (require dirname(__DIR__, 2) . '/database/migrations/2026_11_10_120000_add_is_test_to_agents.php')->up();
         (require dirname(__DIR__, 2) . '/database/migrations/2026_11_11_120000_add_company_equity_percent_to_agent_settings.php')->up();
+        (require dirname(__DIR__, 2) . '/database/migrations/2026_11_11_130000_create_agent_manual_credit_locks.php')->up();
 
         foreach (['recharges', 'withdraws', 'payment_orders'] as $table) {
             Schema::create($table, function (Blueprint $t) use ($table) {