doge 9 часов назад
Родитель
Сommit
0da00df148

+ 3 - 3
app/Services/Sharing/ShareDefaults.php

@@ -8,9 +8,9 @@ final class ShareDefaults
 
     public static function categories(): array
     {
-        return ['third:1' => '第三方真人', 'third:2' => '第三方电子', 'third:3' => '第三方彩票',
-            'third:4' => '第三方体育', 'third:5' => '第三方电竞', 'third:6' => '第三方捕鱼',
-            'third:7' => '第三方棋牌', 'self:sport' => '自有体育', 'self:lottery' => '自有彩票'];
+        return ['third:1' => '真人', 'third:2' => '电子', 'third:3' => '彩票',
+            'third:4' => '体育', 'third:5' => '电竞', 'third:6' => '捕鱼',
+            'third:7' => '棋牌', 'self:sport' => 'F1体育', 'self:lottery' => 'F1彩票'];
     }
 
     public static function rules(): array

+ 2 - 2
app/Support/ActivityCatalog.php

@@ -4,7 +4,7 @@ namespace App\Support;
 
 use App\Services\Sharing\ShareDefaults;
 
-/** Activity selectors share the same game category keys and labels as commission configuration. */
+/** Reuse commission game categories; "other" is an activity-only classification. */
 final class ActivityCatalog
 {
     public static function activityTypes(): array
@@ -14,7 +14,7 @@ final class ActivityCatalog
 
     public static function gameTypes(): array
     {
-        return ShareDefaults::categories();
+        return ShareDefaults::categories() + ['other' => '其他'];
     }
 
     public static function options(): array

+ 201 - 0
docs/前端接口/活动/游戏类型其他选项与名称调整-前端接入说明.md

@@ -0,0 +1,201 @@
+# 游戏类型“其他”选项与名称调整-前端接入说明
+
+活动游戏类型选项为返佣使用的九类游戏,加一个仅用于活动的“其他”,共十项。九类游戏名称统一调整为:真人、电子、彩票、体育、电竞、捕鱼、棋牌、F1体育、F1彩票;原分类键保持不变。
+
+游戏分类仍是活动的关联、展示和筛选信息,不改变活动奖励、流水、资格判定或财务规则。“其他”不会成为新的返佣分类。
+
+## 1. 活动选项接口
+
+`GET /admin/ActivityReward/options`,无参数。URL 相对后台 API 域名,请求头沿用 `Authorization: Bearer <后台登录令牌>`。
+
+完整成功响应如下,游戏类型按返回顺序展示:
+
+```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": "F1体育"},
+      {"value": "self:lottery", "label": "F1彩票"},
+      {"value": "other", "label": "其他"}
+    ],
+    "activity_types": [
+      {"value": 2, "label": "普通活动"},
+      {"value": 3, "label": "即充即送"},
+      {"value": 4, "label": "老用户召集令"},
+      {"value": 5, "label": "USDT使用教学"},
+      {"value": 6, "label": "余额宝"}
+    ]
+  }
+}
+```
+
+下拉单选使用 `data.game_types`,提交 `value`、显示 `label`。接口不提供“默认”“钱包”或空字符串选项。分类键是规范字符串,不是厂商、游戏平台或具体游戏的数字 ID。
+
+| 游戏分类键 | 原显示名称 | 新显示名称 | 适用范围 |
+|---|---|---|---|
+| `third:1` | 第三方真人 | 真人 | 活动、返佣 |
+| `third:2` | 第三方电子 | 电子 | 活动、返佣 |
+| `third:3` | 第三方彩票 | 彩票 | 活动、返佣 |
+| `third:4` | 第三方体育 | 体育 | 活动、返佣 |
+| `third:5` | 第三方电竞 | 电竞 | 活动、返佣 |
+| `third:6` | 第三方捕鱼 | 捕鱼 | 活动、返佣 |
+| `third:7` | 第三方棋牌 | 棋牌 | 活动、返佣 |
+| `self:sport` | 自有体育 | F1体育 | 活动、返佣 |
+| `self:lottery` | 自有彩票 | F1彩票 | 活动、返佣 |
+| `other` | — | 其他 | 仅活动 |
+
+活动业务类型仍使用另一组字段 `type`,不能与 `game_category` 混用:`2` 普通活动、`3` 即充即送、`4` 老用户召集令、`5` USDT使用教学、`6` 余额宝。`type=2` 原称“足球首页”,仅名称调整,ID 和既有业务规则不变。
+
+历史 `type=1` 不出现在新建选项中,后端继续兼容;编辑旧记录时保留原 `type`,不能因选项中没有该值而自动替换为 `2`。历史 `1` 或未知类型的 `type_name` 为 `null`,可显示原 ID 并提示人工确认。
+
+## 2. 保存“其他”及清空分类
+
+`POST /admin/ActivityReward/submit`,请求头同上,使用 `Content-Type: application/json`。
+
+`game_category` 放在请求根级,与活动业务类型 `type` 平级,不放入 `params`,不使用数字参数 `game_type`。
+
+| 操作 | `game_category` 传值 | 结果 |
+|---|---|---|
+| 选择“其他” | `"other"` | 保存分类 `other`,返回名称“其他” |
+| 选择九类游戏之一 | 对应规范字符串,例如 `"third:2"` | 保存原分类键,返回新的简短名称 |
+| 新建时未提交 | 省略字段 | 保持未关联,值为 `null`、名称为 `null` |
+| 编辑时不改变分类 | 省略字段 | 保留原值 |
+| 编辑时清空分类 | `null` 或 `""` | 清除关联,值为 `null`、名称为 `null` |
+
+未关联和“其他”是两种不同状态,不自动将空值改成 `other`。编辑时清空下拉后,明确提交 `null` 或空字符串;若因空值而删除请求字段,会变成保留原分类。
+
+新增和编辑仍需完整基础表单,不能只提交 `id` 和分类。以下为编辑活动并选择“其他”的示例;新增省略 `id`:
+
+```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": "other"
+}
+```
+
+`params` 沿用原活动规则:编辑时保留原对象内容,原值为空时传 `null`,不要重复编码为 JSON 字符串。修改游戏分类不会改变该对象的奖励适用范围。其他活动配置按现有表单保留。
+
+保存成功仍返回 `code=0`、`data=[]`,随后重新请求列表回显:
+
+```json
+{
+  "code": 0,
+  "timestamp": 1792886400,
+  "msg": "OK",
+  "data": []
+}
+```
+
+## 3. 列表回显与筛选
+
+`GET /admin/ActivityReward`
+
+`page`、`limit` 仍必填且大于等于 `1`。可选 `game_category=other` 精确筛选“其他”,也可使用九类规范游戏键;与原平台 `platform=pc/h5`、状态 `status=0/1`、语言 `lang`、标题 `title`、活动业务类型 `type` 按 AND 组合。先筛选,再统计 `data.total` 和分页。
+
+请求示例:
+
+`GET /admin/ActivityReward?page=1&limit=20&game_category=other&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": "other",
+        "game_category_name": "其他",
+        "status": 1,
+        "lang": "zh",
+        "publish_platforms": ["H5"],
+        "params": {"reward_ratio": 5}
+      }
+    ]
+  }
+}
+```
+
+列表使用 `game_category_name` 展示名称,表单用原始 `game_category` 回填。未关联记录仍返回 `game_category:null`、`game_category_name:null`,保持未选状态,不新增“默认”名称。
+
+筛选控件可以额外显示“全部”,但这是前端的无筛选状态,不是选项接口的一项。选择全部时省略 `game_category`;空字符串或 `null` 同样表示查询所有分类记录,包含未关联、九类游戏及“其他”,**不能据此只查询未关联记录**。不要提交 `all` 作为分类键。改变筛选后将 `page` 重置为 `1`,并保留有效的 `status=0`。
+
+## 4. 返佣名称同步
+
+后台 `GET /admin/shareEarning/rateRules` 和会员 `GET /api/share/rates` 使用上表相同的九个简短名称及原分类键,均不增加 `other`。后台仍使用后台 Bearer Token;会员接口继续使用其既有请求封装和身份规则。
+
+后台规则接口的部分字段示例(其余规则、版本字段保留):
+
+```json
+{
+  "code": 0,
+  "timestamp": 1792886400,
+  "msg": "OK",
+  "data": {
+    "categories": [
+      {"category": "third:1", "name": "真人"},
+      {"category": "self:sport", "name": "F1体育"},
+      {"category": "self:lottery", "name": "F1彩票"}
+    ]
+  }
+}
+```
+
+会员比例接口的部分字段示例(其余金额、比例和版本字段保留):
+
+```json
+{
+  "code": 1,
+  "show": 0,
+  "msg": "",
+  "data": {
+    "data": [
+      {"category": "third:1", "category_name": "真人"},
+      {"category": "self:sport", "category_name": "F1体育"},
+      {"category": "self:lottery", "category_name": "F1彩票"}
+    ]
+  }
+}
+```
+
+以上仅截取部分分类展示字段;返佣完整分类仍为九类。显示名称应读取接口返回值,分类选择、规则匹配及提交仍使用原键。名称调整不改变返佣档位、佣金计算或财务处理。
+
+## 5. 失败处理与联调
+
+活动接口未知分类或参数错误沿用 `code=-3`,展示实际 `msg`,不能只判断 HTTP 状态。`wallet`、`all` 均不是合法保存或筛选值;中文显示名也不能作为分类键提交。
+
+- 活动选项严格为九类游戏加“其他”,共十项,不插入空字符串、“默认”或“钱包”选项。
+- 选择“其他”后保存,列表返回 `other/其他`;编辑省略保留,显式清空返回 `null/null`。
+- `game_category=other` 仅查“其他”;省略或清空筛选查询全部分类,`total` 与全部筛选条件一致。
+- 活动与返佣中同一分类键显示相同简短名称,返佣接口仍只有九类且没有“其他”。
+- 新名称、活动游戏关联和“其他”选项均不改变原活动奖励、流水或资格规则。

+ 86 - 8
tests/Integration/ActivityCatalogTest.php

@@ -7,6 +7,7 @@ use App\Http\Controllers\admin\ActivityReward as AdminActivityReward;
 use App\Models\ActivityReward;
 use App\Services\ActivityRewardService;
 use App\Services\Sharing\ShareDefaults;
+use App\Support\ActivityCatalog;
 use Illuminate\Config\Repository;
 use Illuminate\Database\Capsule\Manager;
 use Illuminate\Database\Schema\Blueprint;
@@ -125,10 +126,20 @@ class ActivityCatalogTest extends TestCase
         $this->request('options');
         $body = (new AdminActivityReward())->options()->getData(true);
         $this->assertSame(HttpStatus::OK, $body['code'], $body['msg']);
+        $financialCategories = ['third:1' => '真人', 'third:2' => '电子', 'third:3' => '彩票',
+            'third:4' => '体育', 'third:5' => '电竞', 'third:6' => '捕鱼', 'third:7' => '棋牌',
+            'self:sport' => 'F1体育', 'self:lottery' => 'F1彩票'];
+        $this->assertSame($financialCategories, ShareDefaults::categories());
+        $this->assertCount(9, ShareDefaults::categories());
+        $this->assertArrayNotHasKey('other', ShareDefaults::categories());
+        $this->assertSame($financialCategories + ['other' => '其他'], ActivityCatalog::gameTypes());
         $expectedGames = [];
-        foreach (ShareDefaults::categories() as $value => $label) $expectedGames[] = compact('value', 'label');
-        $this->assertCount(9, $expectedGames);
+        foreach ($financialCategories + ['other' => '其他'] as $value => $label) $expectedGames[] = compact('value', 'label');
+        $this->assertCount(10, $expectedGames);
         $this->assertSame($expectedGames, $body['data']['game_types']);
+        $this->assertNotContains('', array_column($body['data']['game_types'], 'value'));
+        $this->assertNotContains('wallet', array_column($body['data']['game_types'], 'value'));
+        $this->assertNotContains('all', array_column($body['data']['game_types'], 'value'));
         $this->assertSame([
             ['value' => 2, 'label' => '普通活动'],
             ['value' => 3, 'label' => '即充即送'],
@@ -163,7 +174,7 @@ class ActivityCatalogTest extends TestCase
         $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['game_category_name']);
         $this->assertSame('即充即送', $row['type_name']);
         $this->assertSame(37, $row['sort']);
         $this->assertSame(['PC'], $row['publish_platforms']);
@@ -183,7 +194,7 @@ class ActivityCatalogTest extends TestCase
     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) {
+            foreach (ActivityCatalog::gameTypes() 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();
@@ -193,14 +204,44 @@ class ActivityCatalogTest extends TestCase
                 $this->assertSame($label, $row['game_category_name']);
             }
         }
-        $this->assertSame(18, $this->listing()['total']);
+        $this->assertSame(20, $this->listing()['total']);
+    }
+
+    public function test_other_category_is_preserved_on_omitted_edit_and_can_be_cleared_for_type_two_and_three(): void
+    {
+        foreach ([2, 3] as $type) {
+            $result = $this->save(['type' => $type, 'game_category' => 'other']);
+            $this->assertSame(HttpStatus::OK, $result['code'], $result['msg']);
+            $id = DB::table('activity_rewards')->latest('id')->value('id');
+            $this->assertSame('other', DB::table('activity_rewards')->where('id', $id)->value('game_category'));
+            $result = $this->save(['id' => $id, 'type' => $type, 'title' => '编辑其他分类活动']);
+            $this->assertSame(HttpStatus::OK, $result['code'], $result['msg']);
+            $page = $this->listing(['game_category' => 'other', 'type' => (string) $type]);
+            $this->assertSame(1, $page['total']);
+            $this->assertSame($id, $page['data'][0]['id']);
+            $this->assertSame('other', $page['data'][0]['game_category']);
+            $this->assertSame('其他', $page['data'][0]['game_category_name']);
+            $this->assertSame('其他', ActivityReward::findOrFail($id)->toArray()['game_category_name']);
+            foreach ([null, ''] as $clear) {
+                $this->assertSame(HttpStatus::OK, $this->save(['id' => $id, 'type' => $type, 'game_category' => 'other'])['code']);
+                $result = $this->save(['id' => $id, 'type' => $type, '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(0, $this->listing(['game_category' => 'other', 'type' => (string) $type])['total']);
+            }
+        }
+        $this->assertSame(2, DB::table('activity_rewards')->count());
+        $this->assertSame(0, DB::table('activity_reward_recipients')->count());
     }
 
     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 (['unknown', 'third:8', 'wallet', 'all', '其他', '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']);
@@ -238,7 +279,7 @@ class ActivityCatalogTest extends TestCase
             $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['game_category_name']);
                 $this->assertSame('即充即送', $row['type_name']);
             }
             $seen = array_merge($seen, array_column($result['data'], 'id'));
