瀏覽代碼

活动修改

doge 1 天之前
父節點
當前提交
0cf1cd444e

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

@@ -5,6 +5,7 @@ namespace App\Http\Controllers\admin;
 use App\Constants\HttpStatus;
 use App\Http\Controllers\Controller;
 use App\Services\PromotionAdminService;
+use App\Support\PromotionAmountFormatter;
 use Illuminate\Support\Facades\Log;
 use Illuminate\Validation\ValidationException;
 
@@ -12,7 +13,7 @@ class Promotion extends Controller
 {
     private function run(callable $callback)
     {
-        try { return $this->success($callback()); }
+        try { return $this->success(PromotionAmountFormatter::response($callback())); }
         catch (ValidationException $e) { return $this->error(HttpStatus::CUSTOM_ERROR, $e->validator->errors()->first()); }
         catch (\Throwable $e) { Log::error('promotion_admin_failed', ['exception' => $e]); return $this->error(HttpStatus::CUSTOM_ERROR, '独立活动服务暂不可用'); }
     }

+ 2 - 2
app/Models/PromotionActivity.php

@@ -9,10 +9,10 @@ class PromotionActivity extends BaseModel
 {
     protected $table = 'promotion_activities';
     protected $guarded = ['id', 'rewarded_count', 'budget_used'];
-    protected $fillable = ['title', 'sub_title', 'type', 'status', 'start_time', 'end_time', 'lang', 'sort', 'params', 'content', 'image',
+    protected $fillable = ['title', 'sub_title', 'translations', 'type', 'status', 'start_time', 'end_time', 'lang', 'sort', 'params', 'content', 'image',
         'publish_platforms', 'register_time_limited', 'register_start_time', 'register_end_time', 'register_platforms',
         'deposit_limited', 'deposit_min', 'deposit_max', 'show_ineligible', 'reward_quota', 'budget'];
-    protected $casts = ['publish_platforms' => 'array', 'register_platforms' => 'array', 'register_time_limited' => 'boolean',
+    protected $casts = ['translations' => 'array', 'publish_platforms' => 'array', 'register_platforms' => 'array', 'register_time_limited' => 'boolean',
         'deposit_limited' => 'boolean', 'show_ineligible' => 'boolean', 'quota_enabled' => 'boolean', 'budget' => 'decimal:2', 'budget_used' => 'decimal:2',
         'reward_quota' => 'integer', 'rewarded_count' => 'integer', 'deposit_min' => 'decimal:2', 'deposit_max' => 'decimal:2'];
     protected $appends = ['type_name'];

+ 37 - 7
app/Services/PromotionAdminService.php

@@ -4,6 +4,7 @@ namespace App\Services;
 
 use App\Models\PromotionActivity;
 use App\Support\PromotionRules;
+use App\Support\PromotionTranslations;
 use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\Schema;
 use Illuminate\Support\Facades\Validator;
@@ -14,7 +15,8 @@ class PromotionAdminService
     public function options(): array
     {
         $types = []; foreach (PromotionRules::types() as $value => $label) $types[] = compact('value', 'label');
-        return ['types' => $types, 'create_types' => [['value' => PromotionRules::FIRST_DEPOSIT, 'label' => PromotionRules::types()[PromotionRules::FIRST_DEPOSIT]]],
+        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)],
             'prizes' => PromotionRules::PRIZES, 'tasks' => PromotionRules::TASKS];
     }
@@ -38,7 +40,7 @@ class PromotionAdminService
 
     private function format(PromotionActivity $row): array
     {
-        return array_replace($row->toArray(), ['params' => json_decode($row->params, true),
+        return array_replace($row->toArray(), ['translations' => PromotionTranslations::all($row->toArray()), 'params' => json_decode($row->params, true),
             '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')]);
     }
@@ -46,16 +48,27 @@ class PromotionAdminService
     public function save(array $input): array
     {
         $money = ['nullable', 'numeric', 'min:0', 'regex:/^\d{1,12}(\.\d{1,2})?$/'];
-        $data = Validator::make($input, ['id' => ['sometimes', 'integer', 'min:1'], 'title' => ['required', 'string', 'max:140'],
-            'sub_title' => ['sometimes', 'nullable', 'string', 'max:200'], 'type' => ['required', 'integer', 'in:7,8,9'], 'status' => ['required', 'integer', 'in:0,1'],
-            'start_time' => ['required', 'date'], 'end_time' => ['required', 'date', 'after:start_time'], 'lang' => ['sometimes', 'string', 'max:10'],
-            'sort' => ['sometimes', 'integer', 'between:0,4294967295'], 'params' => ['sometimes', 'array'], 'content' => ['sometimes', 'nullable', 'string', 'max:100000'],
+        $textRules = ['translations' => ['required', 'array:zh,en,vi']];
+        $messages = ['translations.required' => '请完整填写中文、英文、越南文的标题、副标题和正文'];
+        foreach (['zh' => '中文', 'en' => '英文', 'vi' => '越南文'] as $lang => $label) {
+            $textRules['translations.' . $lang] = ['required', 'array:title,sub_title,content'];
+            $messages['translations.' . $lang . '.required'] = '请完整填写' . $label . '文本';
+            foreach (['title' => ['标题', 140], 'sub_title' => ['副标题', 200], 'content' => ['正文', 100000]] 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'],
+            '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'],
             'register_time_limited' => ['sometimes', 'boolean'], 'register_start_time' => ['nullable', 'required_if:register_time_limited,1', 'date'],
             'register_end_time' => ['nullable', 'required_if:register_time_limited,1', 'date', 'after_or_equal:register_start_time'],
             '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();
+            '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' => '净亏和转盘活动不支持新增,请编辑现有活动']);
         }
@@ -66,6 +79,8 @@ 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']);
+            $values = array_replace($values, $texts['zh'], ['translations' => $texts, 'lang' => 'zh']);
             $values['params'] = json_encode(PromotionRules::normalize((int)$data['type'], $params));
             $values['start_time'] = strtotime($data['start_time']); $values['end_time'] = strtotime($data['end_time']);
             if (!$row->exists) $values += ['lang' => 'zh', 'sort' => 0, 'publish_platforms' => [], 'register_platforms' => []];
@@ -75,11 +90,26 @@ class PromotionAdminService
         }, 3);
     }
 
+    private function completeTexts(array $texts): array
+    {
+        foreach (['zh' => '中文', 'en' => '英文', 'vi' => '越南文'] as $lang => $label) {
+            foreach (['title' => '标题', 'sub_title' => '副标题', 'content' => '正文'] 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 . '不能为空']);
+                }
+                if ($field !== 'content') $texts[$lang][$field] = trim($texts[$lang][$field]);
+            }
+        }
+        return $texts;
+    }
+
     public function status(int $id, int $status): array
     {
         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()));
             $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);

