doge 4 dni temu
rodzic
commit
4216bd0a42

+ 2 - 0
app/Http/Controllers/admin/Agent.php

@@ -295,9 +295,11 @@ class Agent extends AgentApiController
                 'equity_price_per_percent' => ['required', 'numeric', 'min:0', new DecimalNumber()],
                 'equity_min_percent' => ['required', 'numeric', 'gt:0', 'max:100', new DecimalNumber(3, 4)],
                 'equity_max_percent' => ['required', 'numeric', 'gte:equity_min_percent', 'max:100', new DecimalNumber(3, 4)],
+                'equity_recharge_lock_days' => ['nullable', 'integer', 'between:0,365'],
                 'recharge_channel_group_id' => ['nullable', 'integer', \Illuminate\Validation\Rule::exists('recharge_channel_group', 'id')->whereNull('deleted_at')],
             ]);
             if (array_key_exists('recharge_channel_group_id', $data)) $data['recharge_channel_group_id'] = (int) ($data['recharge_channel_group_id'] ?: 1);
+            if (array_key_exists('equity_recharge_lock_days', $data) && $data['equity_recharge_lock_days'] === null) unset($data['equity_recharge_lock_days']);
             $setting = AgentSetting::current();
             $setting->fill($data + ['updated_by' => (int) request()->user->id]);
             $setting->equity_enabled = $request->boolean('equity_enabled') ? 1 : 0;

+ 2 - 1
app/Http/Controllers/agent/WalletController.php

@@ -113,7 +113,8 @@ class WalletController extends AgentApiController
     /** 余额构成:总余额、保证金、盈亏、可提现。 */
     public function summary(AgentWalletService $wallets)
     {
-        return $this->execute(fn () => $wallets->funds($this->currentAgent()->fresh()) + [
+        return $this->execute(fn () => $wallets->funds($agent = $this->currentAgent()->fresh()) + [
+            'recharge_locks' => $wallets->activeLocks($agent),
             'withdraw_exchange_rate' => AgentExchangeRate::withdraw(),
             'recharge_exchange_rate' => AgentExchangeRate::recharge(),
         ]);

+ 17 - 0
app/Models/Agent/AgentRechargeLock.php

@@ -0,0 +1,17 @@
+<?php
+
+namespace App\Models\Agent;
+
+use App\Models\BaseModel;
+
+/** 入股代理充值锁定:remaining 在 unlock_at 前不可提现,购买股份时优先消耗。 */
+class AgentRechargeLock extends BaseModel
+{
+    protected $table = 'agent_recharge_locks';
+    protected $guarded = ['id'];
+    protected $hidden = ['updated_at'];
+    protected $casts = [
+        'agent_id' => 'integer', 'balance_log_id' => 'integer', 'reference_id' => 'integer',
+        'amount' => 'decimal:4', 'remaining' => 'decimal:4', 'unlock_at' => 'datetime',
+    ];
+}

+ 2 - 2
app/Models/Agent/AgentSetting.php

@@ -4,7 +4,7 @@ namespace App\Models\Agent;
 
 use App\Models\BaseModel;
 
-/** 代理全局配置(单行 id=1):停止分成阈值、入股价格与区间、充值通道组合。 */
+/** 代理全局配置(单行 id=1):停止分成阈值、入股价格与区间、入股充值锁定天数、充值通道组合。 */
 class AgentSetting extends BaseModel
 {
     protected $table = 'agent_settings';
@@ -14,7 +14,7 @@ class AgentSetting extends BaseModel
         'margin_stop_percent' => 'decimal:4', 'equity_enabled' => 'integer',
         'equity_price_per_percent' => 'decimal:4', 'equity_min_percent' => 'decimal:4',
         'equity_max_percent' => 'decimal:4', 'updated_by' => 'integer',
-        'recharge_channel_group_id' => 'integer',
+        'recharge_channel_group_id' => 'integer', 'equity_recharge_lock_days' => 'integer',
     ];
 
     public static function current(): self

+ 66 - 1
app/Services/Agent/AgentWalletService.php

@@ -6,6 +6,7 @@ use App\Models\Agent\Agent;
 use App\Models\Agent\AgentBalanceLog;
 use App\Models\Agent\AgentProfitCommissionProfile;
 use App\Models\Agent\AgentProfitEntry;
+use App\Models\Agent\AgentRechargeLock;
 use App\Models\Agent\AgentSetting;
 use Illuminate\Support\Facades\DB;
 
@@ -17,6 +18,9 @@ use Illuminate\Support\Facades\DB;
  * - 盈亏为负时等于从保证金扣除,之后的盈利先补足保证金;
  * - 只有超出保证金本金的部分可以提现。
  * realtime_balance = balance + 尚未入账的实时分成,是判断「实时保证金」的内部字段,不对代理展示。
+ *
+ * 入股代理:充值计入余额,可以立即购买股份;但充值后 N 天(入股配置 equity_recharge_lock_days,默认 7)内不能提现,
+ * 记录在 agent_recharge_locks。购买股份优先消耗锁定中的充值。可提现 = 余额 − 锁定中的金额。
  */
 class AgentWalletService
 {
@@ -85,11 +89,65 @@ class AgentWalletService
                 'idempotency_key' => $idempotencyKey, 'remark' => $remark,
                 'reference_type' => $referenceType, 'reference_id' => $referenceId,
             ]);
+            if ($type === 'recharge' && $agent->isEquity()) {
+                $this->lockRecharge($agent, $log, $amount, $referenceType, $referenceId);
+            } elseif ($type === 'equity_purchase') {
+                $this->consumeLocks((int) $agent->id, $amount);
+            }
             $this->evaluateActivation($agent);
             return $log;
         });
     }
 