@@ -257,6 +298,43 @@ class ActivityCatalogTest extends TestCase
         $this->assertSame($before, DB::table('activity_rewards')->orderBy('id')->get()->toJson());
     }
 
+    public function test_other_category_filter_combines_with_existing_filters_before_count_and_pagination(): void
+    {
+        $base = ['title' => '目标其他活动', 'type' => 3, 'status' => 0, 'lang' => 'zh', 'game_category' => 'other', 'publish_platforms' => ['H5']];
+        $matching = [];
+        for ($i = 1; $i <= 5; $i++) $matching[] = $this->rawActivity(['sort' => $i] + $base);
+        foreach ([['game_category' => 'third:1'], ['game_category' => null], ['publish_platforms' => ['PC']],
+            ['status' => 1], ['lang' => 'en'], ['title' => '不同标题'], ['type' => 4]] as $extra) {
+            $this->rawActivity($extra + ['sort' => 100] + $base);
+        }
+        $before = DB::table('activity_rewards')->orderBy('id')->get()->toJson();
+        $matching = array_reverse($matching);
+        $filters = ['title' => '目标', 'type' => '3', 'status' => '0', 'lang' => 'zh', 'platform' => 'h5', 'game_category' => 'other', '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('other', $row['game_category']);
+                $this->assertSame('其他', $row['game_category_name']);
+            }
+            $seen = array_merge($seen, array_column($result['data'], 'id'));
+        }
+        $this->assertSame($matching, $seen);
+        $all = $this->listing();
+        $this->assertSame(12, $all['total']);
+        foreach ([null, ''] as $category) {
+            $page = $this->listing(['game_category' => $category]);
+            $this->assertSame($all['total'], $page['total']);
+            $this->assertSame(array_column($all['data'], 'id'), array_column($page['data'], 'id'));
+        }
+        $defaultRows = array_filter($all['data'], fn ($row) => $row['game_category'] === null);
+        $this->assertCount(1, $defaultRows);
+        $this->assertNull(array_values($defaultRows)[0]['game_category_name']);
+        $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]);
@@ -269,7 +347,7 @@ class ActivityCatalogTest extends TestCase
         $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('F1彩票', $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]);