doge 11 часов назад
Родитель
Сommit
5601f826da

+ 7 - 4
app/Http/Controllers/admin/PaymentOrder.php

@@ -79,7 +79,9 @@ class PaymentOrder extends Controller
                 'remark' => ['nullable', 'string'],
             ]);
             $remark = $params['remark'] ?? '';
-            $info = PaymentOrderModel::find($params['id']);
+            $info = PaymentOrderModel::where('id', $params['id'])
+                ->where('type', PaymentOrderService::TYPE_SELF_PAY)
+                ->lockForUpdate()->first();
             if (!$info) throw new Exception('数据不存在', HttpStatus::CUSTOM_ERROR);
             if ($params['status'] == 2 && $info->status != 5) throw new Exception('用户未提交充值凭证', HttpStatus::CUSTOM_ERROR);
             if ($params['status'] == 3 && !in_array($info->status, [0,1,4,5])) throw new Exception('订单已完成,不可驳回', HttpStatus::CUSTOM_ERROR);
@@ -100,11 +102,12 @@ class PaymentOrder extends Controller
 
                 $memberId = $info->member_id;
                 $amount = $info->amount;
-                $changeType = '人工充值';
-                $wallet = Wallet::where('member_id', $memberId)->first();
+                // 前台提交并审核到账的人工充值属于真实充值;后台直接加款仍使用“人工充值”。
+                $changeType = '充值';
+                $wallet = Wallet::where('member_id', $memberId)->lockForUpdate()->first();
                 if (!$wallet) throw new Exception('用户不存在', HttpStatus::CUSTOM_ERROR);
                 $availableBalance = bcadd($wallet->available_balance, $amount, 10);
-                BalanceLogService::addLog($memberId, $amount, $wallet->available_balance, $availableBalance, $changeType, null, $remark);
+                BalanceLogService::addLog($memberId, $amount, $wallet->available_balance, $availableBalance, $changeType, $info->id, $remark);
                 $wallet->available_balance = $availableBalance;
                 $wallet->save();
             }

+ 31 - 0
app/Http/Controllers/api/Rebate.php

@@ -0,0 +1,31 @@
+<?php
+
+namespace App\Http\Controllers\api;
+
+use App\Services\RebateService;
+use Illuminate\Http\JsonResponse;
+use Illuminate\Validation\ValidationException;
+
+class Rebate extends BaseController
+{
+    public function index(): JsonResponse
+    {
+        $memberId = request()->user->member_id ?? null;
+        if (empty($memberId)) {
+            return response()->json(['code' => 401, 'msg' => lang('用户不存在')], 401);
+        }
+
+        try {
+            $search = request()->validate([
+                'page' => ['nullable', 'integer', 'min:1'],
+                'limit' => ['nullable', 'integer', 'min:1', 'max:100'],
+                'date' => ['nullable', 'date_format:Y-m-d'],
+                'status' => ['nullable', 'integer', 'in:0,1'],
+            ]);
+
+            return $this->success(RebateService::paginateForMember((string) $memberId, $search));
+        } catch (ValidationException $e) {
+            return $this->error($e->validator->errors()->first());
+        }
+    }
+}

+ 8 - 3
app/Http/Middleware/CheckToken.php

@@ -30,12 +30,13 @@ class CheckToken
         }
 
         // 3. 查询 user_session:token 存在 + 未过期
+        $time = now()->getTimestamp();
         $session = UserSession::where('token', $token)
-            ->where('expire_time', '>', now()) // 过期时间 > 当前时间 = 未过期
+            ->where('expire_time', '>', $time)
             ->first();
 
         // 4. 会话不存在或已过期
-        if (!$session) {
+        if (!$session || UserSession::validUntil($session->getAttributes()) <= $time) {
             return response()->json([
                 'code' => 401,
                 'msg' => lang('token无效或已过期')
@@ -53,10 +54,14 @@ class CheckToken
             ], 401);
         }
 
+        if (!UserSession::renewActiveToken((string)$token, $time, (int)$session->id)) {
+            return response()->json(['code' => 401, 'msg' => lang('token无效或已过期')], 401);
+        }
+
         // 7. 把用户信息赋值给 $request->user
         $request->user = $user;
 
         // 8. 放行请求,进入控制器
         return $next($request);
     }
-}
+}

+ 25 - 2
app/Models/UserSession.php

@@ -6,6 +6,8 @@ namespace App\Models;
 
 class UserSession extends BaseModel
 {
+    public const IDLE_SECONDS = 86400;
+    public const LAST_ACTIVITY_SQL = 'CASE WHEN update_time > 0 THEN update_time ELSE create_time END';
     protected $table = 'user_session';
     protected $fillable = ['user_id', 'token', 'expire_time'];
     
@@ -18,11 +20,11 @@ class UserSession extends BaseModel
     
     public static function setToken($userId)
     {
-        $time = time();
+        $time = now()->getTimestamp();
         $userSession = UserSession::where('user_id', $userId)->first();
 
         //获取token延长过期的时间
-        $expireTime = $time + 3600 * 24 * 7;
+        $expireTime = $time + self::IDLE_SECONDS;
         $token = create_token($userId);
         //token处理
         if ($userSession) {
@@ -42,5 +44,26 @@ class UserSession extends BaseModel
         }
         return $token;
     }
+
+    public static function validUntil(array $session): int
+    {
+        $lastActivity = (int)($session['update_time'] ?? 0);
+        if ($lastActivity <= 0) $lastActivity = (int)($session['create_time'] ?? 0);
+        return $lastActivity > 0 ? min((int)($session['expire_time'] ?? 0), $lastActivity + self::IDLE_SECONDS) : 0;
+    }
+
+    /** Renew only the same still-active token; never move the activity clock backwards. */
+    public static function renewActiveToken(string $token, int $time, ?int $sessionId = null): ?self
+    {
+        if ($token === '') return null;
+        $sessionId = $sessionId ?? static::where('token', $token)->value('id');
+        if (!$sessionId) return null;
+        static::where('id', $sessionId)->where('token', $token)->where('expire_time', '>', $time)
+            ->whereRaw(self::LAST_ACTIVITY_SQL . ' > (? + 0)', [$time - self::IDLE_SECONDS])
+            ->whereRaw(self::LAST_ACTIVITY_SQL . ' < (? + 0)', [$time])
+            ->update(['expire_time' => $time + self::IDLE_SECONDS, 'update_time' => $time]);
+        $session = static::where('id', $sessionId)->where('token', $token)->where('expire_time', '>', $time)->first();
+        return $session && self::validUntil($session->getAttributes()) > $time ? $session : null;
+    }
 }
 

+ 2 - 1
app/Services/Agent/AgentMemberService.php

@@ -77,7 +77,8 @@ class AgentMemberService
             if (Schema::hasTable('payment_orders')) {
                 $orders = DB::table('payment_orders')->where('member_id', $memberId)
                     ->where('status', PaymentOrderService::STATUS_SUCCESS)->whereBetween('created_at', [$start, $end]);
-                $member->recharge_count += (clone $orders)->where('type', PaymentOrderService::TYPE_PAY)->count();
+                $member->recharge_count += (clone $orders)
+                    ->whereIn('type', [PaymentOrderService::TYPE_PAY, PaymentOrderService::TYPE_SELF_PAY])->count();
                 $member->withdraw_count += (clone $orders)
                     ->whereIn('type', [PaymentOrderService::TYPE_PAYOUT, PaymentOrderService::TYPE_SELF_PAYOUT])->count();
             }

+ 1 - 1
app/Services/Agent/AgentReportService.php