+    /** 入股代理的充值锁定 N 天。调用方已在事务内锁住代理。 */
+    private function lockRecharge(Agent $agent, AgentBalanceLog $log, string $amount, string $referenceType, ?int $referenceId): void
+    {
+        $days = (int) AgentSetting::current()->equity_recharge_lock_days;
+        if ($days <= 0) return;
+        AgentRechargeLock::query()->create([
+            'agent_id' => $agent->id, 'balance_log_id' => $log->id,
+            'reference_type' => $referenceType, 'reference_id' => $referenceId,
+            'amount' => $amount, 'remaining' => $amount, 'unlock_at' => now()->addDays($days),
+        ]);
+    }
+
+    /** 购买股份先用锁定中的充值(先到期的先用),用完再用可提现余额。 */
+    private function consumeLocks(int $agentId, string $amount): void
+    {
+        $left = bcadd($amount, '0', 4);
+        $locks = AgentRechargeLock::query()->where('agent_id', $agentId)->where('unlock_at', '>', now())
+            ->where('remaining', '>', 0)->orderBy('unlock_at')->orderBy('id')->lockForUpdate()->get();
+        foreach ($locks as $lock) {
+            if (bccomp($left, '0', 4) <= 0) break;
+            $take = bccomp((string) $lock->remaining, $left, 4) < 0 ? bcadd((string) $lock->remaining, '0', 4) : $left;
+            $lock->remaining = bcsub((string) $lock->remaining, $take, 4);
+            $lock->save();
+            $left = bcsub($left, $take, 4);
+        }
+    }
+
+    /** 入股代理仍在锁定期内的充值金额。 */
+    public function lockedAmount(Agent $agent): string
+    {
+        if (!$agent->isEquity()) return '0.0000';
+        $sum = AgentRechargeLock::query()->where('agent_id', $agent->id)->where('unlock_at', '>', now())->sum('remaining');
+        return bcadd((string) $sum, '0', 4);
+    }
+
+    /** 入股代理锁定中的充值明细(按到期时间),给资金页展示「X 月 X 日后可提现」。 */
+    public function activeLocks(Agent $agent): array
+    {
+        if (!$agent->isEquity()) return [];
+        return AgentRechargeLock::query()->where('agent_id', $agent->id)->where('unlock_at', '>', now())
+            ->where('remaining', '>', 0)->orderBy('unlock_at')->limit(100)
+            ->get(['id', 'amount', 'remaining', 'unlock_at', 'created_at'])
+            ->map(fn (AgentRechargeLock $lock) => [
+                'amount' => bcadd((string) $lock->amount, '0', 4), 'remaining' => bcadd((string) $lock->remaining, '0', 4),
+                'recharged_at' => $lock->created_at ? \Carbon\Carbon::parse($lock->created_at)->format('Y-m-d H:i:s') : null,
+                'unlock_at' => \Carbon\Carbon::parse($lock->unlock_at)->format('Y-m-d H:i:s'),
+            ])->all();
+    }
+
     /** 每日入账合营分成:实时余额在注单结算时已计入,这里只改 balance。调用方需已在事务内锁住代理。 */
     public function creditProfitShare(Agent $agent, string $signed, string $idempotencyKey, int $recordId, string $remark): ?AgentBalanceLog
     {
@@ -180,6 +238,11 @@ 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';
+            $data['recharge_lock_days'] = (int) AgentSetting::current()->equity_recharge_lock_days;
         }
         return $data;
     }
