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

+ 2 - 1
app/Http/Controllers/admin/Admin.php

@@ -5,6 +5,7 @@ namespace App\Http\Controllers\admin;
 use App\Constants\HttpStatus;
 use App\Http\Controllers\Controller;
 use App\Services\BaseService;
+use App\Services\AdminJwtSession;
 use App\Services\JwtService;
 use Illuminate\Support\Facades\Auth;
 use Illuminate\Support\Facades\Cache;
@@ -150,7 +151,7 @@ class Admin extends Controller
             $username = request()->input('username');
             $password = request()->input('password');
             $user = AdminModel::login($username, $password);
-            $token = $this->jwtService->generateToken($user);
+            $token = app(AdminJwtSession::class)->issue($user);
             Cache::put("user_{$user->getId()}_jwt", $token);
 
 

+ 7 - 7
app/Http/Controllers/admin/Realtime.php

@@ -8,7 +8,7 @@ use App\Models\Admin;
 use App\Services\AdminRealtime\AdminRealtimeAccess;
 use App\Services\AdminRealtime\AdminRealtimeFeed;
 use App\Services\AdminRealtime\AdminRealtimeRows;
-use App\Services\JwtService;
+use App\Services\AdminJwtSession;
 use Exception;
 use Illuminate\Http\JsonResponse;
 use Illuminate\Validation\ValidationException;