@@ -79,7 +79,7 @@ class AgentReportService
             $depositAmount = bcadd((string) $depositAmount, (string) DB::table('payment_orders')
                 ->whereIn('member_id', clone $memberIds)
                 ->where('status', PaymentOrderService::STATUS_SUCCESS)
-                ->where('type', PaymentOrderService::TYPE_PAY)
+                ->whereIn('type', [PaymentOrderService::TYPE_PAY, PaymentOrderService::TYPE_SELF_PAY])
                 ->whereBetween('created_at', [$start, $end])->sum('amount'), 4);
             $withdrawAmount = bcadd((string) $withdrawAmount, (string) DB::table('payment_orders')
                 ->whereIn('member_id', clone $memberIds)

+ 19 - 0
app/Services/RebateService.php

@@ -88,6 +88,25 @@ class RebateService extends BaseService
         return ['total' => $paginator->total(), 'data' => $paginator->items()];
     }
 
+    /** Member list: always scope by the authenticated member and omit admin/member details. */
+    public static function paginateForMember(string $memberId, array $search = []): array
+    {
+        $filters = array_intersect_key($search, array_flip(['date', 'status']));
+        $paginator = Rebate::where('member_id', $memberId)
+            ->where(self::getWhere($filters))
+            ->where('date', '<', Carbon::now('America/New_York')->format('Y-m-d'))
+            ->orderByDesc('date')
+            ->orderBy('status')
+            ->orderByDesc('betting_amount')
+            ->orderByDesc('id')
+            ->paginate($search['limit'] ?? 15, [
+                'id', 'date', 'member_id', 'betting_amount', 'effective_betting_amount',
+                'profit', 'rebate_ratio', 'amount', 'status', 'grant_time',
+            ], 'page', $search['page'] ?? 1);
+
+        return ['total' => $paginator->total(), 'data' => $paginator->items()];
+    }
+
 
     //更新有效投注额
     public static function updateEffectiveBettingAmount($rebate, $amount): void

+ 109 - 0
docs/前端接口/返佣列表.md

@@ -0,0 +1,109 @@
+# 当前登录用户返佣列表
+
+## 接口
+
+`GET /api/rebate`
+
+请求头:
+
+```http
+Authorization: <用户登录返回的原始 token>
+Accept: application/json
+```
+
+沿用前台 `check.token` 登录校验,`Authorization` 直接传用户 token,不添加 `Bearer ` 前缀,也不使用后台管理员 token。
+
+数据来自 `/admin/rebate` 使用的 `rebates` 表,仅查询当前登录用户的 `member_id`。前端无需传用户编号;传入 `member_id`、`user_id`、`username`、`first_name` 等参数不会改变查询归属,也不会按这些字段筛选。
+
+## 查询参数
+
+| 参数 | 必填 | 说明 |
+| --- | --- | --- |
+| `page` | 否 | 页码,正整数,默认 `1` |
+| `limit` | 否 | 每页条数,整数 `1`~`100`,默认 `15` |
+| `date` | 否 | 指定返佣日期,格式 `Y-m-d`,例如 `2026-10-23` |
+| `status` | 否 | `0`:未返佣;`1`:已返佣;不传或为空时返回全部状态 |
+
+日期口径与 `/admin/rebate` 相同:仅返回 `date` 早于服务器当前纽约日期(`America/New_York`)的记录,当天和未来的记录不显示。指定当天或未来日期时返回空列表。
+
+列表按日期倒序、状态升序、投注额倒序排列;以上条件相同时按记录 ID 倒序,保证分页顺序稳定。
+
+```text
+/api/rebate?page=1&limit=15
+/api/rebate?date=2026-10-23&status=1&page=1&limit=15
+/api/rebate?status=0&page=1&limit=15
+```
+
+## 成功响应
+
+```json
+{
+  "code": 0,
+  "timestamp": 1792900000,
+  "msg": "OK",
+  "data": {
+    "total": 1,
+    "data": [
+      {
+        "id": 123,
+        "date": "2026-10-23",
+        "member_id": "100001",
+        "betting_amount": "1000.00",
+        "effective_betting_amount": "800.00",
+        "profit": "-100.00",
+        "rebate_ratio": "0.0050",
+        "amount": "4.00",
+        "status": 1,
+        "grant_time": "2026-10-24 12:30"
+      }
+    ]
+  }
+}
+```
+
+`data.total` 为当前用户在本次筛选条件下的记录总数,`data.data` 为当前页列表。
+
+| 列表字段 | 说明 |
+| --- | --- |
+| `id` | 返佣记录 ID |
+| `date` | 返佣统计日期,`Y-m-d` |
+| `member_id` | 当前登录用户的会员编号 |
+| `betting_amount` | 当日投注金额 |
+| `effective_betting_amount` | 当日有效投注金额 |
+| `profit` | 当日输赢,正数为盈利,负数为亏损 |
+| `rebate_ratio` | 该记录保存的返佣比例,小数形式;`0.0050` 表示 `0.5%`,历史记录可能为 `null` |
+| `amount` | 记录中的返佣金额;未返佣时通常为 `0`,不能当作预计返佣金额 |
+| `status` | `0`:未返佣;`1`:已返佣 |
+| `grant_time` | 发放时间,`Y-m-d H:i`;未发放时为空字符串;时区沿用服务端配置 |
+
+金额和比例沿用数据库返回的数值或数值字符串,前端展示时统一格式化。未返佣记录的比例是记录中保存的值,实际发放时后台会按当时配置更新比例和金额。
+
+列表仅返回上述字段,不包含后台操作人、会员完整资料、后台备注、手机号、IP 或设备标识。接口只查询,不触发返佣发放或钱包变动。
+
+无记录或页码超过末页时:
+
+```json
+{
+  "code": 0,
+  "timestamp": 1792900000,
+  "msg": "OK",
+  "data": {
+    "total": 0,
+    "data": []
+  }
+}
+```
+
+页码超过末页但筛选结果仍有记录时,`total` 保持实际记录总数,只有列表为空。
+
+## 错误处理
+
+- 未携带 token、token 无效或过期、用户不存在或未关联会员编号:HTTP `401`,响应 `code=401`,前端应重新登录。
+- 参数不合法(例如 `page=0`、`limit=101`、日期格式错误、`status=2`):HTTP `200`,业务 `code=-1`,`msg` 返回参数校验提示,`data=[]`。
+
+```json
+{
+  "code": 401,
+  "msg": "请携带token访问"
+}
+```

+ 4 - 1
routes/api.php

@@ -10,6 +10,7 @@ use App\Http\Controllers\api\Issue;
 use App\Http\Controllers\api\Pay;
 use App\Http\Controllers\api\NewPc;
 use App\Http\Controllers\api\PcIssue;
+use App\Http\Controllers\api\Rebate;
 use App\Http\Controllers\api\Wallet;
 
 Route::post("/onMessage", [TelegramWebHook::class, 'handle']);
@@ -78,8 +79,10 @@ Route::fallback(function () {
     ], 200);
 });
 
