doge пре 5 часа
родитељ
комит
470f129772

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

@@ -97,7 +97,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, 'click_position' => true]));
+            $params = array_merge($params, array_intersect_key(request()->all(), ['click_type' => true, 'link' => true, 'link_pc' => true, 'link_h5' => 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']);

+ 6 - 0
app/Http/Controllers/api/ActivityReward.php

@@ -16,6 +16,7 @@ use App\Services\ConfigService;
 use App\Services\PhoneCodeService;
 use App\Services\SmsService;
 use App\Services\UserService;
+use App\Support\ContentLink;
 use Illuminate\Http\JsonResponse;
 use Illuminate\Support\Facades\Cache;
 use Illuminate\Support\Facades\DB;
@@ -202,6 +203,11 @@ class ActivityReward extends BaseController
                 unset($activity->activityUser);
                 return $activity;
             })->toArray();
+            $list = array_map(function (array $row) use ($platform) {
+                $row = array_replace($row, ContentLink::forPlatform($row, $platform));
+                $row['can_jump'] = $row['click_type'] !== 'none' && !empty($row['link']) && $row['click_position'] !== null;
+                return $row;
+            }, $list);
         } catch (ValidationException $e) {
             return $this->error($e->validator->errors()->first());
         } catch (Exception $e) {

+ 8 - 3
app/Models/ActivityReward.php

@@ -5,6 +5,7 @@ namespace App\Models;
 use App\Constants\Util;
 use Illuminate\Database\Eloquent\Relations\HasMany;
 use App\Support\ActivityCatalog;
+use App\Support\ContentLink;
 
 /**
  * @property string $title
@@ -24,9 +25,9 @@ class ActivityReward extends BaseModel
     protected $hidden = ['created_at', 'updated_at', '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',
-        'register_platforms', 'deposit_limited', 'deposit_min', 'deposit_max', 'show_ineligible', 'game_category', 'click_type', 'link', 'click_position'];
+        'register_platforms', 'deposit_limited', 'deposit_min', 'deposit_max', 'show_ineligible', 'game_category', 'click_type', 'link', 'link_pc', 'link_h5', 'click_position'];
 
-    protected $appends = ['type_name', 'game_category_name', 'click_type', 'link', 'click_position', 'can_jump'];
+    protected $appends = ['type_name', 'game_category_name', 'click_type', 'link', 'link_pc', 'link_h5', 'click_position', 'can_jump'];
 
     protected function getClickTypeAttribute($value): string
     {
@@ -41,9 +42,13 @@ class ActivityReward extends BaseModel
 
     protected function getCanJumpAttribute(): bool
     {
-        return in_array($this->click_type, ['external', 'internal'], true) && !empty($this->link) && $this->click_position !== null;
+        $hasLink = $this->click_type === 'internal' ? !empty($this->link_pc) && !empty($this->link_h5) : $this->click_type === 'external' && !empty($this->link);
+        return $hasLink && $this->click_position !== null;
     }
 
+    protected function getLinkPcAttribute(): ?string { return ContentLink::normalize($this->attributes)['link_pc']; }
+    protected function getLinkH5Attribute(): ?string { return ContentLink::normalize($this->attributes)['link_h5']; }
+
     protected function getClickPositionAttribute($value): ?string
     {
         $value = $value ?? ($this->attributes['click_position'] ?? null);

+ 5 - 18
app/Services/ActivityRewardService.php

@@ -12,8 +12,7 @@ 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 App\Support\ContentLink;
 use Exception;
 
 class ActivityRewardService extends BaseService
@@ -204,25 +203,13 @@ 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) && !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, 'click_position' => null];
+        if (!array_intersect_key($params, array_flip(array_merge(ContentLink::FIELDS, ['click_position'])))) return [];
+        $links = ContentLink::forSave($params, $existing?->getAttributes() ?? []);
+        if ($links['click_type'] === 'none') return $links + ['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);
-        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, 'click_position' => $position];
+        return $links + ['click_position' => $position];
     }
 
     public static function promotionalActivity($chatId): array

+ 8 - 17
app/Services/AnnouncementService.php

@@ -5,6 +5,7 @@ namespace App\Services;
 use App\Constants\Util;
 use App\Support\AnnouncementCatalog as Catalog;
 use App\Support\LocalizedContent;
+use App\Support\ContentLink;
 use Carbon\Carbon;
 use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\Validator;
@@ -14,7 +15,7 @@ use Illuminate\Validation\ValidationException;
 class AnnouncementService
 {
     private const FIELDS = ['category', 'publish_platforms', 'display_frequency',
-        'publish_at', 'end_at', 'status', 'click_type', 'link'];
+        'publish_at', 'end_at', 'status', 'click_type', 'link', 'link_pc', 'link_h5'];
 
     public function options(): array
     {
@@ -94,6 +95,8 @@ class AnnouncementService
             'status' => ['sometimes', 'integer', Rule::in([0, 1])],
             'click_type' => ['sometimes', 'required', Rule::in(array_keys(Catalog::CLICKS))],
             'link' => ['nullable', 'string', 'max:2048'],
+            'link_pc' => ['nullable', 'string', 'max:2048'],
+            'link_h5' => ['nullable', 'string', 'max:2048'],
         ], ['click_type.in' => '点击方式仅支持无链接、外站链接、站内路径'])->validate();
 
         return DB::transaction(function () use ($params) {
@@ -107,7 +110,7 @@ class AnnouncementService
             }
             $row = $before ?: ['category' => 'content', 'publish_platforms' => array_keys(Catalog::PLATFORMS),
                 'display_frequency' => 'daily_first_login', 'publish_at' => $now, 'end_at' => null,
-                'status' => 1, 'click_type' => 'none', 'link' => null];
+                'status' => 1, 'click_type' => 'none', 'link' => null, 'link_pc' => null, 'link_h5' => null];
             foreach (self::FIELDS as $field) if (array_key_exists($field, $params)) $row[$field] = $params[$field];
             if (array_key_exists('translations', $params)) $row['translations'] = LocalizedContent::parse($params['translations'], 200, 20000);
             if (empty($row['translations'])) $this->invalid('translations', '至少填写一个语言的标题和正文');
@@ -133,9 +136,7 @@ class AnnouncementService
                 }
                 unset($block);
             }
-            if (in_array($row['click_type'], ['external', 'internal'], true)) {
-                if (!$this->validLink((string) $row['link'], $row['click_type'])) $this->invalid('link', '请填写有效的外站链接或站内相对路径');
-            } else { $row['link'] = null; }
+            $row = array_replace($row, ContentLink::forSave($params, $before));
             $row['status'] = (int) $row['status'];
             $actor = OperationAuditService::actor();
             if (!$actor['operator_id'] || $actor['operator_name'] === '') $this->invalid('sender_id', '请使用后台管理员身份');
@@ -245,25 +246,15 @@ class AnnouncementService
         if (isset($row['publish_platforms']) && is_string($row['publish_platforms'])) $row['publish_platforms'] = json_decode($row['publish_platforms'], true, 512, JSON_THROW_ON_ERROR);
         foreach (['id', 'sender_id', 'status'] as $field) if (isset($row[$field])) $row[$field] = (int) $row[$field];
         unset($row['activity_id']);
-        if (($row['click_type'] ?? 'none') === 'activity') { $row['click_type'] = 'none'; $row['link'] = null; }
-        return $row;
+        return array_replace($row, ContentLink::normalize($row));
     }
 
     private function imageAddress(string $value): bool
     {
-        if (!$this->validLink($value, str_starts_with($value, '/') ? 'internal' : 'external')) return false;
+        if (!ContentLink::valid($value, str_starts_with($value, '/') ? 'internal' : 'external')) return false;
         return (bool) preg_match('/\.(?:jpe?g|png|webp)$/i', (string) parse_url($value, PHP_URL_PATH));
     }
 
-    private function validLink(string $value, string $type): bool
-    {
-        if ($value === '' || preg_match('/[\x00-\x20\x7f\\\\]/', $value)) return false;
-        if ($type === 'internal') return str_starts_with($value, '/') && !str_starts_with($value, '//');
-        $parts = parse_url($value);
-        return filter_var($value, FILTER_VALIDATE_URL) && $parts && in_array(strtolower($parts['scheme'] ?? ''), ['http', 'https'], true)
-            && !isset($parts['user']) && !isset($parts['pass']);
-    }
-
     private function pageRules(): array { return ['page' => ['nullable', 'integer', 'min:1'], 'limit' => ['nullable', 'integer', 'min:1', 'max:200']]; }
     private function dateRangeRules(): array { return [
         'start_date' => ['nullable', 'required_with:end_date', 'date_format:Y-m-d'],

+ 81 - 0
app/Support/ContentLink.php

@@ -0,0 +1,81 @@
+<?php
+
+namespace App\Support;
+
+use Illuminate\Support\Facades\Validator;
+use Illuminate\Validation\Rule;
+use Illuminate\Validation\ValidationException;
+
+/** Shared announcement/activity link contract. */
+final class ContentLink
+{
+    public const FIELDS = ['click_type', 'link', 'link_pc', 'link_h5'];
+
+    public static function normalize(array $row): array
+    {
+        $type = $row['click_type'] ?? 'none';
+        $type = in_array($type, ['none', 'external', 'internal'], true) ? $type : 'none';
+        return [
+            'click_type' => $type,
+            'link' => $type !== 'none' ? ($row['link'] ?? null) : null,
+            'link_pc' => $type === 'internal' ? ($row['link_pc'] ?? $row['link'] ?? null) : null,
+            'link_h5' => $type === 'internal' ? ($row['link_h5'] ?? $row['link'] ?? null) : null,
+        ];
+    }
+
+    public static function forSave(array $input, array $previous = []): array
+    {
+        $before = self::normalize($previous);
+        $type = $input['click_type'] ?? $before['click_type'];
+        // An explicitly empty click type is invalid, rather than an omitted field.
+        if (array_key_exists('click_type', $input)) $type = $input['click_type'];
+        Validator::make(['click_type' => $type], ['click_type' => ['required', 'string', Rule::in(array_keys(AnnouncementCatalog::CLICKS))]],
+            ['click_type.in' => '点击方式仅支持无链接、外站链接、站内路径'])->validate();
+        $result = ['click_type' => $type, 'link' => null, 'link_pc' => null, 'link_h5' => null];
+        if ($type === 'none') return $result;
+        if ($type === 'external') {
+            $result['link'] = array_key_exists('link', $input) ? $input['link'] : ($before['click_type'] === $type ? $before['link'] : null);
+            self::validateLink('link', $result['link'], $type);
+            return $result;
+        }
+        $legacy = array_key_exists('link', $input) && !array_key_exists('link_pc', $input) && !array_key_exists('link_h5', $input);
+        foreach (['link_pc' => 'PC', 'link_h5' => 'H5'] as $field => $label) {
+            $result[$field] = $legacy ? $input['link'] : (array_key_exists($field, $input) ? $input[$field] : ($before['click_type'] === $type ? $before[$field] : null));
+            self::validateLink($field, $result[$field], $type, $label);
+        }
+        // Keep old callers functional; new dual-path submissions have no shared internal target.
+        if ($legacy) $result['link'] = $input['link'];
+        elseif (!array_key_exists('link_pc', $input) && !array_key_exists('link_h5', $input) && $before['click_type'] === $type) $result['link'] = $before['link'];
+        return $result;
+    }
+
+    public static function forPlatform(array $row, ?string $platform): array
+    {
+        $links = self::normalize($row);
+        if ($links['click_type'] === 'internal') {
+            $links['link'] = match ($platform) {
+                'PC' => $links['link_pc'], 'H5', 'APP' => $links['link_h5'],
+                default => $links['link_pc'] === $links['link_h5'] ? $links['link_pc'] : null,
+            };
+        }
+        return $links;
+    }
+
+    public static function valid(string $value, string $type): bool
+    {
+        if ($value === '' || preg_match('/[\x00-\x20\x7f\\\\]/', $value)) return false;
+        if ($type === 'internal') return str_starts_with($value, '/') && !str_starts_with($value, '//');
+        $parts = parse_url($value);
+        return filter_var($value, FILTER_VALIDATE_URL) && $parts && in_array(strtolower($parts['scheme'] ?? ''), ['http', 'https'], true)
+            && !isset($parts['user']) && !isset($parts['pass']);
+    }
+
+    private static function validateLink(string $field, $value, string $type, string $label = ''): void
+    {
+        Validator::make([$field => $value], [$field => ['required', 'string', 'max:2048']],
+            [$field . '.required' => $type === 'internal' ? '请填写' . $label . '站内相对路径' : '请填写跳转链接'])->validate();
+        if (!self::valid($value, $type)) throw ValidationException::withMessages([
+            $field => $type === 'internal' ? '请填写有效的' . $label . '站内相对路径' : '请填写有效的HTTP/HTTPS外站链接',
+        ]);
+    }
+}

+ 28 - 0
database/migrations/2026_10_30_120000_add_platform_links_to_announcements_and_activities.php

@@ -0,0 +1,28 @@
+<?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
+    {
+        foreach (['announcements', 'activity_rewards'] as $table) {
+            Schema::table($table, function (Blueprint $t) {
+                $t->string('link_pc', 2048)->nullable();
+                $t->string('link_h5', 2048)->nullable();
+            });
+            DB::table($table)->where('click_type', 'internal')->update([
+                'link_pc' => DB::raw('link'), 'link_h5' => DB::raw('link'),
+            ]);
+        }
+    }
+
+    public function down(): void
+    {
+        foreach (['announcements', 'activity_rewards'] as $table) {
+            Schema::table($table, function (Blueprint $t) { $t->dropColumn(['link_pc', 'link_h5']); });
+        }
+    }
+};

