doge 19 часов назад
Родитель
Сommit
aaa1aedf9e

+ 4 - 0
app/Services/AnnouncementService.php

@@ -160,6 +160,8 @@ class AnnouncementService
             foreach ($row['translations'] as $language => $block) DB::table('announcement_translations')->insert($block + ['announcement_id' => $id, 'language' => $language]);
             $after = $this->snapshot((int) $id);
             OperationAuditService::record('announcement', (int) $id, $before ? 'update' : 'create', $before, $after, $now);
+            SiteRealtimeService::changed('announcement', (int) $id, $before ? 'update' : 'create',
+                $row['status'] ? ['start' => $row['publish_at'], 'end' => $row['end_at']] : []);
             return $this->dto($after);
         });
     }
@@ -176,6 +178,8 @@ class AnnouncementService
             DB::table('announcements')->where('id', $id)->update(['status' => $status, 'updated_at' => $now]);
             $after = $this->snapshot($id);
             OperationAuditService::record('announcement', $id, 'status', $before, $after, $now);
+            SiteRealtimeService::changed('announcement', $id, 'status',
+                $status ? ['start' => $after['publish_at'], 'end' => $after['end_at']] : []);
             return $this->dto($after);
         });
     }

+ 6 - 1
app/Services/MaintenanceService.php

@@ -95,7 +95,12 @@ class MaintenanceService
                 foreach ($texts as $language => $text) DB::table('maintenance_translations')->insert(['maintenance_id' => $id, 'language' => $language, 'content' => $text['content']]);
             }
             $after = $this->snapshot($id);
-            if ($before !== $after) OperationAuditService::record('maintenance', $id, 'update', $before, $after, $this->now()->setTimezone('Asia/Shanghai')->format('Y-m-d H:i:s'));
+            if ($before !== $after) {
+                OperationAuditService::record('maintenance', $id, 'update', $before, $after, $this->now()->setTimezone('Asia/Shanghai')->format('Y-m-d H:i:s'));
+                $local = static fn ($at) => $at === null ? null : Carbon::parse($at, 'UTC')->setTimezone('Asia/Shanghai')->format('Y-m-d H:i:s');
+                SiteRealtimeService::changed('maintenance', $id, 'update',
+                    $after['enabled'] ? ['start' => $local($after['starts_at']), 'end' => $local($after['ends_at'])] : []);
+            }
             return $this->detail($id);
         });
     }

+ 4 - 1
app/Services/PromotionAdminService.php

@@ -40,13 +40,16 @@ class PromotionAdminService
 
     private function format(PromotionActivity $row): array
     {
-        return array_replace($row->toArray(), ['translations' => PromotionTranslations::all($row->toArray()), 'params' => json_decode($row->params, true),
+        $data = 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')]);
+        if (in_array((int) $row->type, [PromotionRules::LOSS, PromotionRules::WHEEL], true)) unset($data['image']);
+        return $data;
     }
 
     public function save(array $input): array
     {
+        if (in_array($input['type'] ?? null, [7, 9, '7', '9'], 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' => '请完整填写中文、英文、越南文的标题、副标题和正文'];

+ 3 - 2
app/Services/PromotionService.php

@@ -52,9 +52,10 @@ class PromotionService
             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,
-                'image' => $activity->image,
+            $item = ['activity_id' => $activity->id, 'type' => (int)$activity->type, 'type_name' => $activity->type_name,
                 'start_time' => (int)$activity->start_time, 'end_time' => (int)$activity->end_time, 'rules' => $this->rules($activity)] + $text + $eligibility;
+            if ((int) $activity->type === Rules::FIRST_DEPOSIT) $item['image'] = $activity->image;
+            $data[] = $item;
         }
         return ['total' => count($data), 'data' => $data];
     }

+ 3 - 1
app/Services/SiteMessageService.php

@@ -13,7 +13,7 @@ use Illuminate\Validation\Rule;
 class SiteMessageService
 {
     public const CATEGORIES = ['discount' => '优惠活动', 'site_notice' => '网站公告', 'internal' => '站内信', 'promotion' => '活动宣传'];
-    public const TARGETS = ['all_users' => '所有用户', 'specified_users' => '指定用户', 'all_agents' => '所有代理', 'specified_agents' => '指定代理'];
+    public const TARGETS = ['all_users' => '所有人', 'specified_users' => '指定用户', 'all_agents' => '所有代理', 'specified_agents' => '指定代理'];
     public const STATES = ['scheduled' => '待发送', 'sent' => '已发送', 'retracted' => '已撤回'];
 
     public static function canManageAgents(): bool
@@ -116,6 +116,7 @@ class SiteMessageService
                 'recipient_count' => $count, 'skipped_count' => max(0, $targetCount - $count), 'updated_at' => $now]);
             OperationAuditService::record('site_message', $id, 'send', $before, $this->snapshot($id), $now,
                 $actor ?? ['operator_id' => null, 'operator_name' => '系统']);
+            SiteRealtimeService::changed('message', $id, 'send');
             return true;
         }, 3);
     }