+ 20 - 14
app/Services/PromotionService.php

@@ -7,6 +7,7 @@ use App\Models\BalanceLog;
 use App\Models\User;
 use App\Models\Wallet;
 use App\Support\PromotionRules as Rules;
+use App\Support\PromotionTranslations;
 use Illuminate\Support\Facades\DB;
 use Illuminate\Support\Facades\Schema;
 use Illuminate\Validation\ValidationException;
@@ -42,16 +43,18 @@ class PromotionService
     public function listing(int $userId, string $platform, string $lang = 'zh', ?int $type = null): array
     {
         $member = $this->member($userId); $context = ActivityEligibilityService::memberContext($member->member_id);
-        $q = PromotionActivity::where('status', 1)->where('lang', $lang)->where('start_time', '<=', now()->timestamp)->where('end_time', '>=', now()->timestamp);
+        $q = PromotionActivity::where('status', 1)->where('start_time', '<=', now()->timestamp)->where('end_time', '>=', now()->timestamp);
         if ($type !== null) $q->where('type', $type);
         $data = [];
         foreach ($q->orderByDesc('sort')->orderByDesc('id')->get() as $activity) {
+            $text = PromotionTranslations::select($activity->toArray(), $lang);
+            if (!$text) continue;
             if (!ActivityEligibilityService::isPublishedOn($activity, $platform)) continue;
             $eligibility = ActivityEligibilityService::evaluate($activity, $context);
             if (!$eligibility['is_eligible'] && !$activity->show_ineligible) continue;
             $data[] = ['activity_id' => $activity->id, 'type' => (int)$activity->type, 'type_name' => $activity->type_name,
-                'title' => $activity->title, 'sub_title' => $activity->sub_title, 'image' => $activity->image, 'content' => $activity->content,
-                'start_time' => (int)$activity->start_time, 'end_time' => (int)$activity->end_time, 'rules' => $this->rules($activity)] + $eligibility;
+                'image' => $activity->image,
+                'start_time' => (int)$activity->start_time, 'end_time' => (int)$activity->end_time, 'rules' => $this->rules($activity)] + $text + $eligibility;
         }
         return ['total' => count($data), 'data' => $data];
     }
@@ -117,30 +120,30 @@ class PromotionService
         return DB::table('promotion_rounds')->where('account_id', $account->id)->where('round_no', $account->round_no)->lockForUpdate()->first();
     }
 
-    public function detail(int $activityId, int $userId, ?string $platform = null): array
+    public function detail(int $activityId, int $userId, ?string $platform = null, ?string $lang = null): array
     {
         $activity = $this->activity($activityId); $member = $this->member($userId);
         $this->open($activity, $member, $platform);
         $account = DB::table('promotion_accounts')->where('activity_id', $activityId)->where('user_id', $userId)->first();
         $round = $account ? DB::table('promotion_rounds')->where('account_id', $account->id)->where('round_no', $account->round_no)->first() : null;
-        return $this->state($activity, $member, $account, $round);
+        return $this->state($activity, $member, $account, $round, $lang);
     }
 
-    public function progress(int $activityId, int $userId, ?string $platform = null): array
+    public function progress(int $activityId, int $userId, ?string $platform = null, ?string $lang = null): array
     {
-        return $this->mutate($activityId, $userId, function ($activity, $member) use ($platform) {
+        return $this->mutate($activityId, $userId, function ($activity, $member) use ($platform, $lang) {
             $start = $this->open($activity, $member, $platform); $account = $this->account($activity, $member); $round = null;
             if ((int)$activity->type === Rules::WHEEL) {
                 $round = $this->round($account) ?? $this->newRound($account, $start);
                 if ($round->status === 'active') $this->refreshWheel($activity, $member, $account, $round);
             }
-            return $this->state($activity, $member, $account, $round);
+            return $this->state($activity, $member, $account, $round, $lang);
         });
     }
 
-    public function apply(int $activityId, int $userId, ?string $platform = null): array
+    public function apply(int $activityId, int $userId, ?string $platform = null, ?string $lang = null): array
     {
-        return $this->mutate($activityId, $userId, function ($activity, $member) use ($platform) {
+        return $this->mutate($activityId, $userId, function ($activity, $member) use ($platform, $lang) {
             $start = $this->open($activity, $member, $platform);
             if ((int)$activity->type !== Rules::LOSS) $this->invalid('只有净亏邀请活动需要申请');
             $account = $this->account($activity, $member);
@@ -155,7 +158,7 @@ class PromotionService
                 DB::table('promotion_accounts')->where('id', $account->id)->update(['applied_at' => $account->applied_at,
                     'application_snapshot' => $account->application_snapshot, 'updated_at' => PromotionFacts::now()]);
             }
-            return $this->state($activity, $member, $account, null);
+            return $this->state($activity, $member, $account, null, $lang);
         });
     }
 
@@ -337,9 +340,10 @@ class PromotionService
                 'created_at' => PromotionFacts::now(), 'updated_at' => PromotionFacts::now()]);
             if (!$inserted) return;
             $activities = PromotionActivity::where('type', Rules::FIRST_DEPOSIT)->where('status', 1)->where('start_time', '<=', now()->timestamp)
-                ->where('end_time', '>=', now()->timestamp)->where('lang', $member->language ?: 'zh')->orderByDesc('sort')->orderByDesc('id')->get();
+                ->where('end_time', '>=', now()->timestamp)->orderByDesc('sort')->orderByDesc('id')->get();
             foreach ($activities as $candidate) {
                 $activity = $this->activity($candidate->id, true);
+                if (!PromotionTranslations::select($activity->toArray(), $member->language ?: 'zh')) continue;
                 if ((int)$activity->status !== 1 || (int)$activity->type !== Rules::FIRST_DEPOSIT) continue;
                 $published = DB::table('promotion_campaigns')->where('activity_id', $activity->id)->value('first_published_at');
                 $creditedAt = (string)$deposit->getRawOriginal('created_at');
@@ -355,11 +359,13 @@ class PromotionService
         }, 3);
     }
 