+ 30 - 0
docs/前端接口/公告PC与H5站内路径-前端接入.md

@@ -0,0 +1,30 @@
+# 公告:PC、H5 站内路径
+
+公告选择「站内相对路径」时,将原来的一个输入框改为「PC 路径」「H5 路径」两个输入框。
+
+`POST /admin/announcement/submit` 在原保存参数中提交:
+
+```json
+{"click_type":"internal","link_pc":"/pc/notice?id=7","link_h5":"/h5/notice?id=7"}
+```
+
+| 字段 | 填写要求 |
+| --- | --- |
+| `link_pc` | PC 站内路径,必填 |
+| `link_h5` | H5 站内路径,必填;APP 也使用此路径 |
+
+两个路径都必须以单个 `/` 开头,最长 2048 字符,可包含查询参数、锚点。其余保存参数照常提交;编辑时携带 `id`。
+
+`GET /admin/announcement` 的 `data.data[]`、`GET /admin/announcement/detail?id=7` 的 `data` 均返回这两个字段,编辑时分别回填。编辑时省略某一路径会保留原值。
+
+前台仍请求 `GET /api/announcement/lists?platform=PC`,H5 传 `platform=H5`,APP 传 `platform=APP`。`data.list[]` 示例:
+
+```json
+{"click_type":"internal","link_pc":"/pc/notice?id=7","link_h5":"/h5/notice?id=7","link":"/pc/notice?id=7","can_jump":true}
+```
+
+`link` 已按请求的 `platform` 选择对应路径。`can_jump=true` 时,用站内路由打开 `link`;否则不跳转。
+
+选择「外站链接」仍提交 `click_type=external` 和完整 HTTP/HTTPS `link`,不需要两个站内路径。选择「无链接」提交 `click_type=none`,全部链接清空。
+
+原来只配置一个站内路径的公告,PC、H5 会显示相同路径,可分别修改。