@@ -191,7 +254,9 @@ class AgentWalletService
     {
         $balance = bcadd((string) $agent->balance, '0', 4);
         if (!$agent->isJoint()) {
-            return bccomp($balance, '0', 4) > 0 ? $balance : '0.0000';
+            // 入股:锁定期内的充值不能提现
+            $free = bcsub($balance, $this->lockedAmount($agent), 4);
+            return bccomp($free, '0', 4) > 0 ? $free : '0.0000';
         }
         $available = bcsub(bcadd($balance, $this->pendingFloor($agent), 4), (string) $agent->margin_amount, 4);
         return bccomp($available, '0', 4) > 0 ? $available : '0.0000';

+ 6 - 1
app/Services/Agent/AgentWithdrawalService.php

@@ -165,7 +165,12 @@ class AgentWithdrawalService
                 $scale = 2; // 三方按分打款,手续费和到账金额保留 2 位
             }
             if (bccomp($this->wallets->withdrawable($lockedAgent), $rmbAmount, 4) < 0) {
-                throw new \RuntimeException($lockedAgent->isJoint() ? '可提现盈利不足(保证金不可提现)' : '代理余额不足');
+                if ($lockedAgent->isJoint()) throw new \RuntimeException('可提现盈利不足(保证金不可提现)');
+                if (bccomp((string) $lockedAgent->balance, $rmbAmount, 4) >= 0) {
+                    $days = (int) AgentSetting::current()->equity_recharge_lock_days;
+                    throw new \RuntimeException("可提现余额不足(充值未满 {$days} 天的金额暂不可提现)");
+                }
+                throw new \RuntimeException('代理余额不足');
             }
 
             $fee = bcdiv(bcmul($rmbAmount, (string) $lockedAgent->withdraw_fee_rate, 8), '100', $scale);

+ 41 - 0
database/migrations/2026_11_08_190000_add_agent_recharge_locks.php

