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

+ 9 - 2
app/Http/Controllers/admin/ActivityReward.php

@@ -38,7 +38,7 @@ class ActivityReward extends Controller
             // 同步真实状态后再统计和分页,避免调度延迟时仍显示为上架。
             ActivityRewardService::expire();
             $where = ActivityRewardService::getWhere(array_intersect_key($params, ['title' => true, 'type' => true]));
-            $query = ActivityRewardModel::where($where);
+            $query = ActivityRewardModel::where($where)->where('type', '!=', ActivityCatalog::YUEBAO_TYPE);
             if (isset($params['status']) && $params['status'] !== '') {
                 $query->where('status', (int)$params['status']);
             }
@@ -91,12 +91,13 @@ class ActivityReward extends Controller
                 'detail_image' => ['required'],
                 'pc_image' => ['required'],
                 'status' => ['required', 'integer', 'min:0', 'max:1'],
-                'type' => ['required', 'integer', 'min:1', 'max:6'],
+                'type' => ['required', 'integer', 'min:1', 'max:6', Rule::notIn([ActivityCatalog::YUEBAO_TYPE])],
                 'lang' => ['required', 'string'],
                 'content' => ['nullable', 'string'],
                 'params' => ['nullable', 'array'],
                 'game_category' => ['nullable', 'string', Rule::in(array_keys(ActivityCatalog::gameTypes()))],
             ], [
+                'type.not_in' => '余额宝请在余额宝配置中维护',
                 'start_time.required_with' => '活动开始和结束时间必须同时填写或同时留空',
                 'end_time.required_with' => '活动开始和结束时间必须同时填写或同时留空',
             ]);
@@ -105,6 +106,9 @@ class ActivityReward extends Controller
             // Wrong-mode inputs are rejected rather than silently disabling a limit.
             $params = array_merge($params, array_intersect_key(request()->all(), ['budget' => true, 'budget_used' => true]));
             $params['params'] = empty($params['params']) ? null : json_encode($params['params']);
+            if (!empty($params['id']) && ActivityRewardModel::where('id', $params['id'])->value('type') == ActivityCatalog::YUEBAO_TYPE) {
+                throw new Exception('余额宝请在余额宝配置中维护', HttpStatus::CUSTOM_ERROR);
+            }
             ActivityRewardService::submit($params);
             DB::commit();
         } catch (ValidationException $e) {
@@ -124,6 +128,9 @@ class ActivityReward extends Controller
                 'id' => ['required', 'integer', 'min:1'],
             ]);
             $id = request()->input('id');