+ 40 - 0
docs/前端接口/活动/活动PC与H5站内路径-前端接入.md

@@ -0,0 +1,40 @@
+# 活动:PC、H5 站内路径
+
+活动选择「站内相对路径」时,将原来的一个输入框改为「PC 路径」「H5 路径」两个输入框。
+
+`POST /admin/ActivityReward/submit` 在原保存参数中提交以下根字段,不放入 `params`:
+
+```json
+{"click_type":"internal","link_pc":"/pc/promotion?id=5","link_h5":"/h5/promotion?id=5","click_position":"detail"}
+```
+
+| 字段 | 填写要求 |
+| --- | --- |
+| `link_pc` | PC 站内路径,必填 |
+| `link_h5` | H5 站内路径,必填;APP 也使用此路径 |
+| `click_position` | 必填,`cover` 活动封面;`detail` 详情按钮 |
+
+两个路径都必须以单个 `/` 开头,最长 2048 字符,可包含查询参数、锚点。其余保存参数照常提交;编辑时携带 `id`。
+
+`GET /admin/ActivityReward` 的 `data.data[]` 返回两个路径和 `click_position`,编辑时分别回填。编辑时省略某一路径会保留原值。
+
+前台接口均传当前端的 `platform=PC/H5/APP`:
+
+| 接口 | 字段位置 |
+| --- | --- |
+| `GET /api/index/activity` | `data.list[]` |
+| `GET /api/index/activityInfo?id=5` | `data` |
+
+```json
+{"click_type":"internal","link_pc":"/pc/promotion?id=5","link_h5":"/h5/promotion?id=5","link":"/h5/promotion?id=5","click_position":"detail","can_jump":true}
+```
+
+上例为 H5 返回值,`link` 已按请求的 `platform` 选择对应路径,APP 使用 H5 路径。
+
+- `can_jump=true`、`click_position=cover`:点击封面,用站内路由打开 `link`。
+- `can_jump=true`、`click_position=detail`:封面进入活动详情,详情按钮用站内路由打开 `link`。
+- `can_jump=false`:不执行额外跳转、不显示链接按钮,封面保留进入详情的原行为。
+
+选择「外站链接」仍提交 `click_type=external`、完整 HTTP/HTTPS `link` 和 `click_position`,不需要两个站内路径。选择「无链接」提交 `click_type=none`,链接和触发位置清空。
+
+原来只配置一个站内路径的活动,PC、H5 会显示相同路径,可分别修改。申请、领取等原有按钮沿用现有逻辑。