-// 足球app充值提现的接口需要校验 token 的接口
+// 前台登录用户接口,需要校验 token
 Route::middleware('check.token')->group(function () {
+    Route::get('/rebate', [Rebate::class, 'index']);
+
     Route::prefix('/wallet')->group(function () {
         Route::get("/scan", [Wallet::class, 'scan']);
         Route::post("/recharge", [Wallet::class, 'recharge']);

+ 21 - 0
tests/Integration/ActivityRewardEligibilityTest.php

@@ -5,6 +5,7 @@ namespace Tests\Integration;
 use App\Constants\HttpStatus;
 use App\Http\Controllers\admin\ActivityReward as AdminActivityReward;
 use App\Http\Controllers\admin\ActivityUser as AdminActivityUser;
+use App\Http\Controllers\admin\PaymentOrder as AdminPaymentOrder;
 use App\Http\Controllers\api\ActivityReward as ApiActivityReward;
 use App\Models\ActivityReward;
 use App\Services\ActivityQuotaService;
@@ -709,6 +710,26 @@ class ActivityRewardEligibilityTest extends TestCase
         ];
     }
 
+    public function test_frontend_manual_deposit_approval_receives_configured_recharge_activities(): void
+    {
+        [$recharge, $return] = $this->automaticRewardFixtures();
+        DB::connection()->getSchemaBuilder()->table('payment_orders', function (Blueprint $t) {
+            $t->integer('state')->default(0); $t->text('remark')->nullable();
+        });
+        DB::table('payment_orders')->insert([
+            'id' => 900, 'member_id' => '1001', 'type' => 3, 'status' => 5, 'amount' => '100.00',
+        ]);
+        $this->request('/admin/paymentOrder/manualAudit', ['id' => 900, 'status' => 2], 'POST');
+        $this->assertSame(0, (new AdminPaymentOrder())->manualAudit()->getData(true)['code']);
+        $this->assertEquals('200.00', DB::table('wallets')->value('available_balance'));
+        $this->assertEquals('14.00', DB::table('wallets')->value('frozen_balance'));
+        $this->assertSame(1, (int)$recharge->fresh()->rewarded_count);
+        $this->assertSame(1, (int)$return->fresh()->rewarded_count);
+        $this->assertSame(2, DB::table('wallet_bonus')->where('related_id', 900)->count());
+        $this->assertSame(2, DB::table('balance_logs')->where('type', 2)->where('related_id', 900)->count());
+        $this->assertSame('充值', DB::table('balance_logs')->where('type', 1)->value('change_type'));
+    }
+
     /** @dataProvider automaticRewardWriteFailures */
     public function test_automatic_reward_failure_rolls_back_recipients_count_bonus_ledger_and_wallet(string $category, string $failure): void
     {

+ 96 - 0
tests/Integration/ManualDepositFinanceTest.php

@@ -0,0 +1,96 @@
+<?php
+
+namespace Tests\Integration;
+
+use App\Models\Agent\Agent;
+use App\Services\Agent\AgentMemberService;
+use App\Services\Agent\AgentReportService;
+use App\Services\PaymentOrderService;
+use Illuminate\Config\Repository;
+use Illuminate\Database\Capsule\Manager;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Foundation\Application;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Facade;
+use PHPUnit\Framework\TestCase;
+
+/** Finance queries against SQLite fixtures, without running MySQL-only index migrations. */
+class ManualDepositFinanceTest extends TestCase
+{
+    protected function setUp(): void
+    {
+        parent::setUp();
+        Facade::clearResolvedInstances();
+        $app = new Application(dirname(__DIR__, 2));
+        $app->instance('config', new Repository(['app' => ['timezone' => 'Asia/Shanghai']]));
+        Facade::setFacadeApplication($app);
+        $db = new Manager($app);
+        $db->addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => 'bot_']);
+        $db->setAsGlobal();
+        $db->bootEloquent();
+        $app->instance('db', $db->getDatabaseManager());
+        $app->bind('db.schema', fn () => $db->schema());
+        $db->schema()->create('agents', function (Blueprint $t) {
+            $t->id(); $t->string('username'); $t->string('path');
+        });
+        $db->schema()->create('users', function (Blueprint $t) {
+            $t->id(); $t->string('member_id'); $t->integer('agent_id'); $t->string('username');
+            $t->string('first_name')->default(''); $t->integer('status')->default(1);
+            $t->string('register_ip')->default(''); $t->string('register_domain')->default(''); $t->timestamps();
+        });
+        $db->schema()->create('wallets', function (Blueprint $t) {
+            $t->id(); $t->string('member_id'); $t->decimal('available_balance', 24, 10)->default(0);
+            $t->decimal('frozen_balance', 24, 10)->default(0);
+        });
+        foreach (['recharges', 'withdraws', 'payment_orders'] as $table) {
+            $db->schema()->create($table, function (Blueprint $t) {
+                $t->id(); $t->string('member_id'); $t->integer('status'); $t->integer('type')->nullable();
+                $t->decimal('amount', 24, 10); $t->timestamps();
+            });
+        }
+        $db->schema()->create('balance_logs', function (Blueprint $t) {
+            $t->id(); $t->string('member_id'); $t->string('change_type'); $t->integer('related_id')->nullable();
+            $t->decimal('amount', 24, 10); $t->timestamps();
+        });
+        DB::table('agents')->insert(['id' => 1, 'username' => 'agent', 'path' => '1']);
+        DB::table('users')->insert(['member_id' => 'm1', 'username' => 'member', 'agent_id' => 1]);
+        DB::table('wallets')->insert(['member_id' => 'm1']);
+    }
+
+    protected function tearDown(): void
+    {
+        DB::disconnect();
+        Facade::clearResolvedInstances();
+        parent::tearDown();
+    }
+
+    public function test_successful_frontend_manual_orders_count_as_deposits_and_admin_credits_remain_separate(): void
+    {
+        $base = ['member_id' => 'm1', 'amount' => 10, 'created_at' => '2026-10-25 12:00:00'];
+        DB::table('recharges')->insert($base + ['status' => 1]);
+        DB::table('withdraws')->insert($base + ['status' => 1]);
+        foreach ([PaymentOrderService::TYPE_PAY, PaymentOrderService::TYPE_SELF_PAY,
+            PaymentOrderService::TYPE_PAYOUT, PaymentOrderService::TYPE_SELF_PAYOUT] as $type) {
+            DB::table('payment_orders')->insert($base + ['type' => $type, 'status' => PaymentOrderService::STATUS_SUCCESS]);
+        }
+        foreach ([PaymentOrderService::TYPE_PAY, PaymentOrderService::TYPE_SELF_PAY] as $type) {
+            foreach ([0, 1, 3, 4, 5, 6] as $status) {
+                DB::table('payment_orders')->insert($base + ['type' => $type, 'status' => $status]);
+            }
+            DB::table('payment_orders')->insert(array_merge($base, ['type' => $type,
+                'status' => PaymentOrderService::STATUS_SUCCESS, 'created_at' => '2026-10-24 12:00:00']));
+            DB::table('payment_orders')->insert(array_merge($base, ['type' => $type,
+                'status' => PaymentOrderService::STATUS_SUCCESS, 'member_id' => 'unrelated']));
+        }
+        DB::table('balance_logs')->insert($base + ['change_type' => '人工充值']);
+        $result = (new AgentMemberService(new AgentReportService()))->finance(Agent::findOrFail(1), [
+            'start_date' => '2026-10-25', 'end_date' => '2026-10-25',
+        ]);
+        $member = $result['list'][0];
+        $this->assertSame(3, $member->recharge_count);
+        $this->assertSame('30.0000', $member->recharge_amount);
+        $this->assertSame('10.0000', $member->manual_credit);
+        $this->assertSame(3, $member->withdraw_count);
+        $this->assertSame('30.0000', $member->withdraw_amount);
+    }
+}

+ 245 - 0
tests/Integration/MemberRebateApiTest.php

@@ -0,0 +1,245 @@
+<?php
+
+namespace Tests\Integration;
+
+use App\Http\Controllers\api\Rebate;
+use App\Http\Middleware\CheckToken;
+use Carbon\Carbon;
+use Illuminate\Config\Repository;
+use Illuminate\Database\Capsule\Manager;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Events\Dispatcher;
+use Illuminate\Foundation\Application;
+use Illuminate\Http\JsonResponse;
+use Illuminate\Http\Request;
+use Illuminate\Routing\Router;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Facade;
+use Illuminate\Translation\ArrayLoader;
+use Illuminate\Translation\Translator;
+use Illuminate\Validation\Factory;
+use PHPUnit\Framework\TestCase;
+
+/** Real API routes, member-token middleware and queries, using SQLite without loading .env. */
+class MemberRebateApiTest extends TestCase
+{
+    private Application $app;
+    private Router $router;
+
+    protected function setUp(): void
+    {
+        parent::setUp();
+        Carbon::setTestNow(Carbon::parse('2026-10-25 00:30:00', 'Asia/Shanghai'));
+        Facade::clearResolvedInstances();
+        $this->app = new Application(dirname(__DIR__, 2));
+        $this->app->instance('config', new Repository(['app' => ['locale' => 'zh', 'timezone' => 'Asia/Shanghai']]));
+        Facade::setFacadeApplication($this->app);
+        $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());
+        $translator = new Translator(new ArrayLoader(), 'zh');
+        $this->app->instance('translator', $translator);
+        $this->app->instance('validator', new Factory($translator, $this->app));
+        $this->app->instance(\Illuminate\Contracts\Routing\ResponseFactory::class, new class {
+            public function json($data, $status = 200, $headers = [], $options = 0) {
+                return new JsonResponse($data, $status, $headers, $options);
+            }
+        });
+        Request::macro('validate', function (array $rules) {
+            return app('validator')->make($this->all(), $rules)->validate();
+        });
+        if (!function_exists('lang')) require_once dirname(__DIR__, 2) . '/app/Helpers/helpers.php';
+        $db->schema()->create('users', function (Blueprint $t) {
+            $t->id(); $t->string('user_id'); $t->string('member_id')->nullable();
+        });
+        $db->schema()->create('user_session', function (Blueprint $t) {
+            $t->id(); $t->string('user_id'); $t->string('token');
+            $t->bigInteger('expire_time'); $t->bigInteger('create_time'); $t->bigInteger('update_time');
+        });
+        foreach ([
+            '2025_10_31_145445_create_rebates.php',
+            '2025_10_31_155143_update_rebates.php',
+            '2025_11_03_144118_update_rebates.php',
+            '2025_11_20_145643_update_rebates.php',
+            '2025_11_20_151650_update_rebates.php',
+            '2026_01_13_153629_update_rebates.php',
+            '2026_01_28_093600_update_rebate.php',
+        ] as $migration) {
+            (require dirname(__DIR__, 2) . '/database/migrations/' . $migration)->up();
+        }
+        foreach (['one' => '100001', 'two' => '200002', 'unlinked' => null] as $suffix => $memberId) {
+            DB::table('users')->insert(['user_id' => 'login-' . $suffix, 'member_id' => $memberId]);
+            DB::table('user_session')->insert([
+                'user_id' => 'login-' . $suffix, 'token' => 'token-' . $suffix,
+                'expire_time' => now()->timestamp + 86400,
+                'create_time' => now()->timestamp, 'update_time' => now()->timestamp,
+            ]);
+        }
+        $this->router = new Router(new Dispatcher($this->app), $this->app);
+        $this->app->instance('router', $this->router);
+        $this->router->aliasMiddleware('check.token', CheckToken::class);
+        $this->router->group(['prefix' => 'api'], function () {
+            require dirname(__DIR__, 2) . '/routes/api.php';
+        });
+    }
+
+    protected function tearDown(): void
+    {
+        Carbon::setTestNow();
+        DB::disconnect();
+        Facade::clearResolvedInstances();
+        parent::tearDown();
+    }
+
+    private function request(array $params = [], ?string $token = 'token-one'): JsonResponse
+    {
+        $request = Request::create('/api/rebate', 'GET', $params);
+        if ($token !== null) $request->headers->set('Authorization', $token);
+        $this->app->instance('request', $request);
+        return $this->router->dispatch($request);
+    }
+
+    private function rebate(array $overrides = []): int
+    {
+        return DB::table('rebates')->insertGetId($overrides + [
+            'member_id' => '100001', 'date' => '2026-10-23',
+            'betting_amount' => '1000.00', 'effective_betting_amount' => '800.00',
+            'profit' => '-100.00', 'rebate_ratio' => '0.0050', 'amount' => '0.00', 'status' => 0,
+            'audited_by' => 'internal-admin', 'username' => 'historical-account', 'first_name' => '历史昵称',
+        ]);
+    }
+
+    private function listing(array $params = [], string $token = 'token-one'): array
+    {
+        $response = $this->request($params, $token);
+        $this->assertSame(200, $response->getStatusCode());
+        $body = $response->getData(true);
+        $this->assertSame(0, $body['code'], $body['msg']);
+        return $body['data'];
+    }
+
+    public function test_route_requires_member_login_and_rejects_missing_invalid_expired_and_unlinked_sessions(): void
+    {
+        $this->rebate();
+        $route = $this->router->getRoutes()->match(Request::create('/api/rebate', 'GET'));
+        $this->assertSame(Rebate::class . '@index', $route->getAction('uses'));
+        $this->assertContains('check.token', $route->middleware());
+        $this->assertSame(['GET', 'HEAD'], $route->methods());
+        foreach ([null, 'invalid', 'Bearer token-one', 'token-unlinked'] as $token) {
+            $response = $this->request([], $token);
+            $this->assertSame(401, $response->getStatusCode());
+            $this->assertSame(401, $response->getData(true)['code']);
+        }
+        DB::table('user_session')->where('token', 'token-one')->update(['expire_time' => now()->timestamp]);
+        $this->assertSame(401, $this->request()->getStatusCode());
+    }
+
+    public function test_user_cannot_select_another_members_rows_or_total_by_overriding_identity_parameters(): void
+    {
+        $own = $this->rebate();
+        $other = $this->rebate(['member_id' => '200002', 'betting_amount' => 9000]);
+        $before = DB::table('rebates')->orderBy('id')->get()->toJson();
+        $list = $this->listing([
+            'member_id' => '200002', 'user_id' => 'login-two', 'id' => $other,
+            'username' => 'another-account', 'first_name' => '其他会员',
+            'user' => ['member_id' => '200002'],
+        ]);
+        $this->assertSame(1, $list['total']);
+        $this->assertSame([$own], array_column($list['data'], 'id'));
+        $this->assertSame(['100001'], array_column($list['data'], 'member_id'));
+        $otherList = $this->listing([], 'token-two');
+        $this->assertSame(1, $otherList['total']);
+        $this->assertSame([$other], array_column($otherList['data'], 'id'));
+        $this->assertSame($before, DB::table('rebates')->orderBy('id')->get()->toJson());
+    }
+
+    public function test_date_cutoff_uses_new_york_midnight_and_never_includes_today_or_future_rows(): void
+    {
+        $yesterday = $this->rebate();
+        $today = $this->rebate(['date' => '2026-10-24']);
+        $this->rebate(['date' => '2026-10-25']);
+        $list = $this->listing();
+        $this->assertSame(1, $list['total']);
+        $this->assertSame([$yesterday], array_column($list['data'], 'id'));
+        $this->assertSame(['total' => 0, 'data' => []], $this->listing(['date' => '2026-10-24']));
+        Carbon::setTestNow(Carbon::parse('2026-10-25 00:00:00', 'America/New_York'));
+        $list = $this->listing();
+        $this->assertSame(2, $list['total']);
+        $this->assertSame([$today, $yesterday], array_column($list['data'], 'id'));
+    }
+
+    public function test_filters_sorting_pagination_and_empty_results_use_the_same_member_scope(): void
+    {
+        $paid = $this->rebate(['status' => 1, 'betting_amount' => 9000]);
+        $small = $this->rebate(['betting_amount' => 100]);
+        $large = $this->rebate(['betting_amount' => 200]);
+        $tied = $this->rebate(['betting_amount' => 200]);
+        $older = $this->rebate(['date' => '2026-10-22', 'betting_amount' => 10000]);
+        $this->rebate(['member_id' => '200002', 'status' => 1]);
+        $all = $this->listing();
+        $this->assertSame(5, $all['total']);
+        $this->assertSame([$tied, $large, $small, $paid, $older], array_column($all['data'], 'id'));
+        $unpaid = $this->listing(['status' => '0', 'date' => '2026-10-23']);
+        $this->assertSame(3, $unpaid['total']);
+        $this->assertSame([$tied, $large, $small], array_column($unpaid['data'], 'id'));
+        $page = $this->listing(['status' => 0, 'limit' => 2, 'page' => 2]);
+        $this->assertSame(4, $page['total']);
+        $this->assertSame([$small, $older], array_column($page['data'], 'id'));
+        $paidList = $this->listing(['status' => '1']);
+        $this->assertSame(1, $paidList['total']);
+        $this->assertSame([$paid], array_column($paidList['data'], 'id'));
+        $this->assertSame(['total' => 5, 'data' => []], $this->listing(['page' => 99]));
+        $this->assertSame(['total' => 0, 'data' => []], $this->listing(['date' => '2020-01-01']));
+        $this->assertSame(5, $this->listing(['date' => null, 'status' => null, 'limit' => null, 'page' => null])['total']);
+        $this->assertSame(5, $this->listing(['date' => '', 'status' => ''])['total']);
+    }
+
+    public function test_list_returns_display_fields_and_saved_money_without_serializing_admin_or_dynamic_backflow_fields(): void
+    {
+        $this->rebate(['rebate_ratio' => null]);
+        $grantTime = Carbon::parse('2026-10-24 10:00:00', 'Asia/Shanghai')->timestamp;
+        $paid = $this->rebate(['status' => 1, 'amount' => '4.00', 'grant_time' => $grantTime]);
+        $before = DB::table('rebates')->get()->toJson();
+        $rows = collect($this->listing()['data'])->keyBy('id');
+        $row = $rows[$paid];
+        $this->assertSame([
+            'id', 'date', 'member_id', 'betting_amount', 'effective_betting_amount',
+            'profit', 'rebate_ratio', 'amount', 'status', 'grant_time',
+        ], array_keys($row));
+        $this->assertEquals(800, $row['effective_betting_amount']);
+        $this->assertEquals(-100, $row['profit']);
+        $this->assertEquals(0.005, $row['rebate_ratio']);
+        $this->assertEquals(4, $row['amount']);
+        $this->assertSame(date('Y-m-d H:i', $grantTime), $row['grant_time']);
+        $this->assertSame('', $rows->first()['grant_time']);
+        $this->assertNull($rows->first()['rebate_ratio']);
+        $this->assertSame($before, DB::table('rebates')->get()->toJson());
+    }
+
+    /** @dataProvider invalidFilters */
+    public function test_invalid_filters_return_a_frontend_validation_error(array $params): void
+    {
+        $response = $this->request($params);
+        $this->assertSame(200, $response->getStatusCode());
+        $body = $response->getData(true);
+        $this->assertSame(-1, $body['code']);
+        $this->assertSame([], $body['data']);
+        $this->assertNotEmpty($body['msg']);
+    }
+
+    public static function invalidFilters(): array
+    {
+        return [
+            'page zero' => [['page' => 0]],
+            'invalid page' => [['page' => 'abc']],
+            'limit zero' => [['limit' => 0]],
+            'oversized limit' => [['limit' => 101]],
+            'invalid date' => [['date' => '2026-02-30']],
+            'invalid status' => [['status' => 2]],
+            'array status' => [['status' => [0, 1]]],
+        ];
+    }
+}

