doge 1 день назад
Родитель
Сommit
13f4500585

+ 5 - 1
app/Services/PromotionAdminService.php

@@ -14,7 +14,8 @@ class PromotionAdminService
     public function options(): array
     {
         $types = []; foreach (PromotionRules::types() as $value => $label) $types[] = compact('value', 'label');
-        return ['types' => $types, 'defaults' => [7 => PromotionRules::defaults(7), 8 => PromotionRules::defaults(8), 9 => PromotionRules::defaults(9)],
+        return ['types' => $types, 'create_types' => [['value' => PromotionRules::FIRST_DEPOSIT, 'label' => PromotionRules::types()[PromotionRules::FIRST_DEPOSIT]]],
+            'defaults' => [7 => PromotionRules::defaults(7), 8 => PromotionRules::defaults(8), 9 => PromotionRules::defaults(9)],
             'prizes' => PromotionRules::PRIZES, 'tasks' => PromotionRules::TASKS];
     }
 
@@ -55,6 +56,9 @@ class PromotionAdminService
             'register_platforms' => ['sometimes', 'array'], 'register_platforms.*' => ['string', 'distinct', 'in:PC,H5,APP'], 'deposit_limited' => ['sometimes', 'boolean'],
             'deposit_min' => [...$money, 'required_if:deposit_limited,1'], 'deposit_max' => [...$money, 'required_if:deposit_limited,1', 'gte:deposit_min'],
             'show_ineligible' => ['sometimes', 'boolean'], 'reward_quota' => ['sometimes', 'nullable', 'integer', 'between:0,2147483647'], 'budget' => ['sometimes', ...$money]])->validate();