@@ -0,0 +1,41 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+/**
+ * 入股代理充值锁定:充值到账的金额计入余额(可以立即购买股份),但 N 天内不能提现。
+ * - agent_recharge_locks:每笔充值一条,remaining 为还锁着的金额,购买股份时优先消耗;
+ * - agent_settings.equity_recharge_lock_days:锁定天数,默认 7,0 表示不锁定。
+ */
+return new class extends Migration {
+    public function up(): void
+    {
+        if (!Schema::hasTable('agent_recharge_locks')) {
+            Schema::create('agent_recharge_locks', function (Blueprint $t) {
+                $t->id();
+                $t->unsignedBigInteger('agent_id');
+                $t->unsignedBigInteger('balance_log_id')->unique()->comment('充值入账的资金流水');
+                $t->string('reference_type', 32)->default('')->comment('agent_recharge / agent_payment_order');
+                $t->unsignedBigInteger('reference_id')->nullable();
+                $t->decimal('amount', 18, 4)->comment('充值金额');
+                $t->decimal('remaining', 18, 4)->comment('仍锁定的金额(购买股份会减少)');
+                $t->timestamp('unlock_at')->comment('到期后可提现');
+                $t->timestamps();
+                $t->index(['agent_id', 'unlock_at']);
+            });
+        }
+        if (!Schema::hasColumn('agent_settings', 'equity_recharge_lock_days')) {
+            Schema::table('agent_settings', fn (Blueprint $t) => $t->unsignedSmallInteger('equity_recharge_lock_days')->default(7)->comment('入股代理充值锁定天数,0 不锁定'));
+        }
+    }
+
+    public function down(): void
+    {
+        if (Schema::hasColumn('agent_settings', 'equity_recharge_lock_days')) {
+            Schema::table('agent_settings', fn (Blueprint $t) => $t->dropColumn('equity_recharge_lock_days'));
+        }
+        Schema::dropIfExists('agent_recharge_locks');
+    }
+};

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

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

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

@@ -26,6 +26,7 @@
   - 方案要求的保证金交足才开始分成。保证金被亏到方案要求的一定比例以下(默认 10%)时暂停分成,需要再充值到方案保证金的 100% 才恢复。只展示「分成中/已暂停」,不展示实时保证金的具体数值。
   - 分成逐笔实时计算,每天 04:10 入账前一天的分成。当天未入账的亏损会预留,不能提现。
 - **入股**:按比例购买股份。余额够就立即开通;不够就生成待付款订单,充值到账后自动扣款开通。分红每月由平台人工发放,到账后计入余额,可以提现。
+  - 充值到账的金额计入余额,可以立即用来购买股份,但**充值后 7 天内不能提现**(天数由总后台「入股配置」设置);购买股份优先使用锁定中的充值。分红、提现退回等非充值的余额不锁定。
 - 余额单位和会员钱包相同(人民币)。人民币充值按订单金额入账;USDT 充值按 `exchange_rate_rmb` 折算进余额;提现按「汇率 + 汇率差」把余额折算成 USDT。
 
 ## 1. 登录与个人信息
@@ -80,6 +81,23 @@
 - `recharge_exchange_rate`:充值汇率,1 USDT 折合多少余额
 - `withdraw_exchange_rate`:提现汇率
 
+入股代理另有充值锁定字段(合营代理没有这些字段,`recharge_locks` 为空数组):
+
+| 字段 | 说明 |
+|---|---|
+| `locked_amount` | 锁定中(充值未满 N 天)的金额,不能提现,但可以购买股份 |
+| `recharge_lock_days` | 锁定天数 N(默认 7,`0` 表示不锁定) |
+| `recharge_locks` | 锁定明细,按到期时间排序:`amount`(充值金额)、`remaining`(仍锁定的金额,买股份会减少)、`recharged_at`(充值时间)、`unlock_at`(到期时间,之后可提现) |
+
+```json
+{"balance":"1500.0000","frozen_balance":"0.0000","withdrawable_amount":"500.0000","equity_percent":"0.5000",
+ "locked_amount":"1000.0000","recharge_lock_days":7,
+ "recharge_locks":[{"amount":"1000.0000","remaining":"1000.0000","recharged_at":"2026-11-08 12:00:00","unlock_at":"2026-11-15 12:00:00"}],
+ "withdraw_exchange_rate":"7.3000","recharge_exchange_rate":"7.2000"}
+```
+
+页面建议:资金页展示「冻结中 `locked_amount`(充值 N 天后可提现)」,并列出 `recharge_locks` 的到期时间。
+
 ### 充值
 
 充值页(USDT、人工充值、808/NO/JD/三斤在线充值,以及充值记录)的完整说明见 **[代理充值-前端接入.md](代理充值-前端接入.md)**。