+ 143 - 0
tests/Integration/MemberTokenIdleTimeoutTest.php

@@ -0,0 +1,143 @@
+<?php
+
+namespace Tests\Integration;
+
+use App\Http\Middleware\CheckToken;
+use App\Models\UserSession;
+use Carbon\Carbon;
+use Illuminate\Config\Repository;
+use Illuminate\Database\Capsule\Manager;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Foundation\Application;
+use Illuminate\Http\JsonResponse;
+use Illuminate\Http\Request;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Facade;
+use Illuminate\Translation\ArrayLoader;
+use Illuminate\Translation\Translator;
+use PHPUnit\Framework\TestCase;
+
+/** Actual member-token middleware and sessions, isolated SQLite and a fixed server clock. */
+class MemberTokenIdleTimeoutTest extends TestCase
+{
+    private int $start;
+
+    protected function setUp(): void
+    {
+        Facade::clearResolvedInstances();
+        Carbon::setTestNow(Carbon::parse('2026-10-25 15:18:55', 'Asia/Shanghai'));
+        $this->start = now()->getTimestamp();
+        $app = new Application(dirname(__DIR__, 2));
+        $app->instance('config', new Repository(['app' => ['locale' => 'zh', 'timezone' => 'Asia/Shanghai']]));
+        Facade::setFacadeApplication($app);
+        $db = new Manager($app);
+        $db->addConnection(['driver' => 'sqlite', 'database' => ':memory:', 'prefix' => 'bot_']);
+        $db->setAsGlobal(); $db->bootEloquent(); $app->instance('db', $db->getDatabaseManager());
+        $app->instance('translator', new Translator(new ArrayLoader(), 'zh'));
+        $app->instance(\Illuminate\Contracts\Routing\ResponseFactory::class, new class {
+            public function json($data, $status = 200, $headers = [], $options = 0) { return new JsonResponse($data, $status, $headers, $options); }
+        });
+        if (!function_exists('create_token') || !function_exists('lang')) require_once dirname(__DIR__, 2) . '/app/Helpers/helpers.php';
+        $db->schema()->create('users', function (Blueprint $t) { $t->id(); $t->string('user_id'); $t->string('member_id'); });
+        $db->schema()->create('user_session', function (Blueprint $t) {
+            $t->id(); $t->string('user_id'); $t->string('token'); $t->bigInteger('expire_time');
+            $t->bigInteger('create_time'); $t->bigInteger('update_time')->nullable();
+        });
+        DB::table('users')->insert(['user_id' => '100001', 'member_id' => '100001']);
+    }
+
+    protected function tearDown(): void
+    {
+        Carbon::setTestNow(); DB::disconnect(); Facade::clearResolvedInstances(); parent::tearDown();
+    }
+
+    private function request(?string $token): JsonResponse
+    {
+        $request = Request::create('/api/wallet/test', 'GET');
+        if ($token !== null) $request->headers->set('Authorization', $token);
+        return (new CheckToken())->handle($request, static fn($r) => new JsonResponse(['code' => 0, 'user_id' => $r->user->user_id]));
+    }
+
+    private function clock(int $time): void { Carbon::setTestNow(Carbon::createFromTimestamp($time, 'Asia/Shanghai')); }
+
+    public function test_login_and_authenticated_activity_use_a_full_sliding_day(): void
+    {
+        $token = UserSession::setToken('100001');
+        $session = DB::table('user_session')->where('token', $token)->first();
+        $this->assertSame($this->start + 86400, (int)$session->expire_time);
+        $this->assertSame($this->start, (int)$session->update_time);
+        $this->clock($this->start + 3600);
+        $this->assertSame(0, $this->request($token)->getData(true)['code']);
+        $this->assertSame($this->start + 3600 + 86400, (int)DB::table('user_session')->value('expire_time'));
+        $lastActivity = $this->start + 25 * 3600 - 1;
+        $this->clock($lastActivity);
+        $this->assertSame(0, $this->request($token)->getData(true)['code']);
+        $this->assertSame($lastActivity + 86400, (int)DB::table('user_session')->value('expire_time'));
+        $this->clock($lastActivity + 86400);
+        $before = (array)DB::table('user_session')->first();
+        $this->assertSame(401, $this->request($token)->getStatusCode());
+        $this->assertSame($before, (array)DB::table('user_session')->first());
+        $this->assertNull(UserSession::renewActiveToken($token, now()->getTimestamp()));
+        $this->assertSame($before, (array)DB::table('user_session')->first());
+    }
+
+    public function test_legacy_long_expiry_cannot_bypass_idle_limit_or_be_renewed(): void
+    {
+        DB::table('user_session')->insert(['user_id' => '100001', 'token' => 'legacy',
+            'expire_time' => $this->start + 7 * 86400, 'create_time' => $this->start, 'update_time' => $this->start]);
+        $this->clock($this->start + 86400);
+        $before = (array)DB::table('user_session')->first();
+        $this->assertSame(401, $this->request('legacy')->getData(true)['code']);
+        $this->assertNull(UserSession::renewActiveToken('legacy', now()->getTimestamp()));
+        $this->assertSame($before, (array)DB::table('user_session')->first());
+        $this->assertSame(401, $this->request(null)->getStatusCode());
+        $this->assertNull(UserSession::renewActiveToken('', now()->getTimestamp()));
+    }
+
+    public function test_replaced_revoked_missing_or_orphaned_sessions_cannot_be_reactivated(): void
+    {
+        $token = UserSession::setToken('100001');
+        DB::table('user_session')->where('token', $token)->update(['token' => 'replacement']);
+        $this->assertSame(401, $this->request($token)->getStatusCode());
+        $this->assertNull(UserSession::renewActiveToken($token, $this->start));
+        $this->assertNull(UserSession::renewActiveToken('missing', $this->start));
+        DB::table('user_session')->update(['expire_time' => $this->start]);
+        $before = (array)DB::table('user_session')->first();
+        $this->assertSame(401, $this->request('replacement')->getStatusCode());
+        $this->assertSame($before, (array)DB::table('user_session')->first());
+        DB::table('user_session')->update(['expire_time' => $this->start + 86400]);
+        DB::table('users')->delete();
+        $before = (array)DB::table('user_session')->first();
+        $this->assertSame(401, $this->request('replacement')->getStatusCode());
+        $this->assertSame($before, (array)DB::table('user_session')->first());
+    }
+
+    public function test_missing_activity_falls_back_to_creation_and_concurrent_renewals_are_monotonic(): void
+    {
+        foreach ([null, 0] as $index => $activity) {
+            $token = 'fallback-' . $index;
+            DB::table('user_session')->insert(['user_id' => '100001', 'token' => $token,
+                'expire_time' => $this->start + 7 * 86400, 'create_time' => $this->start - 3600, 'update_time' => $activity]);
+            $session = UserSession::where('token', $token)->first();
+            $this->assertSame($this->start + 82800, UserSession::validUntil($session->getAttributes()));
+            $this->assertSame(0, $this->request($token)->getData(true)['code']);
+            $this->assertSame($this->start + 86400, (int)DB::table('user_session')->where('token', $token)->value('expire_time'));
+        }
+        DB::table('user_session')->where('token', 'fallback-0')->update(['update_time' => $this->start + 10, 'expire_time' => $this->start + 10 + 86400]);
+        $before = (array)DB::table('user_session')->where('token', 'fallback-0')->first();
+        $this->assertNotNull(UserSession::renewActiveToken('fallback-0', $this->start));
+        $this->assertSame($before, (array)DB::table('user_session')->where('token', 'fallback-0')->first());
+    }
+
+    public function test_renewal_stays_bound_to_the_original_session_identity(): void
+    {
+        $token = UserSession::setToken('100001');
+        $id = (int)DB::table('user_session')->value('id');
+        DB::table('user_session')->where('id', $id)->update(['token' => 'replacement']);
+        DB::table('user_session')->insert(['user_id' => 'other-user', 'token' => $token,
+            'expire_time' => $this->start + 86400, 'create_time' => $this->start, 'update_time' => $this->start]);
+        $before = DB::table('user_session')->orderBy('id')->get()->toJson();
+        $this->assertNull(UserSession::renewActiveToken($token, $this->start + 1, $id));
+        $this->assertSame($before, DB::table('user_session')->orderBy('id')->get()->toJson());
+    }
+}

