doge 7 godzin temu
rodzic
commit
cc3e70c640

+ 1 - 1
app/Http/Controllers/admin/ActivityReward.php

@@ -99,7 +99,7 @@ class ActivityReward extends Controller
                 '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(), ['click_type' => true, 'link' => true]));
+            $params = array_merge($params, array_intersect_key(request()->all(), ['click_type' => true, 'link' => true, 'click_position' => true]));
             // 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']);

+ 9 - 3
app/Models/ActivityReward.php

@@ -23,9 +23,9 @@ class ActivityReward extends BaseModel
     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',
         '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', 'game_category', 'click_type', 'link'];
+        'register_platforms', 'deposit_limited', 'deposit_min', 'deposit_max', 'show_ineligible', 'game_category', 'click_type', 'link', 'click_position'];
 
-    protected $appends = ['type_name', 'game_category_name', 'click_type', 'link', 'can_jump'];
+    protected $appends = ['type_name', 'game_category_name', 'click_type', 'link', 'click_position', 'can_jump'];
 
     protected function getClickTypeAttribute($value): string
     {
@@ -40,7 +40,13 @@ class ActivityReward extends BaseModel
 
     protected function getCanJumpAttribute(): bool
     {
-        return in_array($this->click_type, ['external', 'internal'], true) && !empty($this->link);
+        return in_array($this->click_type, ['external', 'internal'], true) && !empty($this->link) && $this->click_position !== null;
+    }
+
+    protected function getClickPositionAttribute($value): ?string
+    {
+        $value = $value ?? ($this->attributes['click_position'] ?? null);
+        return $this->click_type !== 'none' && in_array($value, ['cover', 'detail'], true) ? $value : null;
     }
 
     protected $casts = [

+ 6 - 3
app/Services/ActivityRewardService.php

@@ -204,11 +204,14 @@ class ActivityRewardService extends BaseService
 
     private static function clickBehavior(array $params, ?ActivityReward $existing): array
     {
-        if (!array_key_exists('click_type', $params) && !array_key_exists('link', $params)) return [];
+        if (!array_key_exists('click_type', $params) && !array_key_exists('link', $params) && !array_key_exists('click_position', $params)) return [];
         $type = array_key_exists('click_type', $params) ? $params['click_type'] : ($existing?->click_type ?? 'none');
         Validator::make(['click_type' => $type], ['click_type' => ['required', 'string', Rule::in(array_keys(AnnouncementCatalog::CLICKS))]],
             ['click_type.in' => '点击方式仅支持无链接、外站链接、站内路径'])->validate();
-        if ($type === 'none') return ['click_type' => 'none', 'link' => null];
+        if ($type === 'none') return ['click_type' => 'none', 'link' => null, 'click_position' => null];
+        $position = array_key_exists('click_position', $params) ? $params['click_position'] : $existing?->click_position;
+        Validator::make(['click_position' => $position], ['click_position' => ['required', 'string', Rule::in(['cover', 'detail'])]],
+            ['click_position.required' => '请选择链接触发位置', 'click_position.in' => '链接触发位置仅支持活动列表封面或活动详情按钮'])->validate();
         $link = array_key_exists('link', $params) ? $params['link'] : $existing?->link;
         Validator::make(['link' => $link], ['link' => ['required', 'string', 'max:2048']], ['link.required' => '请填写跳转链接'])->validate();
         $valid = !preg_match('/[\x00-\x20\x7f\\\\]/', $link);
@@ -219,7 +222,7 @@ class ActivityRewardService extends BaseService
                 && in_array(strtolower($parts['scheme'] ?? ''), ['http', 'https'], true) && !isset($parts['user']) && !isset($parts['pass']);
         }
         if (!$valid) throw ValidationException::withMessages(['link' => '请填写有效的外站链接或站内相对路径']);
-        return ['click_type' => $type, 'link' => $link];
+        return ['click_type' => $type, 'link' => $link, 'click_position' => $position];
     }
 
     public static function promotionalActivity($chatId): array

+ 2 - 1
app/Support/ActivityCatalog.php

@@ -27,6 +27,7 @@ final class ActivityCatalog
             $activityTypes[] = ['value' => $value, 'label' => $label];
         }
         return ['game_types' => $gameTypes, 'activity_types' => $activityTypes,
-            'click_types' => AnnouncementCatalog::options(AnnouncementCatalog::CLICKS)];
+            'click_types' => AnnouncementCatalog::options(AnnouncementCatalog::CLICKS),
+            'click_positions' => [['value' => 'cover', 'label' => '活动列表封面'], ['value' => 'detail', 'label' => '活动详情按钮']]];
     }
 }

