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

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

@@ -99,6 +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]));
             // 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']);

+ 1 - 0
app/Http/Controllers/admin/SharingManagement.php

@@ -39,6 +39,7 @@ class SharingManagement extends Controller
         });
     }
     public function claims(ShareClaimAuditService $service) { return $this->run(fn () => $service->list(request()->all())); }
+    public function claimOptions(ShareClaimAuditService $service) { return $this->run(fn () => $service->options()); }
     public function claimDetail(ShareClaimAuditService $service)
     {
         return $this->run(function () use ($service) {

+ 18 - 2
app/Models/ActivityReward.php

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

+ 23 - 1
app/Services/ActivityRewardService.php

@@ -12,6 +12,8 @@ use App\Models\User;
 use Illuminate\Support\Facades\Validator;
 use Illuminate\Validation\Rule;
 use App\Support\ActivityCatalog;
+use App\Support\AnnouncementCatalog;
+use Illuminate\Validation\ValidationException;
 use Exception;
 
 class ActivityRewardService extends BaseService
@@ -174,7 +176,7 @@ class ActivityRewardService extends BaseService
             ])->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), static::clickBehavior($params, $info));
         unset($params['rewarded_count'], $params['quota_enabled'], $params['budget_used']); // Server-owned fields.
         if (isset($params['start_time']))
             $params['start_time'] = strtotime($params['start_time']);