+        if (!isset($data['id']) && in_array((int) $data['type'], [PromotionRules::LOSS, PromotionRules::WHEEL], true)) {
+            throw ValidationException::withMessages(['id' => '净亏和转盘活动不支持新增,请编辑现有活动']);
+        }
         return DB::transaction(function () use ($data) {
             $row = isset($data['id']) ? PromotionActivity::where('id', $data['id'])->lockForUpdate()->first() : new PromotionActivity();
             if (!$row) throw ValidationException::withMessages(['id' => '独立活动不存在']);

+ 35 - 0
database/migrations/2026_10_27_130000_initialize_fixed_promotion_activities.php

@@ -0,0 +1,35 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Support\Facades\DB;
+
+return new class extends Migration {
+    public function up(): void
+    {
+        $rules = [
+            7 => ['title' => '净亏邀请奖励', 'params' => ['turnover_ratio' => '0.00', 'loss_amount' => '500.00', 'invite_count' => 3, 'reward_amount' => '500.00']],
+            9 => ['title' => '转盘活动', 'params' => ['turnover_ratio' => '0.00', 'completion_probability' => '20.00', 'online_seconds' => 3600,
+                'deposit_amount' => '3000.00', 'team_deposit_amount' => '500.00', 'bet_amount' => '5000.00', 'team_bet_amount' => '3000.00']],
+        ];
+        DB::transaction(function () use ($rules) {
+            $now = now('Asia/Shanghai');
+            foreach ($rules as $type => $config) {
+                if (DB::table('promotion_activities')->where('type', $type)->exists()) continue;
+                $id = DB::table('promotion_activities')->insertGetId([
+                    'title' => $config['title'], 'type' => $type, 'status' => 0,
+                    'start_time' => $now->timestamp, 'end_time' => $now->copy()->addYear()->timestamp,
+                    'lang' => 'zh', 'sort' => 0, 'params' => json_encode($config['params'], JSON_THROW_ON_ERROR),
+                    'publish_platforms' => '[]', 'register_platforms' => '[]',
+                    'register_time_limited' => 0, 'deposit_limited' => 0, 'show_ineligible' => 1,
+                    'reward_quota' => null, 'budget' => null, 'created_at' => $now, 'updated_at' => $now,
+                ]);
+                DB::table('promotion_campaigns')->insert(['activity_id' => $id, 'first_published_at' => null, 'created_at' => $now, 'updated_at' => $now]);
+            }
+        });
+    }
+
+    public function down(): void
+    {
+        // Keep activity IDs, publication dates and participation history when rolling back code.
+    }
+};

+ 3 - 1
docs/总后台/独立活动-后台接口.md

@@ -1,6 +1,6 @@
 # 独立活动后台文档目录
 
-三个活动各自维护完整接入文档,接口和数据独立于原活动列表。活动迁移仅创建业务表,不新增或删除菜单数据;活动菜单和按钮由后台前端自行添加,权限 URI 见各活动文档。
+三个活动各自维护完整接入文档,接口和数据独立于原活动列表。净亏邀请和转盘为固定活动,仅编辑、上架或下架;首充奖励保留新增、编辑及上下架。菜单与按钮由前端配置,权限 URI 见各活动文档。
 
 | 活动 | 独立后台文档 | 对应会员文档 |
 |---|---|---|
@@ -9,3 +9,5 @@
 | 转盘活动 | [转盘活动-后台接口](独立活动/转盘活动-后台接口.md) | [转盘活动-会员接口](../../../melbet_sport-api/docs/独立活动/转盘活动-会员接口.md) |
 
 本页仅保留导航;每个活动的参数、示例、上下架、资格和发奖说明均在各自文档内。
+
+新增按钮与新增类型下拉使用 `GET /admin/promotion/options` 返回的 `create_types`,当前只有首充奖励(`value=8`)。`types` 仍提供三种类型用于筛选。固定活动从列表读取现有 `id` 后编辑保存。

+ 12 - 9
docs/总后台/独立活动/净亏邀请-后台接口.md

@@ -1,8 +1,8 @@
 # 净亏邀请-后台接口
 
-业务类型固定为 `7`。本页为该活动的完整后台接入说明。
+业务类型固定为 `7`。这是固定活动,仅编辑、上架或下架,不提供新增、复制或另存为活动入口。
 
-本活动使用独立的 `promotion_activities` 配置和 `/admin/promotion` 接口,不加入原活动列表或原活动类型目录。以下接口使用现有后台登录和按钮权限,成功 `code=0`,参数或业务错误 `code=-3`,展示实际 `msg`。
+本活动使用独立的 `/admin/promotion` 接口,不加入原活动列表或原活动类型目录。以下接口使用现有后台登录和按钮权限,成功 `code=0`,参数或业务错误 `code=-3`,展示实际 `msg`。
 
 ## 配置接口
 
@@ -11,22 +11,25 @@
 | GET | `/admin/promotion?type=7` | 仅查看本活动类型;可选 `page/limit/status/title` |
 | GET | `/admin/promotion/options` | 读取 `defaults["7"]` 默认规则 |
 | GET | `/admin/promotion/detail?id=1` | 配置详情,返回 `params` 对象及 `first_published_at` |
-| POST | `/admin/promotion/submit` | 新增或编辑本活动,固定 `type=7` |
+| POST | `/admin/promotion/submit` | 编辑现有活动,必须带 `id`,固定 `type=7` |
 | POST | `/admin/promotion/status` | 传 `id/status` 上下架 |
 | GET | `/admin/promotion/records` | 查看参与或发奖记录 |
 
-列表默认 `page=1/limit=20`,`limit` 最大100。`status=0` 为下架、`1` 为上架,不能丢弃0值筛选。新增省略 `id`,编辑带 `id`;创建后不能改变业务类型。
+列表默认 `page=1/limit=20`,`limit` 最大100。`status=0` 为下架、`1` 为上架,不能丢弃0值筛选。先从列表读取现有活动,再使用其 `id` 获取详情、保存或切换状态;不写死示例ID。不带 `id` 保存会返回 `code=-3`,提示“净亏和转盘活动不支持新增,请编辑现有活动”。
 
-保存必填根字段:`title/type/status/start_time/end_time`。时间按UTC+8填写,结束必须晚于开始。可选 `sub_title/lang/sort/content/image/publish_platforms`;平台为 `PC/H5/APP`,空数组表示全部。`params` 传对象,未知规则键报错;新增省略的项使用默认值,编辑局部规则保留其余原值。
+保存必填根字段:`id/title/type/status/start_time/end_time`。时间按UTC+8填写,结束必须晚于开始。可选 `sub_title/lang/sort/content/image/publish_platforms`;平台为 `PC/H5/APP`,空数组表示全部。`params` 传对象,未知规则键报错;编辑局部规则时保留其余原值;表单以详情返回的当前配置回填。
 
-可选通用资格:`register_time_limited/register_start_time/register_end_time/register_platforms`、`deposit_limited/deposit_min/deposit_max`、`show_ineligible`。新增默认不限制,编辑省略字段保留原值。
+可选通用资格:`register_time_limited/register_start_time/register_end_time/register_platforms`、`deposit_limited/deposit_min/deposit_max`、`show_ineligible`。编辑时省略字段保留原值。
 
 可选 `reward_quota` 限制不同获奖会员数,`budget` 限制累计奖励金额;`null` 为不限制,0为无剩余额度。`rewarded_count/budget_used/first_published_at` 为服务端字段,不能手动改写。
 
-## 保存示例
+## 编辑保存示例
+
+`id` 使用读取接口返回值,下面的1仅为示例。
 
 ```json
 {
+  "id": 1,
   "title": "净亏邀请奖励",
   "type": 7,
   "status": 0,
@@ -68,8 +71,8 @@
 
 申请记录包含 `applied_at/application_snapshot`;奖金有 `reserved/locked/paid` 状态和资金流水关联。没有有效申请前不允许领奖,重复领取不会重复入账。停止活动使用下架,不删除参与历史。
 
-## 权限与上线
+## 权限
 
-独立活动菜单(`promotion`)和按钮由后台前端自行添加;配置、保存与上下架、记录权限分别为 `admin/promotion`、`admin/promotion/submit`、`admin/promotion/records`。上线执行新增迁移 `2026_10_26_160000_create_promotion_activities.php`,该迁移仅创建活动业务表,不新增或删除菜单数据。计划任务每分钟运行 `promotions:sync`;有效投注沿用已有分享注单同步。
+独立活动菜单和按钮由后台前端配置;读取、保存与上下架、记录权限分别为 `admin/promotion`、`admin/promotion/submit`、`admin/promotion/records`。
 
 对应会员端文档:[《净亏邀请-会员接口》](../../../../melbet_sport-api/docs/独立活动/净亏邀请-会员接口.md)。

+ 12 - 9
docs/总后台/独立活动/转盘活动-后台接口.md

@@ -1,8 +1,8 @@
 # 转盘活动-后台接口
 
-业务类型固定为 `9`。本页为该活动的完整后台接入说明。
+业务类型固定为 `9`。这是固定活动,仅编辑、上架或下架,不提供新增、复制或另存为活动入口。
 
-本活动使用独立的 `promotion_activities` 配置和 `/admin/promotion` 接口,不加入原活动列表或原活动类型目录。以下接口使用现有后台登录和按钮权限,成功 `code=0`,参数或业务错误 `code=-3`,展示实际 `msg`。
+本活动使用独立的 `/admin/promotion` 接口,不加入原活动列表或原活动类型目录。以下接口使用现有后台登录和按钮权限,成功 `code=0`,参数或业务错误 `code=-3`,展示实际 `msg`。
 
 ## 配置接口
 
@@ -11,22 +11,25 @@
 | GET | `/admin/promotion?type=9` | 仅查看本活动类型;可选 `page/limit/status/title` |
 | GET | `/admin/promotion/options` | 读取 `defaults["9"]` 默认规则 |
 | GET | `/admin/promotion/detail?id=1` | 配置详情,返回 `params` 对象及 `first_published_at` |
-| POST | `/admin/promotion/submit` | 新增或编辑本活动,固定 `type=9` |
+| POST | `/admin/promotion/submit` | 编辑现有活动,必须带 `id`,固定 `type=9` |
 | POST | `/admin/promotion/status` | 传 `id/status` 上下架 |
 | GET | `/admin/promotion/records` | 查看参与或发奖记录 |
 
-列表默认 `page=1/limit=20`,`limit` 最大100。`status=0` 为下架、`1` 为上架,不能丢弃0值筛选。新增省略 `id`,编辑带 `id`;创建后不能改变业务类型。
+列表默认 `page=1/limit=20`,`limit` 最大100。`status=0` 为下架、`1` 为上架,不能丢弃0值筛选。先从列表读取现有活动,再使用其 `id` 获取详情、保存或切换状态;不写死示例ID。不带 `id` 保存会返回 `code=-3`,提示“净亏和转盘活动不支持新增,请编辑现有活动”。
 
-保存必填根字段:`title/type/status/start_time/end_time`。时间按UTC+8填写,结束必须晚于开始。可选 `sub_title/lang/sort/content/image/publish_platforms`;平台为 `PC/H5/APP`,空数组表示全部。`params` 传对象,未知规则键报错;新增省略的项使用默认值,编辑局部规则保留其余原值。
+保存必填根字段:`id/title/type/status/start_time/end_time`。时间按UTC+8填写,结束必须晚于开始。可选 `sub_title/lang/sort/content/image/publish_platforms`;平台为 `PC/H5/APP`,空数组表示全部。`params` 传对象,未知规则键报错;编辑局部规则时保留其余原值;表单以详情返回的当前配置回填。
 
-可选通用资格:`register_time_limited/register_start_time/register_end_time/register_platforms`、`deposit_limited/deposit_min/deposit_max`、`show_ineligible`。新增默认不限制,编辑省略字段保留原值。
+可选通用资格:`register_time_limited/register_start_time/register_end_time/register_platforms`、`deposit_limited/deposit_min/deposit_max`、`show_ineligible`。编辑时省略字段保留原值。
 
 可选 `reward_quota` 限制不同获奖会员数,`budget` 限制累计奖励金额;`null` 为不限制,0为无剩余额度。`rewarded_count/budget_used/first_published_at` 为服务端字段,不能手动改写。
 
-## 保存示例
+## 编辑保存示例
+
+`id` 使用读取接口返回值,下面的1仅为示例。
 
 ```json
 {
+  "id": 1,
   "title": "幸运转盘",
   "type": 9,
   "status": 0,
@@ -95,8 +98,8 @@
 
 会员“我的记录”使用真实抽奖记录。普通播报及达到666播报各最多50条真实脱敏记录;联调随机样例每组50条,见[转盘播报示例](../../../../melbet_sport-api/docs/独立活动/转盘播报示例.json),均带 `is_demo=true`,不作真实中奖记录。
 
-## 权限与上线
+## 权限
 
-独立活动菜单(`promotion`)和按钮由后台前端自行添加;配置、保存与上下架、记录权限分别为 `admin/promotion`、`admin/promotion/submit`、`admin/promotion/records`。上线执行新增迁移 `2026_10_26_160000_create_promotion_activities.php`,该迁移仅创建活动业务表,不新增或删除菜单数据。计划任务每分钟运行 `promotions:sync`;有效投注沿用已有分享注单同步。
+独立活动菜单和按钮由后台前端配置;读取、保存与上下架、记录权限分别为 `admin/promotion`、`admin/promotion/submit`、`admin/promotion/records`。
 
 对应会员端文档:[《转盘活动-会员接口》](../../../../melbet_sport-api/docs/独立活动/转盘活动-会员接口.md)。

+ 6 - 6
docs/总后台/独立活动/首充奖励-后台接口.md

@@ -1,21 +1,21 @@
 # 首充奖励-后台接口
 
-业务类型固定为 `8`。本页为该活动的完整后台接入说明。
+业务类型固定为 `8`。首充奖励保留新增入口,可新增多条配置,也可编辑或上下架现有配置。
 
-本活动使用独立的 `promotion_activities` 配置和 `/admin/promotion` 接口,不加入原活动列表或原活动类型目录。以下接口使用现有后台登录和按钮权限,成功 `code=0`,参数或业务错误 `code=-3`,展示实际 `msg`。
+本活动使用独立的 `/admin/promotion` 接口,不加入原活动列表或原活动类型目录。以下接口使用现有后台登录和按钮权限,成功 `code=0`,参数或业务错误 `code=-3`,展示实际 `msg`。
 
 ## 配置接口
 
 | 方法 | 路径 | 参数与用途 |
 |---|---|---|
 | GET | `/admin/promotion?type=8` | 仅查看本活动类型;可选 `page/limit/status/title` |
-| GET | `/admin/promotion/options` | 读取 `defaults["8"]` 默认规则 |
+| GET | `/admin/promotion/options` | 读取 `create_types` 新增类型及 `defaults["8"]` 默认规则 |
 | GET | `/admin/promotion/detail?id=1` | 配置详情,返回 `params` 对象及 `first_published_at` |
 | POST | `/admin/promotion/submit` | 新增或编辑本活动,固定 `type=8` |
 | POST | `/admin/promotion/status` | 传 `id/status` 上下架 |
 | GET | `/admin/promotion/records` | 查看参与或发奖记录 |
 
-列表默认 `page=1/limit=20`,`limit` 最大100。`status=0` 为下架、`1` 为上架,不能丢弃0值筛选。新增省略 `id`,编辑带 `id`;创建后不能改变业务类型。
+列表默认 `page=1/limit=20`,`limit` 最大100。`status=0` 为下架、`1` 为上架,不能丢弃0值筛选。新增省略 `id`,编辑带 `id`;创建后不能改变业务类型。新增按钮和类型选择使用 `options.create_types`,当前只包含首充奖励(`value=8`);列表筛选仍可使用 `options.types` 的三种类型。
 
 保存必填根字段:`title/type/status/start_time/end_time`。时间按UTC+8填写,结束必须晚于开始。可选 `sub_title/lang/sort/content/image/publish_platforms`;平台为 `PC/H5/APP`,空数组表示全部。`params` 传对象,未知规则键报错;新增省略的项使用默认值,编辑局部规则保留其余原值。
 
@@ -66,8 +66,8 @@
 
 奖金状态 `locked` 表示已进入冻结余额待完成流水,`paid` 表示已进入可用余额。可通过 `credited_log_id/unlocked_log_id` 核对资金流水,`rule_snapshot` 保存使用的规则和首充流水来源。停止活动使用下架,历史记录保留。
 
-## 权限与上线
+## 权限
 
-独立活动菜单(`promotion`)和按钮由后台前端自行添加;配置、保存与上下架、记录权限分别为 `admin/promotion`、`admin/promotion/submit`、`admin/promotion/records`。上线执行新增迁移 `2026_10_26_160000_create_promotion_activities.php`,该迁移仅创建活动业务表,不新增或删除菜单数据。计划任务每分钟运行 `promotions:sync`;有效投注沿用已有分享注单同步。
+独立活动菜单和按钮由后台前端配置;读取、保存与上下架、记录权限分别为 `admin/promotion`、`admin/promotion/submit`、`admin/promotion/records`。
 
 对应会员端文档:[《首充奖励-会员接口》](../../../../melbet_sport-api/docs/独立活动/首充奖励-会员接口.md)。

+ 5 - 7
sql/独立活动-联调初始化.sql

@@ -1,6 +1,6 @@
 -- 三个独立活动的联调配置;在两个项目共用的联调数据库执行。
 -- 依赖已建好的 bot_promotion_* 活动表;默认语言 zh、三端开放、有效期一年。
--- 同一语言重复执行保留已有联调配置;脚本末尾返回活动 ID 和有效会员规则检查结果。
+-- 净亏及转盘复用已有固定配置;首充仅初始化联调条目,重复执行保留已有值。
 
 SET NAMES utf8mb4;
 SET @promotion_seed_lang = 'zh';
@@ -25,7 +25,7 @@ SELECT '联调-净亏邀请', '净亏500后申请,邀请3名有效会员,领
     0, 0, 1, 1, NULL, 0, NULL, 0, @promotion_seed_now, @promotion_seed_now
 FROM (SELECT 1 AS n) seed
 LEFT JOIN `bot_promotion_activities` existing
-  ON existing.`type` = 7 AND existing.`title` = '联调-净亏邀请' AND existing.`lang` = @promotion_seed_lang
+  ON existing.`type` = 7
 WHERE @promotion_seed_locked = 1 AND existing.`id` IS NULL;
 
 INSERT INTO `bot_promotion_activities` (
@@ -55,7 +55,7 @@ SELECT '联调-转盘活动', '完成七项任务后,每次抽奖有20%概率
     0, 0, 1, 1, NULL, 0, NULL, 0, @promotion_seed_now, @promotion_seed_now
 FROM (SELECT 1 AS n) seed
 LEFT JOIN `bot_promotion_activities` existing
-  ON existing.`type` = 9 AND existing.`title` = '联调-转盘活动' AND existing.`lang` = @promotion_seed_lang
+  ON existing.`type` = 9
 WHERE @promotion_seed_locked = 1 AND existing.`id` IS NULL;
 
 INSERT INTO `bot_promotion_campaigns` (`activity_id`, `first_published_at`, `created_at`, `updated_at`)
@@ -77,10 +77,8 @@ SELECT a.`id` AS `activity_id`, a.`type`, a.`title`, a.`status`, a.`lang`,
        a.`start_time`, a.`end_time`, c.`first_published_at`, a.`params`
 FROM `bot_promotion_activities` a
 LEFT JOIN `bot_promotion_campaigns` c ON c.`activity_id` = a.`id`
-WHERE a.`lang` = @promotion_seed_lang
-  AND ((a.`type` = 7 AND a.`title` = '联调-净亏邀请')
-    OR (a.`type` = 8 AND a.`title` = '联调-首充奖励')
-    OR (a.`type` = 9 AND a.`title` = '联调-转盘活动'))
+WHERE a.`type` IN (7, 9)
+   OR (a.`type` = 8 AND a.`title` = '联调-首充奖励' AND a.`lang` = @promotion_seed_lang)
 ORDER BY a.`type`, a.`id`;
 
 -- 有效会员条件只读取已有生效版本,不改动分享配置。

+ 60 - 4
tests/Integration/PromotionActivitiesTest.php

@@ -55,7 +55,7 @@ class PromotionActivitiesTest extends TestCase
             'reporting_activity' => 'id INTEGER PRIMARY KEY AUTOINCREMENT,user_id INTEGER,session_hash TEXT,observed_at TEXT',
             'menus' => 'id INTEGER PRIMARY KEY AUTOINCREMENT,parent_id INTEGER,title TEXT,icon TEXT,uri TEXT,permission_name TEXT,sort INTEGER,status INTEGER,type INTEGER,created_at TEXT,updated_at TEXT',
         ] as $table => $columns) DB::statement('CREATE TABLE bot_' . $table . ' (' . $columns . ')');
-        foreach (['2026_10_13_120000_add_activity_eligibility_fields.php', '2026_10_14_120000_add_activity_reward_quotas.php', '2026_10_25_120000_add_game_category_to_activity_rewards.php', '2026_10_26_160000_create_promotion_activities.php'] as $migration) (require dirname(__DIR__, 2) . '/database/migrations/' . $migration)->up();
+        foreach (['2026_10_13_120000_add_activity_eligibility_fields.php', '2026_10_14_120000_add_activity_reward_quotas.php', '2026_10_25_120000_add_game_category_to_activity_rewards.php', '2026_10_26_160000_create_promotion_activities.php', '2026_10_27_130000_initialize_fixed_promotion_activities.php'] as $migration) (require dirname(__DIR__, 2) . '/database/migrations/' . $migration)->up();
         $this->member(1); $this->member(2, 1);
         DB::table('share_policies')->insert(['id' => 1, 'effective_date' => '2026-10-01', 'valid_member_min_deposit' => '200', 'valid_member_min_effective_bet' => '300']);
         $this->service = new PromotionService();
@@ -70,9 +70,10 @@ class PromotionActivitiesTest extends TestCase
     }
     private function activity(int $type = 9, array $params = [], int $status = 1): int
     {
-        (new PromotionAdminService())->save(['title' => '测试活动', 'sub_title' => '规则', 'type' => $type, 'status' => $status,
-            'start_time' => '2026-10-01 00:00:00', 'end_time' => '2026-12-31 23:59:59', 'lang' => 'zh', 'params' => $params]);
-        return (int)DB::table('promotion_activities')->max('id');
+        $input = ['title' => '测试活动', 'sub_title' => '规则', 'type' => $type, 'status' => $status,
+            'start_time' => '2026-10-01 00:00:00', 'end_time' => '2026-12-31 23:59:59', 'lang' => 'zh', 'params' => $params];
+        if (in_array($type, [7, 9], true)) $input['id'] = (int) DB::table('promotion_activities')->where('type', $type)->value('id');
+        return (int) (new PromotionAdminService())->save($input)['id'];
     }
     private function deposit(int $id, string $amount, ?string $time = null, string $type = '充值'): BalanceLog
     {
@@ -269,6 +270,61 @@ class PromotionActivitiesTest extends TestCase
         $this->assertTrue(DB::connection()->getSchemaBuilder()->hasTable('promotion_activities'));
     }
 
+    public function test_fixed_activities_are_initialized_disabled_without_publishing_or_creating_menus(): void
+    {
+        $this->assertSame(2, DB::table('promotion_activities')->count());
+        foreach ([7, 9] as $type) {
+            $row = DB::table('promotion_activities')->where('type', $type)->first();
+            $this->assertNotNull($row); $this->assertSame(0, (int) $row->status);
+            $this->assertNull(DB::table('promotion_campaigns')->where('activity_id', $row->id)->value('first_published_at'));
+        }
+        $options = (new PromotionAdminService())->options();
+        $this->assertSame([7, 8, 9], array_column($options['types'], 'value'));
+        $this->assertSame([8], array_column($options['create_types'], 'value'));
+        $this->assertSame(0, DB::table('menus')->count());
+    }
+
+    public function test_fixed_types_cannot_be_created_even_when_no_configuration_exists(): void
+    {
+        $admin = new PromotionAdminService();
+        foreach ([7, 9] as $type) {
+            $input = ['title' => '不能新建', 'type' => $type, 'status' => 0,
+                'start_time' => '2026-10-01 00:00:00', 'end_time' => '2026-12-31 23:59:59'];
+            $this->invalid(fn () => $admin->save($input), '不支持新增');
+            $this->assertSame(1, DB::table('promotion_activities')->where('type', $type)->count());
+            $id = DB::table('promotion_activities')->where('type', $type)->value('id');
+            DB::table('promotion_campaigns')->where('activity_id', $id)->delete();
+            DB::table('promotion_activities')->where('id', $id)->delete();
+            $this->invalid(fn () => $admin->save($input), '不支持新增');
+            $this->assertSame(0, DB::table('promotion_activities')->where('type', $type)->count());
+        }
+    }
+
+    public function test_first_deposit_still_allows_multiple_new_configurations_and_type_change_is_rejected(): void
+    {
+        $first = $this->activity(8); $second = $this->activity(8);
+        $this->assertNotSame($first, $second);
+        $this->assertSame(2, DB::table('promotion_activities')->where('type', 8)->count());
+        $this->invalid(fn () => (new PromotionAdminService())->save(['id' => $first, 'title' => '不能换类型', 'type' => 9, 'status' => 0,
+            'start_time' => '2026-10-01 00:00:00', 'end_time' => '2026-12-31 23:59:59']), '不能变更活动类型');
+        $this->assertSame(8, (int) DB::table('promotion_activities')->where('id', $first)->value('type'));
+    }
+
+    public function test_fixed_initialization_and_relisting_do_not_replace_existing_activity_or_reset_its_history(): void
+    {
+        $id = $this->activity(9, ['completion_probability' => 0]);
+        DB::table('promotion_activities')->where('id', $id)->update(['rewarded_count' => 3, 'budget_used' => '1998']);
+        $before = (array) DB::table('promotion_activities')->where('id', $id)->first();
+        $published = DB::table('promotion_campaigns')->where('activity_id', $id)->value('first_published_at');
+        $migration = require dirname(__DIR__, 2) . '/database/migrations/2026_10_27_130000_initialize_fixed_promotion_activities.php';
+        $this->travel('2026-10-27 12:00:00'); $migration->up(); $migration->down();
+        $this->assertSame($before, (array) DB::table('promotion_activities')->where('id', $id)->first());
+        $this->assertSame(2, DB::table('promotion_activities')->count());
+        $admin = new PromotionAdminService(); $admin->status($id, 0); $admin->status($id, 1);
+        $this->assertSame($published, DB::table('promotion_campaigns')->where('activity_id', $id)->value('first_published_at'));
+        $this->assertSame(3, (int) DB::table('promotion_activities')->where('id', $id)->value('rewarded_count'));
+    }
+
     public function test_admin_partial_rule_edit_preserves_other_settings_and_zero_probability(): void
     {
         $id = $this->activity(9, ['turnover_ratio' => 3, 'deposit_amount' => 5000]);