-    private function state(PromotionActivity $activity, User $member, ?object $account, ?object $round): array
+    private function state(PromotionActivity $activity, User $member, ?object $account, ?object $round, ?string $lang = null): array
     {
         $rules = $this->rules($activity); $type = (int)$activity->type;
+        $text = PromotionTranslations::select($activity->toArray(), $lang ?? ($member->language ?: 'zh'));
+        if (!$text) $this->invalid('活动未配置所选语言');
         $result = ['activity_id' => $activity->id, 'type' => $type, 'rules' => $rules, 'account_id' => $account->id ?? null,
-            'round_no' => $account->round_no ?? 1, 'spins' => $account->spins ?? 0, 'server_time' => PromotionFacts::now()];
+            'round_no' => $account->round_no ?? 1, 'spins' => $account->spins ?? 0, 'server_time' => PromotionFacts::now()] + $text;
         if ($type === Rules::WHEEL) {
             $flags = $round ? $this->json($round->completed_tasks) : [];
             $result += ['coins' => $this->money($round->coins_cents ?? 0), 'remaining' => $this->money(66600 - ($round->coins_cents ?? 0)),

+ 38 - 0
app/Support/PromotionAmountFormatter.php

@@ -0,0 +1,38 @@
+<?php
+
+namespace App\Support;
+
+/** Presentation only. Accounting and eligibility keep their original decimal precision. */
+final class PromotionAmountFormatter
+{
+    private const MONEY_FIELDS = ['amount', 'required_flow', 'balance', 'available_balance', 'frozen_balance',
+        'before_balance', 'after_balance', 'budget', 'budget_used', 'deposit_min', 'deposit_max', 'deposit_total',
+        'loss_amount', 'reward_amount', 'min_deposit', 'min_effective_bet', 'deposit_amount', 'team_deposit_amount',
+        'bet_amount', 'team_bet_amount', 'loss', 'coins', 'remaining'];
+    private const MONEY_TASKS = ['deposit', 'team_deposit', 'bet', 'team_bet'];
+
+    public static function amount($value)
+    {
+        if ((!is_string($value) && !is_int($value) && !is_float($value))
+            || !preg_match('/^-?\d+(?:\.\d+)?$/D', (string) $value)) return $value;
+        $fixed = bcadd((string) $value, '0', 2);
+        if (bccomp($fixed, '0', 2) === 0) return '0';
+        return str_ends_with($fixed, '.00') ? substr($fixed, 0, -3) : $fixed;
+    }
+
+    public static function response($value)
+    {
+        if ($value instanceof \JsonSerializable) $value = $value->jsonSerialize();
+        elseif ($value instanceof \Traversable) $value = iterator_to_array($value);
+        $object = $value instanceof \stdClass;
+        if ($object) $value = (array) $value;
+        if (!is_array($value)) return $value;
+        $moneyTask = in_array($value['key'] ?? null, self::MONEY_TASKS, true);
+        $result = [];
+        foreach ($value as $key => $item) {
+            $isAmount = in_array($key, self::MONEY_FIELDS, true) || ($moneyTask && in_array($key, ['value', 'target'], true));
+            $result[$key] = $isAmount ? self::amount($item) : self::response($item);
+        }
+        return $object ? (object) $result : $result;
+    }
+}

+ 34 - 0
app/Support/PromotionTranslations.php

@@ -0,0 +1,34 @@
+<?php
+
+namespace App\Support;
+
+/** Text variants share one campaign identity and one set of financial rules. */
+final class PromotionTranslations
+{
+    public const LANGUAGES = ['zh', 'en', 'vi'];
+    public const FIELDS = ['title', 'sub_title', 'content'];
+
+    public static function all(array $activity): array
+    {
+        $stored = $activity['translations'] ?? null;
+        if (is_string($stored)) $stored = json_decode($stored, true);
+        if (!is_array($stored) || !$stored) {
+            $lang = in_array($activity['lang'] ?? '', self::LANGUAGES, true) ? $activity['lang'] : 'zh';
+            $stored = [$lang => array_intersect_key($activity, array_flip(self::FIELDS))];
+        }
+        $result = [];
+        foreach (self::LANGUAGES as $lang) {
+            foreach (self::FIELDS as $field) $result[$lang][$field] = (string) ($stored[$lang][$field] ?? '');
+        }
+        return $result;
+    }
+
+    public static function select(array $activity, string $lang): ?array
+    {
+        $text = self::all($activity)[$lang] ?? null;
+        if (!$text || trim($text['title']) === '') return null;
+        return ['lang' => $lang, 'title' => $text['title'],
+            'sub_title' => $text['sub_title'] === '' ? null : $text['sub_title'],
+            'content' => $text['content'] === '' ? null : $text['content']];
+    }
+}

+ 28 - 0
database/migrations/2026_10_27_140000_add_promotion_activity_translations.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
+    {
+        Schema::table('promotion_activities', function (Blueprint $t) { $t->json('translations')->nullable(); });
+        DB::table('promotion_activities')->orderBy('id')->chunkById(100, function ($rows) {
+            foreach ($rows as $row) {
+                $texts = [];
+                foreach (['zh', 'en', 'vi'] as $lang) $texts[$lang] = ['title' => '', 'sub_title' => '', 'content' => ''];
+                $lang = in_array($row->lang, ['zh', 'en', 'vi'], true) ? $row->lang : 'zh';
+                $texts[$lang] = ['title' => $row->title, 'sub_title' => $row->sub_title ?? '', 'content' => $row->content ?? ''];
+                DB::table('promotion_activities')->where('id', $row->id)->whereNull('translations')->update([
+                    'translations' => json_encode($texts, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR)]);
+            }
+        });
+    }
+
+    public function down(): void
+    {
+        Schema::table('promotion_activities', function (Blueprint $t) { $t->dropColumn('translations'); });
+    }
+};

+ 21 - 0
docs/后端/独立活动三语文本-升级说明.md

@@ -0,0 +1,21 @@
+# 独立活动三语文本升级
+
+后台项目为 `bot-28`,会员接口项目为 `melbet_sport-api`,共享独立活动数据。前端改造文档分别放在两项目的原文档目录,本文仅供接口部署使用。
+
+新增迁移:
+
+```bash
+php artisan migrate --path=database/migrations/2026_10_27_140000_add_promotion_activity_translations.php --force
+```
+
+本次增加 `promotion_activities.translations` JSON列,保存 `zh/en/vi` 各自的 `title/sub_title/content`。原单语言文本按原 `lang` 放入对应语言,其他语言留空;原语言无法识别时归入中文。保留活动ID、金额规则、首次上架时间和全部参与/资金记录,不添加菜单或权限。
+
+先在两项目共用的数据库执行新增迁移,再发布两项目接口与前端改造。未对任何运行环境执行迁移。
+
+后台保存接口要求三语全部提供且九个文本字段均非空。旧单语言表单不再接受。已有活动在补齐三语之前仍可按原语言读取;上下架接口维持 `id/status` 协议,上架额外校验三语完整,下架不受影响。
+
+后台兼容根字段由中文翻译投影生成,前端读写完整 `translations`。会员端统一按请求头 `lang`(未传头默认 `vi`)选择一套文本,URL和请求体的同名参数不参与语言选择,不再把原活动的 `lang` 当作唯一可见语言。同一活动只保留一份进度、名额、资金和领奖次数。
+
+首充自动发奖根据会员语言匹配该活动已有翻译,不再仅比较活动根级 `lang`。仍按原规则只处理全历史首次成功充值,一次发奖;切换语言不重置资格。
+
+回滚迁移只删除新增翻译列,原根级文本和业务记录保留;会丢失该列中的其他语言文本,回滚前应备份。

+ 145 - 0
docs/总后台/独立活动/三个活动语言Tab与文本联动-后台前端改造.md

@@ -0,0 +1,145 @@
+# 三个活动:语言Tab与文本联动改造
+
+请直接改造现有净亏邀请、首充奖励、转盘活动的新增/编辑页面。本文件是本次前端AI的执行要求;这三个页面的语言、标题、副标题、正文接入方式以本文件为准。各活动原有规则表单继续沿用各自文档。
+
+## 页面效果
+
+编辑同一个活动时,文本编辑区固定显示三个Tab,顺序为「中文」「英文」「越南文」,对应 `zh/en/vi`。默认打开中文Tab。
+
+每个Tab都有独立的标题、副标题、正文编辑框。切换Tab时,这三个输入框一起切换到对应语言的内容。副标题也必须按语言分别填写。语言Tab只是当前编辑视图,不是活动的一项业务属性。
+
+金额、奖励规则、时间、排序、发布平台、资格限制、上下架状态在三个语言间共用,放在Tab文本区外。同一个活动始终使用同一个 `id`。净亏邀请和转盘编辑现有活动;首充奖励保留新增和编辑。
+
+转盘编辑页不展示图片上传、图片预览或图片必填项,保存请求不提交 `image`。转盘盘面、奖品图标和装饰素材沿用页面设计,不通过这个通用活动封面字段配置。
+
+保存按钮位于整个表单底部,一次提交全部三个Tab的文本和公共设置。三个Tab的标题、副标题、正文共9项全部填写后才允许保存;下架状态也不能保存不完整文本。
+
+## 接口和数据位置
+
+使用 `bot-28` 后台API域名,请求头 `Authorization: Bearer <admin_token>`,POST发送JSON。成功 `code=0`;参数/业务错误 `code=-3`,展示返回的 `msg`,保存失败时保留当前表单。
+
+| 方法 | 路径 | 用途 |
+| --- | --- | --- |
+| GET | `/admin/promotion?type=7` | 净亏邀请列表;首充为 `type=8`,转盘为 `type=9` |
+| GET | `/admin/promotion/options` | `data.languages` 返回三个语言Tab;`data.create_types` 为允许新增的类型 |
+| GET | `/admin/promotion/detail?id=12` | 加载本活动完整编辑数据,包括全部 `data.translations` |
+| POST | `/admin/promotion/submit` | 保存整个活动表单 |
+| POST | `/admin/promotion/status` | 传 `id/status`;上架前需要补齐三语文本,下架可直接操作 |
+
+列表数据位于 `data.data[]`。每项以及详情、保存响应的 `data` 都有完整 `translations` 对象:
+
+```json
+{
+  "zh": {
+    "title": "净亏邀请奖励",
+    "sub_title": "完成净亏及邀请任务即可领取奖励",
+    "content": "<p>达到活动净亏门槛后申请,再邀请符合条件的好友领取奖励。</p>"
+  },
+  "en": {
+    "title": "Loss and Referral Reward",
+    "sub_title": "Complete the loss and referral tasks to claim your reward",
+    "content": "<p>Apply after reaching the loss threshold, then invite qualifying friends to claim your reward.</p>"
+  },
+  "vi": {
+    "title": "Thưởng thua lỗ và giới thiệu",
+    "sub_title": "Hoàn thành nhiệm vụ thua lỗ và giới thiệu để nhận thưởng",
+    "content": "<p>Đăng ký sau khi đạt mức thua lỗ yêu cầu, sau đó mời bạn bè đủ điều kiện để nhận thưởng.</p>"
+  }
+}
+```
+
+固定使用 `sub_title` 字段名。列表标题优先读取 `translations.zh.title`;旧数据中文为空时可展示其已有语言标题。编辑器必须按当前Tab读取 `translations[activeLang]`。不再把根级 `lang` 绑定成语言下拉框,也不使用根级 `title/sub_title/content` 回填三个Tab。
+
+## 表单状态和Tab绑定
+
+使用一个表单保存全部内容,`activeLang` 单独表示选中的Tab:
+
+```ts
+let activeLang: 'zh' | 'en' | 'vi' = 'zh' // 按现有框架实现为响应式状态
+const form = {
+  id: 12,
+  type: 7,
+  translations: {
+    zh: { title: '', sub_title: '', content: '' },
+    en: { title: '', sub_title: '', content: '' },
+    vi: { title: '', sub_title: '', content: '' }
+  }
+  // 其余公共设置沿用现有表单
+}
+// 三个输入分别绑定:
+// form.translations[activeLang].title
+// form.translations[activeLang].sub_title
+// form.translations[activeLang].content
+```
+
+1. 打开编辑页后请求一次详情,把全部 `translations` 填入表单。缺少的语言/字段在本地补为空字符串,不复制其他语言文本。
+2. 切换Tab只切换编辑视图,保留三个语言的未保存内容,不提交请求、不重新初始化整个 `translations`。
+3. 富文本编辑器需要受控绑定当前语言。离开Tab前同步旧语言的最新正文,再加载新语言正文;异步编辑回调必须写回它所属的语言,避免把中文覆盖到英文。
+4. 校验所有Tab,不仅校验当前显示的Tab。缺项时在对应Tab标记未完成,点击保存不发送请求,切换到首个未完成Tab并聚焦缺项。
+5. 保存期间防止重复提交。成功后用响应中的完整 `translations` 更新表单;失败时保留输入。
+6. 打开另一个活动时重新加载该ID的详情,不沿用上一个活动的翻译草稿。后到的旧活动响应不能覆盖当前活动表单。
+
+## 必填和长度
+
+| 每个语言Tab的字段 | 必填 | 最大长度 |
+| --- | --- | --- |
+| `title` 标题 | 是 | 140字符 |
+| `sub_title` 副标题 | 是 | 200字符 |
+| `content` 正文 | 是 | 100000字符;富文本长度包含HTML源码 |
+
+空字符串、纯空白,以及只有空标签/空格实体的正文,例如 `<p><br></p>`、`<p>&nbsp;</p>`,均不算填写完成。不得用默认占位文字、自动复制其他语言或隐藏字段来绕过校验。已有活动只填过一种语言时,显示其原有内容,另外两个Tab保持待填写,补齐后再保存。
+
+## 保存请求
+
+编辑带 `id`,新增首充省略 `id`。以下是净亏邀请编辑请求示例;首充、转盘继续提交各自的 `type/params`,不要照搬净亏规则。
+
+```json
+{
+  "id": 12,
+  "type": 7,
+  "status": 0,
+  "start_time": "2026-10-26 00:00:00",
+  "end_time": "2027-10-26 23:59:59",
+  "sort": 0,
+  "publish_platforms": ["PC", "H5", "APP"],
+  "translations": {
+    "zh": {
+      "title": "净亏邀请奖励",
+      "sub_title": "完成净亏及邀请任务即可领取奖励",
+      "content": "<p>达到净亏门槛后申请,再邀请符合条件的好友领取奖励。</p>"
+    },
+    "en": {
+      "title": "Loss and Referral Reward",
+      "sub_title": "Complete the tasks to claim your reward",
+      "content": "<p>Apply after reaching the loss threshold, then invite qualifying friends.</p>"
+    },
+    "vi": {
+      "title": "Thưởng thua lỗ và giới thiệu",
+      "sub_title": "Hoàn thành nhiệm vụ để nhận thưởng",
+      "content": "<p>Đăng ký sau khi đạt mức thua lỗ yêu cầu, sau đó mời bạn bè đủ điều kiện.</p>"
+    }
+  },
+  "params": {
+    "loss_amount": "500",
+    "invite_count": 3,
+    "reward_amount": "500",
+    "turnover_ratio": "0.00"
+  }
+}
+```
+
+提交完整的三个语言对象,不能只提交当前Tab,也不能按语言发送三次保存请求。根级无需再提交 `lang/title/sub_title/content`。原有其他公共设置从详情正确回填,编辑翻译时保留原值;`params` 仍为对象。
+
+## 交付验收
+
+在净亏邀请、首充奖励、转盘三个编辑页分别完成以下验证:
+
+- 三个Tab顺序、文案正确,切换后标题、副标题、正文都联动;来回切换不会丢失输入。
+- 只填写中文,或任意一个语言缺标题/副标题/正文,都不能保存,也不发出保存请求。空富文本同样被阻止。
+- 三语全部填写后只发送一次请求,请求中包含完整 `translations.zh/en/vi`。
+- 保存后重新打开,三种语言各自显示保存的文本;只修改英文副标题后保存,中文和越南文保持原值。
+- 切换Tab时金额、奖励规则、时间、上下架状态保持一致,活动ID及列表条数不因语言增加。
+- 转盘编辑页没有图片上传/预览控件,也不校验或提交 `image`。
+- 净亏和转盘没有新增入口,首充仍可新增;新增首充也必须完整填写三种语言。
+
+原活动规则参见[净亏邀请](净亏邀请-后台接口.md)、[首充奖励](首充奖励-后台接口.md)、[转盘活动](转盘活动-后台接口.md)。会员页面语言展示改造另见[会员端文档](../../../../melbet_sport-api/docs/独立活动/三个活动多语言展示-前端改造.md)。

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

@@ -4,6 +4,8 @@
 
 本活动使用独立的 `/admin/promotion` 接口,不加入原活动列表或原活动类型目录。以下接口使用现有后台登录和按钮权限,成功 `code=0`,参数或业务错误 `code=-3`,展示实际 `msg`。
 
+金额以字符串返回,直接使用接口值展示:整数不带小数,小数保留两位;超过两位的小数截去,不四舍五入。例如 `200.00000000 → 200`、`200.12000000 → 200.12`、`200.1 → 200.10`、`200.129 → 200.12`。页面不再统一补齐多位小数。
+
 ## 配置接口
 
 | 方法 | 路径 | 参数与用途 |
@@ -41,9 +43,9 @@
     "APP"
   ],
   "params": {
-    "loss_amount": "500.00",
+    "loss_amount": "500",
     "invite_count": 3,
-    "reward_amount": "500.00",
+    "reward_amount": "500",
     "turnover_ratio": "0.00"
   }
 }

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