@@ -200,6 +202,26 @@ 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 [];
+        $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];
+        $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);
+        if ($type === 'internal') $valid = $valid && str_starts_with($link, '/') && !str_starts_with($link, '//');
+        else {
+            $parts = parse_url($link);
+            $valid = $valid && filter_var($link, FILTER_VALIDATE_URL) && $parts
+                && 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];
+    }
+
     public static function promotionalActivity($chatId): array
     {
         $keyboard = [];

+ 10 - 1
app/Services/Sharing/ShareClaimAuditService.php

@@ -14,11 +14,20 @@ use Illuminate\Validation\ValidationException;
 
 class ShareClaimAuditService
 {
+    private const AUDIT_TYPE = 'agent_commission';
+
+    public function options(): array
+    {
+        return ['audit_types' => [['value' => self::AUDIT_TYPE, 'label' => '代理佣金']]];
+    }
+
     public function list(array $filters): array
     {
         $params = Validator::make($filters, [
             'page' => ['nullable', 'integer', 'min:1'], 'limit' => ['nullable', 'integer', 'min:1', 'max:200'],
             'status' => ['nullable', Rule::in(['pending', 'approved', 'rejected'])],
+            // This endpoint currently reads only agent-commission claims.
+            'audit_type' => ['nullable', 'string', Rule::in([self::AUDIT_TYPE])],
             'account' => ['nullable', 'string', 'max:100'], 'user_id' => ['nullable', 'integer', 'min:1'],
             'start_date' => ['nullable', 'date_format:Y-m-d'],
             'end_date' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:start_date'],
@@ -103,7 +112,7 @@ class ShareClaimAuditService
 
     private function claim(object $row): array
     {
-        return ['id' => (int)$row->id, 'activity_name' => '代理佣金',
+        return ['id' => (int)$row->id, 'audit_type' => self::AUDIT_TYPE, 'activity_name' => '代理佣金',
             'user_id' => (int)$row->user_id, 'member_id' => (string)($row->member_id ?? ''),
             'account' => (string)($row->account ?? ''), 'nickname' => (string)($row->nickname ?? ''), 'request_id' => (string)$row->request_id,
             'amount' => $this->money($row->amount), 'status' => $row->status,

+ 2 - 1
app/Support/ActivityCatalog.php

@@ -26,6 +26,7 @@ final class ActivityCatalog
         foreach (self::activityTypes() as $value => $label) {
             $activityTypes[] = ['value' => $value, 'label' => $label];
         }
-        return ['game_types' => $gameTypes, 'activity_types' => $activityTypes];
+        return ['game_types' => $gameTypes, 'activity_types' => $activityTypes,
+            'click_types' => AnnouncementCatalog::options(AnnouncementCatalog::CLICKS)];
     }
 }

+ 20 - 0
database/migrations/2026_10_28_120000_add_activity_click_behavior.php

@@ -0,0 +1,20 @@
+<?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 $t) {
+            $t->string('click_type', 16)->default('none');
+            $t->string('link', 2048)->nullable();
+        });
+    }
+
+    public function down(): void
+    {
+        Schema::table('activity_rewards', function (Blueprint $t) { $t->dropColumn(['click_type', 'link']); });
+    }
+};

+ 19 - 0
docs/前端接口/新分享系统/领取审核-审核类型筛选.md

@@ -0,0 +1,19 @@
+# 领取审核:审核类型筛选
+
+领取审核列表增加「审核类型」下拉框,按所属活动筛选。
+
+`GET /admin/shareEarning/claims/options` 返回 `data.audit_types`:
+
+```json
+[{"value":"agent_commission","label":"代理佣金"}]
+```
+
+下拉框加「全部」选项。选择全部时不传 `audit_type`,选择代理佣金时请求:
+
+```http
+GET /admin/shareEarning/claims?audit_type=agent_commission&page=1&limit=20
+```
+
+当前接口只有代理佣金这一类申请。原有 `status/account/user_id/start_date/end_date` 筛选继续使用,可与 `audit_type` 同时提交。列表和详情新增只读 `audit_type` 字段,所属活动文字仍展示 `activity_name`。
+
+`audit_type` 只用于列表筛选,不提交到审核POST接口。

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

@@ -0,0 +1,21 @@
+# 活动列表:点击行为
+
+活动新增、编辑表单增加「点击行为」,选项读取 `GET /admin/ActivityReward/options` 的 `data.click_types`。
+
+| click_type | 表单要求 |
+| --- | --- |
+| `none` 无链接(默认) | 隐藏链接输入框 |
+| `external` 外站链接 | 必填HTTP/HTTPS `link` |
+| `internal` 站内相对路径 | 必填以 `/` 开头的 `link` |
+
+原保存接口 `POST /admin/ActivityReward/submit` 增加两个根字段:`click_type`、`link`,不要放入 `params`。其他原有字段照常提交。
+
+新增字段示例:
+
+```json
+{"click_type":"external","link":"https://example.com/promotion"}
+```
+
+`link` 最长2048字符。选「无链接」时传 `click_type=none`,链接清空;新增不传时默认无链接,编辑不传时保留原配置。
+
+后台活动列表 `data.data[]` 返回 `click_type/link/can_jump`,编辑时用于回填。

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

@@ -0,0 +1,13 @@
+# 活动点击行为升级
+
+先在 `bot-28` 项目、两个项目共用的业务库执行:
+
+```bash
+php artisan migrate --path=database/migrations/2026_10_28_120000_add_activity_click_behavior.php --force
+```
+
+增加 `activity_rewards.click_type/link`,原有活动默认 `none/null`。随后发布两个项目代码。无需新增菜单或按钮;领取审核的选项接口复用原领取审核列表权限。
+
+点击行为与活动资格、发奖条件独立,type=2的资格例外保持原规则。领取审核目前只从分享佣金申请读取数据,`audit_type=agent_commission` 对应该接口全部申请,状态等筛选继续生效,不混入独立活动记录。
+
+尚未在运行环境执行迁移或部署。

+ 1 - 0
routes/admin.php

@@ -347,6 +347,7 @@ Route::middleware(['admin.jwt'])->group(function () {
             Route::post('/levels/save', [SharingManagement::class, 'saveLevel']);
             Route::post('/levels/delete', [SharingManagement::class, 'deleteLevel']);
             Route::get('/claims', [SharingManagement::class, 'claims']);
+            Route::get('/claims/options', [SharingManagement::class, 'claimOptions'])->defaults('permission_uri', 'admin/shareEarning/claims');
             Route::get('/claims/detail', [SharingManagement::class, 'claimDetail']);
             Route::post('/claims/audit', [SharingManagement::class, 'auditClaim']);
             Route::get('/rateRules', [SharingManagement::class, 'rateRules']);

+ 41 - 0
tests/Integration/ActivityCatalogTest.php

@@ -66,6 +66,7 @@ class ActivityCatalogTest extends TestCase
         (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();
+        (require dirname(__DIR__, 2) . '/database/migrations/2026_10_28_120000_add_activity_click_behavior.php')->up();
         $this->request('');
     }
 
@@ -161,6 +162,46 @@ class ActivityCatalogTest extends TestCase
         $this->assertSame(0, DB::table('activity_reward_recipients')->count());
     }
 
+    public function test_activity_click_behavior_saves_preserves_and_clears_for_normal_and_type_two_activities(): void
+    {
+        $this->request('options');
+        $this->assertSame(\App\Support\AnnouncementCatalog::options(\App\Support\AnnouncementCatalog::CLICKS),
+            (new AdminActivityReward())->options()->getData(true)['data']['click_types']);
+        foreach ([2, 3] as $type) {
+            $body = $this->save(['type' => $type]);
+            $this->assertSame(HttpStatus::OK, $body['code']);
+            $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]);
+                $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(HttpStatus::OK, $this->save(['id' => $id, 'type' => $type, 'click_type' => 'none', 'link' => ['unused']])['code']);
+            $row = ActivityReward::findOrFail($id)->toArray();
+            $this->assertSame('none', $row['click_type']); $this->assertNull($row['link']); $this->assertFalse($row['can_jump']);
+            $this->assertNull(DB::table('activity_rewards')->where('id', $id)->value('link'));
+        }
+    }
+
+    public function test_invalid_activity_links_do_not_modify_the_activity(): void
+    {
+        $this->save(['click_type' => 'internal', 'link' => '/original']);
+        $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']],
+            ['click_type' => 'external', 'link' => null], ['click_type' => 'external', 'link' => 'javascript:alert(1)'],
+            ['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);
+            $this->assertSame(HttpStatus::CUSTOM_ERROR, $body['code']);
+            $this->assertEquals($before, DB::table('activity_rewards')->where('id', $id)->first());
+        }
+    }
+
     public function test_new_omission_is_null_edit_omission_preserves_category_and_explicit_values_clear_it(): void
     {
         $result = $this->save();

+ 25 - 0
tests/Integration/ShareAdminManagementTest.php

@@ -430,6 +430,31 @@ class ShareAdminManagementTest extends TestCase
         $this->assertSame(0, DB::table('balance_logs')->count());
     }
 
+    public function test_claim_audit_type_options_and_filter_preserve_existing_filters(): void
+    {
+        $id = $this->pending();
+        $this->assertSame([['value' => 'agent_commission', 'label' => '代理佣金']], $this->claims->options()['audit_types']);
+        $all = $this->claims->list([]);
+        $filtered = $this->claims->list(['audit_type' => 'agent_commission', 'status' => 'pending', 'account' => 'member7', 'limit' => 1]);
+        $this->assertSame($all['total'], $filtered['total']);
+        $this->assertSame($id, $filtered['data'][0]['id']);
+        $this->assertSame('agent_commission', $filtered['data'][0]['audit_type']);
+        $this->assertSame(0, $this->claims->list(['audit_type' => 'agent_commission', 'status' => 'approved'])['total']);
+        $this->assertSame($all['total'], $this->claims->list(['audit_type' => ''])['total']);
+        foreach (['invalid', 'pending', ['agent_commission']] as $type) {
+            try { $this->claims->list(['audit_type' => $type]); $this->fail('Invalid audit type accepted'); }
+            catch (ValidationException $e) { $this->assertArrayHasKey('audit_type', $e->errors()); }
+        }
+        $this->assertSame(0, (new SharingManagement())->claimOptions($this->claims)->getData(true)['code']);
+        $router = new \Illuminate\Routing\Router(new \Illuminate\Events\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/shareEarning/claims/options'));
+        $this->assertSame('admin/shareEarning/claims', $route->defaults['permission_uri']);
+        $this->assertContains('operations.admin', $route->middleware());
+        $this->assertContains('admin.jwt', $route->middleware());
+    }
+
     public function test_admin_controller_envelopes_and_filtered_lists(): void
     {
         $id = $this->pending();

+ 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,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,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',