+ 19 - 0
database/migrations/2026_10_28_130000_add_activity_click_position.php

@@ -0,0 +1,19 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration {
+    public function up(): void
+    {
+        Schema::table('activity_rewards', function (Blueprint $t) { $t->string('click_position', 16)->nullable(); });
+        DB::table('activity_rewards')->whereIn('click_type', ['external', 'internal'])->update(['click_position' => 'cover']);
+    }
+
+    public function down(): void
+    {
+        Schema::table('activity_rewards', function (Blueprint $t) { $t->dropColumn('click_position'); });
+    }
+};

+ 15 - 9
docs/前端接口/活动/点击行为-后台接入.md

@@ -1,21 +1,27 @@
 # 活动列表:点击行为
 
-活动新增、编辑表单增加「点击行为」,选项读取 `GET /admin/ActivityReward/options` 的 `data.click_types`。
+活动新增、编辑表单使用 `GET /admin/ActivityReward/options` 返回的选项:
+
+- `data.click_types`:点击行为。
+- `data.click_positions`:链接触发位置。
 
 | click_type | 表单要求 |
 | --- | --- |
-| `none` 无链接(默认) | 隐藏链接输入框 |
-| `external` 外站链接 | 必填HTTP/HTTPS `link` |
-| `internal` 站内相对路径 | 必填以 `/` 开头的 `link` |
+| `none` 无链接 | 隐藏链接输入和触发位置,不校验这两项 |
+| `external` 外站链接 | 必填HTTP/HTTPS `link` 和 `click_position` |
+| `internal` 站内相对路径 | 必填以 `/` 开头的 `link` 和 `click_position` |
 
-原保存接口 `POST /admin/ActivityReward/submit` 增加两个根字段:`click_type`、`link`,不要放入 `params`。其他原有字段照常提交。
+| click_position | 含义 |
+| --- | --- |
+| `cover` 活动列表封面 | 点击前台活动封面直接跳转链接 |
+| `detail` 活动详情按钮 | 封面正常进入活动详情,详情显示带此链接的按钮 |
 
-新增字段示例:
+原保存接口 `POST /admin/ActivityReward/submit` 增加根字段 `click_type/link/click_position`,其他字段照常提交,不放入 `params`。
 
 ```json
-{"click_type":"external","link":"https://example.com/promotion"}
+{"click_type":"external","link":"https://example.com/promotion","click_position":"cover"}
 ```
 
-`link` 最长2048字符。选「无链接」时传 `click_type=none`,链接清空;新增不传时默认无链接,编辑不传时保留原配置。
+`link` 最长2048字符。选「无链接」时,链接和位置会清空;编辑时省略这三个字段则保留原配置。
 
-后台活动列表 `data.data[]` 返回 `click_type/link/can_jump`,编辑时用于回填。
+后台列表 `data.data[]` 返回 `click_type/link/click_position/can_jump`,编辑时全部回填。

+ 10 - 0
docs/后端/活动点击行为-升级说明.md

@@ -11,3 +11,13 @@ php artisan migrate --path=database/migrations/2026_10_28_120000_add_activity_cl
 点击行为与活动资格、发奖条件独立,type=2的资格例外保持原规则。领取审核目前只从分享佣金申请读取数据,`audit_type=agent_commission` 对应该接口全部申请,状态等筛选继续生效,不混入独立活动记录。
 
 尚未在运行环境执行迁移或部署。
+
+## 链接触发位置
+
+在上面的点击行为迁移已执行后,再执行:
+
+```bash
+php artisan migrate --path=database/migrations/2026_10_28_130000_add_activity_click_position.php --force
+```
+
+新增 nullable `click_position`,旧的外站/站内链接初始化为 `cover`,保留原封面触发方式;原无链接记录保持null。新配置非none时必须选择cover或detail,none时位置清空。先迁移再发布两个项目代码,会员列表会读取该列。

+ 44 - 4
tests/Integration/ActivityCatalogTest.php

@@ -67,6 +67,7 @@ class ActivityCatalogTest extends TestCase
         (require dirname(__DIR__, 2) . '/database/migrations/2026_10_14_120000_add_activity_reward_quotas.php')->up();
         $this->migration()->up();
         (require dirname(__DIR__, 2) . '/database/migrations/2026_10_28_120000_add_activity_click_behavior.php')->up();
+        (require dirname(__DIR__, 2) . '/database/migrations/2026_10_28_130000_add_activity_click_position.php')->up();
         $this->request('');
     }
 
