Browse Source

活动筛选

doge 14 hours ago
parent
commit
d4d491d369

+ 12 - 0
app/Http/Controllers/admin/ActivityReward.php

@@ -10,9 +10,16 @@ use Illuminate\Support\Facades\DB;
 use Illuminate\Validation\ValidationException;
 use Illuminate\Validation\ValidationException;
 use Exception;
 use Exception;
 use App\Models\ActivityReward as ActivityRewardModel;
 use App\Models\ActivityReward as ActivityRewardModel;
+use App\Support\ActivityCatalog;
+use Illuminate\Validation\Rule;
 
 
 class ActivityReward extends Controller
 class ActivityReward extends Controller
 {
 {
+    public function options(): JsonResponse
+    {
+        return $this->success(ActivityCatalog::options());
+    }
+
     public function index(): JsonResponse
     public function index(): JsonResponse
     {
     {
         try {
         try {
@@ -24,6 +31,7 @@ class ActivityReward extends Controller
                 'platform' => ['nullable', 'string', 'in:pc,h5'],
                 'platform' => ['nullable', 'string', 'in:pc,h5'],
                 'status' => ['nullable', 'integer', 'in:0,1'],
                 'status' => ['nullable', 'integer', 'in:0,1'],
                 'lang' => ['nullable', 'string', 'max:10'],
                 'lang' => ['nullable', 'string', 'max:10'],
+                'game_category' => ['nullable', 'string', Rule::in(array_keys(ActivityCatalog::gameTypes()))],
             ]);
             ]);
             $page = request()->input('page', 1);
             $page = request()->input('page', 1);
             $limit = request()->input('limit', 10);
             $limit = request()->input('limit', 10);
@@ -35,6 +43,9 @@ class ActivityReward extends Controller
             if (isset($params['lang']) && $params['lang'] !== '') {
             if (isset($params['lang']) && $params['lang'] !== '') {
                 $query->where('lang', $params['lang']);
                 $query->where('lang', $params['lang']);
             }
             }
+            if (isset($params['game_category']) && $params['game_category'] !== '') {
+                $query->where('game_category', $params['game_category']);
+            }
             if (isset($params['platform']) && $params['platform'] !== '') {
             if (isset($params['platform']) && $params['platform'] !== '') {
                 $platform = strtoupper($params['platform']);
                 $platform = strtoupper($params['platform']);
                 $query->where(function ($platformQuery) use ($platform) {
                 $query->where(function ($platformQuery) use ($platform) {
@@ -85,6 +96,7 @@ class ActivityReward extends Controller
                 'lang' => ['required', 'string'],
                 'lang' => ['required', 'string'],
                 'content' => ['nullable', 'string'],
                 'content' => ['nullable', 'string'],
                 'params' => ['nullable', 'array'],
                 'params' => ['nullable', 'array'],
+                'game_category' => ['nullable', 'string', Rule::in(array_keys(ActivityCatalog::gameTypes()))],
             ]);
             ]);
             $params = array_merge($params, array_intersect_key(request()->all(), ActivityRewardService::configurationDefaults()));
             $params = array_merge($params, array_intersect_key(request()->all(), ActivityRewardService::configurationDefaults()));
             // Wrong-mode inputs are rejected rather than silently disabling a limit.
             // Wrong-mode inputs are rejected rather than silently disabling a limit.

+ 14 - 1
app/Models/ActivityReward.php

@@ -4,6 +4,7 @@ namespace App\Models;
 
 
 use App\Constants\Util;
 use App\Constants\Util;
 use Illuminate\Database\Eloquent\Relations\HasMany;
 use Illuminate\Database\Eloquent\Relations\HasMany;
+use App\Support\ActivityCatalog;
 
 
 /**
 /**
  * @property string $title
  * @property string $title
@@ -22,7 +23,9 @@ class ActivityReward extends BaseModel
     protected $table = 'activity_rewards';
     protected $table = 'activity_rewards';
     protected $fillable = ['title', 'sub_title', 'detail_image', 'start_time', 'end_time', 'status','type','lang','content', 'params','pc_image','detail_banner', 'pc_banner',
     protected $fillable = ['title', 'sub_title', 'detail_image', 'start_time', 'end_time', 'status','type','lang','content', 'params','pc_image','detail_banner', 'pc_banner',
         'publish_platforms', 'budget', 'reward_quota', 'sort', 'register_time_limited', 'register_start_time', 'register_end_time',
         'publish_platforms', 'budget', 'reward_quota', 'sort', 'register_time_limited', 'register_start_time', 'register_end_time',
-        'register_platforms', 'deposit_limited', 'deposit_min', 'deposit_max', 'show_ineligible'];
+        'register_platforms', 'deposit_limited', 'deposit_min', 'deposit_max', 'show_ineligible', 'game_category'];
+
+    protected $appends = ['type_name', 'game_category_name'];
 
 
     protected $casts = [
     protected $casts = [
         'publish_platforms' => 'array', 'register_platforms' => 'array',
         'publish_platforms' => 'array', 'register_platforms' => 'array',
@@ -37,6 +40,16 @@ class ActivityReward extends BaseModel
         return $this->hasMany(ActivityUser::class, 'activity_id', 'id');
         return $this->hasMany(ActivityUser::class, 'activity_id', 'id');
     }
     }
 
 
+    protected function getTypeNameAttribute(): ?string
+    {
+        return ActivityCatalog::activityTypes()[(int) ($this->attributes['type'] ?? 0)] ?? null;
+    }
+
+    protected function getGameCategoryNameAttribute(): ?string
+    {
+        return ActivityCatalog::gameTypes()[$this->attributes['game_category'] ?? ''] ?? null;
+    }
+
     protected function getDetailImageAttribute($value)
     protected function getDetailImageAttribute($value)
     {
     {
         return Util::ensureUrl($value);
         return Util::ensureUrl($value);

+ 7 - 0
app/Services/ActivityRewardService.php

@@ -11,6 +11,7 @@ use App\Models\UserSession;
 use App\Models\User;
 use App\Models\User;
 use Illuminate\Support\Facades\Validator;
 use Illuminate\Support\Facades\Validator;
 use Illuminate\Validation\Rule;
 use Illuminate\Validation\Rule;
+use App\Support\ActivityCatalog;
 use Exception;
 use Exception;
 
 
 class ActivityRewardService extends BaseService
 class ActivityRewardService extends BaseService
@@ -167,6 +168,12 @@ class ActivityRewardService extends BaseService
     {
     {
         $info = !empty($params['id']) ? static::findOne(['id' => $params['id']]) : null;
         $info = !empty($params['id']) ? static::findOne(['id' => $params['id']]) : null;
         if (!empty($params['id']) && !$info) throw new Exception('操作失败', HttpStatus::CUSTOM_ERROR);
         if (!empty($params['id']) && !$info) throw new Exception('操作失败', HttpStatus::CUSTOM_ERROR);
+        if (array_key_exists('game_category', $params)) {
+            Validator::make($params, [
+                'game_category' => ['nullable', 'string', Rule::in(array_keys(ActivityCatalog::gameTypes()))],
+            ])->validate();
+            if ($params['game_category'] === '') $params['game_category'] = null;
+        }
         $params = array_merge($params, static::configuration($params, $info));
         $params = array_merge($params, static::configuration($params, $info));
         unset($params['rewarded_count'], $params['quota_enabled'], $params['budget_used']); // Server-owned fields.
         unset($params['rewarded_count'], $params['quota_enabled'], $params['budget_used']); // Server-owned fields.
         if (isset($params['start_time']))
         if (isset($params['start_time']))

+ 31 - 0
app/Support/ActivityCatalog.php

@@ -0,0 +1,31 @@
+<?php
+
+namespace App\Support;
+
+use App\Services\Sharing\ShareDefaults;
+
+/** Activity selectors share the same game category keys and labels as commission configuration. */
+final class ActivityCatalog
+{
+    public static function activityTypes(): array
+    {
+        return [2 => '普通活动', 3 => '即充即送', 4 => '老用户召集令', 5 => 'USDT使用教学', 6 => '余额宝'];
+    }
+
+    public static function gameTypes(): array
+    {
+        return ShareDefaults::categories();
+    }
+
+    public static function options(): array
+    {
+        $gameTypes = $activityTypes = [];
+        foreach (self::gameTypes() as $value => $label) {
+            $gameTypes[] = ['value' => $value, 'label' => $label];
+        }
+        foreach (self::activityTypes() as $value => $label) {
+            $activityTypes[] = ['value' => $value, 'label' => $label];
+        }
+        return ['game_types' => $gameTypes, 'activity_types' => $activityTypes];
+    }
+}

+ 23 - 0
database/migrations/2026_10_25_120000_add_game_category_to_activity_rewards.php

@@ -0,0 +1,23 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration
+{
+    public function up(): void
+    {
+        Schema::table('activity_rewards', function (Blueprint $table) {
+            $table->string('game_category', 32)->nullable()->index()->comment('游戏分类,与分享返佣分类标识一致');
+        });
+    }
+
+    public function down(): void
+    {
+        Schema::table('activity_rewards', function (Blueprint $table) {
+            $table->dropIndex(['game_category']);
+            $table->dropColumn('game_category');
+        });
+    }
+};

+ 193 - 0
docs/前端接口/活动/游戏类型与活动类型选项-前端接入说明.md

@@ -0,0 +1,193 @@
+# 游戏类型与活动类型选项-前端接入说明
+
+适用范围:后台活动列表、新增和编辑表单。新增统一选项接口,游戏类型与新分享返佣使用同一组九大分类;活动类型原“足球首页”的 `type=2` 改显示为“普通活动”。
+
+本文只说明本次新增字段与名称调整。现有活动类型 ID、奖励、流水和资格判定逻辑保持原规则。**游戏类型是展示、关联和筛选信息,不会把活动奖励或流水条件限定到该游戏分类。**
+
+所有 URL 相对后台 API 域名,请求头为 `Authorization: Bearer <后台登录令牌>`;POST 沿用 `Content-Type: application/json`。成功 `code=0`,参数或业务错误 `code=-3`,展示服务端 `msg`。
+
+## 1. 统一选项接口
+
+`GET /admin/ActivityReward/options`,无参数。
+
+成功响应:
+
+```json
+{
+  "code": 0,
+  "timestamp": 1792886400,
+  "msg": "OK",
+  "data": {
+    "game_types": [
+      {"value": "third:1", "label": "第三方真人"},
+      {"value": "third:2", "label": "第三方电子"},
+      {"value": "third:3", "label": "第三方彩票"},
+      {"value": "third:4", "label": "第三方体育"},
+      {"value": "third:5", "label": "第三方电竞"},
+      {"value": "third:6", "label": "第三方捕鱼"},
+      {"value": "third:7", "label": "第三方棋牌"},
+      {"value": "self:sport", "label": "自有体育"},
+      {"value": "self:lottery", "label": "自有彩票"}
+    ],
+    "activity_types": [
+      {"value": 2, "label": "普通活动"},
+      {"value": 3, "label": "即充即送"},
+      {"value": 4, "label": "老用户召集令"},
+      {"value": 5, "label": "USDT使用教学"},
+      {"value": 6, "label": "余额宝"}
+    ]
+  }
+}
+```
+
+游戏类型下拉读取 `data.game_types`,单选、可清空;活动类型下拉读取 `data.activity_types`。两组均使用 `value` 提交、`label` 展示,不把显示文字作为请求值,也不根据活动名称推导选项。
+
+### 游戏分类与提交值
+
+| 游戏分类 | `game_category` 值 |
+|---|---|
+| 第三方真人 | `third:1` |
+| 第三方电子 | `third:2` |
+| 第三方彩票 | `third:3` |
+| 第三方体育 | `third:4` |
+| 第三方电竞 | `third:5` |
+| 第三方捕鱼 | `third:6` |
+| 第三方棋牌 | `third:7` |
+| 自有体育 | `self:sport` |
+| 自有彩票 | `self:lottery` |
+
+这是通用分类,不是厂商、第三方游戏平台或某个具体游戏的 ID。值必须完整保留字符串格式;例如 `third:2` 不能改为数字 `2`。
+
+### 活动类型与历史记录
+
+| 活动类型 | `type` 值 | 接入说明 |
+|---|---|---|
+| 普通活动 | `2` | 原“足球首页”,只调整显示名称,不改 ID 或既有业务逻辑 |
+| 即充即送 | `3` | 沿用现有表单及业务规则 |
+| 老用户召集令 | `4` | 沿用现有表单及业务规则 |
+| USDT使用教学 | `5` | 沿用现有表单及业务规则 |
+| 余额宝 | `6` | 沿用现有表单及业务规则 |
+
+历史 `type=1` 不出现在新选项中,但后端仍保留兼容。编辑旧记录时保留原 `type`,不要因下拉中没有该值而自动改为 `2` 或提交空值。列表对 `type=1` 及未知类型返回 `type_name=null`,可显示“历史类型(原 ID)”或提示人工确认;不要自行给未知类型分配新名称。新建表单使用选项接口提供的五项。
+
+## 2. 保存游戏类型
+
+`POST /admin/ActivityReward/submit`
+
+新增字段放在请求根级,与 `type` 平级:
+
+| 字段 | 类型 | 必填 | 说明 |
+|---|---|---|---|
+| `game_category` | string 或 `null` | 否 | 单个游戏分类,取自 `game_types[].value`;新增省略为 `null`,编辑省略保留原值 |
+| `type` | integer | 是 | 活动业务类型,取自 `activity_types[].value`;编辑历史记录按原类型保留 |
+
+清除已选游戏类型时明确传 `game_category:null` 或空字符串 `""`,保存后均为未关联;若只是修改其他字段,不想改变游戏分类,省略 `game_category` 即可。此字段不放入 `params`,也不提交名为 `game_type` 的数字参数。
+
+该接口仍要求完整基础表单,不能只提交 `id` 和 `game_category`。新增省略 `id`;编辑提交现有活动 `id`。标题、副标题、起止时间、四个图片字段、`status`、`type`、`lang` 继续按原约定填写,其他活动配置保留现有接入方式。
+
+编辑并关联“第三方电子”的完整基础表单示例:
+
+```json
+{
+  "id": 123,
+  "title": "秋季充值活动",
+  "sub_title": "活动详情以页面规则为准",
+  "start_time": "2026-10-25 00:00:00",
+  "end_time": "2026-11-30 23:59:59",
+  "detail_image": "/uploads/activity-detail.jpg",
+  "pc_image": "/uploads/activity-pc.jpg",
+  "detail_banner": "/uploads/activity-detail-banner.jpg",
+  "pc_banner": "/uploads/activity-pc-banner.jpg",
+  "status": 1,
+  "type": 3,
+  "lang": "zh",
+  "content": "活动详细规则",
+  "params": {"reward_ratio": 5},
+  "game_category": "third:2"
+}
+```
+
+`params` 仍是原活动规则对象,编辑时保留原字段再按原表单修改;原值为空时传 `null`,不能再次编码为 JSON 字符串。上例中的游戏分类不改变 `params` 的奖励适用范围。
+
+保存成功响应仍为:
+
+```json
+{
+  "code": 0,
+  "timestamp": 1792886400,
+  "msg": "OK",
+  "data": []
+}
+```
+
+成功后刷新列表,读取名称和已保存的分类。保存响应不新增活动 ID 或分类明细。
+
+## 3. 列表返回与筛选
+
+`GET /admin/ActivityReward`
+
+列表中的相关字段如下,原有字段和分页结构保留:
+
+| 字段 | 类型 | 说明 |
+|---|---|---|
+| `game_category` | string 或 `null` | 已关联游戏分类;未关联为 `null` |
+| `game_category_name` | string 或 `null` | 对应分类名称;未关联时为 `null` |
+| `type` | 原活动类型 ID | 活动业务类型,编辑时保留其原值 |
+| `type_name` | string 或 `null` | 活动类型名称;`2` 返回“普通活动”,历史 `1` 或未知类型为 `null` |
+
+列表新增可选查询参数 `game_category`,值为九大分类之一,精确匹配。省略、空字符串或 `null` 表示全部,不表示只查未关联记录。该条件与原平台、状态、语言、标题、活动类型筛选按 AND 组合,服务端先筛选,再统计 `data.total` 和分页。
+
+| 查询参数 | 必填 | 说明 |
+|---|---|---|
+| `page`、`limit` | 是 | 原分页参数,均为大于等于 `1` 的整数 |
+| `game_category` | 否 | 精确游戏分类,例如 `third:2` |
+| `platform` | 否 | 原发布平台筛选,小写 `pc/h5` |
+| `status` | 否 | 原活动配置状态,`0` 下架、`1` 上架;不要丢弃 `0` |
+| `lang` | 否 | 原语言代码,例如 `zh/en` |
+| `title` | 否 | 原标题模糊筛选 |
+| `type` | 否 | 原活动业务类型,不是游戏分类或平台 |
+
+请求示例:
+
+`GET /admin/ActivityReward?page=1&limit=20&game_category=third%3A2&platform=h5&status=1&lang=zh&type=3`
+
+部分列表字段示例:
+
+```json
+{
+  "code": 0,
+  "timestamp": 1792886400,
+  "msg": "OK",
+  "data": {
+    "total": 1,
+    "data": [
+      {
+        "id": 123,
+        "title": "秋季充值活动",
+        "type": 3,
+        "type_name": "即充即送",
+        "game_category": "third:2",
+        "game_category_name": "第三方电子",
+        "status": 1,
+        "lang": "zh",
+        "publish_platforms": ["H5"],
+        "params": {"reward_ratio": 5}
+      }
+    ]
+  }
+}
+```
+
+切换筛选条件时将 `page` 重置为 `1`,选择“全部游戏类型”时省略 `game_category`。名称优先使用返回的 `game_category_name/type_name`;回填表单使用原值 `game_category/type`。
+
+## 4. 失败处理与联调
+
+游戏分类不是九个规范值之一、基础表单缺失或其他参数错误时,沿用活动接口的 `code=-3`,展示实际 `msg`,保留表单输入。不要把 HTTP 200 直接当作保存成功。
+
+- 选项接口返回九个游戏分类、五个活动类型;`type=2` 显示“普通活动”,不会另造新 ID。
+- 新建未选游戏分类时为 `null`;编辑省略保留原分类,显式清空后列表名称和值均为 `null`。
+- 游戏分类筛选与平台、状态、语言、标题、活动类型共同生效,`total` 与筛选结果一致。
+- 编辑 `type=1` 的历史记录时保留原值,不自动替换为“普通活动”。
+- 修改游戏分类只改变关联、展示和筛选信息,不改变原活动的奖励、流水、资格规则。
+
+其他列表筛选规则见 [列表筛选](列表筛选.md);原活动配置字段见 [资格条件与展示开关](资格条件与展示开关.md)。本页中的类型选项与新名称以统一选项接口为准。

+ 1 - 0
routes/admin.php

@@ -118,6 +118,7 @@ Route::middleware(['admin.jwt'])->group(function () {
 
 
         Route::prefix('/ActivityReward')->group(function () {
         Route::prefix('/ActivityReward')->group(function () {
             Route::get('/', [ActivityReward::class, 'index']);
             Route::get('/', [ActivityReward::class, 'index']);
+            Route::get('/options', [ActivityReward::class, 'options']);
             Route::post('/submit', [ActivityReward::class, 'store']);
             Route::post('/submit', [ActivityReward::class, 'store']);
             Route::post('/delete', [ActivityReward::class, 'destroy']);
             Route::post('/delete', [ActivityReward::class, 'destroy']);
         });
         });

+ 305 - 0
tests/Integration/ActivityCatalogTest.php

@@ -0,0 +1,305 @@
+<?php
+
+namespace Tests\Integration;
+
+use App\Constants\HttpStatus;
+use App\Http\Controllers\admin\ActivityReward as AdminActivityReward;
+use App\Models\ActivityReward;
+use App\Services\ActivityRewardService;
+use App\Services\Sharing\ShareDefaults;
+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\Support\Facades\Schema;
+use Illuminate\Translation\ArrayLoader;
+use Illuminate\Translation\Translator;
+use Illuminate\Validation\Factory;
+use Illuminate\Validation\ValidationException;
+use PHPUnit\Framework\TestCase;
+
+/** Real controllers and service validation against SQLite only; no application boot or external databases. */
+class ActivityCatalogTest extends TestCase
+{
+    private Application $app;
+
+    protected function setUp(): void
+    {
+        Facade::clearResolvedInstances();
+        $this->app = new Application(dirname(__DIR__, 2));
+        $this->app->instance('config', new Repository([
+            'app' => ['locale' => 'zh', 'url' => 'https://activity.example.test', '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();
+        });
+        $db->schema()->create('activity_rewards', function (Blueprint $t) {
+            $t->id(); $t->string('title'); $t->string('sub_title');
+            $t->integer('start_time'); $t->integer('end_time'); $t->integer('status')->default(1);
+            $t->integer('type')->default(1); $t->string('lang')->default('zh');
+            foreach (['detail_image', 'pc_image', 'detail_banner', 'pc_banner', 'content', 'params'] as $field) {
+                $t->text($field)->nullable();
+            }
+            $t->timestamps();
+        });
+        (require dirname(__DIR__, 2) . '/database/migrations/2026_10_13_120000_add_activity_eligibility_fields.php')->up();
+        (require dirname(__DIR__, 2) . '/database/migrations/2026_10_14_120000_add_activity_reward_quotas.php')->up();
+        $this->migration()->up();
+        $this->request('');
+    }
+
+    protected function tearDown(): void
+    {
+        DB::disconnect(); Facade::clearResolvedInstances(); parent::tearDown();
+    }
+
+    private function migration()
+    {
+        return require dirname(__DIR__, 2) . '/database/migrations/2026_10_25_120000_add_game_category_to_activity_rewards.php';
+    }
+
+    private function request(string $action, array $params = [], string $method = 'GET'): void
+    {
+        $this->app->instance('request', Request::create('/admin/ActivityReward/' . $action, $method, $params));
+    }
+
+    private function form(array $overrides = []): array
+    {
+        return $overrides + [
+            'title' => '活动分类测试', 'sub_title' => '分类说明', 'type' => 3, 'status' => 1, 'lang' => 'zh',
+            'start_time' => '2026-10-01 12:00:00', 'end_time' => '2026-12-31 12:00:00',
+            'detail_image' => '/app.png', 'pc_image' => '/pc.png',
+            'detail_banner' => '/app-banner.png', 'pc_banner' => '/pc-banner.png',
+        ];
+    }
+
+    private function save(array $params = []): array
+    {
+        $this->request('submit', $this->form($params), 'POST');
+        return (new AdminActivityReward())->store()->getData(true);
+    }
+
+    private function listing(array $filters = []): array
+    {
+        $this->request('', $filters + ['page' => 1, 'limit' => 100]);
+        $result = (new AdminActivityReward())->index()->getData(true);
+        $this->assertSame(HttpStatus::OK, $result['code'], $result['msg']);
+        return $result['data'];
+    }
+
+    private function rawActivity(array $overrides = []): int
+    {
+        $data = $this->form($overrides);
+        $data['start_time'] = strtotime($data['start_time']);
+        $data['end_time'] = strtotime($data['end_time']);
+        if (isset($data['publish_platforms']) && is_array($data['publish_platforms'])) {
+            $data['publish_platforms'] = json_encode($data['publish_platforms']);
+        }
+        return DB::table('activity_rewards')->insertGetId($data);
+    }
+
+    public function test_options_match_all_shared_game_categories_and_five_activity_types_on_an_authenticated_route(): void
+    {
+        $this->rawActivity(['type' => 1]);
+        $before = DB::table('activity_rewards')->get()->toJson();
+        $this->request('options');
+        $body = (new AdminActivityReward())->options()->getData(true);
+        $this->assertSame(HttpStatus::OK, $body['code'], $body['msg']);
+        $expectedGames = [];
+        foreach (ShareDefaults::categories() as $value => $label) $expectedGames[] = compact('value', 'label');
+        $this->assertCount(9, $expectedGames);
+        $this->assertSame($expectedGames, $body['data']['game_types']);
+        $this->assertSame([
+            ['value' => 2, 'label' => '普通活动'],
+            ['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);
+        $this->app->instance('router', $router);
+        $router->group(['prefix' => 'admin'], function () {
+            require dirname(__DIR__, 2) . '/routes/admin.php';
+        });
+        $route = $router->getRoutes()->match(Request::create('/admin/ActivityReward/options', 'GET'));
+        $this->assertSame(AdminActivityReward::class . '@options', $route->getAction('uses'));
+        $this->assertContains('admin.jwt', $route->middleware());
+        $this->assertContains('check.button.uri', $route->middleware());
+        $this->assertSame($before, DB::table('activity_rewards')->get()->toJson());
+        $this->assertSame(0, DB::table('activity_reward_recipients')->count());
+    }
+
+    public function test_new_omission_is_null_edit_omission_preserves_category_and_explicit_values_clear_it(): void
+    {
+        $result = $this->save();
+        $this->assertSame(HttpStatus::OK, $result['code'], $result['msg']);
+        $id = DB::table('activity_rewards')->value('id');
+        $this->assertNull(DB::table('activity_rewards')->where('id', $id)->value('game_category'));
+        $this->assertNull(ActivityReward::findOrFail($id)->toArray()['game_category_name']);
+        $result = $this->save(['id' => $id, 'game_category' => 'third:1', 'sort' => 37, 'publish_platforms' => ['PC']]);
+        $this->assertSame(HttpStatus::OK, $result['code'], $result['msg']);
+        $result = $this->save(['id' => $id, 'title' => '只修改标题']);
+        $this->assertSame(HttpStatus::OK, $result['code'], $result['msg']);
+        $row = ActivityReward::findOrFail($id)->toArray();
+        $this->assertSame('third:1', $row['game_category']);
+        $this->assertSame('第三方真人', $row['game_category_name']);
+        $this->assertSame('即充即送', $row['type_name']);
+        $this->assertSame(37, $row['sort']);
+        $this->assertSame(['PC'], $row['publish_platforms']);
+        foreach ([null, ''] as $clear) {
+            $this->assertSame(HttpStatus::OK, $this->save(['id' => $id, 'game_category' => 'self:sport'])['code']);
+            $result = $this->save(['id' => $id, 'game_category' => $clear]);
+            $this->assertSame(HttpStatus::OK, $result['code'], $result['msg']);
+            $this->assertNull(DB::table('activity_rewards')->where('id', $id)->value('game_category'));
+            $row = collect($this->listing()['data'])->keyBy('id')->get($id);
+            $this->assertNull($row['game_category']);
+            $this->assertNull($row['game_category_name']);
+        }
+        $this->assertSame(1, DB::table('activity_rewards')->count());
+        $this->assertSame(0, DB::table('activity_reward_recipients')->count());
+    }
+
+    public function test_every_game_category_is_saved_and_named_for_normal_and_type_two_activities(): void
+    {
+        foreach ([2 => '普通活动', 3 => '即充即送'] as $type => $typeName) {
+            foreach (ShareDefaults::categories() as $category => $label) {
+                $result = $this->save(['type' => $type, 'game_category' => $category]);
+                $this->assertSame(HttpStatus::OK, $result['code'], $result['msg']);
+                $row = ActivityReward::query()->latest('id')->firstOrFail()->toArray();
+                $this->assertSame($type, (int) $row['type']);
+                $this->assertSame($typeName, $row['type_name']);
+                $this->assertSame($category, $row['game_category']);
+                $this->assertSame($label, $row['game_category_name']);
+            }
+        }
+        $this->assertSame(18, $this->listing()['total']);
+    }
+
+    public function test_invalid_categories_are_rejected_by_controller_and_service_even_for_type_two(): void
+    {
+        $id = $this->rawActivity(['game_category' => 'third:1']);
+        $before = DB::table('activity_rewards')->get()->toJson();
+        foreach (['unknown', 'third:8', '1', 1, ['third:1']] as $category) {
+            foreach ([2, 3] as $type) {
+                $result = $this->save(['id' => $id, 'type' => $type, 'title' => '不应保存', 'game_category' => $category]);
+                $this->assertSame(HttpStatus::CUSTOM_ERROR, $result['code']);
+                try {
+                    ActivityRewardService::submit($this->form(['id' => $id, 'type' => $type, 'game_category' => $category]));
+                    $this->fail('Expected direct service category validation');
+                } catch (ValidationException $e) {
+                    $this->assertArrayHasKey('game_category', $e->errors());
+                }
+            }
+            $this->request('', ['page' => 1, 'limit' => 10, 'game_category' => $category]);
+            $result = (new AdminActivityReward())->index()->getData(true);
+            $this->assertSame(HttpStatus::CUSTOM_ERROR, $result['code']);
+        }
+        $this->assertSame($before, DB::table('activity_rewards')->get()->toJson());
+    }
+
+    public function test_combined_category_filters_apply_before_count_and_pagination_with_type_two_platform_exception(): void
+    {
+        $base = ['title' => '目标活动', 'type' => 3, 'status' => 0, 'lang' => 'zh', 'game_category' => 'third:1', 'publish_platforms' => ['PC']];
+        $matching = [];
+        for ($i = 1; $i <= 5; $i++) $matching[] = $this->rawActivity(['sort' => $i] + $base);
+        foreach ([['game_category' => 'third:2'], ['publish_platforms' => ['H5']], ['status' => 1],
+            ['lang' => 'en'], ['title' => '其他活动'], ['type' => 4], ['game_category' => null]] as $extra) {
+            $this->rawActivity($extra + ['sort' => 100] + $base);
+        }
+        $ordinary = $this->rawActivity(['type' => 2, 'publish_platforms' => ['H5'], 'sort' => 101] + $base);
+        $before = DB::table('activity_rewards')->orderBy('id')->get()->toJson();
+        $matching = array_reverse($matching);
+        $filters = ['title' => '目标', 'type' => '3', 'status' => '0', 'lang' => 'zh', 'platform' => 'pc', 'game_category' => 'third:1', 'limit' => 2];
+        $seen = [];
+        foreach ([1, 2, 3, 4] as $page) {
+            $result = $this->listing($filters + ['page' => $page]);
+            $this->assertSame(5, $result['total']);
+            $this->assertSame(array_slice($matching, ($page - 1) * 2, 2), array_column($result['data'], 'id'));
+            foreach ($result['data'] as $row) {
+                $this->assertSame('third:1', $row['game_category']);
+                $this->assertSame('第三方真人', $row['game_category_name']);
+                $this->assertSame('即充即送', $row['type_name']);
+            }
+            $seen = array_merge($seen, array_column($result['data'], 'id'));
+        }
+        $this->assertSame($matching, $seen);
+        $ordinaryPage = $this->listing(['type' => '2', 'platform' => 'pc', 'status' => '0', 'lang' => 'zh', 'game_category' => 'third:1']);
+        $this->assertSame(1, $ordinaryPage['total']);
+        $this->assertSame([$ordinary], array_column($ordinaryPage['data'], 'id'));
+        $this->assertSame('普通活动', $ordinaryPage['data'][0]['type_name']);
+        $all = $this->listing();
+        foreach ([null, ''] as $category) {
+            $result = $this->listing(['game_category' => $category]);
+            $this->assertSame($all['total'], $result['total']);
+            $this->assertSame(array_column($all['data'], 'id'), array_column($result['data'], 'id'));
+        }
+        $this->assertSame($before, DB::table('activity_rewards')->orderBy('id')->get()->toJson());
+    }
+
+    public function test_legacy_type_one_remains_readable_and_editable_without_becoming_an_option(): void
+    {
+        $result = $this->save(['type' => 1]);
+        $this->assertSame(HttpStatus::OK, $result['code'], $result['msg']);
+        $id = DB::table('activity_rewards')->value('id');
+        DB::table('activity_rewards')->where('id', $id)->update(['budget_used' => '12.34', 'rewarded_count' => 2]);
+        $result = $this->save(['id' => $id, 'type' => 1, 'title' => '旧类型活动', 'game_category' => 'self:lottery']);
+        $this->assertSame(HttpStatus::OK, $result['code'], $result['msg']);
+        $row = $this->listing(['type' => '1'])['data'][0];
+        $this->assertSame(1, (int) $row['type']);
+        $this->assertNull($row['type_name']);
+        $this->assertSame('self:lottery', $row['game_category']);
+        $this->assertSame('自有彩票', $row['game_category_name']);
+        $this->assertSame('12.34', $row['budget_used']);
+        $this->assertSame(2, $row['rewarded_count']);
+        $legacy = $this->rawActivity(['type' => 99, 'game_category' => null]);
+        $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'));
+    }
+
+    public function test_category_migration_adds_nullable_index_without_changing_existing_rows_and_rolls_back(): void
+    {
+        $this->migration()->down();
+        $this->assertFalse(Schema::hasColumn('activity_rewards', 'game_category'));
+        $id = $this->rawActivity(['type' => 1, 'sort' => 19]);
+        $before = (array) DB::table('activity_rewards')->find($id);
+        $this->migration()->up();
+        $after = (array) DB::table('activity_rewards')->find($id);
+        $this->assertNull($after['game_category']);
+        unset($after['game_category']);
+        $this->assertSame($before, $after);
+        $column = collect(DB::select('PRAGMA table_info(bot_activity_rewards)'))->firstWhere('name', 'game_category');
+        $this->assertSame(0, (int) $column->notnull);
+        $indexes = DB::select("SELECT sql FROM sqlite_master WHERE type = 'index' AND tbl_name = ?", ['bot_activity_rewards']);
+        $this->assertNotEmpty(array_filter($indexes, fn ($index) => str_contains((string) $index->sql, 'game_category')));
+        $newId = $this->rawActivity();
+        $this->assertNull(DB::table('activity_rewards')->where('id', $newId)->value('game_category'));
+        $this->migration()->down();
+        $this->assertFalse(Schema::hasColumn('activity_rewards', 'game_category'));
+        $this->assertTrue(Schema::hasColumn('activity_rewards', 'reward_quota'));
+        $this->assertSame($before, (array) DB::table('activity_rewards')->find($id));
+        $this->assertSame(2, DB::table('activity_rewards')->count());
+    }
+}