@@ -142,6 +143,7 @@ class SiteMessageService
             $before = $this->snapshot($id); $now = $this->now();
             DB::table('site_messages')->where('id', $id)->update(['status' => 'retracted', 'retracted_at' => $now, 'updated_at' => $now]);
             OperationAuditService::record('site_message', $id, 'retract', $before, $this->snapshot($id), $now);
+            SiteRealtimeService::changed('message', $id, 'retract');
             return $this->detail($id);
         }, 3);
     }

+ 24 - 0
app/Services/SiteRealtimeService.php

@@ -0,0 +1,24 @@
+<?php
+
+namespace App\Services;
+
+use Carbon\Carbon;
+use Illuminate\Support\Facades\DB;
+use InvalidArgumentException;
+
+/** Written inside the same transaction as the change; consumed by the member WebSocket worker. */
+final class SiteRealtimeService
+{
+    public static function changed(string $module, int $id, string $action, array $boundaries = []): void
+    {
+        if (!in_array($module, ['message', 'announcement', 'maintenance'], true) || $id < 1) throw new InvalidArgumentException('Invalid site event');
+        $now = Carbon::now('Asia/Shanghai')->format('Y-m-d H:i:s');
+        DB::table('site_realtime_events')->where('module', $module)->where('resource_id', $id)
+            ->whereNull('dispatched_at')->where('available_at', '>', $now)->delete();
+        $rows = [['module' => $module, 'resource_id' => $id, 'action' => $action, 'available_at' => $now, 'created_at' => $now]];
+        foreach ($boundaries as $edge => $at) {
+            if ($at !== null && $at > $now) $rows[] = ['module' => $module, 'resource_id' => $id, 'action' => $edge, 'available_at' => $at, 'created_at' => $now];
+        }
+        DB::table('site_realtime_events')->insert($rows);
+    }
+}

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

@@ -0,0 +1,20 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+return new class extends Migration {
+    public function up(): void
+    {
+        Schema::create('site_realtime_events', function (Blueprint $t) {
+            $t->id(); $t->string('module', 16); $t->unsignedBigInteger('resource_id');
+            $t->string('action', 16); $t->dateTime('available_at'); $t->dateTime('dispatched_at')->nullable();
+            $t->dateTime('created_at');
+            $t->index(['dispatched_at', 'available_at'], 'site_realtime_pending');
+            $t->index(['module', 'resource_id'], 'site_realtime_resource');
+        });
+    }
+
+    public function down(): void { Schema::dropIfExists('site_realtime_events'); }
+};

+ 23 - 0
docs/后端/信息公告维护-WebSocket部署.md

@@ -0,0 +1,23 @@
+# 信息、公告、维护:WebSocket部署
+
+职责:`bot-28` 在业务事务内写入通知事件;`melbet_sport-api` 的现有 `websocket:start` 单实例服务消费事件并推送。两项目使用原有共享业务数据库,无需新增WebSocket地址或广播密钥。
+
+## 发布
+
+先在后台项目执行新增迁移:
+
+```bash
+php artisan migrate --path=database/migrations/2026_10_27_150000_create_site_realtime_events.php --force
+```
+
+随后发布两个项目的接口代码,并按当前进程管理方式重启会员项目已有的 `php think websocket:start` 服务,不重复启动同端口服务。原有每分钟 `messages:dispatch` 定时任务保持运行。
+
+事件表 `site_realtime_events` 与对应业务写入同事务,事务回滚不会发出通知。WebSocket服务每秒取最多50条已到时间的待推送事件,推送失败保持待处理,下一轮重试;没有在线接收者时事件完成。客户端仍通过原HTTP接口取得当前状态,通知不承载完整正文。
+
+公告及维护的未来开始/结束边界会生成到时事件;修改时间或停用会取消旧的待执行边界。维护配置的UTC时间转换为事件调度的Asia/Shanghai时间。定时信息在实际投递成功后产生通知。
+
+公开事件可发给访客。指定用户信息按收件记录匹配真实会员主键,并校验连接token当前仍有效;同一会员的多个连接均可收到。原有会员收件语言快照、已读和未读接口不变。代理信息不通过会员WebSocket发送。
+
+游客可以读取公开信息和当前语言的公开公告;指定用户信息不可匿名读取。无效或过期token仍返回401,不能用这些token进入个人收件箱。游客的活动跳转沿用原有活动可见规则。
+
+本地验证使用隔离SQLite和真实框架权限逻辑,不读取.env、不连接线上数据库或WebSocket。Swoole服务的实际握手和网络投递需要在具备该扩展的运行环境核验。未执行部署或数据库迁移。