+ 2 - 1
docs/代理/代理提现-前端接入.md

@@ -32,7 +32,7 @@
 
 ## 1. 规则(页面文案需要用到)
 
-- **可提现金额**:`GET /agent/wallet/summary` 的 `withdrawable_amount`(余额单位,人民币)。合营代理只能提取超出保证金的盈利,入股代理可以提取全部余额。
+- **可提现金额**:`GET /agent/wallet/summary` 的 `withdrawable_amount`(余额单位,人民币)。合营代理只能提取超出保证金的盈利;入股代理可以提取余额中**充值已满 N 天(默认 7 天)**的部分,锁定中的金额见 summary 的 `locked_amount`、`recharge_locks`。
 - **USDT**:输入 USDT 数量,扣除余额 = USDT 数量 × 提现汇率(`withdraw_exchange_rate`,即汇率 + 汇率差)。
 - **其他通道**:输入人民币金额,最多 2 位小数,扣除余额 = 输入金额。
 - **金额区间**:通道的 `config.min`~`config.max`(USDT 通道按 USDT 数量,其他按人民币)。
@@ -174,6 +174,7 @@ GET /agent/wallet/withdrawals  →  查看进度
 | `不支持此提现方式` | 账户的通道已不在可用列表(总后台调整了通道组合) |
 | `请先在提现账户中填写真实姓名` | 需要实名的通道,代理没有真实姓名 |
 | `可提现盈利不足(保证金不可提现)` / `代理余额不足` | 超过可提现金额 |
+| `可提现余额不足(充值未满 7 天的金额暂不可提现)` | 入股代理:余额够,但其中一部分是锁定期内的充值 |
 | `扣除手续费后到账金额必须大于0` | 金额太小 |
 | `提现密码错误` / `代理账号已禁用` | |
 

+ 1 - 0
docs/后端/代理合营与入股-部署说明.md

@@ -14,6 +14,7 @@ php artisan migrate --force
 | `2026_11_07_130000_add_agent_joint_venture_admin_menus.php` | 总后台菜单:入股配置、分成方案申请、代理充值记录、会员盈亏日报,以及保存/审核按钮。代理菜单挂在标题含「代理」的一级菜单下;找不到时挂到根节点,需要手动调整 |
 | `2026_11_08_150000_add_type_to_agent_applications.php` | `agent_applications.type`(1 合营,默认 / 2 入股):会员申请时选择的代理类型,由 melbet_sport-api 的 `POST /api/agent/apply` 写入。**需要先于 melbet 新代码执行**,否则会员提交申请会因缺少该列报错 |
 | `2026_11_08_170000_add_agent_withdrawal_channels.php` | 代理提现支持全部提现通道:`agent_withdrawals` 新增 `pay_no`、`pay_data`、`callback_data`(三方代付)。通道存在原有的 `account_type`,提现账户沿用 `agent_withdraw_accounts`(`type` = 通道,`details` 存开户行/收款码/收款方式) |
+| `2026_11_08_190000_add_agent_recharge_locks.php` | 入股代理充值锁定:新表 `agent_recharge_locks`(每笔充值一条,`remaining` 仍锁定的金额,`unlock_at` 到期时间),`agent_settings.equity_recharge_lock_days`(默认 7)。`AgentWalletService::adjust` 在入股代理 `recharge` 时写入、`equity_purchase` 时按到期时间先后消耗;可提现 = 余额 − 未到期的 `remaining`。**需要和代码一起上线**(充值入账会写这张表)。上线前已到账的充值不锁定 |
 | `2026_11_08_180000_add_agent_equity_order_admin.php` | 总后台「入股购买记录」菜单 `agentEquityOrders` 和取消按钮;`agent_equity_orders` 新增 `cancelled_by`、`cancel_remark`。**代理取消入股订单也会写这两列,需要和代码一起上线** |
 | `2026_11_08_120000_create_agent_payment_orders.php` | 代理人民币充值:新表 `agent_payment_orders`(字段和状态码同 `payment_orders`)、`agent_settings.recharge_channel_group_id`(默认 1),以及「代理人民币充值」菜单和 4 个按钮(挂在「代理充值记录」同一个父菜单下) |
 

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