+            if (ActivityRewardModel::where('id', $id)->value('type') == ActivityCatalog::YUEBAO_TYPE) {
+                throw new Exception('余额宝请在余额宝配置中维护', HttpStatus::CUSTOM_ERROR);
+            }
             ActivityRewardService::deleteAll(['id' => $id]);
             DB::commit();
         } catch (ValidationException $e) {

+ 83 - 1
app/Http/Controllers/admin/YueBao.php

@@ -3,10 +3,16 @@
 namespace App\Http\Controllers\admin;
 
 use App\Http\Controllers\Controller;
+use App\Models\ActivityReward;
 use App\Models\YuebaoItem;
 use App\Models\YuebaoLog;
+use App\Services\ActivityRewardService;
+use App\Support\ActivityCatalog;
 use Exception;
 use App\Constants\HttpStatus;
+use Illuminate\Http\JsonResponse;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Validation\ValidationException;
 
 class YueBao extends Controller
 {
@@ -120,4 +126,80 @@ class YueBao extends Controller
 
     }
 
-}
+    /** 余额宝配置固定为一条 type=6 活动行;与会员端选取顺序一致取排序最高、ID 最大的一条。 */
+    private static function configRow(): ?ActivityReward
+    {
+        return ActivityReward::where('type', ActivityCatalog::YUEBAO_TYPE)
+            ->orderByDesc('sort')->orderByDesc('id')->first();
+    }
+
+    /**
+     * 余额宝配置详情
+     */
+    public function config(): JsonResponse
+    {
+        $row = self::configRow();
+        if (!$row) {
+            return $this->success(null);
+        }
+        $data = $row->toArray();
+        $params = json_decode((string)$row->getRawOriginal('params'), true) ?: [];
+        $data['daily_interest_rate'] = $params['daily_interest_rate'] ?? null;
+        $data['number_of_consecutive_days_of_deposit'] = $params['number_of_consecutive_days_of_deposit'] ?? null;
+        // 活动类型、游戏分类、点击行为、排序不属于余额宝配置。
+        unset($data['params'], $data['type'], $data['type_name'], $data['game_category'], $data['game_category_name'],
+            $data['click_type'], $data['link'], $data['link_pc'], $data['link_h5'], $data['click_position'], $data['can_jump'], $data['sort']);
+        return $this->success($data);
+    }
+
+    /**
+     * 保存余额宝配置(无则新建)
+     */
+    public function saveConfig(): JsonResponse
+    {
+        DB::beginTransaction();
+        try {
+            $params = request()->validate([
+                'title' => ['required', 'string', 'min:1', 'max:140'],
+                'sub_title' => ['required', 'string', 'min:1', 'max:140'],
+                'start_time' => ['nullable', 'required_with:end_time', 'date'],
+                'end_time' => ['nullable', 'required_with:start_time', 'date', 'after_or_equal:start_time'],
+                'detail_image' => ['required'],
+                'pc_image' => ['required'],
+                'status' => ['required', 'integer', 'in:0,1'],
+                'lang' => ['required', 'string'],
+                'content' => ['nullable', 'string'],
+                'daily_interest_rate' => ['required', 'numeric', 'min:0', 'max:100'],
+                'number_of_consecutive_days_of_deposit' => ['required', 'integer', 'min:1', 'max:3650'],
+            ], [
+                'start_time.required_with' => '活动开始和结束时间必须同时填写或同时留空',
+                'end_time.required_with' => '活动开始和结束时间必须同时填写或同时留空',
+                'daily_interest_rate.required' => '请填写日化率',
+                'number_of_consecutive_days_of_deposit.required' => '请填写连续存款天数',
+            ]);
+            $row = self::configRow();
+            $stored = $row ? (json_decode((string)$row->getRawOriginal('params'), true) ?: []) : [];
+            $stored['daily_interest_rate'] = (string)$params['daily_interest_rate'];
+            $stored['number_of_consecutive_days_of_deposit'] = (int)$params['number_of_consecutive_days_of_deposit'];
+            unset($params['daily_interest_rate'], $params['number_of_consecutive_days_of_deposit']);
+
+            $defaults = ActivityRewardService::configurationDefaults();
+            unset($defaults['sort']);
+            $params = array_merge($params, array_intersect_key(request()->all(), $defaults + ['budget' => true]));
+            $params['type'] = ActivityCatalog::YUEBAO_TYPE;
+            $params['params'] = json_encode($stored);
+            if ($row) {
+                $params['id'] = $row->id;
+            }
+            ActivityRewardService::submit($params);
+            DB::commit();
+        } catch (ValidationException $e) {
+            DB::rollBack();
+            return $this->error(HttpStatus::CUSTOM_ERROR, $e->validator->errors()->first());
+        } catch (Exception $e) {
+            DB::rollBack();
+            return $this->error($e->getCode() ?: HttpStatus::CUSTOM_ERROR, $e->getMessage());
+        }
+        return $this->success();
+    }
+}

+ 3 - 0
app/Support/ActivityCatalog.php