+ 13 - 0
docs/总后台/信息公告维护-WebSocket补充.md

@@ -0,0 +1,13 @@
+# 信息、公告、维护:后台接入补充
+
+后台继续使用现有发送、保存、上下架和撤回接口。操作成功后,会员端通过WebSocket收到对应变更通知,无需新增后台按钮或发送请求。
+
+| 模块 | 通知时机 |
+| --- | --- |
+| 信息管理 | 实际发送成功、撤回 |
+| 公告管理 | 保存、上下架、定时开始和结束 |
+| 维护设定 | 设置变更、定时开始和结束 |
+
+信息发送对象 `all_users` 显示为「所有人」,包含未登录访客。指定用户和代理范围沿用原规则。送达人数仍表示实际账号收件人数,不统计未登录访客。
+
+[会员端WebSocket接入](../../../melbet_sport-api/docs/信息公告维护-WebSocket接入.md)

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

@@ -10,7 +10,7 @@
 
 金额、奖励规则、时间、排序、发布平台、资格限制、上下架状态在三个语言间共用,放在Tab文本区外。同一个活动始终使用同一个 `id`。净亏邀请和转盘编辑现有活动;首充奖励保留新增和编辑。
 
-转盘编辑页不展示图片上传、图片预览或图片必填项,保存请求不提交 `image`。转盘盘面、奖品图标和装饰素材沿用页面设计,不通过这个通用活动封面字段配置。
+净亏和转盘编辑页都没有图片字段,不展示图片上传/预览,也不校验或提交 `image`。转盘盘面、奖品图标和装饰素材沿用页面设计,不通过这个通用活动封面字段配置。
 
 保存按钮位于整个表单底部,一次提交全部三个Tab的文本和公共设置。三个Tab的标题、副标题、正文共9项全部填写后才允许保存;下架状态也不能保存不完整文本。
 