@@ -162,7 +162,7 @@
 
 ```json
 {"id":1,"margin_stop_percent":"10.0000","equity_enabled":1,"equity_price_per_percent":"100000.0000",
- "equity_min_percent":"0.1000","equity_max_percent":"1.0000","recharge_channel_group_id":1,"updated_by":1,"updated_at":"…"}
+ "equity_min_percent":"0.1000","equity_max_percent":"1.0000","recharge_channel_group_id":1,"equity_recharge_lock_days":7,"updated_by":1,"updated_at":"…"}
 ```
 
 ### `POST /admin/agent/settings`
@@ -174,6 +174,7 @@
 | `equity_price_per_percent` | 是 | 每 1% 股份的价格(余额单位) |
 | `equity_min_percent` | 是 | 单次最少购买比例,例如 0.1 |
 | `equity_max_percent` | 是 | 单次最多购买比例,同时也是每个代理累计持股上限,例如 1 |
+| `equity_recharge_lock_days` | 否 | 入股代理充值后多少天内不能提现,默认 7,`0` 不锁定,最大 365。锁定期内的充值可以买股份(优先使用),只是不能提现;修改只影响之后的充值 |
 | `recharge_channel_group_id` | 否 | 代理充值使用的**通道组合**(会员管理里的充值通道组合 ID,不能选已删除的组合),默认 1。代理能看到的充值通道 = 该组合的充值类型 ∩ 已启用的通道和支付公司。页面可以用现有的通道组合列表做下拉 |
 
 ## 7. 代理充值记录

+ 90 - 0
tests/Integration/AgentEquityRechargeLockTest.php

