doge il y a 9 heures
Parent
commit
5da254fd90

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

@@ -24,7 +24,7 @@ class Promotion extends Controller
     public function detail(PromotionAdminService $s)
     {
         return $this->run(function () use ($s) {
-            $p = request()->validate(['id' => ['required_without:type', 'integer', 'min:1'], 'type' => ['required_without:id', 'integer', 'in:7,9']]);
+            $p = request()->validate(['id' => ['required_without:type', 'integer', 'min:1'], 'type' => ['required_without:id', 'integer', 'in:7,9,10']]);
             return $s->detail(isset($p['id']) ? (int)$p['id'] : null, isset($p['type']) ? (int)$p['type'] : null);
         });
     }

+ 78 - 23
app/Services/PromotionAdminService.php

@@ -18,7 +18,8 @@ class PromotionAdminService
         $types = []; foreach (PromotionRules::types() as $value => $label) $types[] = compact('value', 'label');
         return ['types' => $types, 'languages' => [['value' => 'zh', 'label' => '中文'], ['value' => 'en', 'label' => '英文'], ['value' => 'vi', 'label' => '越南文']],
             '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)],
+            'defaults' => [7 => PromotionRules::defaults(7), 8 => PromotionRules::defaults(8), 9 => PromotionRules::defaults(9), 10 => PromotionRules::defaults(10)],
+            'share_levels' => $this->shareLevels(),
             'prizes' => PromotionRules::PRIZES, 'tasks' => PromotionRules::TASKS,
             'reward_modes' => [['value' => 'cash', 'label' => '现金'], ['value' => 'coupon', 'label' => '优惠券']],
             'coupon_categories' => array_map(fn ($value) => ['value' => $value, 'label' => ShareDefaults::categories()[$value]], PromotionRules::COUPON_CATEGORIES)];
@@ -27,18 +28,18 @@ class PromotionAdminService
     public function listing(array $input): array
     {
         $input = Validator::make($input, ['page' => ['sometimes', 'integer', 'min:1'], 'limit' => ['sometimes', 'integer', 'between:1,100'],
-            'type' => ['sometimes', 'integer', 'in:7,8,9'], 'status' => ['sometimes', 'integer', 'in:0,1'], 'title' => ['sometimes', 'string', 'max:140']])->validate();
+            'type' => ['sometimes', 'integer', 'in:7,8,9,10'], 'status' => ['sometimes', 'integer', 'in:0,1'], 'title' => ['sometimes', 'string', 'max:140']])->validate();
         $q = PromotionActivity::query();
         foreach (['type', 'status'] as $key) if (isset($input[$key])) $q->where($key, $input[$key]);
         if (!empty($input['title'])) $q->where('title', 'like', '%' . $input['title'] . '%');
         return ['total' => $q->count(), 'data' => $q->orderByDesc('sort')->orderByDesc('id')->forPage($input['page'] ?? 1, $input['limit'] ?? 20)->get()->map(fn ($r) => $this->format($r))->all()];
     }
 
-    /** Fixed activities (loss invite, wheel) have one row, so the flat edit form can load it by type alone. */
+    /** Fixed activities (loss invite, wheel, upgrade invite) have one row, so the flat edit form can load it by type alone. */
     public function detail(?int $id, ?int $type = null): array
     {
-        if ($id === null && !in_array($type, [PromotionRules::LOSS, PromotionRules::WHEEL], true)) {
-            throw ValidationException::withMessages(['type' => '仅净亏邀请和转盘活动可按type读取']);
+        if ($id === null && !in_array($type, PromotionRules::FIXED, true)) {
+            throw ValidationException::withMessages(['type' => '仅净亏邀请、转盘活动和邀请升级奖励可按type读取']);
         }
         $row = $id !== null ? PromotionActivity::find($id) : PromotionActivity::where('type', $type)->orderBy('id')->first();
         if (!$row) throw ValidationException::withMessages(['id' => '独立活动不存在']);
@@ -51,32 +52,38 @@ class PromotionAdminService
             'start_time' => date('Y-m-d H:i:s', (int)$row->start_time), 'end_time' => date('Y-m-d H:i:s', (int)$row->end_time),
             'first_published_at' => DB::table('promotion_campaigns')->where('activity_id', $row->id)->value('first_published_at')]);
         unset($data['sub_title']);