@@ -4,6 +4,8 @@
 
 本活动使用独立的 `/admin/promotion` 接口,不加入原活动列表或原活动类型目录。以下接口使用现有后台登录和按钮权限,成功 `code=0`,参数或业务错误 `code=-3`,展示实际 `msg`。
 
+金额以字符串返回,直接使用接口值展示:整数不带小数,小数保留两位;超过两位的小数截去,不四舍五入。例如 `200.00000000 → 200`、`200.12000000 → 200.12`、`200.1 → 200.10`、`200.129 → 200.12`。页面不再统一补齐多位小数。
+
 ## 配置接口
 
 | 方法 | 路径 | 参数与用途 |
@@ -17,12 +19,14 @@
 
 列表默认 `page=1/limit=20`,`limit` 最大100。`status=0` 为下架、`1` 为上架,不能丢弃0值筛选。先从列表读取现有活动,再使用其 `id` 获取详情、保存或切换状态;不写死示例ID。不带 `id` 保存会返回 `code=-3`,提示“净亏和转盘活动不支持新增,请编辑现有活动”。
 
-保存必填根字段:`id/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/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`。编辑时省略字段保留原值。
 
 可选 `reward_quota` 限制不同获奖会员数,`budget` 限制累计奖励金额;`null` 为不限制,0为无剩余额度。`rewarded_count/budget_used/first_published_at` 为服务端字段,不能手动改写。
 