+ 48 - 0
tests/Integration/ActivityCatalogTest.php

@@ -68,6 +68,8 @@ class ActivityCatalogTest extends TestCase
         $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();
+        Schema::create('announcements', function (Blueprint $t) { $t->id(); $t->string('click_type'); $t->string('link', 2048)->nullable(); });
+        (require dirname(__DIR__, 2) . '/database/migrations/2026_10_30_120000_add_platform_links_to_announcements_and_activities.php')->up();
         $this->request('');
     }
 
@@ -121,6 +123,52 @@ class ActivityCatalogTest extends TestCase
         return DB::table('activity_rewards')->insertGetId($data);
     }
 
+    public function test_dual_routes_round_trip_through_admin_save_and_listing_with_cover_and_detail(): void
+    {
+        foreach (['cover', 'detail'] as $position) {
+            $saved = $this->save(['type' => 2, 'click_type' => 'internal', 'link_pc' => '/pc/promotion?id=5', 'link_h5' => '/h5/promotion?id=5', 'click_position' => $position]);
+            $this->assertSame(HttpStatus::OK, $saved['code'], $saved['msg']);
+            $row = $this->listing()['data'][0]; $id = $row['id'];
+            $this->assertNull($row['link']); $this->assertTrue($row['can_jump']);
+            $this->assertSame('/pc/promotion?id=5', $row['link_pc']); $this->assertSame('/h5/promotion?id=5', $row['link_h5']);
+            $this->assertSame($position, $row['click_position']);
+            $this->assertSame(HttpStatus::OK, $this->save(['id' => $id, 'title' => '仅改标题'])['code']);
+            $this->assertSame('/h5/promotion?id=5', ActivityReward::findOrFail($id)->link_h5);
+            $this->assertSame(HttpStatus::OK, $this->save(['id' => $id, 'link_h5' => '/mobile'])['code']);
+            $this->assertSame('/pc/promotion?id=5', ActivityReward::findOrFail($id)->link_pc);
+            $this->assertSame('/mobile', ActivityReward::findOrFail($id)->link_h5);
+            $before = DB::table('activity_rewards')->where('id', $id)->first();
+            foreach (['link_pc', 'link_h5'] as $field) {
+                foreach ([null, '', '//external.test', '/\\bad', 'https://example.com', ['bad'], '/' . str_repeat('x', 2048)] as $value) {
+                    $this->assertSame(HttpStatus::CUSTOM_ERROR, $this->save(['id' => $id, $field => $value])['code']);
+                    $this->assertEquals($before, DB::table('activity_rewards')->where('id', $id)->first());
+                }
+            }
+            $this->assertSame(HttpStatus::OK, $this->save(['id' => $id, 'click_type' => 'external', 'link' => 'https://example.com'])['code']);
+            $external = ActivityReward::findOrFail($id)->toArray();
+            $this->assertSame('https://example.com', $external['link']);
+            $this->assertNull($external['link_pc']); $this->assertNull($external['link_h5']);
+            $this->assertSame(HttpStatus::OK, $this->save(['id' => $id, 'click_type' => 'none'])['code']);
+            $plain = ActivityReward::findOrFail($id)->toArray();
+            foreach (['link', 'link_pc', 'link_h5', 'click_position'] as $field) $this->assertNull($plain[$field]);
+            $this->assertFalse($plain['can_jump']);
+        }
+        $this->assertSame(HttpStatus::CUSTOM_ERROR, $this->save(['click_type' => 'internal', 'link_pc' => '/pc', 'click_position' => 'cover'])['code']);
+    }
+
+    public function test_bot_member_list_resolves_the_dual_route_for_pc_h5_and_app(): void
+    {
+        $this->assertSame(HttpStatus::OK, $this->save(['type' => 1, 'click_type' => 'internal', 'link_pc' => '/pc/promo', 'link_h5' => '/h5/promo', 'click_position' => 'cover',
+            'start_time' => '2000-01-01', 'end_time' => '2099-12-31'])['code']);
+        foreach (['PC' => '/pc/promo', 'H5' => '/h5/promo', 'APP' => '/h5/promo'] as $platform => $target) {
+            $this->app->instance('request', Request::create('/api/ActivityReward', 'GET', ['platform' => $platform]));
+            $body = (new \App\Http\Controllers\api\ActivityReward())->index()->getData(true);
+            $this->assertSame(0, $body['code'], $body['msg']);
+            $this->assertSame($target, $body['data'][0]['link']);
+            $this->assertTrue($body['data'][0]['can_jump']);
+        }
+    }
+
     public function test_options_match_all_shared_game_categories_and_five_activity_types_on_an_authenticated_route(): void
     {
         $this->rawActivity(['type' => 1]);

+ 64 - 1
tests/Integration/AnnouncementTest.php

@@ -54,9 +54,10 @@ class AnnouncementTest extends TestCase
             $t->id(); $t->string('resource_type'); $t->integer('resource_id'); $t->string('action'); $t->json('changes');
             $t->integer('operator_id'); $t->string('operator_name'); $t->timestamp('created_at')->useCurrent();
         });