@@ -0,0 +1,90 @@
+<?php
+
+namespace Tests\Integration;
+
+use App\Models\Agent\Agent;
+use App\Models\Agent\AgentRechargeLock;
+use App\Models\Agent\AgentSetting;
+use App\Models\Agent\AgentWithdrawAccount;
+use App\Services\Agent\AgentEquityService;
+use App\Services\Agent\AgentWalletService;
+use App\Services\Agent\AgentWithdrawalService;
+use Carbon\Carbon;
+use Illuminate\Support\Facades\DB;
+use PHPUnit\Framework\TestCase;
+use Tests\Support\AgentTestEnvironment;
+
+/** 入股代理充值后 N 天内不能提现,可以立即购买股份(优先消耗锁定中的充值)。 */
+class AgentEquityRechargeLockTest extends TestCase
+{
+    use AgentTestEnvironment;
+
+    private AgentWalletService $wallets;
+
+    private function recharge(Agent $agent, string $amount, string $key): void
+    {
+        $this->wallets->adjust($agent->id, $amount, 'recharge', '充值', $key, 'system', null, 'agent_recharge', 1);
+    }
+
+    private function setUpEquity(int $lockDays = 7): void
+    {
+        $this->wallets = new AgentWalletService();
+        AgentSetting::current();
+        DB::table('agent_settings')->where('id', 1)->update(['equity_enabled' => 1, 'equity_price_per_percent' => 1000,
+            'equity_min_percent' => 0.1, 'equity_max_percent' => 1, 'equity_recharge_lock_days' => $lockDays]);
+    }
+
+    public function test_recharge_is_locked_for_seven_days_then_withdrawable(): void
+    {
+        $this->setUpEquity();
+        $this->paymentChannels();
+        $agent = $this->agent(Agent::TYPE_EQUITY);
+        $this->recharge($agent, '1000', 'r1');
+        $agent = $agent->fresh();
+        $this->assertSame('0.0000', $this->wallets->withdrawable($agent));
+        $funds = $this->wallets->funds($agent);
+        $this->assertSame(['1000.0000', '1000.0000', '0.0000', 7], [$funds['balance'], $funds['locked_amount'], $funds['withdrawable_amount'], $funds['recharge_lock_days']]);
+        $locks = $this->wallets->activeLocks($agent);
+        $this->assertSame('2026-09-14 12:00:00', $locks[0]['unlock_at']);
+
+        $account = AgentWithdrawAccount::query()->create(['agent_id' => $agent->id, 'type' => 'usdt', 'network' => 'TRC20',
+            'account' => 'TQn9Y2khEsLJW1ChVWFMSMeRDow5KcbLSE', 'status' => 1]);
+        try { (new AgentWithdrawalService($this->wallets))->request($agent, $account->id, '10', 'withdraw-old', 'locked'); $this->fail('locked'); }
+        catch (\RuntimeException $e) { $this->assertSame('可提现余额不足(充值未满 7 天的金额暂不可提现)', $e->getMessage()); }
+
+        Carbon::setTestNow(Carbon::parse('2026-09-14 12:00:01'));
+        $this->assertSame('1000.0000', $this->wallets->withdrawable($agent->fresh()));
+        $this->assertSame([], $this->wallets->activeLocks($agent));
+        $this->assertSame('0.0000', $this->wallets->funds($agent->fresh())['locked_amount']);
+    }
+
+    public function test_share_purchase_uses_locked_recharge_first(): void
+    {
+        $this->setUpEquity();
+        $agent = $this->agent(Agent::TYPE_EQUITY);
+        $this->wallets->adjust($agent->id, '500', 'credit', '分红', 'c1', 'admin', 1); // 人工加款不锁定
+        $this->recharge($agent, '1000', 'r1');
+        $this->assertSame('500.0000', $this->wallets->withdrawable($agent->fresh()));
+
+        $order = (new AgentEquityService($this->wallets))->order($agent->fresh(), '0.6'); // 600
+        $this->assertSame('completed', $order->status);
+        $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');
+    }
+
+    public function test_joint_agents_and_zero_days_are_not_locked(): void
+    {
+        $this->setUpEquity(0);
+        $equity = $this->agent(Agent::TYPE_EQUITY);
+        $this->recharge($equity, '300', 'r-equity');
+        $this->assertSame('300.0000', $this->wallets->withdrawable($equity->fresh()));
+
+        DB::table('agent_settings')->where('id', 1)->update(['equity_recharge_lock_days' => 7]);
+        $joint = $this->agent(Agent::TYPE_JOINT);
+        $this->recharge($joint, '300', 'r-joint');
+        $this->assertSame(0, AgentRechargeLock::query()->count(), 'joint recharges are margin, not locked');
+        $this->assertArrayNotHasKey('locked_amount', $this->wallets->funds($joint->fresh()));
+    }
+}

+ 1 - 0
tests/Support/AgentTestEnvironment.php

@@ -91,6 +91,7 @@ trait AgentTestEnvironment
             'rules' => json_encode(['third:1' => [], 'third:3' => [], 'self:lottery' => []])]);
         (require dirname(__DIR__, 2) . '/database/migrations/2026_11_07_120000_agent_joint_venture_and_equity.php')->up();
         (require dirname(__DIR__, 2) . '/database/migrations/2026_11_08_180000_add_agent_equity_order_admin.php')->up();
+        (require dirname(__DIR__, 2) . '/database/migrations/2026_11_08_190000_add_agent_recharge_locks.php')->up();
 
         foreach (['recharges', 'withdraws', 'payment_orders'] as $table) {
             Schema::create($table, function (Blueprint $t) {