+转盘编辑页不展示图片上传、图片预览或图片必填项,保存时不提交 `image`。转盘盘面、奖品图标和装饰素材沿用页面设计。三语言文本表单及保存协议以[《三个活动语言Tab与文本联动》](三个活动语言Tab与文本联动-后台前端改造.md)为准。
+
 ## 编辑保存示例
 
 `id` 使用读取接口返回值,下面的1仅为示例。
@@ -43,10 +47,10 @@
   "params": {
     "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",
+    "deposit_amount": "3000",
+    "team_deposit_amount": "500",
+    "bet_amount": "5000",
+    "team_bet_amount": "3000",
     "turnover_ratio": "0.00"
   }
 }
@@ -96,7 +100,7 @@
 
 `GET /admin/promotion/records?activity_id=1&kind=spins&page=1&limit=20`,`kind` 支持 `accounts/rounds/tasks/spins/rewards`,可加 `user_id`,返回 `total/data`。抽奖记录保存实际概率、是否全部达标、奖项和累计金币;领奖记录有关联资金流水。达到666后的状态依次为 `reserved` 待领取、`locked` 冻结待流水或 `paid` 已可用。
 
-会员“我的记录”使用真实抽奖记录。普通播报及达到666播报各最多50条真实脱敏记录;联调随机样例每组50条,见[转盘播报示例](../../../../melbet_sport-api/docs/独立活动/转盘播报示例.json),均带 `is_demo=true`,不作真实中奖记录。
+会员“我的记录”使用真实抽奖记录。会员播报在测试环境普通请求下返回两组各50条,真实记录不足时补充邮箱、数字账号或海外手机号联调数据,按前三位+`****`+后三位脱敏。正式环境播报读取真实记录,各最多50条。样例见[转盘播报示例](../../../../melbet_sport-api/docs/独立活动/转盘播报示例.json)。
 
 ## 权限
 

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