@@ -17,13 +17,13 @@ use Illuminate\Validation\ValidationException;
 class Realtime extends Controller
 {
     /** Echo 连接参数、可订阅频道、当前 seq 与登录过期时间 */
-    public function config(JwtService $jwtService): JsonResponse
+    public function config(AdminJwtSession $jwtSession): JsonResponse
     {
         try {
             if (!config('admin_realtime.enabled')) {
                 throw new Exception('实时推送未启用');
             }
-            $session = $jwtService->validateToken($this->bearerToken());
+            $session = request()->attributes->get('admin_jwt') ?? $jwtSession->validate($this->bearerToken());
             $client = config('admin_realtime.client');
             $result = [
                 'broadcaster' => 'reverb',
@@ -34,7 +34,7 @@ class Realtime extends Controller
                 'auth_endpoint' => '/admin/broadcasting/auth',
                 'channels' => AdminRealtimeAccess::allowedChannels((int) request()->user->id),
                 'last_seq' => AdminRealtimeFeed::lastSeq(),
-                'session_expires_at' => isset($session->exp) ? (int) $session->exp : null,
+                'session_expires_at' => $session ? $jwtSession->realtimeExpiresAt($session) : null,
             ];
         } catch (Exception $e) {
             return $this->error(HttpStatus::CUSTOM_ERROR, $e->getMessage());
@@ -46,12 +46,12 @@ class Realtime extends Controller
      * Echo 私有频道授权(Pusher 协议),直接返回 {auth, channel_data}。
      * 不走 admin.jwt:该中间件未登录时仍返回 HTTP 200,Echo 无法区分,这里改用 401 / 403。
      */
-    public function auth(JwtService $jwtService): JsonResponse
+    public function auth(AdminJwtSession $jwtSession): JsonResponse
     {
         if (!config('admin_realtime.enabled')) {
             return $this->denied(403, '实时推送未启用');
         }
-        $session = $jwtService->validateToken($this->bearerToken());
+        $session = $jwtSession->validate($this->bearerToken());
         $admin = $session && isset($session->user_id) ? Admin::query()->find($session->user_id) : null;
         if (!$admin) {
             return $this->denied(401, __('messages.' . HttpStatus::AUTHORIZATION_HEADER_NOT_FOUND));
@@ -62,7 +62,7 @@ class Realtime extends Controller
             return $this->denied(403, '无权订阅该频道');
         }
         try {
-            $auth = AdminRealtimeAccess::authorize((int) $admin->id, isset($session->exp) ? (int) $session->exp : null, $channelName, (string) request()->input('socket_id'));
+            $auth = AdminRealtimeAccess::authorize((int) $admin->id, $jwtSession->realtimeExpiresAt($session), $channelName, (string) request()->input('socket_id'));
         } catch (\Throwable $e) {
             return $this->denied(403, '参数错误');
         }

+ 14 - 2
app/Http/Controllers/api/Wallet.php

@@ -92,8 +92,20 @@ class Wallet extends BaseController
         try {
             $params = request()->validate([
                 'amount' => ['required', 'numeric', 'min:0.01'],
-                'payment_type' => ['required', 'string'],
+                'payment_type' => ['required_without:channel_id', 'nullable', 'string'],
+                'channel_id' => ['nullable', 'integer', 'min:1'],
             ]);
+            $channelId = !empty($params['channel_id']) ? (int)$params['channel_id'] : null;
+            if ($channelId) {
+                $channelType = RechargeChannel::query()->where('data_type', 1)->whereKey($channelId)->value('type');
+                if ($channelType === null) {
+                    throw new Exception(lang("充值通道不存在"));
+                }
+                if (!empty($params['payment_type']) && $params['payment_type'] !== $channelType) {
+                    throw new Exception(lang("充值通道与充值方式不匹配"));
+                }
+                $params['payment_type'] = $channelType;
+            }
             if ($params['payment_type'] == 'rgcz') {
                 return $this->error('参数有误');
             }
@@ -115,7 +127,7 @@ class Wallet extends BaseController
                 throw new Exception(lang("不支持此充值方式").$user->recharge_channel_group_id);
             }
             
-            $res = PaymentOrderService::createPay($member_id, $params['amount'], $params['payment_type']);
+            $res = PaymentOrderService::createPay($member_id, $params['amount'], $params['payment_type'], $channelId);
             if ($res['code'] == 0) { 
                 return $this->success($res);
             }

+ 6 - 3
app/Http/Middleware/JwtAdminMiddleware.php

@@ -5,7 +5,7 @@ namespace App\Http\Middleware;
 use App\Constants\HttpStatus;
 use App\Models\Admin;
 use Closure;
-use App\Services\JwtService;
+use App\Services\AdminJwtSession;
 use Illuminate\Http\Request;
 use Illuminate\Support\Facades\Cache;
 
@@ -13,7 +13,7 @@ class JwtAdminMiddleware
 {
     protected $jwtService;
 
-    public function __construct(JwtService $jwtService)
+    public function __construct(AdminJwtSession $jwtService)
     {
         $this->jwtService = $jwtService;
     }
@@ -31,9 +31,12 @@ class JwtAdminMiddleware
             ]);
         }
         $token = str_replace('Bearer ', '', $authHeader);
-        $user = $this->jwtService->validateToken($token);
+        $user = $this->jwtService->validate($token);
         if ($user) {
             $request->user = Admin::findOrFail($user->user_id);
+            $request->attributes->set('admin_jwt', $user);
+            // 滑动续期:有操作就顺延空闲失效时间
+            $this->jwtService->touch($user);
 
             //单点登录
 //            $oldToken = Cache::get("user_{$request->user->getId()}_jwt");

+ 25 - 0
app/Models/RechargeChannel.php

@@ -156,11 +156,21 @@ class RechargeChannel extends BaseModel
                 $config = $pv;
             }
             
+            $channels = $list[$pv['type']]['channels'] ?? [];
+            $channels[] = [
+                'id' => (int)$pv['id'],
+                'label' => lang($pv['name']),
+                'min' => $pv['min'],
+                'max' => $pv['max'],
+                'fixed' => $pv['fixed'],
+                'rate' => $pv['rate'],
+            ];
             $list[$pv['type']] = [
                 'key' => $key,
                 'label' => lang($pv['name']),
                 'value' => $pv['type'],
                 'config' => $config ?? [],
+                'channels' => $channels,
             ];
             if (\App\Services\Payment\RechargeChannelLogoService::supportsDirection($direction)) {
                 $logo = $direction === 1 && array_key_exists($pv['type'], $logos)
@@ -186,6 +196,21 @@ class RechargeChannel extends BaseModel
             ->available()->orderBy('sort')->first() ?: false;
     }
     
+    /**
+     * 按通道ID取会员可用的充值通道:必须是充值通道、未停用、支付公司未禁用,且类型在会员充值分组内。
+     * 不满足时返回 false。
+     */
+    public static function findRechargeChannel(int $id, $recharge_channel_group_id = 1) {
+        $recharge_channel_group_id = $recharge_channel_group_id > 0 ? $recharge_channel_group_id : 1;
+        $group = RechargeChannelGroup::query()->find($recharge_channel_group_id);
+        $rechargeTypes = $group ? (array)$group->recharge_type : [];
+        $row = self::query()->where('data_type', 1)->whereKey($id)->available()->first();
+        if (!$row || !in_array($row->type, $rechargeTypes, true)) {
+            return false;
+        }
+        return $row;
+    }
+
     //校验是否支持此充值方式
     public static function checkRechargeChannel($type, $recharge_channel_group_id = 1) {
         $recharge_channel_group_id = $recharge_channel_group_id > 0 ? $recharge_channel_group_id : 1;

+ 107 - 0
app/Services/AdminJwtSession.php

@@ -0,0 +1,107 @@
+<?php
+
+namespace App\Services;
+
+use App\Models\Admin;
+use Illuminate\Support\Carbon;
+use Illuminate\Support\Facades\Cache;
+use Illuminate\Support\Str;
+
+/**
+ * 后台登录态滑动续期:JWT 的 exp 只是绝对上限(config app.admin_jwt_max_lifetime),
+ * 真正的有效期由缓存里按 sid 记的最后活跃时间决定,空闲超过 app.admin_jwt_idle 秒失效。
+ * 每次通过 admin.jwt 的请求都会刷新活跃时间,前端无需换 token。
+ * 不带 sid 的旧 token(上线前签发)仍按自身 exp 判断,自然过期后重新登录即可。
+ */
+class AdminJwtSession
+{
+    /** 活跃时间写缓存的最小间隔,避免每个请求都写 Redis */
+    private const TOUCH_INTERVAL = 60;
+
+    public function __construct(private JwtService $jwt)
+    {
+    }
+
+    public static function idleSeconds(): int
+    {
+        return max(60, (int) config('app.admin_jwt_idle', 86400));
+    }
+
+    public function issue(Admin $admin): string
+    {
+        $sid = Str::random(32);
+        $token = $this->jwt->generateToken($admin, max(self::idleSeconds(), (int) config('app.admin_jwt_max_lifetime', 2592000)), ['sid' => $sid]);
+        Cache::put(self::key($sid), $this->now(), self::idleSeconds());
+        return $token;
+    }
+
+    /** 校验 token:签名/exp 有效且未空闲超时,返回解码后的载荷,否则 null */
+    public function validate(?string $token): ?object
+    {
+        $session = $this->jwt->validateToken((string) $token);
+        if (!$session || !isset($session->user_id)) return null;
+        if (!isset($session->sid)) return $session;
+        return $this->lastActivity($session) === null ? null : $session;
+    }
+
+    /** 记一次活跃,续上空闲时长 */
+    public function touch(object $session): void
+    {
+        if (!isset($session->sid)) return;
+        $now = $this->now();
+        $last = $this->lastActivity($session);
+        if ($last !== null && $now - $last < self::TOUCH_INTERVAL) return;
+        Cache::put(self::key((string) $session->sid), $now, self::idleSeconds());
+    }
+
+    /** 当前登录态的失效时间戳:min(JWT exp, 最后活跃 + 空闲时长) */
+    public function expiresAt(object $session): ?int
+    {
+        $exp = isset($session->exp) ? (int) $session->exp : null;
+        if (!isset($session->sid)) return $exp;
+        $last = $this->lastActivity($session);
+        if ($last === null) return null;
+        $idleEnd = $last + self::idleSeconds();
+        return $exp === null ? $idleEnd : min($exp, $idleEnd);
+    }
+
+    /**
+     * 实时推送用的会话到期时间。同一 socket 上各频道的到期时间必须一致(见 RejectExpiredAdminRealtimeSubscription),
+     * 所以首次授权时定下 min(exp, 最后活跃 + 空闲时长),到点前复用;到点后 relay 断开连接,
+     * Echo 重连时若登录态仍有效则拿到新的到期时间。该值不晚于真实空闲失效时间(活跃只会顺延)。
+     */
+    public function realtimeExpiresAt(object $session): ?int
+    {
+        if (!isset($session->sid)) return $this->expiresAt($session);
+        $key = self::key((string) $session->sid) . ':realtime';
+        $cached = (int) Cache::get($key, 0);
+        $now = $this->now();
+        if ($cached > $now) return $cached;
+        $expiresAt = $this->expiresAt($session);
+        if ($expiresAt !== null && $expiresAt > $now) Cache::put($key, $expiresAt, $expiresAt - $now);
+        return $expiresAt;
+    }
+
+    public function revoke(object $session): void
+    {
+        if (!isset($session->sid)) return;
+        Cache::forget(self::key((string) $session->sid));
+        Cache::forget(self::key((string) $session->sid) . ':realtime');
+    }
+
+    private function lastActivity(object $session): ?int
+    {
+        $value = Cache::get(self::key((string) $session->sid));
+        return $value === null ? null : (int) $value;
+    }
+
+    private static function key(string $sid): string
+    {
+        return "admin_jwt_active:{$sid}";
+    }
+
+    private function now(): int
+    {
+        return Carbon::now()->getTimestamp();
+    }
+}

+ 4 - 4
app/Services/JwtService.php

@@ -21,16 +21,16 @@ class JwtService
     }
 
 // 生成 JWT
-    public function generateToken($user)
+    public function generateToken($user, ?int $ttl = null, array $claims = [])
     {
         $issuedAt = time();
-        $expirationTime = $issuedAt + $this->exp;
-        $payload = [
+        $expirationTime = $issuedAt + ($ttl ?? $this->exp);
+        $payload = array_merge($claims, [
             'iat' => $issuedAt,
             'exp' => $expirationTime,
             'sub' => $user->id,
             'user_id' => $user->id,
-        ];
+        ]);
         $token = JWT::encode($payload, $this->secretKey, 'HS256');
         if ($user instanceof User) {
             Cache::put("user_{$user->id}_jwt", $token, $this->exp);

+ 17 - 5
app/Services/PaymentOrderService.php

@@ -221,7 +221,7 @@ class PaymentOrderService extends BaseService
      * @param {*} $paymentType 支付类型:支付宝、数字人民币
      * @return {*}
      */
-    public static function createPay($memberId, $amount, $paymentType)
+    public static function createPay($memberId, $amount, $paymentType, ?int $channelId = null)
     {
         $result = [];
         $result['chat_id'] = $memberId;
@@ -234,15 +234,17 @@ class PaymentOrderService extends BaseService
         }
         $user = User::query()->where('member_id', $memberId)->first();
         $groupId = (int)($user->recharge_channel_group_id ?? 1);
-        $channelConfig = RechargeChannel::checkRechargeChannel($paymentType, $groupId);
-        if ($channelConfig === false) {
+        $channelConfig = $channelId
+            ? RechargeChannel::findRechargeChannel($channelId, $groupId)
+            : RechargeChannel::checkRechargeChannel($paymentType, $groupId);
+        if ($channelConfig === false || (string)$channelConfig->type !== (string)$paymentType) {
             $result['text'] = '不支持此充值方式';
             $result['code'] = 20001;
             return $result;
         }
         $bonusSnapshot = RechargeBonusService::capture((string)$channelConfig->type, (string)$memberId);
         $extra = $bonusSnapshot === null ? [] : ['recharge_bonus_snapshot' => $bonusSnapshot];
-        return self::createPayOrder(PaymentOrder::class, $memberId, $amount, $paymentType, $groupId, $channelConfig, $extra);
+        return self::createPayOrder(PaymentOrder::class, $memberId, $amount, $paymentType, $groupId, $channelConfig, $extra, '', $channelId);
     }
 
     /**
@@ -250,9 +252,10 @@ class PaymentOrderService extends BaseService
      * - $model:PaymentOrder(会员)或 AgentPaymentOrder(代理);
      * - $memberId:付款人标识,会员为 member_id,代理为 AG{代理ID};
      * - $extra:额外写入订单的字段;$orderPrefix:订单号前缀(代理为 ag)。
+     * - $channelId:指定通道ID时只用这一条通道(金额区间、费率、三方产品都按它),不再按金额自动挑选。
      * 调用方需先校验支付公司启用、通道可用。成功时返回值带 order_no。
      */
-    public static function createPayOrder(string $model, $memberId, $amount, $paymentType, int $groupId, $channelConfig, array $extra = [], string $orderPrefix = ''): array
+    public static function createPayOrder(string $model, $memberId, $amount, $paymentType, int $groupId, $channelConfig, array $extra = [], string $orderPrefix = '', ?int $channelId = null): array
     {
         $result = [];
         $result['chat_id'] = $memberId;
@@ -268,6 +271,9 @@ class PaymentOrderService extends BaseService
 
         $geText = '';
         foreach ($product as $k => $v) {
+            if ($channelId && (int)$v['id'] !== $channelId) {
+                continue;
+            }
             if ($v['type'] == $paymentType) {
                 $selectedProduct = $v;
                 if ($v['type'] == 'zfbge') {
@@ -301,6 +307,12 @@ class PaymentOrderService extends BaseService
             }
         }
 
+        if ($channelId && $selectedProduct === null) {
+            $result['text'] = '充值通道不可用';
+            $result['code'] = 20001;
+            return $result;
+        }
+
         $isDirectProvider = JdPayService::isChannel($paymentType)
             || NoPayService::isRechargeChannel($paymentType)
             || ZimuPayService::isRechargeChannel($paymentType);

+ 3 - 0
config/app.php

@@ -104,6 +104,9 @@ return [
 
     'jwt_secret' => env('JWT_SECRET', '881FrcXs'),
     'jwt_exp' => env('JWT_EXP', 86400),
+    // 后台登录态:空闲超过 ADMIN_JWT_IDLE 秒失效(每次请求续期),最长不超过 ADMIN_JWT_MAX_LIFETIME 秒
+    'admin_jwt_idle' => (int) env('ADMIN_JWT_IDLE', 86400),
+    'admin_jwt_max_lifetime' => (int) env('ADMIN_JWT_MAX_LIFETIME', 2592000),
     'unique_identification' => env('unique_identification', 'likeadmin'),
 
     /*

+ 1 - 0
docs/前端交付/bot-28/后台/README.md

@@ -5,6 +5,7 @@
 | 功能 | 文档 |
 |---|---|
 | 实时推送:Laravel Echo + Reverb、频道授权、表格更新、断线补发(2026-10-31 增补) | [后台实时推送-前端接入](后台实时推送-前端接入.md) |
+| 登录态:后台 token 改为滑动续期,有操作不再中途掉线;前端一般无需改动,勿按 exp/session_expires_at 主动登出(2026-11-09) | [后台登录态滑动续期-前端说明](后台登录态滑动续期-前端说明.md) |
 | 公告:多语言、展示平台、发布时段、启停和日志 | [公告系统-后台接口](公告系统-后台接口.md) |
 | 信息:发送对象、多语言、定时发送、撤回和日志 | [信息管理-后台接口](信息管理-后台接口.md) |
 | 维护:整站与游戏分类、平台、时区、起止时间、开关和日志 | [整站与游戏维护-后台接口](整站与游戏维护-后台接口.md) |

+ 2 - 2
docs/前端交付/bot-28/后台/后台实时推送-前端接入.md

@@ -70,7 +70,7 @@ Authorization: Bearer <后台管理员 token>
 - `key`、`ws_host`、`ws_port`、`force_tls` 对应 Echo 的 `key`、`wsHost`、`wsPort`/`wssPort`、`forceTLS`。`key` 是公开值,不是密钥。
 - `auth_endpoint` 相对后台 API 域名,拼接方式与其它后台接口一致。
 - `channels` 按当前管理员列表权限过滤;不要假定每个账号都能订阅全部频道。服务端的频道权限查询结果默认缓存 60 秒。
-- `last_seq` 是首次补发的起点,`session_expires_at` 是 token 过期时间戳。
+- `last_seq` 是首次补发的起点,`session_expires_at` 是本次 WebSocket 连接的到期时间戳(不是登录失效时间,后台登录态已改为滑动续期,见《后台登录态滑动续期-前端说明》),不要据此主动登出。
 - HTTP 请求失败、业务 `code != 0` 时保留 HTTP 列表;返回「实时推送未启用」时不要持续重试。
 
 创建 Echo 示例(`config` 为上述 `data`,`API_BASE` 为后台 API 地址):
@@ -353,7 +353,7 @@ Authorization: Bearer <后台管理员 token>
 | --- | --- |
 | 网络断开、Reverb 重启 | Echo 自动重连并重新订阅,订阅成功回调里按上节补发 |
 | 连接长时间处于 `unavailable` | 保留 HTTP 列表与手动刷新,Echo 会继续尝试 |
-| token 到期 | 服务端主动断开连接,Echo 重连时授权返回 401,按上表停止 Echo 并走登录失效流程;也可按 `session_expires_at` 提前处理 |
+| 到达 `session_expires_at` | 服务端主动断开连接,Echo 自动重连:登录态仍有效则授权成功,按上节补发;已空闲失效则授权返回 401,按上表停止 Echo 并走登录失效流程 |
 | 退出登录 | 参考连接管理器 `stop()`,关闭 Echo,清空去重记录和游标,并使旧 HTTP 请求失效 |
 
 心跳由 Echo 与 Reverb 自动完成,前端无需实现。

+ 29 - 0
docs/前端交付/bot-28/后台/后台登录态滑动续期-前端说明.md

@@ -0,0 +1,29 @@
+# 后台登录态滑动续期:前端说明(2026-11-09)
+
+## 变更内容
+
+以前后台 token 从登录时开始计时,24 小时后一定失效,即使管理员一直在操作,也会在中途被踢下线。
+
+现在改为按空闲时间计算:
+
+| 项 | 规则 | 服务端配置(默认值) |
+| --- | --- | --- |
+| 空闲失效 | 超过 24 小时没有调用任何需要登录的后台接口才失效;每调用一次就重新计时 | `ADMIN_JWT_IDLE=86400` |
+| 最长有效期 | 无论是否活跃,登录满 30 天必须重新登录 | `ADMIN_JWT_MAX_LIFETIME=2592000` |
+
+续期完全在服务端完成,**token 字符串不变**,响应里也不会返回新 token。
+
+## 前端需要做什么
+
+**一般不用改。** 现有的“携带 `Authorization: Bearer xxx` → 收到登录失效码就跳转登录页”流程保持不变,失效时返回的 `code` 也和以前一样。
+
+只需要检查以下两点:
+
+1. **不要在本地按时间主动登出。** 如果前端解析了 JWT 的 `exp`,或者用 `/admin/realtime/config` 返回的 `session_expires_at` 设置了定时器,到点就清除 token、跳转登录页,请删掉这段逻辑。JWT 的 `exp` 现在是 30 天上限,不再代表实际失效时间,是否失效以接口返回为准。
+2. **实时推送的 `session_expires_at` 含义变了。** 它现在表示当前 WebSocket 连接的到期时间,不再是登录失效时间。到点后服务端会断开连接,Echo 会自动重连并重新授权:如果登录态仍然有效,授权成功、订阅恢复,按原有逻辑补发即可;如果已经空闲失效,授权返回 `401`,按原有流程停止 Echo,走登录失效处理。不要在这个时间点主动登出。
+
+## 兼容说明
+
+- 上线前已经登录的 token 仍按原来的 24 小时固定到期,过期后重新登录即可得到新 token,之后自动按新规则续期。
+- 服务端 Redis 缓存被清空时,所有后台登录态会失效,需要重新登录。
+- 会员前台(`/api`)的 token 原本就是滑动续期(24 小时无请求才失效),本次没有改动。

+ 38 - 0
docs/前端接口/充值指定通道-前端接入.md

@@ -0,0 +1,38 @@
+# 充值:用户自选通道
+
+同一种充值方式(如“支付宝扫码”)后台可能配了多条通道,金额区间、费率各不相同。以前下单只传 `payment_type`,后台按金额自动挑一条;现在可以传 `channel_id`,让用户自己选。
+
+## 1. 获取通道:`GET /api/wallet/channel`
+
+`data.list[]` 每个充值方式新增 `channels[]`,列出这个方式下用户可用的所有通道(已按排序),原有字段不变:
+
+```json
+{
+  "label": "支付宝扫码",
+  "value": "zfbsm",
+  "config": { "...": "原样保留" },
+  "channels": [
+    { "id": 6, "label": "支付宝扫码", "min": "100.00", "max": "200.00", "fixed": null, "rate": "0.0850" },
+    { "id": 7, "label": "支付宝扫码", "min": "200.00", "max": "1000.00", "fixed": null, "rate": "0.0570" }
+  ]
+}
+```
+
+- `min` / `max`:这条通道的单笔金额区间。
+- `fixed`:固定金额通道(如支付宝固额)为金额数组,此时只能充数组里的金额;其他通道为 `null`。
+- `rate`:通道费率,由平台承担,只用于展示,不从用户充值金额里扣。
+
+只有一条通道的方式,`channels` 也是一个元素的数组,前端可以不显示选择器,直接带上这条的 `id`。
+
+## 2. 下单:`POST /api/wallet/createPay`
+
+| 参数 | 必填 | 说明 |
+| --- | --- | --- |
+| `amount` | 是 | 充值金额 |
+| `channel_id` | 否 | 用户选的通道,取自 `channels[].id` |
+| `payment_type` | 不传 `channel_id` 时必填 | 充值方式,取自 `value` |
+
+- 传了 `channel_id`:只用这条通道下单,金额必须在这条通道的区间内(固额通道必须是它的固定金额之一),否则返回错误,**不会**自动换到别的通道。`payment_type` 可以不传;传了就必须和通道一致。
+- 不传 `channel_id`:沿用旧逻辑,后台按金额自动分配,旧版前端不受影响。
+
+返回结构不变。可能的新错误提示:`充值通道不存在`、`充值通道与充值方式不匹配`、`不支持此充值方式`(通道已停用、被删除、支付公司禁用或不在用户分组里),以及该通道的金额区间提示。建议前端在提交前先按所选通道的 `min` / `max` / `fixed` 校验金额。

+ 49 - 0
docs/前端接口/活动/余额宝持仓列表-利率与可转入金额-前台接入.md

@@ -0,0 +1,49 @@
+# 余额宝持仓列表:当前利率与可转入金额(前台接入)
+
+接口项目:`melbet_sport-api` 会员前台 API,需要登录。
+
+`GET /api/yuebao/itemList` 原有的 `count`、`list` 不变,新增 `extend` 字段,返回当前余额宝配置的利率和当前会员最多可以转入多少钱。转入弹窗可以直接用这些数据,不用再单独请求。
+
+```json
+{
+  "code": 1,
+  "data": {
+    "count": 2,
+    "list": [ ... ],
+    "extend": {
+      "daily_interest_rate": "0.246",
+      "number_of_consecutive_days_of_deposit": 7,
+      "min_amount": "1.00",
+      "max_amount": "100.00",
+      "can_deposit": true,
+      "reason": null
+    }
+  }
+}
+```
+
+| 字段 | 类型 | 说明 |
+|---|---|---|
+| `daily_interest_rate` | string | 当前配置的日化率,单位是 %。`"0.246"` 表示每天 0.246%。`list` 里每笔持仓的 `daily_interest_rate` 是转入时锁定的利率,可能和这里不一样 |
+| `number_of_consecutive_days_of_deposit` | int | 存期(天),新转入的持仓到期后会自动转出 |
+| `min_amount` | string | 单笔最少转入金额,目前是 `1.00` |
+| `max_amount` | string | 当前最多可转入的金额,两位小数,计算方法见下 |
+| `can_deposit` | bool | 现在能不能转入(`max_amount ≥ min_amount`,并且符合活动条件) |
+| `reason` | string\|null | 不能转入时的原因,已按请求头 `Lang`(`zh`/`en`/`vi`)翻译,可以直接展示 |
+
+`max_amount` = min(可用余额,今日有效投注 − 当前余额宝持仓),结果小于 0 时按 0 算。这个规则和转入接口 `POST /api/yuebao/add` 的校验一致。
+
+`reason` 可能的值(下表是中文原文,英文和越南文请求返回对应译文):
+
+| 值 | 场景 |
+|---|---|
+| `账户余额不足` | 可用余额小于 1 |
+| `超出余额宝存入上限` | 余额宝总持仓已经达到今日有效投注 |
+| 资格不符的原因(例如注册时间、存款总额不满足) | 不符合活动参与条件。此时 `max_amount` 为 `0.00`,但仍会返回当前配置的利率 |
+| `活动获奖人数已达上限` / `活动预算已用完` | 名额或预算已经用完 |
+
+前端建议:
+- 把 `can_deposit=false` 时的转入按钮置灰,并展示 `reason`。
+- 转入输入框的上限设为 `max_amount`。
+- 这里只是预览,最终以 `POST /api/yuebao/add` 的结果为准,例如在此期间余额发生了变化。
+- 后台没有上架的余额宝配置时,`daily_interest_rate` 为 `"0"`,这时仍然可以转入,但不会产生利息。

+ 2 - 2
docs/总后台/后台实时推送-前端接入.md

@@ -70,7 +70,7 @@ Authorization: Bearer <后台管理员 token>
 - `key`、`ws_host`、`ws_port`、`force_tls` 对应 Echo 的 `key`、`wsHost`、`wsPort`/`wssPort`、`forceTLS`。`key` 是公开值,不是密钥。
 - `auth_endpoint` 相对后台 API 域名,拼接方式与其它后台接口一致。
 - `channels` 按当前管理员列表权限过滤;不要假定每个账号都能订阅全部频道。服务端的频道权限查询结果默认缓存 60 秒。
-- `last_seq` 是首次补发的起点,`session_expires_at` 是 token 过期时间戳。
+- `last_seq` 是首次补发的起点,`session_expires_at` 是本次 WebSocket 连接的到期时间戳(不是登录失效时间,后台登录态已改为滑动续期,见《后台登录态滑动续期-前端说明》),不要据此主动登出。
 - HTTP 请求失败、业务 `code != 0` 时保留 HTTP 列表;返回「实时推送未启用」时不要持续重试。
 
 创建 Echo 示例(`config` 为上述 `data`,`API_BASE` 为后台 API 地址):
@@ -353,7 +353,7 @@ Authorization: Bearer <后台管理员 token>
 | --- | --- |
 | 网络断开、Reverb 重启 | Echo 自动重连并重新订阅,订阅成功回调里按上节补发 |
 | 连接长时间处于 `unavailable` | 保留 HTTP 列表与手动刷新,Echo 会继续尝试 |
-| token 到期 | 服务端主动断开连接,Echo 重连时授权返回 401,按上表停止 Echo 并走登录失效流程;也可按 `session_expires_at` 提前处理 |
+| 到达 `session_expires_at` | 服务端主动断开连接,Echo 自动重连:登录态仍有效则授权成功,按上节补发;已空闲失效则授权返回 401,按上表停止 Echo 并走登录失效流程 |
 | 退出登录 | 参考连接管理器 `stop()`,关闭 Echo,清空去重记录和游标,并使旧 HTTP 请求失效 |
 
 心跳由 Echo 与 Reverb 自动完成,前端无需实现。

+ 29 - 0
docs/总后台/后台登录态滑动续期-前端说明.md

@@ -0,0 +1,29 @@
+# 后台登录态滑动续期:前端说明(2026-11-09)
+
+## 变更内容
+
+以前后台 token 从登录时开始计时,24 小时后一定失效,即使管理员一直在操作,也会在中途被踢下线。
+
+现在改为按空闲时间计算:
+
+| 项 | 规则 | 服务端配置(默认值) |
+| --- | --- | --- |
+| 空闲失效 | 超过 24 小时没有调用任何需要登录的后台接口才失效;每调用一次就重新计时 | `ADMIN_JWT_IDLE=86400` |
+| 最长有效期 | 无论是否活跃,登录满 30 天必须重新登录 | `ADMIN_JWT_MAX_LIFETIME=2592000` |
+
+续期完全在服务端完成,**token 字符串不变**,响应里也不会返回新 token。
+
+## 前端需要做什么
+
+**一般不用改。** 现有的“携带 `Authorization: Bearer xxx` → 收到登录失效码就跳转登录页”流程保持不变,失效时返回的 `code` 也和以前一样。
+
+只需要检查以下两点:
+
+1. **不要在本地按时间主动登出。** 如果前端解析了 JWT 的 `exp`,或者用 `/admin/realtime/config` 返回的 `session_expires_at` 设置了定时器,到点就清除 token、跳转登录页,请删掉这段逻辑。JWT 的 `exp` 现在是 30 天上限,不再代表实际失效时间,是否失效以接口返回为准。
+2. **实时推送的 `session_expires_at` 含义变了。** 它现在表示当前 WebSocket 连接的到期时间,不再是登录失效时间。到点后服务端会断开连接,Echo 会自动重连并重新授权:如果登录态仍然有效,授权成功、订阅恢复,按原有逻辑补发即可;如果已经空闲失效,授权返回 `401`,按原有流程停止 Echo,走登录失效处理。不要在这个时间点主动登出。
+
+## 兼容说明
+
+- 上线前已经登录的 token 仍按原来的 24 小时固定到期,过期后重新登录即可得到新 token,之后自动按新规则续期。
+- 服务端 Redis 缓存被清空时,所有后台登录态会失效,需要重新登录。
+- 会员前台(`/api`)的 token 原本就是滑动续期(24 小时无请求才失效),本次没有改动。

+ 3 - 0
example.env

@@ -186,6 +186,9 @@ JWT_SECRET=
 # 计划任务/运维 URL(/admin/sync/*、/admin/fake、/api/setWebHook 等)访问密钥,必须设置
 CRON_TOKEN=
 JWT_EXP=86400
+# 后台登录态:空闲 N 秒无请求失效(每次请求续期),最长 N 秒必须重新登录
+ADMIN_JWT_IDLE=86400
+ADMIN_JWT_MAX_LIFETIME=2592000
 
 #缓存配置
 CACHE_DRIVER=redis

+ 124 - 0
tests/Integration/AdminJwtSessionTest.php

@@ -0,0 +1,124 @@
+<?php
+
+namespace Tests\Integration;
+
+use App\Http\Middleware\JwtAdminMiddleware;
+use App\Models\Admin;
+use App\Services\AdminJwtSession;
+use App\Services\JwtService;
+use Carbon\Carbon;
+use Firebase\JWT\JWT;
+use Illuminate\Cache\ArrayStore;
+use Illuminate\Cache\Repository as CacheRepository;
+use Illuminate\Config\Repository;
+use Illuminate\Database\Capsule\Manager;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Foundation\Application;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Facade;
+use Illuminate\Support\Facades\Schema;
+use Illuminate\Translation\ArrayLoader;
+use Illuminate\Translation\Translator;
+use PHPUnit\Framework\TestCase;
+
+class AdminJwtSessionTest extends TestCase
+{
+    private Application $app;
+    private AdminJwtSession $session;
+
+    protected function setUp(): void
+    {
+        Carbon::setTestNow(Carbon::createFromTimestamp(time()));
+        Facade::clearResolvedInstances();
+        $this->app = new Application(dirname(__DIR__, 2));
+        $this->app->instance('config', new Repository(['app' => [
+            'locale' => 'zh', 'jwt_secret' => str_repeat('k', 32), 'jwt_exp' => 86400,
+            'admin_jwt_idle' => 3600, 'admin_jwt_max_lifetime' => 86400 * 30,
+        ]]));
+        Facade::setFacadeApplication($this->app);
+        $this->app->instance('cache', new CacheRepository(new ArrayStore()));
+        $this->app->instance('translator', new Translator(new ArrayLoader(), 'zh'));
+        $this->app->instance(\Illuminate\Contracts\Routing\ResponseFactory::class, new \Tests\Support\JsonResponseFactory());
+        $db = new Manager($this->app); $db->addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => 'bot_']);
+        $db->setAsGlobal(); $db->bootEloquent(); $this->app->instance('db', $db->getDatabaseManager());
+        $this->app->bind('db.schema', fn () => $db->schema());
+        Schema::create('admin', function (Blueprint $t) { $t->id(); $t->string('username'); $t->string('password')->default(''); $t->timestamps(); });
+        DB::table('admin')->insert(['id' => 1, 'username' => 'root']);
+        $this->session = new AdminJwtSession(new JwtService());
+    }
+
+    protected function tearDown(): void { Carbon::setTestNow(); DB::disconnect(); Facade::clearResolvedInstances(); parent::tearDown(); }
+
+    private function request(string $token): array
+    {
+        $request = Request::create('/admin/x', 'GET');
+        $request->headers->set('Authorization', 'Bearer ' . $token);
+        $passed = false;
+        $response = (new JwtAdminMiddleware($this->session))->handle($request, function () use (&$passed) { $passed = true; return 'ok'; });
+        return [$passed, $response];
+    }
+
+    private function travel(int $seconds): void
+    {
+        Carbon::setTestNow(Carbon::now()->addSeconds($seconds));
+    }
+
+    public function test_activity_keeps_the_session_alive_beyond_the_idle_window(): void
+    {
+        $token = $this->session->issue(Admin::find(1));
+        $payload = $this->session->validate($token);
+        $this->assertSame(86400 * 30, $payload->exp - $payload->iat, 'JWT exp 只作为绝对上限');
+
+        // 连续操作 5 小时(空闲窗口只有 1 小时),始终有效
+        for ($i = 0; $i < 10; $i++) {
+            $this->travel(1800);
+            $this->assertTrue($this->request($token)[0], "第 {$i} 次请求应仍在登录态");
+        }
+    }
+
+    public function test_session_expires_after_idle_window_without_requests(): void
+    {
+        $token = $this->session->issue(Admin::find(1));
+        $this->travel(3599);
+        $this->assertTrue($this->request($token)[0]);
+        $this->travel(3601);
+        [$passed, $response] = $this->request($token);
+        $this->assertFalse($passed, '空闲超过 1 小时应失效');
+        $this->assertSame(\App\Constants\HttpStatus::AUTHORIZATION_HEADER_NOT_FOUND, $response->getData(true)['code']);
+    }
+
+    public function test_revoked_and_forged_tokens_are_rejected(): void
+    {
+        $token = $this->session->issue(Admin::find(1));
+        $this->session->revoke($this->session->validate($token));
+        $this->assertFalse($this->request($token)[0]);
+        $this->assertFalse($this->request($token . 'x')[0]);
+    }
+
+    public function test_legacy_tokens_without_sid_follow_their_own_expiry(): void
+    {
+        $legacy = JWT::encode(['iat' => time(), 'exp' => time() + 600, 'sub' => 1, 'user_id' => 1], str_repeat('k', 32), 'HS256');
+        $this->assertTrue($this->request($legacy)[0], '上线前签发的 token 不受影响');
+        $this->assertSame(time() + 600, $this->session->expiresAt($this->session->validate($legacy)));
+    }
+
+    public function test_realtime_expiry_is_stable_within_a_connection_and_renews_after_it(): void
+    {
+        $token = $this->session->issue(Admin::find(1));
+        $first = $this->session->realtimeExpiresAt($this->session->validate($token));
+        $this->assertSame(Carbon::now()->getTimestamp() + 3600, $first);
+
+        // 期间有操作,活跃时间顺延,但同一连接内推送到期时间保持不变(各频道 channel_data 一致)
+        $this->travel(1800);
+        $this->request($token);
+        $this->assertSame($first, $this->session->realtimeExpiresAt($this->session->validate($token)));
+        $this->assertSame(Carbon::now()->getTimestamp() + 3600, $this->session->expiresAt($this->session->validate($token)));
+
+        // 到点后重连拿到新的到期时间
+        $this->travel(1801);
+        $renewed = $this->session->realtimeExpiresAt($this->session->validate($token));
+        $this->assertGreaterThan($first, $renewed);
+        $this->assertLessThanOrEqual($this->session->expiresAt($this->session->validate($token)), $renewed);
+    }
+}

+ 1 - 1
tests/Integration/AdminRealtimeTest.php

@@ -350,7 +350,7 @@ class AdminRealtimeTest extends TestCase
             $request = Request::create('/admin/broadcasting/auth', 'POST', ['socket_id' => '123.456', 'channel_name' => $channel]);
             if ($token) $request->headers->set('Authorization', 'Bearer ' . $token);
             $this->app->instance('request', $request);
-            return (new RealtimeController())->auth(new JwtService());
+            return (new RealtimeController())->auth(new \App\Services\AdminJwtSession(new JwtService()));
         };
 
         $response = $call($token, 'private-admin.orders.withdraw');

+ 31 - 0
tests/Integration/AgentPaymentOrderTest.php

@@ -230,6 +230,37 @@ class AgentPaymentOrderTest extends TestCase
         $this->assertNotInstanceOf(AgentPaymentOrder::class, PaymentOrderService::findPayOrder('ag-member-1'));
     }
 
+    public function test_member_can_pick_a_specific_channel_by_id(): void
+    {
+        $this->payments();
+        $this->fullPaymentOrders();
+        $base = DB::table('recharge_channel')->where('data_type', 1)->where('type', 'JDpay')->first();
+        DB::table('recharge_channel')->where('id', $base->id)->update(['rate' => '0.0100', 'fee_fixed' => '0', 'min' => 10, 'max' => 5000]);
+        $row = (array) $base;
+        unset($row['id']);
+        $pickedId = DB::table('recharge_channel')->insertGetId(['key' => 'JD-B', 'rate' => '0.0300', 'fee_fixed' => '0', 'min' => 50, 'max' => 500, 'sort' => 999] + $row);
+
+        // 列表把同类型下每条通道都列出来,前端可直接拿 id
+        $listed = array_column(RechargeChannel::getFormatChannel(1, 2), null, 'value')['JDpay']['channels'];
+        $this->assertSame([(int) $base->id, $pickedId], array_column($listed, 'id'));
+
+        // 指定通道:费率按选中的那条,而不是自动挑的
+        $this->fakeJd(['code' => 200, 'data' => ['orderNo' => 'JD-PICK', 'url' => '']]);
+        $this->assertSame(0, ($picked = PaymentOrderService::createPay('M1001', 100, 'JDpay', $pickedId))['code'], $picked['text'] ?? '');
+        $this->assertSame('0.030000', bcadd((string) PaymentOrderService::findPayOrder($picked['order_no'])->fee_rate, '0', 6));
+
+        // 金额超出选中通道区间时拒绝,即使别的通道能接
+        $out = PaymentOrderService::createPay('M1001', 1000, 'JDpay', $pickedId);
+        $this->assertSame(20001, $out['code']);
+        $this->assertStringContainsString('50-500', $out['text']);
+
+        // 通道类型与 payment_type 不符、通道停用都拒绝
+        $this->assertSame(20001, PaymentOrderService::createPay('M1001', 100, 'NOpay12', $pickedId)['code']);
+        DB::table('recharge_channel')->where('id', $pickedId)->update(['status' => 0]);
+        $this->assertFalse(RechargeChannel::findRechargeChannel($pickedId, 2));
+        $this->assertSame(20001, PaymentOrderService::createPay('M1001', 100, 'JDpay', $pickedId)['code']);
+    }
+
     public function test_fee_snapshot_migration_backfills_rates_from_recorded_fees(): void
     {
         $this->payments();