-        Schema::create('activity_rewards', function (Blueprint $t) { $t->id(); $t->string('title'); });
+        Schema::create('activity_rewards', function (Blueprint $t) { $t->id(); $t->string('title'); $t->string('click_type')->default('none'); $t->string('link', 2048)->nullable(); });
         DB::table('activity_rewards')->insert(['id' => 1, 'title' => '活动']);
         $this->migration()->up(); (require dirname(__DIR__, 2) . '/database/migrations/2026_10_27_150000_create_site_realtime_events.php')->up();
+        (require dirname(__DIR__, 2) . '/database/migrations/2026_10_30_120000_add_platform_links_to_announcements_and_activities.php')->up();
         $this->service = new AnnouncementService();
         $this->request();
     }
@@ -86,6 +87,68 @@ class AnnouncementTest extends TestCase
         $this->assertSame(0, DB::table('site_realtime_events')->count());
     }
 
+    public function test_dual_internal_routes_save_list_edit_and_clear_independently_of_external_links(): void
+    {
+        $row = $this->create(['click_type' => 'internal', 'link_pc' => '/pc/news?id=7#info', 'link_h5' => '/h5/news?id=7#info']);
+        $this->assertNull($row['link']);
+        $this->assertSame('/pc/news?id=7#info', $row['link_pc']);
+        $this->assertSame('/h5/news?id=7#info', $this->service->listing([])['data'][0]['link_h5']);
+        $same = $this->service->save(['id' => $row['id'], 'status' => 1]);
+        $this->assertEquals($row, $same);
+        $this->assertSame(1, DB::table('operation_audits')->count());
+        $edited = $this->service->save(['id' => $row['id'], 'link_h5' => '/mobile/news']);
+        $this->assertSame($row['link_pc'], $edited['link_pc']);
+        $this->assertSame('/mobile/news', $edited['link_h5']);
+        $external = $this->service->save(['id' => $row['id'], 'click_type' => 'external', 'link' => 'https://example.com/news']);
+        $this->assertSame('https://example.com/news', $external['link']);
+        $this->assertNull($external['link_pc']); $this->assertNull($external['link_h5']);
+        $plain = $this->service->save(['id' => $row['id'], 'click_type' => 'none']);
+        foreach (['link', 'link_pc', 'link_h5'] as $field) $this->assertNull($plain[$field]);
+    }
+
+    public function test_invalid_or_missing_platform_routes_do_not_change_announcement_or_audit(): void
+    {
+        $row = $this->create(['click_type' => 'internal', 'link_pc' => '/pc', 'link_h5' => '/h5']);
+        $before = DB::table('announcements')->get()->toJson();
+        $logs = DB::table('operation_audits')->count();
+        foreach (['link_pc', 'link_h5'] as $field) {
+            foreach ([null, '', '//example.com', '/\\evil', 'https://example.com', "/a\nb", ['bad'], '/' . str_repeat('x', 2048)] as $value) {
+                try { $this->service->save(['id' => $row['id'], $field => $value]); $this->fail('Invalid platform path accepted'); }
+                catch (ValidationException $e) { $this->assertArrayHasKey($field, $e->errors()); }
+                $this->assertSame($before, DB::table('announcements')->get()->toJson());
+                $this->assertSame($logs, DB::table('operation_audits')->count());
+            }
+        }
+        try { $this->create(['click_type' => 'internal', 'link_pc' => '/pc']); $this->fail('Missing H5 path accepted'); }
+        catch (ValidationException $e) { $this->assertArrayHasKey('link_h5', $e->errors()); }
+    }
+
+    public function test_platform_link_migration_preserves_existing_shared_targets_in_both_tables(): void
+    {
+        $old = $this->create(['click_type' => 'internal', 'link' => '/shared']);
+        $external = $this->create(['click_type' => 'external', 'link' => 'https://example.com']);
+        DB::table('activity_rewards')->where('id', 1)->update(['click_type' => 'internal', 'link' => '/activity']);
+        $migration = require dirname(__DIR__, 2) . '/database/migrations/2026_10_30_120000_add_platform_links_to_announcements_and_activities.php';
+        $migration->down();
+        $before = [];
+        foreach (['announcements', 'activity_rewards'] as $table) {
+            $this->assertFalse(Schema::hasColumn($table, 'link_pc'));
+            $before[$table] = DB::table($table)->orderBy('id')->get()->toArray();
+        }
+        $migration->up();
+        foreach (['announcements', 'activity_rewards'] as $table) {
+            foreach (DB::table($table)->orderBy('id')->get() as $index => $record) {
+                $row = (array) $record;
+                $expected = $row['click_type'] === 'internal' ? $row['link'] : null;
+                $this->assertSame($expected, $row['link_pc']); $this->assertSame($expected, $row['link_h5']);
+                unset($row['link_pc'], $row['link_h5']);
+                $this->assertSame((array) $before[$table][$index], $row);
+            }
+        }
+        $this->assertSame('/shared', $this->service->detail($old['id'])['link_h5']);
+        $this->assertNull($this->service->detail($external['id'])['link_h5']);
+    }
+
     private function create(array $params = []): array
     {
         if (!array_key_exists('translations', $params)) $params['translations'] = ['zh' => ['title' => $params['title'] ?? '网站维护', 'content' => $params['content'] ?? '维护通知']];