@@ -4,6 +4,8 @@
 
 本活动使用独立的 `/admin/promotion` 接口,不加入原活动列表或原活动类型目录。以下接口使用现有后台登录和按钮权限,成功 `code=0`,参数或业务错误 `code=-3`,展示实际 `msg`。
 
+金额以字符串返回,直接使用接口值展示:整数不带小数,小数保留两位;超过两位的小数截去,不四舍五入。例如 `200.00000000 → 200`、`200.12000000 → 200.12`、`200.1 → 200.10`、`200.129 → 200.12`。页面不再统一补齐多位小数。
+
 ## 配置接口
 
 | 方法 | 路径 | 参数与用途 |
@@ -38,8 +40,8 @@
     "APP"
   ],
   "params": {
-    "min_deposit": "100.00",
-    "reward_amount": "25.00",
+    "min_deposit": "100",
+    "reward_amount": "25",
     "turnover_ratio": "0.00"
   }
 }

+ 137 - 5
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', '2026_10_27_130000_initialize_fixed_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', '2026_10_27_140000_add_promotion_activity_translations.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,11 +70,18 @@ class PromotionActivitiesTest extends TestCase
     }
     private function activity(int $type = 9, array $params = [], int $status = 1): int
     {
-        $input = ['title' => '测试活动', 'sub_title' => '规则', 'type' => $type, 'status' => $status,
+        $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');
         return (int) (new PromotionAdminService())->save($input)['id'];
     }
+    private function texts(): array
+    {
+        return ['zh' => ['title' => '测试活动', 'sub_title' => '规则', 'content' => '<p>中文活动正文</p>'],
+            'en' => ['title' => 'Test promotion', 'sub_title' => 'English subtitle', 'content' => '<p>English content</p>'],
+            'vi' => ['title' => 'Khuyến mãi', 'sub_title' => 'Phụ đề', 'content' => '<p>Nội dung</p>']];
+    }
+
     private function deposit(int $id, string $amount, ?string $time = null, string $type = '充值'): BalanceLog
     {
         return BalanceLog::create(['member_id' => 'm' . $id, 'type' => 1, 'amount' => $amount, 'change_type' => $type, 'created_at' => $time ?? PromotionFacts::now()]);
@@ -103,6 +110,117 @@ class PromotionActivitiesTest extends TestCase
         $this->travel('2026-10-26 14:00:00'); return $this->service->progress($activity, 1, 'H5');
     }
 
+    public function test_three_languages_edit_one_campaign_and_keep_rules_history_and_other_tabs(): void
+    {
+        $admin = new PromotionAdminService();
+        foreach ([7, 8, 9] as $type) {
+            $id = $this->activity($type); $before = $admin->detail($id);
+            $count = DB::table('promotion_activities')->count();
+            $input = $before; $input['translations']['en']['sub_title'] = 'Updated English subtitle';
+            $saved = $admin->save($input);
+            $this->assertSame($id, $saved['id']);
+            $this->assertSame($count, DB::table('promotion_activities')->count());
+            $this->assertSame($before['translations']['zh'], $saved['translations']['zh']);
+            $this->assertSame($before['translations']['vi'], $saved['translations']['vi']);
+            $this->assertSame('Updated English subtitle', $admin->detail($id)['translations']['en']['sub_title']);
+            foreach (['params', 'rewarded_count', 'budget_used', 'first_published_at'] as $field) $this->assertSame($before[$field], $saved[$field]);
+            foreach (['zh', 'en', 'vi'] as $lang) {
+                $rows = $this->service->listing(1, 'H5', $lang, $type)['data'];
+                $this->assertCount(1, $rows); $this->assertSame($id, $rows[0]['activity_id']);
+                $this->assertSame($saved['translations'][$lang]['sub_title'], $rows[0]['sub_title']);
+                $state = $this->service->detail($id, 1, 'H5', $lang);
+                $this->assertSame($lang, $state['lang']);
+                $this->assertSame($saved['translations'][$lang]['title'], $state['title']);
+                $this->assertSame($saved['translations'][$lang]['content'], $state['content']);
+            }
+        }
+        $this->assertSame(['zh', 'en', 'vi'], array_column($admin->options()['languages'], 'value'));
+    }
+
+    public function test_all_nine_text_fields_are_required_and_invalid_tabs_cannot_overwrite_saved_content(): void
+    {
+        $id = $this->activity(9); $admin = new PromotionAdminService(); $input = $admin->detail($id);
+        $before = DB::table('promotion_activities')->where('id', $id)->first();
+        foreach (['zh', 'en', 'vi'] as $lang) {
+            foreach (['title', 'sub_title', 'content'] as $field) {
+                $bad = $input; unset($bad['translations'][$lang][$field]);
+                try { $admin->save($bad); $this->fail('Missing language text was accepted'); }
+                catch (ValidationException $e) { $this->assertTrue($e->validator->errors()->has('translations.' . $lang . '.' . $field)); }
+                $bad = $input; $bad['translations'][$lang][$field] = '<p>&nbsp;<br></p>';
+                $this->invalid(fn () => $admin->save($bad), '不能为空');
+            }
+            $bad = $input; unset($bad['translations'][$lang]);
+            $this->invalid(fn () => $admin->save($bad), '完整填写');
+        }
+        $bad = $input; unset($bad['translations']); $bad['title'] = '旧单语言表单';
+        $this->invalid(fn () => $admin->save($bad), '完整填写');
+        $bad = $input; $bad['translations']['fr'] = $input['translations']['en'];
+        try { $admin->save($bad); $this->fail('Unknown language was accepted'); }
+        catch (ValidationException $e) { $this->assertTrue($e->validator->errors()->has('translations')); }
+        $bad = $input; $bad['translations']['en']['sub_title'] = str_repeat('a', 201);
+        try { $admin->save($bad); $this->fail('Oversized subtitle was accepted'); }
+        catch (ValidationException $e) { $this->assertTrue($e->validator->errors()->has('translations.en.sub_title')); }
+        $this->assertEquals($before, DB::table('promotion_activities')->where('id', $id)->first());
+    }
+
+    public function test_admin_http_save_returns_all_tabs_and_rejects_partial_language_payloads(): void
+    {
+        $controller = new \App\Http\Controllers\admin\Promotion(); $admin = new PromotionAdminService();
+        $input = ['type' => 8, 'status' => 0, 'start_time' => '2026-10-26', 'end_time' => '2026-12-31', 'translations' => $this->texts()];
+        $this->app->instance('request', Request::create('/admin/promotion/submit', 'POST', $input));
+        $saved = $controller->submit($admin)->getData(true);
+        $this->assertSame(0, $saved['code']); $this->assertSame($this->texts(), $saved['data']['translations']);
+        $input['id'] = $saved['data']['id']; unset($input['translations']['en']);
+        $this->app->instance('request', Request::create('/admin/promotion/submit', 'POST', $input));
+        $failed = $controller->submit($admin)->getData(true);
+        $this->assertSame(-3, $failed['code']); $this->assertStringContainsString('英文', $failed['msg']);
+        $this->assertSame($this->texts(), $admin->detail($input['id'])['translations']);
+    }
+
+    public function test_incomplete_legacy_activity_can_be_hidden_but_cannot_be_published_without_all_tabs(): void
+    {
+        $id = (int) DB::table('promotion_activities')->where('type', 7)->value('id');
+        $admin = new PromotionAdminService();
+        $this->assertSame(0, (int) $admin->status($id, 0)['status']);
+        $this->invalid(fn () => $admin->status($id, 1), '不能为空');
+        $this->assertSame(0, (int) DB::table('promotion_activities')->where('id', $id)->value('status'));
+        $this->assertNull(DB::table('promotion_campaigns')->where('activity_id', $id)->value('first_published_at'));
+    }
+
+    public function test_first_deposit_translation_does_not_split_or_duplicate_awards(): void
+    {
+        $id = $this->activity(8, ['min_deposit' => 100, 'reward_amount' => 25]);
+        DB::table('users')->where('id', 1)->update(['language' => 'vi']);
+        $deposit = $this->deposit(1, '100'); $this->service->onDeposit($deposit);
+        $this->assertSame(1, DB::table('promotion_rewards')->where('activity_id', $id)->count());
+        DB::table('users')->where('id', 1)->update(['language' => 'en']);
+        $this->service->onDeposit($deposit);
+        $this->assertSame(1, DB::table('promotion_rewards')->where('activity_id', $id)->count());
+        $this->assertSame('25.0000000000', (string) DB::table('wallets')->where('member_id', 'm1')->value('available_balance'));
+        $this->assertSame(1, DB::table('promotion_accounts')->where('activity_id', $id)->count());
+    }
+
+    public function test_translation_migration_preserves_legacy_texts_and_campaign_identity(): void
+    {
+        $migration = require dirname(__DIR__, 2) . '/database/migrations/2026_10_27_140000_add_promotion_activity_translations.php';
+        $migration->down();
+        $id = DB::table('promotion_activities')->where('type', 7)->value('id');
+        DB::table('promotion_activities')->where('id', $id)->update(['lang' => 'en', 'title' => 'Existing title', 'sub_title' => 'Existing subtitle', 'content' => '<p>Existing content</p>']);
+        $rows = DB::table('promotion_activities')->orderBy('id')->get();
+        $published = DB::table('promotion_campaigns')->orderBy('activity_id')->get();
+        $migration->up();
+        foreach ($rows as $row) {
+            $saved = DB::table('promotion_activities')->where('id', $row->id)->first();
+            foreach ((array) $row as $field => $value) $this->assertSame($value, $saved->$field);
+        }
+        $texts = (new PromotionAdminService())->detail((int) $id)['translations'];
+        $this->assertSame(['title' => 'Existing title', 'sub_title' => 'Existing subtitle', 'content' => '<p>Existing content</p>'], $texts['en']);
+        $this->assertSame('', $texts['zh']['title']);
+        $this->assertEquals($published, DB::table('promotion_campaigns')->orderBy('activity_id')->get());
+        $this->assertSame(0, DB::table('menus')->count());
+        $this->assertSame(0, $this->service->listing(1, 'H5', 'vi', 7)['total']);
+    }
+
     public function test_probability_default_zero_boundaries_and_incomplete_gate(): void
     {
         $this->assertSame('20.00', PromotionRules::normalize(9, [])['completion_probability']);
@@ -270,6 +388,20 @@ class PromotionActivitiesTest extends TestCase
         $this->assertTrue(DB::connection()->getSchemaBuilder()->hasTable('promotion_activities'));
     }
 
+    public function test_admin_http_amounts_are_compact_without_changing_saved_rules_or_counters(): void
+    {
+        $id = $this->activity(8, ['min_deposit' => '200', 'reward_amount' => '10.12']);
+        $request = Request::create('/admin/promotion/detail', 'GET', ['id' => $id]); $this->app->instance('request', $request);
+        $response = (new \App\Http\Controllers\admin\Promotion())->detail(new PromotionAdminService())->getData(true);
+        $this->assertSame(0, $response['code']);
+        $this->assertSame('200', $response['data']['params']['min_deposit']);
+        $this->assertSame('10.12', $response['data']['params']['reward_amount']);
+        $this->assertSame($id, $response['data']['id']);
+        $saved = json_decode(DB::table('promotion_activities')->where('id', $id)->value('params'), true);
+        $this->assertSame('200.00', $saved['min_deposit']);
+        $this->assertSame('10.12', $saved['reward_amount']);
+    }
+
     public function test_fixed_activities_are_initialized_disabled_without_publishing_or_creating_menus(): void
     {
         $this->assertSame(2, DB::table('promotion_activities')->count());
@@ -288,7 +420,7 @@ class PromotionActivitiesTest extends TestCase
     {
         $admin = new PromotionAdminService();
         foreach ([7, 9] as $type) {
-            $input = ['title' => '不能新建', 'type' => $type, 'status' => 0,
+            $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), '不支持新增');
             $this->assertSame(1, DB::table('promotion_activities')->where('type', $type)->count());
@@ -305,7 +437,7 @@ class PromotionActivitiesTest extends TestCase
         $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,
+        $this->invalid(fn () => (new PromotionAdminService())->save(['id' => $first, 'translations' => $this->texts(), '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'));
     }
@@ -328,7 +460,7 @@ class PromotionActivitiesTest extends TestCase
     public function test_admin_partial_rule_edit_preserves_other_settings_and_zero_probability(): void
     {
         $id = $this->activity(9, ['turnover_ratio' => 3, 'deposit_amount' => 5000]);
-        $saved = (new PromotionAdminService())->save(['id' => $id, 'title' => '独立转盘', 'type' => 9, 'status' => 1,
+        $saved = (new PromotionAdminService())->save(['id' => $id, 'translations' => $this->texts(), 'type' => 9, 'status' => 1,
             'start_time' => '2026-10-01', 'end_time' => '2026-12-31', 'params' => ['completion_probability' => 0]]);
         $this->assertSame('0.00', $saved['params']['completion_probability']); $this->assertSame('3.00', $saved['params']['turnover_ratio']);
         $this->assertSame('5000.00', $saved['params']['deposit_amount']); $this->assertSame(0, DB::table('activity_rewards')->count());

+ 55 - 0
tests/Unit/PromotionAmountFormatterTest.php

@@ -0,0 +1,55 @@
+<?php
+
+namespace Tests\Unit;
+
+use App\Support\PromotionAmountFormatter;
+use Illuminate\Support\Collection;
+use PHPUnit\Framework\TestCase;
+
+class PromotionAmountFormatterTest extends TestCase
+{
+    /** @dataProvider amounts */
+    public function test_amounts_use_two_truncated_decimals_but_hide_a_zero_fraction($input, $expected): void
+    {
+        $this->assertSame($expected, PromotionAmountFormatter::amount($input));
+    }
+
+    public static function amounts(): array
+    {
+        return [['200.00000000', '200'], ['200.12000000', '200.12'], ['200.10000000', '200.10'],
+            ['200.129', '200.12'], ['199.9999999999', '199.99'], ['200', '200'], [200, '200'],
+            ['0.0000000000', '0'], ['0.009999', '0'], ['-0.009', '0'], ['-200.129', '-200.12'],
+            ['99999999999999.1299999999', '99999999999999.12'], [null, null], ['invalid', 'invalid']];
+    }
+
+    public function test_only_monetary_fields_are_formatted_and_count_time_probability_and_snapshots_are_preserved(): void
+    {
+        $input = ['activity_id' => 200, 'spins' => 2, 'rules' => ['min_deposit' => '200.00000000', 'completion_probability' => '20.00'],
+            'valid_member_policy' => ['min_deposit' => '200.0000000000', 'min_effective_bet' => '300.1200000000'],
+            'tasks' => [
+                ['key' => 'deposit', 'value' => '200.1299999999', 'target' => '3000.00'],
+                ['key' => 'online', 'value' => 200, 'target' => 3600, 'remaining_seconds' => 3400],
+                ['key' => 'invite', 'value' => 1, 'target' => 1],
+            ], 'rule_snapshot' => '{"amount":"200.00000000"}', 'budget' => null];
+        $actual = PromotionAmountFormatter::response($input);
+        $this->assertSame('200', $actual['rules']['min_deposit']);
+        $this->assertSame('200.12', $actual['tasks'][0]['value']); $this->assertSame('3000', $actual['tasks'][0]['target']);
+        $this->assertSame('200', $actual['valid_member_policy']['min_deposit']);
+        $this->assertSame('300.12', $actual['valid_member_policy']['min_effective_bet']);
+        $this->assertSame($input['activity_id'], $actual['activity_id']); $this->assertSame($input['spins'], $actual['spins']);
+        $this->assertSame('20.00', $actual['rules']['completion_probability']);
+        $this->assertSame($input['tasks'][1], $actual['tasks'][1]); $this->assertSame($input['tasks'][2], $actual['tasks'][2]);
+        $this->assertSame($input['rule_snapshot'], $actual['rule_snapshot']); $this->assertNull($actual['budget']);
+        $this->assertSame('200.1299999999', $input['tasks'][0]['value']);
+    }
+
+    public function test_admin_record_collections_are_presented_without_mutating_the_original_rows(): void
+    {
+        $row = (object) ['amount' => '200.00000000', 'required_flow' => '400.12000000', 'user_id' => 106];
+        $actual = PromotionAmountFormatter::response(['total' => 1, 'data' => new Collection([$row])]);
+        $this->assertSame('200', $actual['data'][0]->amount);
+        $this->assertSame('400.12', $actual['data'][0]->required_flow);
+        $this->assertSame(106, $actual['data'][0]->user_id);
+        $this->assertSame('200.00000000', $row->amount);
+    }
+}