@@ -8,6 +8,8 @@ use App\Services\Sharing\ShareDefaults;
 final class ActivityCatalog
 {
     public const PUBLISH_PLATFORMS = ['PC', 'H5', 'APP'];
+    /** 余额宝已独立到 /admin/yuebao/config 维护,不再出现在活动列表中。 */
+    public const YUEBAO_TYPE = 6;
 
     public static function activityTypes(): array
     {
@@ -28,6 +30,7 @@ final class ActivityCatalog
             $gameTypes[] = ['value' => $value, 'label' => $label];
         }
         foreach (self::activityTypes() as $value => $label) {
+            if ($value === self::YUEBAO_TYPE) continue;
             $activityTypes[] = ['value' => $value, 'label' => $label];
         }
         return ['game_types' => $gameTypes, 'activity_types' => $activityTypes,

+ 4 - 0
docs/前端交付/README.md

@@ -2,6 +2,10 @@
 
 本目录是2026-10-26的接口文档交付副本,按**项目 → 前台/后台/代理端**组织。
 
+## 入股代理充值冻结(2026-11-09)
+
+- [入股代理充值冻结-前端修正单](入股代理充值冻结-前端修正单.md):入股代理充值的钱 7 天内不能提现,用新字段 `locked_amount`(充值冻结)返回。这笔钱仍包含在 `balance` 里,和 `frozen_balance`(提现冻结)不是一回事,资金页要新增展示。
+
 ## 代理后台上传图片修正(2026-11-09)
 
 - [代理后台上传图片-前端修正单](代理后台上传图片-前端修正单.md):上传时提示 `101011 请登录`,原因是用错了接口。代理后台的上传全部改用 `POST /agent/upload/image`,把返回的 `path` 作为凭证 `image` 或收款码 `qr_image` 提交。

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

@@ -0,0 +1,78 @@
+# 入股代理充值冻结-前端修正单(2026-11-09)
+
+## 现象
+
+入股代理充值后,页面上的冻结金额(`frozen_balance`)还是 0,但可提现金额少了。例如:
+
+```json
+{"type": 2, "balance": "105000.0000", "frozen_balance": "0.0000",
+ "withdrawable_amount": "95000.0000", "locked_amount": "10000.0000", "recharge_lock_days": 7}
+```
+
+## 原因
+
+新规则:**入股代理充值的钱在 7 天内不能提现**(天数由总后台「入股配置」设置),但可以马上用来买股份。这部分金额用新字段 `locked_amount` 返回,前端还没有展示。
+
+`locked_amount` 和 `frozen_balance` 是两种不同的冻结:
+
+| 字段 | 页面名称 | 含义 | 是否包含在 `balance` 里 |
+|---|---|---|---|
+| `balance` | 总余额 | 全部余额,可以用来买股份 | — |
+| `locked_amount` | **充值冻结** | 充值未满 N 天的金额:不能提现,可以买股份;到期自动变成可提现 | **包含** |
+| `withdrawable_amount` | 可提现 | `balance − locked_amount` | 包含 |
+| `frozen_balance` | 提现冻结 | 已提交、还在审核或打款中的提现金额 | **不包含**(提交提现时已从余额扣出) |
+
+**不要把 `locked_amount` 加到 `frozen_balance` 里显示**,否则会让人以为这笔钱不在余额里。
+
+## 需要修改
+
+只对入股代理(`type=2`)。合营代理没有这些字段,不用改。
+
+### 1. 余额 / 资金页
+
+在「提现冻结」旁边新增「充值冻结」:
+
+| 项 | 字段 |
+|---|---|
+| 总余额 | `balance` |
+| 可提现 | `withdrawable_amount` |
+| 充值冻结 | `locked_amount`,旁边提示「充值 `recharge_lock_days` 天后可提现」 |
+| 提现冻结 | `frozen_balance` |
+
+字段来源:`GET /agent/profile`、`POST /agent/auth/login` 返回的代理信息,或 `GET /agent/wallet/summary`。
+
+### 2. 冻结明细(建议)
+
+`GET /agent/wallet/summary` 返回明细 `recharge_locks`,按到期时间排序(profile 接口里没有这个字段):
+
+```json
+"recharge_locks": [
+  {"amount": "10000.0000", "remaining": "10000.0000", "recharged_at": "2026-11-09 10:00:00", "unlock_at": "2026-11-16 10:00:00"}
+]
+```
+
+| 字段 | 说明 |
+|---|---|
+| `amount` | 这笔充值的金额 |
+| `remaining` | 还冻结的金额(买股份会先用冻结中的钱,所以可能小于 `amount`) |
+| `recharged_at` | 充值时间 |
+| `unlock_at` | 到期时间,之后可以提现 |
+
+可以在「充值冻结」后面加「明细」,弹出列表:「`unlock_at` 后可提现 `remaining`」。
+
+### 3. 提现页
+
+- 最多可提金额用 `withdrawable_amount`,**不要用 `balance`**。
+- 超出时后端会提示「可提现余额不足(充值未满 7 天的金额暂不可提现)」,直接展示即可。
+
+### 4. 充值成功提示(建议)
+
+入股代理充值成功后提示:「已到账,可以立即购买股份;充值 N 天后可提现」。
+
+## 验收
+
+1. 入股代理充值 10000:`balance` 增加 10000,「充值冻结」显示 10000,「可提现」不变,「提现冻结」不变。
+2. 提现金额超过「可提现」:显示后端提示,不能提交成功。
+3. 用冻结中的钱买股份:余额减少,「充值冻结」同步减少,「可提现」不变。
+4. 7 天后刷新页面:「充值冻结」变为 0,「可提现」增加。
+5. 合营代理的页面不出现「充值冻结」。

+ 2 - 0
routes/admin.php

@@ -558,6 +558,8 @@ Route::middleware(['admin.jwt'])->group(function () {
         Route::prefix('/yuebao')->group(function () {
             Route::get('/item', [YueBao::class, 'item']);
             Route::get('/log', [YueBao::class, 'log']);
+            Route::get('/config', [YueBao::class, 'config']);
+            Route::post('/config', [YueBao::class, 'saveConfig']);
         });
 
         

+ 81 - 5
tests/Integration/ActivityCatalogTest.php

@@ -165,7 +165,7 @@ class ActivityCatalogTest extends TestCase
 
     public function test_legacy_activity_dates_are_paired_nullable_and_permanent_rows_do_not_expire(): void
     {
-        foreach ([1, 2, 3, 4, 5, 6] as $type) {
+        foreach ([1, 2, 3, 4, 5] as $type) {
             foreach ([[null, null], ['', '']] as [$start, $end]) {
                 $result = $this->save(['type' => $type, 'start_time' => $start, 'end_time' => $end]);
                 $this->assertSame(HttpStatus::OK, $result['code'], $result['msg']);
@@ -181,8 +181,8 @@ class ActivityCatalogTest extends TestCase
             }
         }
         $this->assertSame(0, ActivityRewardService::expire());
-        $this->assertSame(12, $this->listing(['status' => 1])['total']);
-        $this->assertCount(12, \App\Support\ActivityPeriod::active(ActivityReward::query(), time())->get());
+        $this->assertSame(10, $this->listing(['status' => 1])['total']);
+        $this->assertCount(10, \App\Support\ActivityPeriod::active(ActivityReward::query(), time())->get());
         $this->request('');
         $this->assertCount(2, (new \App\Http\Controllers\api\ActivityReward())->index()->getData(true)['data']);
     }
@@ -365,7 +365,6 @@ class ActivityCatalogTest extends TestCase
             ['value' => 3, 'label' => '即充即送'],
             ['value' => 4, 'label' => '老用户召集令'],
             ['value' => 5, 'label' => 'USDT使用教学'],
-            ['value' => 6, 'label' => '余额宝'],
         ], $body['data']['activity_types']);
         $this->assertNotContains(1, array_column($body['data']['activity_types'], 'value'));
         $router = new Router(new Dispatcher($this->app), $this->app);
@@ -692,7 +691,7 @@ class ActivityCatalogTest extends TestCase
         $this->assertNull(ActivityReward::findOrFail($legacy)->toArray()['type_name']);
         $this->request('options');
         $options = (new AdminActivityReward())->options()->getData(true)['data']['activity_types'];
-        $this->assertSame([2, 3, 4, 5, 6], array_column($options, 'value'));
+        $this->assertSame([2, 3, 4, 5], array_column($options, 'value'));
     }
 
     public function test_lottery_merge_migration_preserves_activities_and_their_business_settings(): void
@@ -743,4 +742,81 @@ class ActivityCatalogTest extends TestCase
         $this->assertSame($before, (array) DB::table('activity_rewards')->find($id));
         $this->assertSame(2, DB::table('activity_rewards')->count());
     }
+
+    private function yuebaoForm(array $overrides = []): array
+    {
+        return $overrides + [
+            'title' => '余额宝', 'sub_title' => '余额宝', 'status' => 1, 'lang' => 'zh',
+            'start_time' => '2026-04-01 00:00:00', 'end_time' => '2027-12-31 00:00:00',
+            'detail_image' => '/app.png', 'pc_image' => '/pc.png', 'content' => '<p>说明</p>',
+            'daily_interest_rate' => '0.246', 'number_of_consecutive_days_of_deposit' => 7,
+        ];
+    }
+
+    private function yuebao(string $action, array $params = [], string $method = 'GET'): array
+    {
+        $this->app->instance('request', Request::create('/admin/yuebao/config', $method, $params));
+        $controller = new \App\Http\Controllers\admin\YueBao();
+        return ($method === 'GET' ? $controller->config() : $controller->saveConfig())->getData(true);
+    }
+
+    public function test_yuebao_is_hidden_from_activity_management(): void
+    {
+        $yuebao = $this->rawActivity(['type' => 6, 'title' => '余额宝']);
+        $this->rawActivity(['type' => 3]);
+        $this->assertNotContains($yuebao, array_column($this->listing()['data'], 'id'));
+        $this->assertSame(0, $this->listing(['type' => '6'])['total']);
+        $this->assertSame(HttpStatus::CUSTOM_ERROR, $this->save(['type' => 6])['code']);
+        $this->assertSame(HttpStatus::CUSTOM_ERROR, $this->save(['id' => $yuebao, 'type' => 3])['code']);
+        $this->assertSame(6, (int)DB::table('activity_rewards')->where('id', $yuebao)->value('type'));
+        $this->request('delete', ['id' => $yuebao], 'POST');
+        $this->assertSame(HttpStatus::CUSTOM_ERROR, (new AdminActivityReward())->destroy()->getData(true)['code']);
+        $this->assertTrue(DB::table('activity_rewards')->where('id', $yuebao)->exists());
+    }
+
+    public function test_yuebao_config_creates_then_updates_single_row(): void
+    {
+        $this->assertNull($this->yuebao('config')['data']);
+        $result = $this->yuebao('config', $this->yuebaoForm(['type' => 2, 'sort' => 50, 'game_category' => 'hot',
+            'register_platforms' => ['H5'], 'publish_platforms' => ['APP']]), 'POST');
+        $this->assertSame(HttpStatus::OK, $result['code'], $result['msg'] ?? '');
+        $this->assertSame(1, DB::table('activity_rewards')->where('type', 6)->count());
+        $row = ActivityReward::where('type', 6)->firstOrFail();
+        $this->assertSame(0, $row->sort);
+        $this->assertNull($row->getRawOriginal('game_category'));
+        $this->assertSame(['daily_interest_rate' => '0.246', 'number_of_consecutive_days_of_deposit' => 7], json_decode($row->getRawOriginal('params'), true));
+
+        $data = $this->yuebao('config')['data'];
+        $this->assertSame('0.246', $data['daily_interest_rate']);
+        $this->assertSame(7, $data['number_of_consecutive_days_of_deposit']);
+        $this->assertSame(['APP'], $data['publish_platforms']);
+        $this->assertSame(['H5'], $data['register_platforms']);
+        foreach (['type', 'sort', 'game_category', 'click_type', 'link', 'params'] as $hidden) {
+            $this->assertArrayNotHasKey($hidden, $data);
+        }
+
+        DB::table('activity_rewards')->where('id', $row->id)->update(['params' => json_encode(['daily_interest_rate' => 1, 'extra' => 'keep'])]);
+        $result = $this->yuebao('config', $this->yuebaoForm(['daily_interest_rate' => '0.5', 'number_of_consecutive_days_of_deposit' => 3, 'status' => 0]), 'POST');
+        $this->assertSame(HttpStatus::OK, $result['code'], $result['msg'] ?? '');
+        $this->assertSame(1, DB::table('activity_rewards')->where('type', 6)->count());
+        $fresh = $row->fresh();
+        $this->assertSame(0, (int)$fresh->status);
+        $this->assertSame(['daily_interest_rate' => '0.5', 'extra' => 'keep', 'number_of_consecutive_days_of_deposit' => 3], json_decode($fresh->getRawOriginal('params'), true));
+
+        foreach ([['daily_interest_rate' => ''], ['daily_interest_rate' => -1], ['number_of_consecutive_days_of_deposit' => 0], ['title' => ''], ['end_time' => null]] as $invalid) {
+            $this->assertSame(HttpStatus::CUSTOM_ERROR, $this->yuebao('config', $this->yuebaoForm($invalid), 'POST')['code']);
+        }
+    }
+
+    public function test_yuebao_config_edits_highest_priority_legacy_row_in_budget_mode(): void
+    {
+        $old = $this->rawActivity(['type' => 6, 'sort' => 0]);
+        $main = $this->rawActivity(['type' => 6, 'sort' => 5, 'budget' => '100.00']);
+        $this->assertSame($main, $this->yuebao('config')['data']['id']);
+        $result = $this->yuebao('config', $this->yuebaoForm(['budget' => '200']), 'POST');
+        $this->assertSame(HttpStatus::OK, $result['code'], $result['msg'] ?? '');
+        $this->assertSame('200.00', ActivityReward::findOrFail($main)->budget);
+        $this->assertSame(5, ActivityReward::findOrFail($main)->sort);
+        $this->assertSame('活动分类测试', ActivityReward::findOrFail($old)->title);
+    }
 }