-        if (in_array((int) $row->type, [PromotionRules::LOSS, PromotionRules::WHEEL], true)) unset($data['image']);
+        if (in_array((int) $row->type, PromotionRules::FIXED, true)) unset($data['image']);
+        if (PromotionTranslations::fields((int) $row->type) === ['content']) {
+            foreach ($data['translations'] as &$text) unset($text['title']);
+            unset($text);
+        }
         return $data;
     }
 
     public function save(array $input): array
     {
         unset($input['sub_title']);
+        $type = (int)(is_scalar($input['type'] ?? null) ? $input['type'] : 0);
+        $fields = PromotionTranslations::fields($type);
         if (isset($input['translations']) && is_array($input['translations'])) {
-            foreach ($input['translations'] as &$text) if (is_array($text)) unset($text['sub_title']);
+            foreach ($input['translations'] as &$text) if (is_array($text)) { unset($text['sub_title']); if (!in_array('title', $fields, true)) unset($text['title']); }
             unset($text);
         }
-        if (in_array($input['type'] ?? null, [7, 9, '7', '9'], true)) unset($input['image']);
+        if (in_array((string)($input['type'] ?? ''), array_map('strval', PromotionRules::FIXED), true)) unset($input['image']);
         $money = ['nullable', 'numeric', 'min:0', 'regex:/^\d{1,12}(\.\d{1,2})?$/'];
         $textRules = ['translations' => ['required', 'array:zh,en,vi']];
-        $messages = ['translations.required' => '请完整填写中文、英文、越南文的标题和正文'];
+        $messages = ['translations.required' => '请完整填写中文、英文、越南文的' . ($fields === ['content'] ? '正文' : '标题和正文')];
         foreach (['zh' => '中文', 'en' => '英文', 'vi' => '越南文'] as $lang => $label) {
-            $textRules['translations.' . $lang] = ['required', 'array:title,content'];
+            $textRules['translations.' . $lang] = ['required', 'array:' . implode(',', $fields)];
             $messages['translations.' . $lang . '.required'] = '请完整填写' . $label . '文本';
-            foreach (['title' => ['标题', 140], 'content' => ['正文', 100000]] as $field => [$name, $max]) {
+            foreach (array_intersect_key(['title' => ['标题', 140], 'content' => ['正文', 100000]], array_flip($fields)) as $field => [$name, $max]) {
                 $key = 'translations.' . $lang . '.' . $field;
                 $textRules[$key] = ['required', 'string', 'max:' . $max];
                 $messages[$key . '.required'] = $label . $name . '不能为空';
             }
         }
         $data = Validator::make($input, $textRules + ['id' => ['sometimes', 'integer', 'min:1'],
-            'type' => ['required', 'integer', 'in:7,8,9'], 'status' => ['required', 'integer', 'in:0,1'],
+            'type' => ['required', 'integer', 'in:7,8,9,10'], 'status' => ['required', 'integer', 'in:0,1'],
             'start_time' => ['required', 'date'], 'end_time' => ['required', 'date', 'after:start_time'],
             'sort' => ['sometimes', 'integer', 'between:0,4294967295'], 'params' => ['sometimes', 'array'],
             'image' => ['sometimes', 'nullable', 'string', 'max:2048'], 'publish_platforms' => ['sometimes', 'array'], 'publish_platforms.*' => ['string', 'distinct', 'in:PC,H5,APP'],
@@ -85,8 +92,8 @@ 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]], $messages)->validate();
-        if (!isset($data['id']) && in_array((int) $data['type'], [PromotionRules::LOSS, PromotionRules::WHEEL], true)) {
-            throw ValidationException::withMessages(['id' => '净亏和转盘活动不支持新增,请编辑现有活动']);
+        if (!isset($data['id']) && in_array((int) $data['type'], PromotionRules::FIXED, true)) {
+            throw ValidationException::withMessages(['id' => '净亏、转盘和邀请升级活动不支持新增,请编辑现有活动']);
         }
         return DB::transaction(function () use ($data) {
             $row = isset($data['id']) ? PromotionActivity::where('id', $data['id'])->lockForUpdate()->first() : new PromotionActivity();
@@ -95,9 +102,13 @@ class PromotionAdminService
             $before = $row->exists ? $row->toArray() : [];
             $params = array_replace($row->params ? json_decode($row->params, true) : [], $data['params'] ?? []);
             $values = $data; unset($values['id']);
-            $texts = $this->completeTexts($data['translations']);
+            $texts = $this->completeTexts($data['translations'], (int)$data['type']);
             $values = array_replace($values, $texts['zh'], ['translations' => $texts, 'lang' => 'zh']);
-            $values['params'] = json_encode(PromotionRules::normalize((int)$data['type'], $params));
+            // Content-only activities keep the type name in the required title column for admin lists.
+            if ($values['title'] === '') $values['title'] = PromotionRules::types()[(int)$data['type']];
+            $rules = PromotionRules::normalize((int)$data['type'], $params);
+            if ((int)$data['type'] === PromotionRules::UPGRADE_INVITE) $this->checkUpgradeTiers($rules, (int)$values['status'] === 1);
+            $values['params'] = json_encode($rules);
             $values['start_time'] = strtotime($data['start_time']); $values['end_time'] = strtotime($data['end_time']);
             if ((int)$values['status'] === 1 && $values['end_time'] < now()->timestamp) throw ValidationException::withMessages(['end_time' => '活动已结束,不能上架']);
             if (!$row->exists) $values += ['lang' => 'zh', 'sort' => 0, 'publish_platforms' => [], 'register_platforms' => []];
@@ -107,10 +118,29 @@ class PromotionAdminService
         }, 3);
     }
 
-    private function completeTexts(array $texts): array
+    /** Tier levels must be configured share levels; an empty tier list may be saved but not published. */
+    private function checkUpgradeTiers(array $rules, bool $publishing): void
+    {
+        if ($publishing && !$rules['tiers']) throw ValidationException::withMessages(['params' => '请至少配置一档升级奖励']);
+        $levels = array_column($this->shareLevels(), 'level');
+        foreach ($rules['tiers'] as $tier) {
+            if (!in_array($tier['level'], $levels, true)) throw ValidationException::withMessages(['params' => '分享等级' . $tier['level'] . '不存在']);
+        }
+    }
+
+    private function shareLevels(): array
     {
+        if (!Schema::hasTable('share_levels')) return [];
+        return DB::table('share_levels')->orderBy('level')->get(['level', 'name'])
+            ->map(fn ($row) => ['level' => (int)$row->level, 'name' => (string)$row->name])->all();
+    }
+
+    private function completeTexts(array $texts, int $type): array
+    {
+        $fields = PromotionTranslations::fields($type);
         foreach (['zh' => '中文', 'en' => '英文', 'vi' => '越南文'] as $lang => $label) {
-            foreach (['title' => '标题', 'content' => '正文'] as $field => $name) {
+            $texts[$lang] += ['title' => ''];
+            foreach (array_intersect_key(['title' => '标题', 'content' => '正文'], array_flip($fields)) as $field => $name) {
                 $value = html_entity_decode(strip_tags($texts[$lang][$field]), ENT_QUOTES | ENT_HTML5, 'UTF-8');
                 if (preg_replace('/[\s\p{Z}\x{200B}\x{FEFF}]+/u', '', $value) === '') {
                     throw ValidationException::withMessages(['translations.' . $lang . '.' . $field => $label . $name . '不能为空']);
@@ -126,8 +156,11 @@ class PromotionAdminService
         return DB::transaction(function () use ($id, $status) {
             $row = PromotionActivity::where('id', $id)->lockForUpdate()->first();
             if (!$row) throw ValidationException::withMessages(['id' => '独立活动不存在']);
-            if ($status === 1) $this->completeTexts(PromotionTranslations::all($row->toArray()));
+            if ($status === 1) $this->completeTexts(PromotionTranslations::all($row->toArray()), (int)$row->type);
             if ($status === 1 && (int)$row->end_time < now()->timestamp) throw ValidationException::withMessages(['end_time' => '活动已结束,不能上架']);
+            if ($status === 1 && (int)$row->type === PromotionRules::UPGRADE_INVITE) {
+                $this->checkUpgradeTiers(PromotionRules::normalize(PromotionRules::UPGRADE_INVITE, json_decode($row->params, true) ?: []), true);
+            }
             $before = $row->toArray(); $row->status = $status; $row->save(); PromotionService::publish($row);
             if (Schema::hasTable('operation_audits')) OperationAuditService::record('promotion', $id, 'status', $before, $row->toArray());
             return $this->format($row);
@@ -152,9 +185,14 @@ class PromotionAdminService
 
     public function records(array $input): array
     {
-        $input = Validator::make($input, ['activity_id' => ['required', 'integer', 'min:1'], 'user_id' => ['sometimes', 'integer', 'min:1'],
-            'kind' => ['required', 'in:accounts,rounds,tasks,spins,rewards,coupons'], 'status' => ['sometimes', 'in:unused,used'],
+        $input = Validator::make($input, ['activity_id' => ['required', 'integer', 'min:1'], 'type' => ['sometimes', 'integer', 'in:7,8,9,10'], 'user_id' => ['sometimes', 'integer', 'min:1'],
+            'kind' => ['required', 'in:accounts,rounds,tasks,spins,rewards,coupons,upgrades'], 'status' => ['sometimes', 'in:unused,used'],
             'page' => ['sometimes', 'integer', 'min:1'], 'limit' => ['sometimes', 'integer', 'between:1,100']])->validate();
+        // An explicit type guards against a misconfigured activity_id showing another campaign's records.
+        $type = DB::table('promotion_activities')->where('id', $input['activity_id'])->value('type');
+        if ($type === null) throw ValidationException::withMessages(['activity_id' => '独立活动不存在']);
+        if (isset($input['type']) && (int)$input['type'] !== (int)$type) throw ValidationException::withMessages(['type' => 'activity_id与type不匹配']);
+        if ($input['kind'] === 'upgrades' && (int)$type !== PromotionRules::UPGRADE_INVITE) throw ValidationException::withMessages(['kind' => 'upgrades仅用于邀请升级奖励']);
         if ($input['kind'] === 'coupons') {
             // Invitee coupons belong to the invitee, so they are found by activity rather than by the inviter's account.
             $q = DB::table('promotion_coupons')->where('activity_id', $input['activity_id']);
@@ -169,6 +207,7 @@ class PromotionAdminService
             }
             return ['total' => $q->count(), 'data' => $rows];
         }
+        if ($input['kind'] === 'upgrades') return $this->upgradeGrants($input);
         $q = DB::table('promotion_accounts as a')->where('a.activity_id', $input['activity_id']);
         if (isset($input['user_id'])) $q->where('a.user_id', $input['user_id']);
         if ($input['kind'] === 'accounts') $q->select('a.*')->orderByDesc('a.id');
@@ -178,13 +217,29 @@ class PromotionAdminService
         }
         $total = (clone $q)->count();
         $rows = $q->forPage($input['page'] ?? 1, $input['limit'] ?? 20)->get();
-        if (in_array($input['kind'], ['accounts', 'rewards'], true) && $rows->isNotEmpty()
-            && (int)DB::table('promotion_activities')->where('id', $input['activity_id'])->value('type') === PromotionRules::LOSS) {
+        if (in_array($input['kind'], ['accounts', 'rewards'], true) && $rows->isNotEmpty() && (int)$type === PromotionRules::LOSS) {
             $this->lossRecordPeople($rows, $input['kind']);
         }
         return ['total' => $total, 'data' => $rows];
     }
 
+    /** Upgrade invite payouts, one row per invitee tier; user_id matches either the inviter or the invitee. */
+    private function upgradeGrants(array $input): array
+    {
+        $q = DB::table('promotion_upgrade_grants')->where('activity_id', $input['activity_id']);
+        if (isset($input['user_id'])) $q->where(fn ($q) => $q->where('inviter_user_id', $input['user_id'])->orWhere('invitee_user_id', $input['user_id']));
+        $rows = (clone $q)->orderByDesc('id')->forPage($input['page'] ?? 1, $input['limit'] ?? 20)->get();
+        $accounts = $this->recordAccounts($rows->flatMap(fn ($row) => [$row->inviter_user_id, $row->invitee_user_id])->all());
+        $names = array_column($this->shareLevels(), 'name', 'level');
+        foreach ($rows as $row) {
+            $row->inviter_account = $accounts[(int)$row->inviter_user_id] ?? null;
+            $row->invitee_account = $accounts[(int)$row->invitee_user_id] ?? null;
+            $row->level_name = $names[(int)$row->level] ?? null;
+            $row->invitee_level_name = $names[(int)$row->invitee_level] ?? null;
+        }
+        return ['total' => $q->count(), 'data' => $rows];
+    }
+
     /** Loss-invite record display only: historical recipients and actual issued coupon quantities. */
     private function lossRecordPeople($rows, string $kind): void
     {

+ 28 - 2
app/Support/PromotionRules.php

@@ -10,6 +10,9 @@ final class PromotionRules
     public const LOSS = 7;
     public const FIRST_DEPOSIT = 8;
     public const WHEEL = 9;
+    public const UPGRADE_INVITE = 10;
+    /** Fixed campaigns have exactly one row: edited in place, never created. */
+    public const FIXED = [self::LOSS, self::WHEEL, self::UPGRADE_INVITE];
     public const TASKS = ['online', 'login', 'invite', 'deposit', 'team_deposit', 'bet', 'team_bet'];
     public const DAILY = ['online', 'login'];
     public const PRIZES = ['0-666', '100', '50', '200', '350', '500', '150', '366'];
@@ -17,11 +20,13 @@ final class PromotionRules
     public const COUPON_CATEGORIES = ['self:sport', 'self:lottery'];
     private const COUPON_KEYS = ['reward_mode', 'coupon_valid_from', 'coupon_valid_until', 'coupon_categories', 'inviter_coupons', 'invitee_coupons'];
 
-    public static function supports(int $type): bool { return in_array($type, [7, 8, 9], true); }
-    public static function types(): array { return [7 => '净亏邀请奖励', 8 => '首充奖励', 9 => '转盘活动']; }
+    public static function supports(int $type): bool { return in_array($type, [7, 8, 9, 10], true); }
+    public static function types(): array { return [7 => '净亏邀请奖励', 8 => '首充奖励', 9 => '转盘活动', 10 => '邀请升级奖励']; }
 
     public static function defaults(int $type): array
     {
+        // Upgrade invite pays straight to the available balance, so it has no turnover ratio.
+        if ($type === self::UPGRADE_INVITE) return ['tiers' => []];
         $common = ['turnover_ratio' => '0.00'];
         return $common + match ($type) {
             7 => ['loss_amount' => '500.00', 'invite_count' => 3, 'reward_amount' => '500.00', 'reward_mode' => 'cash',
@@ -39,6 +44,7 @@ final class PromotionRules
         $defaults = self::defaults($type);
         Validator::make(['params' => $input], ['params' => ['array:' . implode(',', array_keys($defaults))]])->validate();
         $data = array_replace($defaults, $input);
+        if ($type === self::UPGRADE_INVITE) return ['tiers' => self::tiers($data['tiers'])];
         $coupon = array_intersect_key($data, array_flip(self::COUPON_KEYS));
         $data = array_diff_key($data, $coupon);
         $rules = [];
@@ -104,6 +110,26 @@ final class PromotionRules
         return $data;
     }
 
+    /** Upgrade tiers: one bonus per share level reached, distinct levels from 2 up, sorted by level. */
+    public static function tiers($tiers): array
+    {
+        $invalid = fn (string $message) => throw ValidationException::withMessages(['params' => $message]);
+        if (!is_array($tiers) || array_values($tiers) !== $tiers || count($tiers) > 50) $invalid('tiers参数错误:最多50档');
+        $data = [];
+        foreach ($tiers as $tier) {
+            if (!is_array($tier) || array_diff(array_keys($tier), ['level', 'amount']) || count($tier) !== 2) $invalid('tiers参数错误:每档须包含level和amount');
+            $level = is_scalar($tier['level']) ? filter_var($tier['level'], FILTER_VALIDATE_INT) : false;
+            if ($level === false || $level < 2) $invalid('tiers参数错误:等级须为2及以上的整数');
+            if (isset($data[$level])) $invalid('tiers参数错误:等级' . $level . '重复');
+            if (!is_numeric($tier['amount']) || !preg_match('/^\d{1,12}(\.\d{1,2})?$/', (string)$tier['amount']) || bccomp((string)$tier['amount'], '0', 2) <= 0) {
+                $invalid('tiers参数错误:奖励金额须大于0且最多两位小数');
+            }
+            $data[$level] = ['level' => $level, 'amount' => bcadd((string)$tier['amount'], '0', 2)];
+        }
+        ksort($data);
+        return array_values($data);
+    }
+
     /** Total discount of one coupon set: sum of discount × quantity. */
     public static function faceValue(array $tiers): string
     {

+ 11 - 0
app/Support/PromotionTranslations.php

@@ -23,9 +23,20 @@ final class PromotionTranslations
         return $result;
     }
 
+    /** Upgrade invite (type 10) shows only body text; it has no title in any language. */
+    public const CONTENT_ONLY = [10];
+
+    public static function fields(int $type): array
+    {
+        return in_array($type, self::CONTENT_ONLY, true) ? ['content'] : self::FIELDS;
+    }
+
     public static function select(array $activity, string $lang): ?array
     {
         $text = self::all($activity)[$lang] ?? null;
+        if ($text && in_array((int)($activity['type'] ?? 0), self::CONTENT_ONLY, true)) {
+            return trim($text['content']) === '' ? null : ['lang' => $lang, 'content' => $text['content']];
+        }
         if (!$text || trim($text['title']) === '') return null;
         return ['lang' => $lang, 'title' => $text['title'],
             'content' => $text['content'] === '' ? null : $text['content']];

+ 44 - 0
database/migrations/2026_11_04_120000_create_upgrade_invite_promotion.php

@@ -0,0 +1,44 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+
+/** 邀请升级奖励 (type 10): one fixed campaign; each (invitee, share level tier) pays its inviter once. */
+return new class extends Migration {
+    public function up(): void
+    {
+        if (!Schema::hasTable('promotion_upgrade_grants')) {
+            Schema::create('promotion_upgrade_grants', function (Blueprint $t) {
+                $t->id(); $t->unsignedBigInteger('activity_id'); $t->unsignedBigInteger('reward_id')->index();
+                $t->unsignedBigInteger('inviter_user_id'); $t->unsignedBigInteger('invitee_user_id');
+                $t->unsignedInteger('level')->comment('档位:分享等级 share_levels.level');
+                $t->decimal('amount', 24, 2); $t->unsignedInteger('invitee_level')->comment('领取时下级的分享等级');
+                $t->unique(['activity_id', 'invitee_user_id', 'level'], 'promotion_upgrade_grant_once');
+                $t->index(['activity_id', 'inviter_user_id']); $t->timestamps();
+            });
+        }
+        DB::transaction(function () {
+            if (DB::table('promotion_activities')->where('type', 10)->exists()) return;
+            $now = now('Asia/Shanghai');
+            // Content-only campaign: the title column keeps the type name for admin lists; members see only the body text.
+            $texts = array_fill_keys(['zh', 'en', 'vi'], ['title' => '', 'content' => '']);
+            $id = DB::table('promotion_activities')->insertGetId([
+                'title' => '邀请升级奖励', 'type' => 10, 'status' => 0,
+                'start_time' => $now->timestamp, 'end_time' => $now->copy()->addYear()->timestamp,
+                'lang' => 'zh', 'sort' => 0, 'params' => json_encode(['tiers' => []], JSON_THROW_ON_ERROR),
+                'translations' => json_encode($texts, JSON_UNESCAPED_UNICODE | 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 the campaign and its paid grants on a code rollback.
+    }
+};

+ 7 - 0
docs/前端交付/README.md

@@ -2,6 +2,13 @@
 
 本目录是2026-10-26的接口文档交付副本,按**项目 → 前台/后台/代理端**组织。
 
+## 邀请升级奖励(2026-11-04)
+
+新增固定独立活动,业务类型 `10`:会员的直属下级分享等级达到配置档位时,邀请人获得奖金,跨档叠加(等级2奖8、等级4奖18,升到等级4共26),只统计活动首次上架后绑定的下级,手动领取进可用余额。本活动只有正文,没有标题。
+
+- [邀请升级-后台接口](bot-28/后台/独立活动/邀请升级-后台接口.md):`detail?type=10` 平铺表单,`params.tiers=[{level, amount}]`,记录 `kind=upgrades`。
+- [邀请升级-会员接口](melbet_sport-api/前台/独立活动/邀请升级-会员接口.md):`upgrades` 下级列表与总待领取 `pending_amount`,`claim` 一次领取全部。
+
 ## 净亏邀请可重复参加(2026-11-03)
 
 - **先看** [净亏邀请-前端修改清单](bot-28/后台/独立活动/净亏邀请-前端修改清单.md):会员前端、管理后台各自需要改什么,以及验收项。

+ 1 - 0
docs/前端交付/bot-28/后台/README.md

@@ -11,6 +11,7 @@
 | 三个独立活动的公共约定和目录 | [独立活动-后台接口](独立活动-后台接口.md) |
 | 净亏邀请:可重复参加与奖励金额修正,会员前端和后台需要改什么(2026-11-03) | [净亏邀请-前端修改清单](独立活动/净亏邀请-前端修改清单.md) |
 | 净亏邀请:平铺配置表单、现金/优惠券模式、申请/发奖/已发券记录(含中文列名、名单与获券) | [净亏邀请-后台接口](独立活动/净亏邀请-后台接口.md) |
+| 邀请升级奖励:直属下级分享等级达到档位奖励邀请人,档位配置与发放明细(2026-11-04) | [邀请升级-后台接口](独立活动/邀请升级-后台接口.md) |
 | 首充奖励配置与记录 | [首充奖励-后台接口](独立活动/首充奖励-后台接口.md) |
 | 转盘任务、概率配置与记录 | [转盘活动-后台接口](独立活动/转盘活动-后台接口.md) |
 | 报表页面修正:移除三个入口,统一有效会员条件 | [报表页面调整-移除三个入口](报表页面调整-移除三个入口.md) |

+ 99 - 0
docs/前端交付/bot-28/后台/独立活动/邀请升级-后台接口.md

@@ -0,0 +1,99 @@
+# 邀请升级奖励-后台接口
+
+本文面向管理后台前端,说明邀请升级奖励的配置表单和发放记录。
+
+业务类型固定为 `10`。这是固定活动,只有一条配置,仅编辑、上架或下架,不提供新增入口。接口、登录与按钮权限沿用 `/admin/promotion`(与净亏邀请、转盘相同),成功 `code=0`,参数或业务错误 `code=-3`,展示实际 `msg`。金额展示规则同其他独立活动:整数不带小数,小数保留两位。
+
+## 规则
+
+- 会员的**直属下级**(分享关系中的直接下级)的**分享等级**达到某一档时,邀请人获得该档奖金。
+- 每档只按"下级 × 档位"发一次;一个下级达到多档时逐档叠加。例:配置等级2奖8、等级4奖18,下级升到等级4(无论是否一次跳级)共得 `8 + 18 = 26`。
+- 多个下级各自计算后累加。
+- 只统计活动**首次上架之后**绑定的直属下级(`first_published_at` 之后)。
+- 会员在前台手动领取,一次领取当前全部待领取奖金,直接进入可用余额,无流水要求。
+- 修改档位金额只影响尚未领取的档位;已领取的档位按当时金额记录,不会重发。
+- 预算 `budget`、获奖人数 `reward_quota` 等通用字段与其他独立活动相同;预算不足时会员领取失败。
+
+## 页面形式:单页平铺表单
+
+1. 进入页面时并行请求 `GET /admin/promotion/options` 和 `GET /admin/promotion/detail?type=10`。
+2. 用详情回填表单,保存的 `id` 取详情返回值。
+3. "保存"调用 `POST /admin/promotion/submit`;上下架调用 `POST /admin/promotion/status`。
+4. 发放记录放在同页 Tab,按需请求 `GET /admin/promotion/records`,带详情返回的 `activity_id` 和 `type=10`。
+
+## 配置接口
+
+| 方法 | 路径 | 参数与用途 |
+|---|---|---|
+| GET | `/admin/promotion/detail?type=10` | 读取唯一配置;也可用 `id` 读取 |
+| GET | `/admin/promotion/options` | `defaults["10"]`,以及档位下拉用的 `share_levels` |
+| POST | `/admin/promotion/submit` | 保存,必须带 `id`,固定 `type=10` |
+| POST | `/admin/promotion/status` | 传 `id/status` 上下架 |
+| GET | `/admin/promotion/records` | `activity_id` + `type=10`;`kind=upgrades` 发放明细,`kind=rewards` 每次领取记录 |
+
+`options` 新增:
+
+```json
+{
+  "defaults": {"10": {"tiers": []}},
+  "share_levels": [{"level": 1, "name": "等级1"}, {"level": 2, "name": "VIP2"}, {"level": 4, "name": "VIP4"}]
+}
+```
+
+## 表单字段
+
+**本活动只有正文,没有标题和图片。** 多语言 `translations` 每种语言只传 `content`:
+
+```json
+{"zh": {"content": "<p>中文正文</p>"}, "en": {"content": "<p>English</p>"}, "vi": {"content": "<p>Nội dung</p>"}}
+```
+
+表单不显示标题输入框;即使传了 `title` 也会被忽略,`detail` 返回的 `translations` 也不含 `title`。三种语言正文都必填,空正文提示"英文正文不能为空"等。接口顶层的 `title` 固定为"邀请升级奖励",仅用于后台识别,不需要编辑。
+
+其余通用字段(`status`、`start_time`、`end_time`、展示平台、注册限制、充值限制、`reward_quota`、`budget` 等)与净亏邀请相同。
+
+活动规则 `params`:
+
+```json
+{
+  "tiers": [
+    {"level": 2, "amount": "8"},
+    {"level": 4, "amount": "18"}
+  ]
+}
+```
+
+| 字段 | 说明 |
+|---|---|
+| `tiers` | 档位列表,最多50档,可增删行。保存后按等级升序返回 |
+| `tiers[].level` | 直属下级分享等级达到多少,下拉选项取 `options.share_levels`,须为2及以上且不可重复(等级1是所有会员的初始等级) |
+| `tiers[].amount` | 奖励金额,大于0,最多两位小数 |
+
+校验提示:
+
+- 档位为空时可以保存为下架状态,但上架(保存 `status=1` 或调用 `status`)提示"请至少配置一档升级奖励"。
+- 等级不存在提示"分享等级N不存在";等级重复、等级小于2、金额不合法均返回 `tiers参数错误:…`。
+
+## 记录查询
+
+`GET /admin/promotion/records?activity_id=&type=10&kind=upgrades&user_id=&page=&limit=`
+
+- `activity_id` 必填,取 `detail?type=10` 返回的 `id`,不要写死。
+- `type=10` 建议必传:`records` 接口是所有独立活动共用的,带上 `type` 后,如果 `activity_id` 配成了其他活动(如首充奖励),会返回 `code=-3`"activity_id与type不匹配",而不是显示其他活动的记录。
+- `kind=upgrades` 只能用于本活动,用于其他活动会提示"upgrades仅用于邀请升级奖励"。
+- 领取记录同样调用 `GET /admin/promotion/records?activity_id=&type=10&kind=rewards`,只返回本活动的领取记录,与首充奖励的 `kind=rewards` 互不影响。
+
+`user_id` 可选,匹配邀请人或下级。每行是一个"下级 × 档位"的已发放奖金:
+
+| 字段 | 中文列名 |
+|---|---|
+| `id` | 编号 |
+| `inviter_user_id` / `inviter_account` | 邀请人ID/账号 |
+| `invitee_user_id` / `invitee_account` | 下级ID/账号 |
+| `level` / `level_name` | 奖励档位(分享等级) |
+| `amount` | 奖励金额 |
+| `invitee_level` / `invitee_level_name` | 领取时下级等级 |
+| `reward_id` | 所属领取记录编号(对应 `kind=rewards` 的 `id`) |
+| `created_at` | 发放时间 |
+
+`kind=rewards`(同样带 `activity_id` 和 `type=10`)返回每次领取一行:`user_id` 邀请人、`round_no` 第几次领取、`amount` 本次合计、`status=paid`、`created_at`;`rule_snapshot.grants` 为本次包含的明细。

+ 1 - 0
docs/前端交付/melbet_sport-api/前台/README.md

@@ -10,6 +10,7 @@
 | [净亏邀请-会员接口](独立活动/净亏邀请-会员接口.md) | 正文 |
 | [净亏邀请-前端修改清单(可重复参加,2026-11-03)](../../bot-28/后台/独立活动/净亏邀请-前端修改清单.md) | 修正单 |
 | [优惠券券包-会员接口](独立活动/优惠券券包-会员接口.md) | 正文 |
+| [邀请升级-会员接口](独立活动/邀请升级-会员接口.md) | 正文 |
 | [首充奖励-会员接口](独立活动/首充奖励-会员接口.md) | 正文 |
 | [转盘活动-会员接口](独立活动/转盘活动-会员接口.md) | 正文 |
 | [转盘播报示例](独立活动/转盘播报示例.json) | 示例数据 |

+ 86 - 0
docs/前端交付/melbet_sport-api/前台/独立活动/邀请升级-会员接口.md

@@ -0,0 +1,86 @@
+# 邀请升级奖励-会员接口
+
+业务类型为 `10`。
+
+通过 **melbet_sport-api主机** 的 `/api/promotion/*` 接入,Token、请求头 `Authorization`、`lang`、`platform=PC/H5/APP` 与金额展示规则同其他独立活动。成功 `code=1`,业务错误 `code=0`,Token过期 `code=401`,展示实际 `msg`。
+
+本活动**只有正文,没有标题**:`list`、`detail` 只返回 `content`(富文本),不返回 `title`,页面不要显示标题区域。某语言没有正文时,该语言下不返回本活动。
+
+## 规则
+
+- 我的**直属下级**分享等级达到配置的档位时,我获得该档奖金;一个下级达到多档时逐档叠加(例:等级2奖8、等级4奖18,下级升到等级4共得26,一次跳级也一样)。多个下级累加。
+- 只统计活动首次上架后绑定的直属下级。
+- 奖金需手动领取,一次领取全部待领取金额,直接进入可用余额,无流水要求。每个"下级 × 档位"只能领取一次。
+
+## 接口
+
+| 方法 | 路径 | 参数及用途 |
+|---|---|---|
+| GET | `/api/promotion/list` | `type=10`,活动入口,`rules.tiers` 为档位配置 |
+| GET | `/api/promotion/detail` | `activity_id`,活动文案与汇总:`pending_amount`、`claimed_amount`、`invitee_count` |
+| GET | `/api/promotion/upgrades` | `activity_id/page/limit`,下级列表及待领取总额 |
+| POST | `/api/promotion/claim` | `activity_id`,领取全部待领取奖金,不需要 `round_no` |
+| GET | `/api/promotion/rewards` | `activity_id/type=10/page/limit`,我的领取记录 |
+
+分页默认1/20,`limit` 最大100。
+
+**领取记录必须传真实的 `activity_id`,并建议同时传 `type=10`**:`/api/promotion/rewards?activity_id=5&type=10&platform=H5`。该接口在不传 `activity_id`(或传空、0)且不传 `type` 时默认返回首充奖励(`type=8`),公共请求方法漏传 ID 会误显示首充奖金。带上 `type=10` 后,漏传 `activity_id` 会返回参数错误,ID 与类型不符会提示"activity_id与type不匹配"。
+
+### 下级列表 `GET /api/promotion/upgrades`
+
+```json
+{
+  "total": 2,
+  "pending_amount": "34",
+  "claimed_amount": "0",
+  "tiers": [{"level": 2, "amount": "8"}, {"level": 4, "amount": "18"}],
+  "data": [
+    {
+      "user_id": 31,
+      "account": "mem****r31",
+      "bound_at": "2026-11-04 10:00:00",
+      "level": 4,
+      "level_name": "VIP4",
+      "tiers": [
+        {"level": 2, "level_name": "VIP2", "amount": "8", "status": "pending"},
+        {"level": 4, "level_name": "VIP4", "amount": "18", "status": "pending"}
+      ]
+    },
+    {
+      "user_id": 30,
+      "account": "mem****r30",
+      "bound_at": "2026-11-04 09:00:00",
+      "level": 3,
+      "level_name": "VIP3",
+      "tiers": [
+        {"level": 2, "level_name": "VIP2", "amount": "8", "status": "pending"},
+        {"level": 4, "level_name": "VIP4", "amount": "18", "status": "locked"}
+      ]
+    }
+  ]
+}
+```
+
+| 字段 | 说明 |
+|---|---|
+| `pending_amount` | 总待领取奖金(全部下级,不受分页影响),为 `"0"` 时领取按钮置灰 |
+| `claimed_amount` | 已领取合计 |
+| `data[]` | 符合条件的直属下级,最新绑定在前;`account` 已脱敏 |
+| `data[].level` / `level_name` | 下级当前分享等级 |
+| `data[].tiers[].status` | `claimed` 已领取(金额为实际发放金额)、`pending` 已达到待领取、`locked` 未达到 |
+
+### 领取 `POST /api/promotion/claim`
+
+```json
+{
+  "reward": {"id": 12, "activity_id": 5, "round_no": 1, "amount": "34", "required_flow": "0", "status": "paid", "created_at": "2026-11-04 12:00:00"},
+  "round_no": 2,
+  "spins": 0,
+  "replayed": false
+}
+```
+
+成功后刷新 `upgrades` 与钱包余额。常见失败 `msg`:
+
+- `暂无可领取的升级奖励`
+- `活动未上架或不在活动时间内`、`活动奖励额度不足`、`活动获奖份数已用完`

+ 99 - 0
docs/总后台/独立活动/邀请升级-后台接口.md

@@ -0,0 +1,99 @@
+# 邀请升级奖励-后台接口
+
+本文面向管理后台前端,说明邀请升级奖励的配置表单和发放记录。
+
+业务类型固定为 `10`。这是固定活动,只有一条配置,仅编辑、上架或下架,不提供新增入口。接口、登录与按钮权限沿用 `/admin/promotion`(与净亏邀请、转盘相同),成功 `code=0`,参数或业务错误 `code=-3`,展示实际 `msg`。金额展示规则同其他独立活动:整数不带小数,小数保留两位。
+
+## 规则
+
+- 会员的**直属下级**(分享关系中的直接下级)的**分享等级**达到某一档时,邀请人获得该档奖金。
+- 每档只按"下级 × 档位"发一次;一个下级达到多档时逐档叠加。例:配置等级2奖8、等级4奖18,下级升到等级4(无论是否一次跳级)共得 `8 + 18 = 26`。
+- 多个下级各自计算后累加。
+- 只统计活动**首次上架之后**绑定的直属下级(`first_published_at` 之后)。
+- 会员在前台手动领取,一次领取当前全部待领取奖金,直接进入可用余额,无流水要求。
+- 修改档位金额只影响尚未领取的档位;已领取的档位按当时金额记录,不会重发。
+- 预算 `budget`、获奖人数 `reward_quota` 等通用字段与其他独立活动相同;预算不足时会员领取失败。
+
+## 页面形式:单页平铺表单
+
+1. 进入页面时并行请求 `GET /admin/promotion/options` 和 `GET /admin/promotion/detail?type=10`。
+2. 用详情回填表单,保存的 `id` 取详情返回值。
+3. "保存"调用 `POST /admin/promotion/submit`;上下架调用 `POST /admin/promotion/status`。
+4. 发放记录放在同页 Tab,按需请求 `GET /admin/promotion/records`,带详情返回的 `activity_id` 和 `type=10`。
+
+## 配置接口
+
+| 方法 | 路径 | 参数与用途 |
+|---|---|---|
+| GET | `/admin/promotion/detail?type=10` | 读取唯一配置;也可用 `id` 读取 |
+| GET | `/admin/promotion/options` | `defaults["10"]`,以及档位下拉用的 `share_levels` |
+| POST | `/admin/promotion/submit` | 保存,必须带 `id`,固定 `type=10` |
+| POST | `/admin/promotion/status` | 传 `id/status` 上下架 |
+| GET | `/admin/promotion/records` | `activity_id` + `type=10`;`kind=upgrades` 发放明细,`kind=rewards` 每次领取记录 |
+
+`options` 新增:
+
+```json
+{
+  "defaults": {"10": {"tiers": []}},
+  "share_levels": [{"level": 1, "name": "等级1"}, {"level": 2, "name": "VIP2"}, {"level": 4, "name": "VIP4"}]
+}
+```
+
+## 表单字段
+
+**本活动只有正文,没有标题和图片。** 多语言 `translations` 每种语言只传 `content`:
+
+```json
+{"zh": {"content": "<p>中文正文</p>"}, "en": {"content": "<p>English</p>"}, "vi": {"content": "<p>Nội dung</p>"}}
+```
+
+表单不显示标题输入框;即使传了 `title` 也会被忽略,`detail` 返回的 `translations` 也不含 `title`。三种语言正文都必填,空正文提示"英文正文不能为空"等。接口顶层的 `title` 固定为"邀请升级奖励",仅用于后台识别,不需要编辑。
+
+其余通用字段(`status`、`start_time`、`end_time`、展示平台、注册限制、充值限制、`reward_quota`、`budget` 等)与净亏邀请相同。
+
+活动规则 `params`:
+
+```json
+{
+  "tiers": [
+    {"level": 2, "amount": "8"},
+    {"level": 4, "amount": "18"}
+  ]
+}
+```
+
+| 字段 | 说明 |
+|---|---|
+| `tiers` | 档位列表,最多50档,可增删行。保存后按等级升序返回 |
+| `tiers[].level` | 直属下级分享等级达到多少,下拉选项取 `options.share_levels`,须为2及以上且不可重复(等级1是所有会员的初始等级) |
+| `tiers[].amount` | 奖励金额,大于0,最多两位小数 |
+
+校验提示:
+
+- 档位为空时可以保存为下架状态,但上架(保存 `status=1` 或调用 `status`)提示"请至少配置一档升级奖励"。
+- 等级不存在提示"分享等级N不存在";等级重复、等级小于2、金额不合法均返回 `tiers参数错误:…`。
+
+## 记录查询
+
+`GET /admin/promotion/records?activity_id=&type=10&kind=upgrades&user_id=&page=&limit=`
+
+- `activity_id` 必填,取 `detail?type=10` 返回的 `id`,不要写死。
+- `type=10` 建议必传:`records` 接口是所有独立活动共用的,带上 `type` 后,如果 `activity_id` 配成了其他活动(如首充奖励),会返回 `code=-3`"activity_id与type不匹配",而不是显示其他活动的记录。
+- `kind=upgrades` 只能用于本活动,用于其他活动会提示"upgrades仅用于邀请升级奖励"。
+- 领取记录同样调用 `GET /admin/promotion/records?activity_id=&type=10&kind=rewards`,只返回本活动的领取记录,与首充奖励的 `kind=rewards` 互不影响。
+
+`user_id` 可选,匹配邀请人或下级。每行是一个"下级 × 档位"的已发放奖金:
+
+| 字段 | 中文列名 |
+|---|---|
+| `id` | 编号 |
+| `inviter_user_id` / `inviter_account` | 邀请人ID/账号 |
+| `invitee_user_id` / `invitee_account` | 下级ID/账号 |
+| `level` / `level_name` | 奖励档位(分享等级) |
+| `amount` | 奖励金额 |
+| `invitee_level` / `invitee_level_name` | 领取时下级等级 |
+| `reward_id` | 所属领取记录编号(对应 `kind=rewards` 的 `id`) |
+| `created_at` | 发放时间 |
+
+`kind=rewards`(同样带 `activity_id` 和 `type=10`)返回每次领取一行:`user_id` 邀请人、`round_no` 第几次领取、`amount` 本次合计、`status=paid`、`created_at`;`rule_snapshot.grants` 为本次包含的明细。

+ 80 - 7
tests/Integration/PromotionActivitiesTest.php

@@ -51,9 +51,10 @@ class PromotionActivitiesTest extends TestCase
             'share_bet_facts' => 'id INTEGER PRIMARY KEY AUTOINCREMENT,user_id INTEGER,source TEXT,source_id TEXT,bet_at TEXT,bet_amount TEXT,net_profit TEXT,effective_amount TEXT,currency TEXT,settled INTEGER DEFAULT 1,voided INTEGER DEFAULT 0',
             'user_login' => 'id INTEGER PRIMARY KEY AUTOINCREMENT,user_id TEXT,status INTEGER,platform TEXT,created_at TEXT',
             'reporting_activity' => 'id INTEGER PRIMARY KEY AUTOINCREMENT,user_id INTEGER,session_hash TEXT,observed_at TEXT',
+            'share_levels' => 'id INTEGER PRIMARY KEY AUTOINCREMENT,level INTEGER UNIQUE,name 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', '2026_10_27_130000_initialize_fixed_promotion_activities.php', '2026_10_27_140000_add_promotion_activity_translations.php', '2026_11_03_120000_add_promotion_coupons.php', '2026_11_03_170000_repeat_loss_invite_rounds.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', '2026_10_27_140000_add_promotion_activity_translations.php', '2026_11_03_120000_add_promotion_coupons.php', '2026_11_03_170000_repeat_loss_invite_rounds.php', '2026_11_04_120000_create_upgrade_invite_promotion.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,7 +71,7 @@ class PromotionActivitiesTest extends TestCase
     {
         $input = ['translations' => $this->texts(), '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');
+        if (in_array($type, PromotionRules::FIXED, true)) $input['id'] = (int) DB::table('promotion_activities')->where('type', $type)->value('id');
         return (int) (new PromotionAdminService())->save($input)['id'];
     }
     private function texts(): array
@@ -584,14 +585,14 @@ class PromotionActivitiesTest extends TestCase
 
     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) {
+        $this->assertSame(3, DB::table('promotion_activities')->count());
+        foreach ([7, 9, 10] 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([7, 8, 9, 10], array_column($options['types'], 'value'));
         $this->assertSame([8], array_column($options['create_types'], 'value'));
         $this->assertSame(0, DB::table('menus')->count());
     }
@@ -599,7 +600,7 @@ class PromotionActivitiesTest extends TestCase
     public function test_fixed_types_cannot_be_created_even_when_no_configuration_exists(): void
     {
         $admin = new PromotionAdminService();
-        foreach ([7, 9] as $type) {
+        foreach ([7, 9, 10] as $type) {
             $input = ['translations' => $this->texts(), '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), '不支持新增');
@@ -631,7 +632,7 @@ class PromotionActivitiesTest extends TestCase
         $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());
+        $this->assertSame(3, 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'));
@@ -726,4 +727,76 @@ class PromotionActivitiesTest extends TestCase
         $this->assertNotSame(0, (new \App\Http\Controllers\admin\PaymentOrder())->manualAudit()->getData(true)['code']);
         $this->assertSame(1, DB::table('promotion_rewards')->count());
     }
+
+    public function test_upgrade_invite_tiers_are_validated_sorted_and_must_exist_before_publishing(): void
+    {
+        foreach ([1 => '等级1', 2 => 'VIP2', 3 => 'VIP3', 4 => 'VIP4'] as $level => $name) DB::table('share_levels')->insert(['level' => $level, 'name' => $name]);
+        $admin = new PromotionAdminService();
+        $this->assertSame(['tiers' => []], $admin->options()['defaults'][10]);
+        $this->assertSame([2, 3, 4], array_slice(array_column($admin->options()['share_levels'], 'level'), 1));
+        $id = (int) DB::table('promotion_activities')->where('type', 10)->value('id');
+        $this->assertSame($id, $admin->detail(null, 10)['id']);
+        $this->assertArrayNotHasKey('image', $admin->detail(null, 10));
+        $this->invalid(fn () => $this->activity(10, ['tiers' => []]), '至少配置一档');
+        $this->activity(10, ['tiers' => []], 0);
+        $this->invalid(fn () => $admin->status($id, 1), '至少配置一档');
+        foreach ([
+            [[['level' => 1, 'amount' => '8']], '2及以上'],
+            [[['level' => 2, 'amount' => '8'], ['level' => 2, 'amount' => '9']], '重复'],
+            [[['level' => 2, 'amount' => '0']], '大于0'],
+            [[['level' => 2, 'amount' => '8.001']], '两位小数'],
+            [[['level' => 2]], 'level和amount'],
+            [[['level' => 9, 'amount' => '8']], '分享等级9不存在'],
+            [['level' => 2, 'amount' => '8'], 'tiers参数错误'],
+        ] as [$tiers, $message]) $this->invalid(fn () => $this->activity(10, ['tiers' => $tiers]), $message);
+        $this->activity(10, ['tiers' => [['level' => '4', 'amount' => '18'], ['level' => 2, 'amount' => 8]]]);
+        $saved = $admin->detail(null, 10);
+        $this->assertSame([['level' => 2, 'amount' => '8.00'], ['level' => 4, 'amount' => '18.00']], $saved['params']['tiers']);
+        $this->assertSame(1, (int) $saved['status']);
+        $this->assertNotNull(DB::table('promotion_campaigns')->where('activity_id', $id)->value('first_published_at'));
+        $this->assertSame(1, DB::table('promotion_activities')->where('type', 10)->count());
+    }
+
+    public function test_upgrade_invite_has_body_text_only_without_titles(): void
+    {
+        DB::table('share_levels')->insert(['level' => 2, 'name' => 'VIP2']);
+        $admin = new PromotionAdminService();
+        $id = (int) DB::table('promotion_activities')->where('type', 10)->value('id');
+        $base = ['id' => $id, 'type' => 10, 'status' => 1, 'start_time' => '2026-10-01 00:00:00', 'end_time' => '2026-12-31 23:59:59',
+            'params' => ['tiers' => [['level' => 2, 'amount' => '8']]]];
+        $texts = ['zh' => ['content' => '<p>中文正文</p>'], 'en' => ['content' => '<p>English</p>'], 'vi' => ['content' => '<p>Nội dung</p>']];
+        $this->invalid(fn () => $admin->save($base + ['translations' => array_replace($texts, ['en' => ['content' => '<p> </p>']])]), '英文正文不能为空');
+        $saved = $admin->save($base + ['translations' => array_replace_recursive($texts, ['zh' => ['title' => '会被忽略']])]);
+        $this->assertSame(['content' => '<p>中文正文</p>'], $saved['translations']['zh']);
+        $this->assertSame('邀请升级奖励', $saved['title']);
+        $row = DB::table('promotion_activities')->where('id', $id)->first();
+        $this->assertSame(['lang' => 'en', 'content' => '<p>English</p>'], \App\Support\PromotionTranslations::select((array) $row, 'en'));
+        $admin->status($id, 0); $this->assertSame(1, (int) $admin->status($id, 1)['status']);
+        $this->invalid(fn () => $admin->save(['id' => (int) DB::table('promotion_activities')->where('type', 7)->value('id'), 'type' => 7, 'status' => 0,
+            'start_time' => '2026-10-01 00:00:00', 'end_time' => '2026-12-31 23:59:59', 'translations' => $texts]), '标题');
+    }
+
+    public function test_upgrade_invite_records_list_each_paid_tier_with_both_accounts(): void
+    {
+        foreach ([2 => 'VIP2', 4 => 'VIP4'] as $level => $name) DB::table('share_levels')->insert(['level' => $level, 'name' => $name]);
+        $id = (int) DB::table('promotion_activities')->where('type', 10)->value('id');
+        DB::table('users')->whereIn('id', [1, 2])->update(['account' => DB::raw("'acc' || id")]);
+        $this->member(3, 1);
+        foreach ([[2, 2], [2, 4], [3, 2]] as $i => [$invitee, $level]) DB::table('promotion_upgrade_grants')->insert(['activity_id' => $id, 'reward_id' => 1,
+            'inviter_user_id' => 1, 'invitee_user_id' => $invitee, 'level' => $level, 'amount' => $level === 2 ? '8.00' : '18.00', 'invitee_level' => 4]);
+        $admin = new PromotionAdminService();
+        $all = $admin->records(['activity_id' => $id, 'kind' => 'upgrades']);
+        $this->assertSame(3, $all['total']);
+        $first = $all['data'][2];
+        $this->assertSame(['acc1', 'acc2', 'VIP2', 'VIP4'], [$first->inviter_account, $first->invitee_account, $first->level_name, $first->invitee_level_name]);
+        $this->assertSame(2, $admin->records(['activity_id' => $id, 'kind' => 'upgrades', 'user_id' => 2])['total']);
+        $this->assertSame(3, $admin->records(['activity_id' => $id, 'kind' => 'upgrades', 'user_id' => 1])['total']);
+        $this->assertNull($admin->records(['activity_id' => $id, 'kind' => 'upgrades', 'user_id' => 3])['data'][0]->invitee_account);
+        $this->assertSame(3, $admin->records(['activity_id' => $id, 'type' => 10, 'kind' => 'upgrades'])['total']);
+        $loss = (int) DB::table('promotion_activities')->where('type', 7)->value('id');
+        $this->invalid(fn () => $admin->records(['activity_id' => $loss, 'type' => 10, 'kind' => 'rewards']), 'activity_id与type不匹配');
+        $this->invalid(fn () => $admin->records(['activity_id' => $loss, 'kind' => 'upgrades']), 'upgrades仅用于邀请升级奖励');
+        $this->invalid(fn () => $admin->records(['activity_id' => 99999, 'kind' => 'rewards']), '独立活动不存在');
+        $this->assertSame(0, $admin->records(['activity_id' => $loss, 'type' => 7, 'kind' => 'rewards'])['total']);
+    }
 }