@@ -173,22 +174,61 @@ class ActivityCatalogTest extends TestCase
             $id = (int) DB::table('activity_rewards')->latest('id')->value('id');
             $this->assertSame('none', DB::table('activity_rewards')->where('id', $id)->value('click_type'));
             foreach (['external' => 'https://example.com/promotion?from=activity', 'internal' => '/promotion/detail?id=3'] as $mode => $link) {
-                $body = $this->save(['id' => $id, 'type' => $type, 'click_type' => $mode, 'link' => $link]);
+                $body = $this->save(['id' => $id, 'type' => $type, 'click_type' => $mode, 'link' => $link, 'click_position' => $mode === 'external' ? 'cover' : 'detail']);
                 $this->assertSame(HttpStatus::OK, $body['code'], $body['msg']);
                 $this->assertSame(HttpStatus::OK, $this->save(['id' => $id, 'type' => $type, 'title' => '只改标题'])['code']);
                 $row = collect($this->listing()['data'])->keyBy('id')->get($id);
                 $this->assertSame($mode, $row['click_type']); $this->assertSame($link, $row['link']); $this->assertTrue($row['can_jump']);
+                $this->assertSame($mode === 'external' ? 'cover' : 'detail', $row['click_position']);
             }
-            $this->assertSame(HttpStatus::OK, $this->save(['id' => $id, 'type' => $type, 'click_type' => 'none', 'link' => ['unused']])['code']);
+            $this->assertSame(HttpStatus::OK, $this->save(['id' => $id, 'type' => $type, 'click_type' => 'none', 'link' => ['unused'], 'click_position' => ['unused']])['code']);
             $row = ActivityReward::findOrFail($id)->toArray();
             $this->assertSame('none', $row['click_type']); $this->assertNull($row['link']); $this->assertFalse($row['can_jump']);
+            $this->assertNull($row['click_position']);
+            $this->assertNull(DB::table('activity_rewards')->where('id', $id)->value('click_position'));
             $this->assertNull(DB::table('activity_rewards')->where('id', $id)->value('link'));
         }
     }
 