+ 98 - 0
tests/Integration/ShareCommissionRecordingTest.php

@@ -3,10 +3,12 @@
 namespace Tests\Integration;
 
 use App\Console\Commands\BackfillShareCommissions;
+use App\Http\Controllers\admin\PaymentOrder as AdminPaymentOrder;
 use App\Http\Controllers\admin\ShareEarning;
 use App\Models\BalanceLog;
 use App\Models\ShareCommissionRecord;
 use App\Services\BalanceLogService;
+use App\Services\PaymentOrderService;
 use App\Services\ShareCommissionRecorder;
 use App\Services\ShareCommissionDiagnostics;
 use App\Services\ShareEarningReportService;
@@ -68,6 +70,11 @@ class ShareCommissionRecordingTest extends TestCase
             $t->decimal('before_balance', 24, 10); $t->decimal('after_balance', 24, 10);
             $t->integer('related_id')->nullable(); $t->text('remark')->nullable(); $t->timestamps();
         });
+        $db->schema()->create('payment_orders', function (Blueprint $t) {
+            $t->id(); $t->string('member_id'); $t->integer('type'); $t->integer('status');
+            $t->integer('state')->default(0); $t->string('channel'); $t->decimal('amount', 24, 10);
+            $t->text('remark')->nullable(); $t->timestamps();
+        });
         $db->schema()->create('share_commission_records', function (Blueprint $t) {
             $t->id(); $t->date('stat_date'); $t->integer('promoter_user_id');
             $t->integer('invitee_user_id')->nullable(); $t->string('type'); $t->string('category');
@@ -156,6 +163,97 @@ class ShareCommissionRecordingTest extends TestCase
         $this->assertSame('2.5000', $record->amount);
     }
 
+    private function manualOrder(int $status = PaymentOrderService::STATUS_AUDIT, int $type = PaymentOrderService::TYPE_SELF_PAY): void
+    {
+        DB::table('payment_orders')->insert([
+            'id' => 88, 'member_id' => '1002', 'type' => $type, 'status' => $status,
+            'channel' => 'rgcz', 'amount' => '100.00', 'created_at' => now(), 'updated_at' => now(),
+        ]);
+    }
+
+    private function manualAudit(int $status = PaymentOrderService::STATUS_SUCCESS): array
+    {
+        $this->app->instance('request', Request::create('/admin/paymentOrder/manualAudit', 'POST', [
+            'id' => 88, 'status' => $status, 'remark' => '凭证核对',
+        ]));
+        return (new AdminPaymentOrder())->manualAudit()->getData(true);
+    }
+
+    public function test_frontend_manual_deposit_approval_pays_and_records_rebate_once(): void
+    {
+        $this->manualOrder();
+        $this->assertSame(0, ShareCommissionRecord::count());
+        $this->assertSame(0, $this->manualAudit()['code']);
+        $deposit = BalanceLog::where('member_id', '1002')->firstOrFail();
+        $this->assertSame('充值', $deposit->change_type);
+        $this->assertSame(88, (int)$deposit->related_id);
+        $this->assertEquals('200.00', DB::table('wallets')->where('member_id', '1002')->value('available_balance'));
+        $this->assertEquals('22.50', DB::table('wallets')->where('member_id', '1001')->value('available_balance'));
+        $this->assertSame('2.5000', ShareCommissionRecord::firstOrFail()->amount);
+        $this->assertSame(88, (int)BalanceLog::where('change_type', '充值返现')->value('related_id'));
+        $this->assertSame(PaymentOrderService::STATUS_SUCCESS, (int)DB::table('payment_orders')->value('status'));
+        $this->assertSame(1, (int)DB::table('payment_orders')->value('state'));
+
+        $this->app->instance('request', Request::create('/admin/shareEarning/summary'));
+        $body = (new ShareEarning())->summary(new ShareEarningReportService())->getData(true);
+        $this->assertSame('100.0000', $body['data']['data'][0]['deposit']);
+        $this->assertSame('2.5000', $body['data']['summary']['total_commission']);
+
+        $this->assertNotSame(0, $this->manualAudit()['code']);
+        $this->assertSame(1, ShareCommissionRecord::count());
+        $this->assertSame(2, BalanceLog::count());
+        $this->assertEquals('200.00', DB::table('wallets')->where('member_id', '1002')->value('available_balance'));
+        $this->assertEquals('22.50', DB::table('wallets')->where('member_id', '1001')->value('available_balance'));
+    }
+
+    public static function unapprovedManualOrders(): array
+    {
+        return [
+            'waiting for processing' => [0, 3], 'processing' => [1, 3],
+            'already successful' => [2, 3], 'rejected' => [3, 3],
+            'waiting for receipt' => [4, 3], 'cancelled' => [6, 3],
+            'provider deposit' => [5, 1], 'provider payout' => [5, 2], 'manual payout' => [5, 4],
+        ];
+    }
+
+    /** @dataProvider unapprovedManualOrders */
+    public function test_unapproved_or_unrelated_order_cannot_be_credited_by_manual_deposit_audit(int $status, int $type): void
+    {
+        $this->manualOrder($status, $type);
+        $this->assertNotSame(0, $this->manualAudit()['code']);
+        $this->assertSame($status, (int)DB::table('payment_orders')->value('status'));
+        $this->assertSame(0, ShareCommissionRecord::count());
+        $this->assertSame(0, BalanceLog::count());
+        $this->assertEquals('100.00', DB::table('wallets')->where('member_id', '1002')->value('available_balance'));
+        $this->assertEquals('20.00', DB::table('wallets')->where('member_id', '1001')->value('available_balance'));
+    }
+
+    public function test_rejecting_frontend_manual_deposit_does_not_pay_rewards(): void
+    {
+        $this->manualOrder();
+        $this->assertSame(0, $this->manualAudit(PaymentOrderService::STATUS_FAIL)['code']);
+        $this->assertSame(PaymentOrderService::STATUS_FAIL, (int)DB::table('payment_orders')->value('status'));
+        $this->assertSame(0, ShareCommissionRecord::count());
+        $this->assertSame(0, BalanceLog::count());
+        $this->assertEquals('100.00', DB::table('wallets')->where('member_id', '1002')->value('available_balance'));
+        $this->assertEquals('20.00', DB::table('wallets')->where('member_id', '1001')->value('available_balance'));
+    }
+
+    public function test_manual_deposit_and_rewards_roll_back_together_on_commission_failure(): void
+    {
+        $this->manualOrder();
+        DB::statement("CREATE TRIGGER fail_share_insert BEFORE INSERT ON bot_share_commission_records
+            BEGIN SELECT RAISE(ABORT, 'injected share failure'); END");
+        $this->assertNotSame(0, $this->manualAudit()['code']);
+        $this->assertSame(PaymentOrderService::STATUS_AUDIT, (int)DB::table('payment_orders')->value('status'));
+        $this->assertSame(0, (int)DB::table('payment_orders')->value('state'));
+        $this->assertSame(0, ShareCommissionRecord::count());
+        $this->assertSame(0, BalanceLog::count());
+        $this->assertEquals('100.00', DB::table('wallets')->where('member_id', '1002')->value('available_balance'));
+        $this->assertEquals('20.00', DB::table('wallets')->where('member_id', '1001')->value('available_balance'));
+        $this->assertSame(0, DB::transactionLevel());
+    }
+
     public static function unrelatedDeposits(): array
     {
         return [['人工充值', 'PROMO', 1], ['优惠活动', 'PROMO', 1], ['充值', '', 1], ['充值', 'MISSING', 1], ['充值', 'PROMO', 2]];

+ 150 - 0
tests/Regression/member_token_idle_timeout.php

@@ -0,0 +1,150 @@
+<?php
+
+namespace think\facade {
+    final class Cache {
+        public static array $entries = [];
+        public static function get($key, $default = null) {
+            $entry = self::$entries[$key] ?? null;
+            if (!$entry || $entry['until'] <= $GLOBALS['idle_token_now']) { unset(self::$entries[$key]); return $default; }
+            return $entry['value'];
+        }
+        public static function set($key, $value, $ttl): bool { self::$entries[$key] = ['value' => $value, 'until' => $GLOBALS['idle_token_now'] + $ttl, 'ttl' => $ttl]; return true; }
+        public static function delete($key): bool { unset(self::$entries[$key]); return true; }
+    }
+}
+namespace app\common\cache { function time(): int { return $GLOBALS['idle_token_now']; } }
+namespace app\api\service { function time(): int { return $GLOBALS['idle_token_now']; } }
+namespace app\common\service {
+    final class JsonService { public static function fail($msg, $data = [], $code = 0, $show = 1): array { return compact('msg', 'data', 'code', 'show'); } }
+}
+namespace {
+    error_reporting(E_ALL & ~E_DEPRECATED);
+    function app($name = null) { $app = \think\Container::getInstance(); return $name === null ? $app : $app->make($name); }
+    function create_token($userId): string { static $sequence = 0; return 'token-' . $userId . '-' . ++$sequence; }
+    $repo = getenv('TOKEN_SITE_REPO') ?: dirname(__DIR__, 3) . '/melbet_sport-api';
+    $stage = getenv('TOKEN_SITE_STAGE') ?: $repo;
+    $loader = require $repo . '/vendor/autoload.php';
+    $loader->setPsr4('app\\', [$stage . '/app/', $repo . '/app/']);
+    $app = new \think\App($repo . '/'); \think\Container::setInstance($app);
+    $db = new \think\DbManager();
+    $db->setConfig(['default' => 'isolated', 'connections' => ['isolated' => [
+        'type' => 'sqlite', 'database' => ':memory:', 'prefix' => 'bot_', 'fields_strict' => true, 'debug' => false,
+    ]], 'auto_timestamp' => false]);
+    $app->instance(\think\DbManager::class, $db); $app->instance('log', new \Psr\Log\NullLogger());
+    $db->execute('CREATE TABLE bot_user_session (id INTEGER PRIMARY KEY,user_id TEXT,token TEXT,expire_time INTEGER,create_time INTEGER,update_time INTEGER)');
+    $db->execute('CREATE TABLE bot_users (id INTEGER PRIMARY KEY,user_id TEXT,account TEXT,avatar TEXT,"from" INTEGER,recharge_channel_group_id INTEGER,delete_time TEXT,created_at TEXT,updated_at TEXT)');
+    \think\facade\Db::table('bot_users')->insert(['id' => 1, 'user_id' => '100001', 'account' => 'member', 'avatar' => '', 'from' => 1, 'recharge_channel_group_id' => 1]);
+    $GLOBALS['idle_token_now'] = 1792912735;
+    $start = $GLOBALS['idle_token_now'];
+    $checks = 0;
+    function check($actual, $expected, string $message): void {
+        global $checks; ++$checks;
+        if ($actual !== $expected) throw new \RuntimeException($message . ': actual=' . json_encode($actual) . ' expected=' . json_encode($expected));
+    }
+    function row(string $token): ?array { return \think\facade\Db::table('bot_user_session')->where('token', $token)->find(); }
+    class TokenRequest {
+        public $userInfo; public $userId;
+        public object $controllerObject;
+        public function __construct(private ?string $token, bool $public = false) {
+            $this->controllerObject = new class($public) { public function __construct(private bool $public) {} public function isNotNeedLogin(): bool { return $this->public; } };
+        }
+        public function header($name) { return $this->token; }
+    }
+    function authenticate(?string $token, bool $public = false): array {
+        $request = new TokenRequest($token, $public);
+        return (new \app\api\http\middleware\LoginMiddleware())->handle($request, static fn($r) => ['code' => 1, 'user_id' => $r->userId, 'userInfo' => $r->userInfo]);
+    }
+    $cache = new \app\common\cache\UserTokenCache();
+    $info = \app\api\service\UserTokenService::setToken('100001');
+    $token = $info['token'];
+    check($info['expire_time'], $start + 86400, 'Login issues 24-hour token');
+    check(row($token)['update_time'], $start, 'Login records the activity clock');
+    check(\think\facade\Cache::$entries['token_user_' . $token]['ttl'], 86400, 'Cache TTL equals the effective expiry');
+
+    $GLOBALS['idle_token_now'] = $start + 3600;
+    $unchanged = row($token);
+    $readOnly = $cache->getUserInfo($token);
+    check($readOnly['expire_time'], $start + 86400, 'Read-only crypto/WS lookup preserves deadline');
+    check(row($token), $unchanged, 'Cache lookup never renews activity');
+    \think\facade\Cache::$entries = [];
+    check($cache->getUserInfo($token)['expire_time'], $start + 86400, 'Clearing cache does not restart the inactivity window');
+    check(\think\facade\Cache::$entries['token_user_' . $token]['ttl'], 82800, 'Reloaded cache only gets remaining lifetime');
+
+    $authenticated = authenticate($token);
+    check($authenticated['code'], 1, 'Authenticated request succeeds');
+    check($authenticated['userInfo']['expire_time'], $start + 3600 + 86400, 'Every valid request slides deadline by a full day');
+    check(row($token)['update_time'], $start + 3600, 'Activity saved in database');
+    check(\think\facade\Cache::$entries['token_user_' . $token]['ttl'], 86400, 'Renewal refreshes cache TTL');
+    $GLOBALS['idle_token_now'] = $start + 25 * 3600 - 1;
+    check(authenticate($token, true)['code'], 1, 'Valid authenticated polling on a public endpoint also renews');
+    check(row($token)['expire_time'], $GLOBALS['idle_token_now'] + 86400, 'Active users stay logged in beyond an absolute login day');
+    $lastActivity = $GLOBALS['idle_token_now'];
+    $GLOBALS['idle_token_now'] = $lastActivity + 86400;
+    $expiredBefore = row($token);
+    \think\facade\Cache::set('token_user_' . $token, $info + ['unexpected' => 'old'], 7 * 86400);
+    check(authenticate($token)['code'], 401, 'Exactly 24 hours without activity rejects authentication');
+    check(\app\api\service\UserTokenService::overtimeToken($token), false, 'Expired token cannot be renewed directly');
+    check(row($token), $expiredBefore, 'Expired request cannot revive the session');
+    check($cache->getUserInfo($token), false, 'Stale cache cannot bypass expiry');
+    check(authenticate($token, true)['user_id'], 0, 'Public endpoint treats invalid token as anonymous');
+    check(authenticate(null)['code'], 401, 'Protected endpoint still requires a token');
+    check(authenticate(null, true)['code'], 1, 'Anonymous public access stays available');
+
+    // Interleave real logout/new-login between identity lookup and the conditional renewal.
+    $raceMode = null; $raceToken = null; $replacementToken = null;
+    $db->event('before_find', static function ($query) use (&$raceMode, &$raceToken, &$replacementToken) {
+        if ($query->getTable() !== 'bot_users' || $raceMode === null) return;
+        $mode = $raceMode; $raceMode = null;
+        ++$GLOBALS['idle_token_now'];
+        if ($mode === 'logout') \app\api\service\UserTokenService::expireToken($raceToken);
+        else $replacementToken = \app\api\service\UserTokenService::setToken('100001')['token'];
+    });
+    foreach (['logout', 'replace'] as $mode) {
+        $GLOBALS['idle_token_now'] += 60;
+        $raceToken = \app\api\service\UserTokenService::setToken('100001')['token'];
+        $GLOBALS['idle_token_now'] += 60;
+        $raceMode = $mode;
+        check(authenticate($raceToken)['code'], 401, 'Concurrent ' . $mode . ' wins over a stale renewal');
+        if ($mode === 'logout') check(row($raceToken)['expire_time'], $GLOBALS['idle_token_now'], 'Concurrent logout is not overwritten');
+        else {
+            check(row($raceToken), null, 'Old token is not restored after a concurrent new login');
+            check(row($replacementToken)['expire_time'], $GLOBALS['idle_token_now'] + 86400, 'Replacement token retains its deadline');
+        }
+    }
+
+    // Seven-day legacy expiration cannot hide more than 24h inactivity.
+    $GLOBALS['idle_token_now'] = $start + 3 * 86400;
+    \think\facade\Db::table('bot_user_session')->insert(['id' => 2, 'user_id' => '100001', 'token' => 'legacy', 'expire_time' => $start + 7 * 86400, 'create_time' => $start, 'update_time' => $start]);
+    $legacy = row('legacy');
+    check(authenticate('legacy')['code'], 401, 'Legacy long expiry still obeys the last-activity cutoff');
+    check(row('legacy'), $legacy, 'Legacy expired sessions are not silently reactivated');
+    foreach ([null, 0] as $index => $updateTime) {
+        $fallbackToken = 'fallback-' . $index;
+        \think\facade\Db::table('bot_user_session')->insert(['user_id' => '100001', 'token' => $fallbackToken, 'expire_time' => $GLOBALS['idle_token_now'] + 7 * 86400, 'create_time' => $GLOBALS['idle_token_now'] - 3600, 'update_time' => $updateTime]);
+        check($cache->getUserInfo($fallbackToken)['expire_time'], $GLOBALS['idle_token_now'] + 82800, 'Missing activity falls back to creation time');
+        check(authenticate($fallbackToken)['userInfo']['expire_time'], $GLOBALS['idle_token_now'] + 86400, 'Valid legacy session can renew without a new login');
+    }
+
+    $fresh = \app\api\service\UserTokenService::setToken('100001');
+    $newToken = $fresh['token'];
+    \think\facade\Cache::set('token_user_' . $token, $info, 86400);
+    check($cache->getUserInfo($token), false, 'Old token remains invalid after a new login even with stale cache');
+    $GLOBALS['idle_token_now'] += 60;
+    check(\app\api\service\UserTokenService::expireToken($newToken), true, 'Logout revokes current token');
+    $revoked = row($newToken);
+    check(authenticate($newToken)['code'], 401, 'Logout token cannot authenticate');
+    check(row($newToken), $revoked, 'Logout token cannot be resurrected by renewal');
+    check(\app\api\service\UserTokenService::overtimeToken('does-not-exist'), false, 'Missing session fails without a null dereference');
+
+    $fresh = \app\api\service\UserTokenService::setToken('100001'); $newToken = $fresh['token'];
+    $future = $GLOBALS['idle_token_now'] + 10;
+    \think\facade\Db::table('bot_user_session')->where('token', $newToken)->update(['update_time' => $future, 'expire_time' => $future + 86400]);
+    $laterRequest = row($newToken);
+    check(authenticate($newToken)['code'], 1, 'Older concurrent request still sees the valid renewed session');
+    check(row($newToken), $laterRequest, 'An older renewal never moves deadline backwards');
+    \think\facade\Db::table('bot_users')->where('user_id', '100001')->update(['delete_time' => time()]);
+    $beforeMissingUser = row($newToken);
+    check(authenticate($newToken)['code'], 401, 'Deleted user cannot authenticate through a cached profile');
+    check(row($newToken), $beforeMissingUser, 'Failed identity check does not count as activity');
+    echo 'PASS ' . $checks . " member idle-token assertions (isolated SQLite and memory cache; no live sessions)\n";
+}