@@ -139,7 +139,7 @@ const form = {
 - 三语全部填写后只发送一次请求,请求中包含完整 `translations.zh/en/vi`。
 - 保存后重新打开,三种语言各自显示保存的文本;只修改英文副标题后保存,中文和越南文保持原值。
 - 切换Tab时金额、奖励规则、时间、上下架状态保持一致,活动ID及列表条数不因语言增加。
-- 转盘编辑页没有图片上传/预览控件,也不校验或提交 `image`。
+- 净亏和转盘编辑页均没有图片上传/预览控件,也不校验或提交 `image`。
 - 净亏和转盘没有新增入口,首充仍可新增;新增首充也必须完整填写三种语言。
 
 原活动规则参见[净亏邀请](净亏邀请-后台接口.md)、[首充奖励](首充奖励-后台接口.md)、[转盘活动](转盘活动-后台接口.md)。会员页面语言展示改造另见[会员端文档](../../../../melbet_sport-api/docs/独立活动/三个活动多语言展示-前端改造.md)。

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

@@ -19,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`。
+
 ## 编辑保存示例
 
 `id` 使用读取接口返回值,下面的1仅为示例。

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

@@ -25,7 +25,7 @@
 
 可选 `reward_quota` 限制不同获奖会员数,`budget` 限制累计奖励金额;`null` 为不限制,0为无剩余额度。`rewarded_count/budget_used/first_published_at` 为服务端字段,不能手动改写。
 
-转盘编辑页不展示图片上传、图片预览或图片必填项,保存时不提交 `image`。转盘盘面、奖品图标和装饰素材沿用页面设计。三语言文本表单及保存协议以[《三个活动语言Tab与文本联动》](三个活动语言Tab与文本联动-后台前端改造.md)为准。
+转盘编辑页没有图片字段,不展示图片上传/预览,也不校验或提交 `image`。转盘盘面、奖品图标和装饰素材沿用页面设计。三语言文本表单及保存协议以[《三个活动语言Tab与文本联动》](三个活动语言Tab与文本联动-后台前端改造.md)为准。
 
 ## 编辑保存示例
 

+ 12 - 1
tests/Integration/AnnouncementTest.php

@@ -56,7 +56,7 @@ class AnnouncementTest extends TestCase
         });
         Schema::create('activity_rewards', function (Blueprint $t) { $t->id(); $t->string('title'); });
         DB::table('activity_rewards')->insert(['id' => 1, 'title' => '活动']);
-        $this->migration()->up();
+        $this->migration()->up(); (require dirname(__DIR__, 2) . '/database/migrations/2026_10_27_150000_create_site_realtime_events.php')->up();
         $this->service = new AnnouncementService();
         $this->request();
     }
@@ -75,6 +75,17 @@ class AnnouncementTest extends TestCase
         $this->app->instance('request', $request);
     }
 
+    public function test_websocket_events_cover_publication_boundaries_cancel_old_schedules_and_roll_back(): void
+    {
+        $row = $this->create(['publish_immediately' => false, 'publish_at' => '2026-10-26 11:00:00', 'no_end' => false, 'end_at' => '2026-10-26 12:00:00']);
+        $this->assertSame(['create', 'start', 'end'], DB::table('site_realtime_events')->orderBy('id')->pluck('action')->all());
+        $this->assertSame(['2026-10-26 10:00:00', '2026-10-26 11:00:00', '2026-10-26 12:00:00'], DB::table('site_realtime_events')->orderBy('id')->pluck('available_at')->all());
+        $this->service->status($row['id'], 0); $this->service->status($row['id'], 0);
+        $this->assertSame(['create', 'status'], DB::table('site_realtime_events')->orderBy('id')->pluck('action')->all());
+        DB::beginTransaction(); $this->create(); DB::rollBack();
+        $this->assertSame(2, DB::table('site_realtime_events')->count());
+    }
+
     private function create(array $params = []): array
     {
         if (!array_key_exists('translations', $params)) $params['translations'] = ['zh' => ['title' => $params['title'] ?? '网站维护', 'content' => $params['content'] ?? '维护通知']];

+ 12 - 1
tests/Integration/MaintenanceTest.php

@@ -53,7 +53,7 @@ class MaintenanceTest extends TestCase
         });
         Schema::create('users', function (Blueprint $t) { $t->id(); $t->string('user_id'); $t->string('language'); $t->bigInteger('delete_time')->nullable(); $t->integer('is_banned')->default(0); });
         Schema::create('user_session', function (Blueprint $t) { $t->id(); $t->string('user_id'); $t->string('token'); $t->bigInteger('expire_time'); $t->bigInteger('create_time'); $t->bigInteger('update_time'); });
-        $this->migration()->up(); $this->service = new MaintenanceService(); $this->request();
+        $this->migration()->up(); (require dirname(__DIR__, 2) . '/database/migrations/2026_10_27_150000_create_site_realtime_events.php')->up(); $this->service = new MaintenanceService(); $this->request();
     }
 
     protected function tearDown(): void { Carbon::setTestNow(); DB::disconnect(); Facade::clearResolvedInstances(); parent::tearDown(); }
@@ -73,6 +73,17 @@ class MaintenanceTest extends TestCase
         return Policy::select($this->service->runtimeRows('zh'), $platform, $category, now()->getTimestamp());
     }
 
+    public function test_websocket_events_use_local_delivery_times_for_utc_maintenance_and_cancel_disabled_boundaries(): void
+    {
+        $row = $this->save(['starts_at' => '2026-10-26 11:00:00', 'ends_at' => '2026-10-26 12:00:00']);
+        $this->assertSame(['update', 'start', 'end'], DB::table('site_realtime_events')->orderBy('id')->pluck('action')->all());
+        $this->assertSame(['2026-10-26 10:00:00', '2026-10-26 11:00:00', '2026-10-26 12:00:00'], DB::table('site_realtime_events')->orderBy('id')->pluck('available_at')->all());
+        $this->service->status($row['id'], 0); $this->service->status($row['id'], 0);
+        $this->assertSame(['update', 'update'], DB::table('site_realtime_events')->orderBy('id')->pluck('action')->all());
+        DB::beginTransaction(); $this->save(); DB::rollBack();
+        $this->assertSame(2, DB::table('site_realtime_events')->count());
+    }
+
     public function test_migration_defaults_and_catalog_match_commission_types(): void
     {
         $this->assertSame(10, DB::table('maintenance_settings')->count());

+ 15 - 0
tests/Integration/PromotionActivitiesTest.php

@@ -110,6 +110,21 @@ class PromotionActivitiesTest extends TestCase
         $this->travel('2026-10-26 14:00:00'); return $this->service->progress($activity, 1, 'H5');
     }
 
+    public function test_images_are_only_configurable_for_first_deposit_not_fixed_loss_or_wheel_campaigns(): void
+    {
+        $admin = new PromotionAdminService();
+        foreach ([7, 8, 9] as $type) {
+            $id = $this->activity($type);
+            DB::table('promotion_activities')->where('id', $id)->update(['image' => 'previous.png']);
+            $input = $admin->detail($id); $input['image'] = $type === 8 ? 'first-deposit.png' : ['ignored-legacy-upload'];
+            $saved = $admin->save($input);
+            foreach ([$saved, $admin->detail($id), $admin->listing(['type' => $type])['data'][0], $this->service->listing(1, 'H5', 'zh', $type)['data'][0]] as $row) {
+                $this->assertSame($type === 8, array_key_exists('image', $row));
+            }
+            $this->assertSame($type === 8 ? 'first-deposit.png' : 'previous.png', DB::table('promotion_activities')->where('id', $id)->value('image'));
+        }
+    }
+
     public function test_three_languages_edit_one_campaign_and_keep_rules_history_and_other_tabs(): void
     {
         $admin = new PromotionAdminService();

+ 17 - 1
tests/Integration/SiteMessageTest.php

@@ -59,7 +59,7 @@ class SiteMessageTest extends TestCase
             $t->id(); $t->string('resource_type'); $t->integer('resource_id'); $t->string('action'); $t->json('changes');
             $t->integer('operator_id')->nullable(); $t->string('operator_name'); $t->timestamp('created_at')->useCurrent();
         });
-        $this->migration()->up();
+        $this->migration()->up(); (require dirname(__DIR__, 2) . '/database/migrations/2026_10_27_150000_create_site_realtime_events.php')->up();
         foreach ([1 => ['zh', 1], 2 => ['en', 1], 3 => ['vi', 1], 4 => [null, 1], 5 => ['zh', -1], 6 => ['zh', 2], 7 => ['zh', 0], 8 => ['zh', 1]] as $id => [$lang, $from]) {
             DB::table('users')->insert(['id' => $id, 'user_id' => (string) (100 + $id), 'member_id' => 'M' . $id, 'account' => 'member' . $id,
                 'username' => 'name' . $id, 'first_name' => '昵称' . $id, 'language' => $lang, 'from' => $from, 'delete_time' => $id === 8 ? now() : null]);
@@ -96,6 +96,22 @@ class SiteMessageTest extends TestCase
         $this->expectException(ValidationException::class); $this->send(['request_id' => 'same', 'category' => 'promotion']);
     }
 
+    public function test_websocket_events_are_committed_with_delivery_and_retraction_and_retries_do_not_duplicate(): void
+    {
+        $message = $this->send(['request_id' => 'push-once']); $this->send(['request_id' => 'push-once']);
+        $this->assertSame(1, DB::table('site_realtime_events')->count());
+        $this->assertSame('message', DB::table('site_realtime_events')->first()->module);
+        $this->assertSame($message['id'], (int) DB::table('site_realtime_events')->first()->resource_id);
+        $this->service->retract($message['id']); $this->service->retract($message['id']);
+        $this->assertSame(['send', 'retract'], DB::table('site_realtime_events')->orderBy('id')->pluck('action')->all());
+        $this->send(['send_at' => '2026-10-26 11:00:00']);
+        $this->assertSame(2, DB::table('site_realtime_events')->count());
+        Carbon::setTestNow(Carbon::parse('2026-10-26 11:00:00', 'Asia/Shanghai')); $this->service->dispatchDue();
+        $this->assertSame(3, DB::table('site_realtime_events')->count());
+        DB::beginTransaction(); $this->send(); DB::rollBack();
+        $this->assertSame(3, DB::table('site_realtime_events')->count());
+    }
+
     public function test_specified_users_and_agents_use_distinct_id_namespaces_and_retraction_hides_every_read_path(): void
     {
         $user = $this->send(['recipient_type' => 'specified_users', 'recipient_ids' => [1, 2, 1], 'request_id' => 'select']);