+    public function test_nonempty_links_require_a_trigger_position_and_can_switch_position_independently(): void
+    {
+        $this->request('options');
+        $this->assertSame([['value' => 'cover', 'label' => '活动列表封面'], ['value' => 'detail', 'label' => '活动详情按钮']],
+            (new AdminActivityReward())->options()->getData(true)['data']['click_positions']);
+        foreach (['external' => 'https://example.com', 'internal' => '/detail'] as $mode => $link) {
+            foreach ([null, '', 'invalid', ['cover']] as $position) {
+                $this->assertSame(HttpStatus::CUSTOM_ERROR, $this->save(['click_type' => $mode, 'link' => $link, 'click_position' => $position])['code']);
+            }
+            $this->assertSame(HttpStatus::CUSTOM_ERROR, $this->save(['click_type' => $mode, 'link' => $link])['code']);
+            $this->assertSame(0, DB::table('activity_rewards')->count());
+        }
+        $this->save(['click_type' => 'internal', 'link' => '/detail', 'click_position' => 'cover']);
+        $id = (int) DB::table('activity_rewards')->value('id');
+        $this->assertSame(HttpStatus::OK, $this->save(['id' => $id, 'click_position' => 'detail'])['code']);
+        $row = ActivityReward::findOrFail($id)->toArray();
+        $this->assertSame('detail', $row['click_position']); $this->assertSame('/detail', $row['link']);
+        $this->assertSame('internal', $row['click_type']); $this->assertTrue($row['can_jump']);
+    }
+
+    public function test_trigger_position_migration_keeps_old_links_on_the_cover(): void
+    {
+        $migration = require dirname(__DIR__, 2) . '/database/migrations/2026_10_28_130000_add_activity_click_position.php';
+        $migration->down();
+        $linked = $this->rawActivity(['click_type' => 'external', 'link' => 'https://example.com']);
+        $plain = $this->rawActivity();
+        $before = DB::table('activity_rewards')->orderBy('id')->get();
+        $migration->up();
+        $this->assertSame('cover', DB::table('activity_rewards')->where('id', $linked)->value('click_position'));
+        $this->assertNull(DB::table('activity_rewards')->where('id', $plain)->value('click_position'));
+        foreach ($before as $old) {
+            $new = DB::table('activity_rewards')->where('id', $old->id)->first();
+            foreach ((array) $old as $field => $value) $this->assertSame($value, $new->$field);
+        }
+    }
+
     public function test_invalid_activity_links_do_not_modify_the_activity(): void
     {
-        $this->save(['click_type' => 'internal', 'link' => '/original']);
+        $this->save(['click_type' => 'internal', 'link' => '/original', 'click_position' => 'cover']);
         $id = (int) DB::table('activity_rewards')->value('id');
         $before = DB::table('activity_rewards')->where('id', $id)->first();
         foreach ([['click_type' => 'activity'], ['click_type' => null], ['click_type' => ['none']],
@@ -196,7 +236,7 @@ class ActivityCatalogTest extends TestCase
             ['click_type' => 'external', 'link' => 'https://user:pass@example.com'], ['click_type' => 'external', 'link' => '/relative'],
             ['click_type' => 'internal', 'link' => '//example.com'], ['click_type' => 'internal', 'link' => '/\\evil'],
             ['click_type' => 'internal', 'link' => 'https://example.com'], ['click_type' => 'external', 'link' => ['bad']]] as $values) {
-            $body = $this->save(['id' => $id] + $values);
+            $body = $this->save(['id' => $id] + $values + ['click_position' => 'cover']);
             $this->assertSame(HttpStatus::CUSTOM_ERROR, $body['code']);
             $this->assertEquals($before, DB::table('activity_rewards')->where('id', $id)->first());
         }

+ 1 - 1
tests/Regression/activity_site_eligibility.php

@@ -47,7 +47,7 @@ namespace {
         'bot_user_login' => 'id INTEGER PRIMARY KEY,user_id TEXT,status INTEGER,platform TEXT',
         'bot_balance_logs' => 'id INTEGER PRIMARY KEY,member_id TEXT,type INTEGER,change_type TEXT,amount TEXT,created_at TEXT,updated_at TEXT,remark TEXT,before_balance TEXT,after_balance TEXT,related_id INTEGER,frozen_status INTEGER',
         'bot_recharge_channel_group' => 'id INTEGER PRIMARY KEY,activity_type TEXT',
-        'bot_activity_rewards' => 'id INTEGER PRIMARY KEY,title TEXT,sub_title TEXT,start_time INTEGER,end_time INTEGER,detail_image TEXT,type INTEGER,lang TEXT,game_category TEXT,click_type TEXT DEFAULT "none",link TEXT,pc_image TEXT,detail_banner TEXT,pc_banner TEXT,publish_platforms TEXT,budget TEXT,budget_used TEXT DEFAULT "0",quota_enabled INTEGER DEFAULT 0,reward_quota INTEGER,rewarded_count INTEGER DEFAULT 0,sort INTEGER DEFAULT 0,register_time_limited INTEGER,register_start_time TEXT,register_end_time TEXT,register_platforms TEXT,deposit_limited INTEGER,deposit_min TEXT,deposit_max TEXT,show_ineligible INTEGER DEFAULT 1,status INTEGER,params TEXT,created_at TEXT,updated_at TEXT',
+        'bot_activity_rewards' => 'id INTEGER PRIMARY KEY,title TEXT,sub_title TEXT,start_time INTEGER,end_time INTEGER,detail_image TEXT,type INTEGER,lang TEXT,game_category TEXT,click_type TEXT DEFAULT "none",link TEXT,click_position TEXT,pc_image TEXT,detail_banner TEXT,pc_banner TEXT,publish_platforms TEXT,budget TEXT,budget_used TEXT DEFAULT "0",quota_enabled INTEGER DEFAULT 0,reward_quota INTEGER,rewarded_count INTEGER DEFAULT 0,sort INTEGER DEFAULT 0,register_time_limited INTEGER,register_start_time TEXT,register_end_time TEXT,register_platforms TEXT,deposit_limited INTEGER,deposit_min TEXT,deposit_max TEXT,show_ineligible INTEGER DEFAULT 1,status INTEGER,params TEXT,created_at TEXT,updated_at TEXT',
         'bot_activity_reward_recipients' => 'id INTEGER PRIMARY KEY,activity_id INTEGER NOT NULL,member_id TEXT NOT NULL,created_at TEXT,UNIQUE(activity_id,member_id)',
         'bot_wallets' => 'id INTEGER PRIMARY KEY,member_id TEXT,available_balance TEXT,frozen_balance TEXT,yuebao TEXT,yuebao_invest TEXT',
         'bot_yuebao_item' => 'id INTEGER PRIMARY KEY,user_id TEXT,activity_id INTEGER,principal TEXT,surplus_principal TEXT,status INTEGER DEFAULT 1,daily_interest_rate TEXT,expire_time INTEGER,create_time INTEGER DEFAULT 0,update_time INTEGER',