doge 15 часов назад
Родитель
Сommit
8b39a7899f

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

@@ -5,6 +5,7 @@ namespace App\Http\Controllers\admin;
 use App\Constants\HttpStatus;
 use App\Http\Controllers\Controller;
 use App\Services\AnnouncementService;
+use App\Support\AnnouncementFailure;
 use Illuminate\Support\Facades\Log;
 use Illuminate\Validation\ValidationException;
 use Throwable;
@@ -36,7 +37,7 @@ class Announcement extends Controller
         catch (ValidationException $e) { return $this->error(HttpStatus::CUSTOM_ERROR, $e->validator->errors()->first()); }
         catch (Throwable $e) {
             Log::error('announcement_admin_failed', ['exception' => $e]);
-            return $this->error(HttpStatus::CUSTOM_ERROR, '公告服务暂不可用,请稍后重试');
+            return $this->error(HttpStatus::CUSTOM_ERROR, AnnouncementFailure::message($e));
         }
     }
 }

+ 6 - 11
app/Services/AnnouncementService.php

@@ -14,7 +14,7 @@ use Illuminate\Validation\ValidationException;
 class AnnouncementService
 {
     private const FIELDS = ['category', 'publish_platforms', 'display_frequency',
-        'publish_at', 'end_at', 'status', 'click_type', 'link', 'activity_id'];
+        'publish_at', 'end_at', 'status', 'click_type', 'link'];
 
     public function options(): array
     {
@@ -94,8 +94,7 @@ class AnnouncementService
             'status' => ['sometimes', 'integer', Rule::in([0, 1])],
             'click_type' => ['sometimes', 'required', Rule::in(array_keys(Catalog::CLICKS))],
             'link' => ['nullable', 'string', 'max:2048'],
-            'activity_id' => ['nullable', 'integer', 'min:1'],
-        ])->validate();
+        ], ['click_type.in' => '点击方式仅支持无链接、外站链接、站内路径'])->validate();
 
         return DB::transaction(function () use ($params) {
             $now = $this->now();
@@ -108,7 +107,7 @@ class AnnouncementService
             }
             $row = $before ?: ['category' => 'content', 'publish_platforms' => array_keys(Catalog::PLATFORMS),
                 'display_frequency' => 'daily_first_login', 'publish_at' => $now, 'end_at' => null,
-                'status' => 1, 'click_type' => 'none', 'link' => null, 'activity_id' => null];
+                'status' => 1, 'click_type' => 'none', 'link' => null];
             foreach (self::FIELDS as $field) if (array_key_exists($field, $params)) $row[$field] = $params[$field];
             if (array_key_exists('translations', $params)) $row['translations'] = LocalizedContent::parse($params['translations'], 200, 20000);
             if (empty($row['translations'])) $this->invalid('translations', '至少填写一个语言的标题和正文');
@@ -136,13 +135,8 @@ class AnnouncementService
             }
             if (in_array($row['click_type'], ['external', 'internal'], true)) {
                 if (!$this->validLink((string) $row['link'], $row['click_type'])) $this->invalid('link', '请填写有效的外站链接或站内相对路径');
-                $row['activity_id'] = null;
-            } elseif ($row['click_type'] === 'activity') {
-                if (empty($row['activity_id']) || !DB::table('activity_rewards')->where('id', $row['activity_id'])->exists()) $this->invalid('activity_id', '请选择存在的优惠活动');
-                $row['link'] = null;
-            } else { $row['link'] = null; $row['activity_id'] = null; }
+            } else { $row['link'] = null; }
             $row['status'] = (int) $row['status'];
-            $row['activity_id'] = $row['activity_id'] === null ? null : (int) $row['activity_id'];
             $actor = OperationAuditService::actor();
             if (!$actor['operator_id'] || $actor['operator_name'] === '') $this->invalid('sender_id', '请使用后台管理员身份');
             $values = array_intersect_key($row, array_flip(self::FIELDS));
@@ -233,7 +227,8 @@ class AnnouncementService
     {
         if (isset($row['publish_platforms']) && is_string($row['publish_platforms'])) $row['publish_platforms'] = json_decode($row['publish_platforms'], true, 512, JSON_THROW_ON_ERROR);
         foreach (['id', 'sender_id', 'status'] as $field) if (isset($row[$field])) $row[$field] = (int) $row[$field];
-        if (isset($row['activity_id'])) $row['activity_id'] = (int) $row['activity_id'];
+        unset($row['activity_id']);
+        if (($row['click_type'] ?? 'none') === 'activity') { $row['click_type'] = 'none'; $row['link'] = null; }
         return $row;
     }
 

+ 1 - 1
app/Services/Sharing/ShareClaimAuditService.php

@@ -103,7 +103,7 @@ class ShareClaimAuditService
 
     private function claim(object $row): array
     {
-        return ['id' => (int)$row->id, 'activity_name' => '分享赚钱',
+        return ['id' => (int)$row->id, 'activity_name' => '代理佣金',
             'user_id' => (int)$row->user_id, 'member_id' => (string)($row->member_id ?? ''),
             'account' => (string)($row->account ?? ''), 'nickname' => (string)($row->nickname ?? ''), 'request_id' => (string)$row->request_id,
             'amount' => $this->money($row->amount), 'status' => $row->status,

+ 5 - 0
app/Services/SiteRealtimeService.php

@@ -4,6 +4,8 @@ namespace App\Services;
 
 use Carbon\Carbon;
 use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Schema;
+use Illuminate\Validation\ValidationException;
 use InvalidArgumentException;
 
 /** Written inside the same transaction as the change; consumed by the member WebSocket worker. */
@@ -12,6 +14,9 @@ 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');
+        if (!Schema::hasTable('site_realtime_events')) {
+            throw ValidationException::withMessages(['realtime' => '实时通知服务未初始化,请先完成系统更新']);
+        }
         $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();

+ 1 - 1
app/Support/AnnouncementCatalog.php

@@ -8,7 +8,7 @@ final class AnnouncementCatalog
     public const CATEGORIES = ['content' => '弹出内容公告', 'image' => '弹出图片公告', 'update' => '弹出更新公告'];
     public const PLATFORMS = ['PC' => 'PC', 'H5' => 'H5', 'APP' => 'App'];
     public const FREQUENCIES = ['daily_first_login' => '每日首次登录', 'every_login' => '每次登录'];
-    public const CLICKS = ['none' => '无链接', 'external' => '外站链接', 'internal' => '站内相对路径', 'activity' => '优惠活动'];
+    public const CLICKS = ['none' => '无链接', 'external' => '外站链接', 'internal' => '站内相对路径'];
     public const STATES = ['disabled' => '关闭', 'scheduled' => '未开始', 'active' => '展示中', 'expired' => '已结束'];
 
     public static function state(array $row, string $now): string

+ 33 - 0
app/Support/AnnouncementFailure.php

@@ -0,0 +1,33 @@
+<?php
+
+namespace App\Support;
+
+use PDOException;
+use Throwable;
+
+final class AnnouncementFailure
+{
+    public static function message(Throwable $error): string
+    {
+        if ($error instanceof PDOException) {
+            $state = (string) ($error->errorInfo[0] ?? $error->getCode());
+            $code = (int) ($error->errorInfo[1] ?? 0);
+            $text = strtolower($error->getMessage());
+            if ($state === '42S02' || str_contains($text, 'no such table')) {
+                if (str_contains($text, 'site_realtime_events')) return '实时通知服务未初始化,请先完成系统更新';
+                if (str_contains($text, 'operation_audits')) return '操作记录未初始化,请先完成系统更新';
+                return '公告数据未初始化,请先完成系统更新';
+            }
+            if ($state === '42S22' || str_contains($text, 'no such column') || str_contains($text, 'has no column named')) return '公告字段结构未更新完整,请先完成系统更新';
+            if (in_array($code, [2002, 2003, 2006, 2013], true) || str_starts_with($state, '08')) return '数据库连接失败,请检查数据库服务后重新保存';
+            if ($code === 1406 || $state === '22001') return '公告内容超出存储长度,请缩短内容后保存';
+            if (in_array($code, [1205, 1213], true)) return '公告数据正在被更新,请重新提交';
+            if ($code === 1062) return '公告数据重复,请刷新页面后重新保存';
+            if (in_array($code, [1048, 1364], true)) return '公告缺少必要数据,请检查填写内容及系统更新状态';
+            return '公告数据库操作失败(错误代码' . ($code ?: preg_replace('/[^A-Za-z0-9]/', '', $state)) . ')';
+        }
+        if ($error instanceof \JsonException) return '公告内容格式错误,请检查后重新保存';
+        if ($error instanceof \TypeError) return '公告字段类型处理错误,请联系管理员修正';
+        return '公告处理失败(' . class_basename($error) . '),请联系管理员检查该错误';
+    }
+}

+ 6 - 4
docs/前端接口/新分享系统/后台领取审核-所属活动.md

@@ -1,17 +1,19 @@
 # 后台领取审核:所属活动
 
-领取审核列表增加「所属活动」一列,直接展示 `activity_name`。
+领取审核列表增加「所属活动」一列,审核详情也增加「所属活动」一项,均直接展示返回的 `activity_name`。
 
-| 接口 | 字段位置 |
+| 接口 | 返回字段位置 |
 | --- | --- |
 | GET `/admin/shareEarning/claims` | `data.data[].activity_name` |
 | GET `/admin/shareEarning/claims/detail?id=17` | `data.activity_name` |
 | POST `/admin/shareEarning/claims/audit` | 审核结果的 `data.activity_name` |
 
+`activity_name` 仅用于展示,不作为请求参数。审核POST仍只提交 `id/action/remark`。
+
 新增字段示例:
 
 ```json
-{"activity_name":"分享赚钱"}
+{"activity_name":"代理佣金"}
 ```
 
-该接口目前均为分享赚钱佣金领取申请。金额、状态、筛选条件和审核操作沿用现有实现。
+该接口的所属活动名称统一显示为「代理佣金」。金额、状态、筛选条件和审核操作沿用现有实现。

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

@@ -12,6 +12,8 @@ php artisan migrate --path=database/migrations/2026_10_27_150000_create_site_rea
 
 随后发布两个项目的接口代码,并按当前进程管理方式重启会员项目已有的 `php think websocket:start` 服务,不重复启动同端口服务。原有每分钟 `messages:dispatch` 定时任务保持运行。
 
+保存公告提示“实时通知服务未初始化,请先完成系统更新”时,先检查以上迁移是否已在当前业务库执行。事件表缺失时保存事务会回滚,不会留下半条公告或审计记录;补齐迁移后可直接重新保存。旧版本在同一情况下只显示“公告服务暂不可用,请稍后重试”。其他错误按故障类型返回明确原因,完整异常仍写入后台 `announcement_admin_failed` 日志。
+
 事件表 `site_realtime_events` 与对应业务写入同事务,事务回滚不会发出通知。WebSocket服务每秒取最多50条已到时间的待推送事件,推送失败保持待处理,下一轮重试;没有在线接收者时事件完成。客户端仍通过原HTTP接口取得当前状态,通知不承载完整正文。
 
 公告及维护的未来开始/结束边界会生成到时事件;修改时间或停用会取消旧的待执行边界。维护配置的UTC时间转换为事件调度的Asia/Shanghai时间。定时信息在实际投递成功后产生通知。

+ 22 - 0
docs/总后台/公告保存-点击行为补充.md

@@ -0,0 +1,22 @@
+# 公告保存:点击行为
+
+公告不关联活动。删除活动选择器及其必填校验,不提交 `activity_id`。
+
+| click_type | 表单要求 |
+| --- | --- |
+| `none` 无链接(默认) | 隐藏链接输入,不需要跳转目标 |
+| `external` 外站链接 | 填写完整HTTP/HTTPS `link` |
+| `internal` 站内路径 | 填写以 `/` 开头的 `link` |
+
+点击方式只保留上述三项。保存失败直接显示接口返回的 `msg`,不要在前端替换成固定的“公告服务暂不可用”。
+
+原保存接口 `POST /admin/announcement/submit`,普通公告示例:
+
+```json
+{
+  "click_type": "none",
+  "translations": {
+    "zh": {"title": "公告标题", "content": "公告正文"}
+  }
+}
+```

+ 6 - 9
docs/总后台/公告系统-后台接口.md

@@ -25,7 +25,7 @@
 | language | `zh` 中文;`en` 英文;`vi` 越南语 |
 | publish_platforms | 非空、去重数组,元素为 `PC/H5/APP`;三项全选为全部 |
 | display_frequency | `daily_first_login` 每日首次登录;`every_login` 每次登录 |
-| click_type | `none` 无链接;`external` 外站;`internal` 站内路径;`activity` 优惠活动 |
+| click_type | `none` 无链接;`external` 外站;`internal` 站内路径 |
 | status | 整数 `0` 关闭、`1` 开启 |
 
 创建默认:内容公告、全部平台、每日首次登录、立即发布、不结束、开启、无链接。**标题和正文改用translations,不再提交顶层title/content。** 每个语言独立填写标题和正文;图片公告每个语言分别上传一张图,仍用content保存图片地址。
@@ -46,8 +46,7 @@
   "end_at":"2026-10-28 09:00:00",
   "status":1,
   "click_type":"none",
-  "link":null,
-  "activity_id":null
+  "link":null
 }
 ```
 
@@ -61,14 +60,12 @@
 
 图片上传复用 `POST /admin/upload/uploadFile`,multipart字段image,最大10MB,支持jpg/jpeg/png/webp,返回data.path。该地址放入对应语言 `translations.zh.content` 等;图片类型没有额外文字正文,另外两类没有版本升级功能。
 
-所有语言共用click_type、link、activity_id:
+所有语言共用click_type、link。公告不关联活动,不显示活动选择器。
 
-- none清空两个目标字段。
+- none不跳转,link清空。
 - external需要完整HTTP/HTTPS link,禁止其他协议和URL账号密码。
 - internal需要单个 `/` 开头的站内路径,禁止 `//外站` 和反斜杠。
-- activity需要已存在的activity_id,link清空;活动选择器复用 `GET /admin/ActivityReward`,取ID和标题。
 
-活动未开始、失效、被删、语言不符或对当前会员不可见时,公告照常展示,会员接口只禁用跳转。活动可见但会员暂不能参加时仍允许打开详情。
 
 ## 时间与状态
 
@@ -103,7 +100,7 @@
 
 一条多语言公告只占一行,语言和标题过滤在统计分页前完成。按publish_at、ID倒序,返回 `data.total/data.data`。列表提供 `languages:["zh","en"]`、`titles:{"zh":"网站维护","en":"Maintenance"}`,不批量返回正文;选择语言后标题列读取titles[language],不选择时可按语言标签分行展示。
 
-公共返回字段为id、category/category_name、publish_platforms/platform_label、display_frequency/display_frequency_name、publish_at、end_at、status、click_type、link、activity_id、sender_id/sender_name/sender_display、created_at/updated_at、publication_state/publication_state_name、effective_status、publish_immediately、no_end。
+公共返回字段为id、category/category_name、publish_platforms/platform_label、display_frequency/display_frequency_name、publish_at、end_at、status、click_type、link、sender_id/sender_name/sender_display、created_at/updated_at、publication_state/publication_state_name、effective_status、publish_immediately、no_end。
 
 “发送时间”列对应publish_at,“发送者”使用sender_display,例如 `批核者:admin`,账号不可写死。“不结束”对应end_at=null。
 
@@ -138,4 +135,4 @@ action为create/update/status。old_values/new_values只含变化字段。语言
 php artisan migrate --path=database/migrations/2026_10_26_100000_create_announcements.php --force
 ```
 
-依赖现有operation_audits表,后台及melbet_sport-api共享业务库并同步部署。校验/不存在错误code=-3,内部异常统一“公告服务暂不可用,请稍后重试”。
+依赖现有operation_audits表,后台及melbet_sport-api共享业务库并同步部署。校验/不存在错误code=-3,操作失败时展示返回的msg,包含数据未初始化、字段结构未更新、内容过长或数据库连接失败等具体原因。

+ 72 - 6
tests/Integration/AnnouncementTest.php

@@ -96,6 +96,7 @@ class AnnouncementTest extends TestCase
     public function test_options_defaults_create_sender_and_no_approval_state(): void
     {
         $options = $this->service->options();
+        $this->assertSame(['none', 'external', 'internal'], array_column($options['click_types'], 'value'));
         $this->assertSame(['content', 'image', 'update'], array_column($options['categories'], 'value'));
         $this->assertSame(['PC', 'H5', 'APP'], array_column($options['platforms'], 'value'));
         $row = $this->create();
@@ -194,14 +195,79 @@ class AnnouncementTest extends TestCase
         ]);
     }
 
-    public function test_click_targets_use_explicit_types_and_stale_targets_are_cleared(): void
+    public function test_announcement_activity_linkage_is_removed_including_historical_links(): void
     {
-        $row = $this->create(['click_type' => 'activity', 'activity_id' => 1]);
-        $this->assertSame(1, $row['activity_id']); $this->assertNull($row['link']);
-        $row = $this->service->save(['id' => $row['id'], 'click_type' => 'internal', 'link' => '/activity/1']);
-        $this->assertNull($row['activity_id']);
+        $row = $this->create(['click_type' => 'external', 'link' => 'https://example.com']);
+        DB::table('announcements')->where('id', $row['id'])->update(['click_type' => 'activity', 'activity_id' => 1]);
+        $legacy = $this->service->detail($row['id']);
+        $this->assertSame('none', $legacy['click_type']); $this->assertNull($legacy['link']);
+        $this->assertArrayNotHasKey('activity_id', $legacy);
+        $row = $this->service->save(['id' => $row['id'], 'click_type' => 'internal', 'link' => '/news']);
+        $this->assertArrayNotHasKey('activity_id', $row);
         $row = $this->service->save(['id' => $row['id'], 'click_type' => 'none']);
-        $this->assertNull($row['activity_id']); $this->assertNull($row['link']);
+        $this->assertArrayNotHasKey('activity_id', $row); $this->assertNull($row['link']);
+        Schema::drop('activity_rewards');
+        $this->assertArrayNotHasKey('activity_id', $this->create());
+    }
+
+    public function test_normal_announcements_do_not_require_or_validate_a_hidden_activity_picker(): void
+    {
+        foreach (['none' => null, 'external' => 'https://example.com', 'internal' => '/news'] as $click => $link) {
+            foreach ([null, 0, '', ['stale picker value']] as $activity) {
+                $row = $this->create(['click_type' => $click, 'link' => $link, 'activity_id' => $activity]);
+                $this->assertArrayNotHasKey('activity_id', $row); $this->assertSame($click, $row['click_type']);
+            }
+        }
+        foreach ([null, 0, [], 'invalid'] as $activity) {
+            try { $this->create(['click_type' => 'activity', 'activity_id' => $activity]); $this->fail('Invalid activity accepted'); }
+            catch (ValidationException $e) { $this->assertTrue($e->validator->errors()->has('click_type')); }
+        }
+    }
+
+    public function test_missing_websocket_table_save_failure_is_atomic_and_migration_restores_saving(): void
+    {
+        $this->app->instance('log', new \Psr\Log\NullLogger());
+        Schema::drop('site_realtime_events');
+        $this->request(['click_type' => 'none', 'translations' => ['zh' => ['title' => '普通公告', 'content' => '公告正文']]]);
+        $controller = new AnnouncementController();
+        $failed = $controller->submit($this->service)->getData(true);
+        $this->assertSame(-3, $failed['code']);
+        $this->assertSame('实时通知服务未初始化,请先完成系统更新', $failed['msg']);
+        $this->assertSame(0, DB::table('announcements')->count());
+        $this->assertSame(0, DB::table('operation_audits')->count());
+        (require dirname(__DIR__, 2) . '/database/migrations/2026_10_27_150000_create_site_realtime_events.php')->up();
+        $saved = $controller->submit($this->service)->getData(true);
+        $this->assertSame(0, $saved['code']); $this->assertArrayNotHasKey('activity_id', $saved['data']);
+    }
+
+    /** @dataProvider saveErrors */
+    public function test_save_failures_report_the_reason_without_exposing_sql(string $state, int $code, string $expected): void
+    {
+        $this->app->instance('log', new \Psr\Log\NullLogger());
+        $error = new \PDOException('SQLSTATE[' . $state . '] secret-value SELECT * FROM bot_announcements');
+        $error->errorInfo = [$state, $code, 'secret-value'];
+        $service = new class($error) extends AnnouncementService {
+            private \Throwable $error;
+            public function __construct(\Throwable $error) { $this->error = $error; }
+            public function save(array $input): array { throw $this->error; }
+        };
+        $body = (new AnnouncementController())->submit($service)->getData(true);
+        $this->assertSame(-3, $body['code']); $this->assertSame($expected, $body['msg']);
+        $this->assertStringNotContainsString('secret-value', json_encode($body));
+        $this->assertStringNotContainsString('SELECT', json_encode($body));
+    }
+
+    public static function saveErrors(): array
+    {
+        return [
+            ['42S02', 1146, '公告数据未初始化,请先完成系统更新'],
+            ['42S22', 1054, '公告字段结构未更新完整,请先完成系统更新'],
+            ['HY000', 2002, '数据库连接失败,请检查数据库服务后重新保存'],
+            ['22001', 1406, '公告内容超出存储长度,请缩短内容后保存'],
+            ['40001', 1213, '公告数据正在被更新,请重新提交'],
+            ['23000', 1062, '公告数据重复,请刷新页面后重新保存'],
+            ['42000', 1064, '公告数据库操作失败(错误代码1064)'],
+        ];
     }
 
     public function test_audit_failure_rolls_back_create_edit_and_status(): void

+ 4 - 4
tests/Integration/ShareAdminManagementTest.php

@@ -433,18 +433,18 @@ class ShareAdminManagementTest extends TestCase
     public function test_admin_controller_envelopes_and_filtered_lists(): void
     {
         $id = $this->pending();
-        $this->assertSame('分享赚钱', $this->claims->list(['status' => 'pending'])['data'][0]['activity_name']);
-        $this->assertSame('分享赚钱', $this->claims->detail($id)['activity_name']);
+        $this->assertSame('代理佣金', $this->claims->list(['status' => 'pending'])['data'][0]['activity_name']);
+        $this->assertSame('代理佣金', $this->claims->detail($id)['activity_name']);
         $this->assertSame(0, (new ShareEarning())->settings($this->admin)->getData(true)['code']);
         $request = Request::create('/admin/shareEarning/claims/audit', 'POST', ['id' => $id, 'action' => 'approve']);
         $request->user = (object)['id' => 8, 'username' => 'admin'];
         $this->app->instance('request', $request);
         $body = (new SharingManagement())->auditClaim($this->claims)->getData(true);
         $this->assertSame(0, $body['code']); $this->assertSame('approved', $body['data']['status']);
-        $this->assertSame('分享赚钱', $body['data']['activity_name']);
+        $this->assertSame('代理佣金', $body['data']['activity_name']);
         $list = $this->claims->list(['status' => 'approved', 'account' => 'member7', 'start_date' => '2026-10-24', 'end_date' => '2026-10-24']);
         $this->assertSame(1, $list['total']); $this->assertSame('10.01', $list['data'][0]['amount']);
-        $this->assertSame('分享赚钱', $list['data'][0]['activity_name']);
+        $this->assertSame('代理佣金', $list['data'][0]['activity_name']);
         $this->assertSame(0, $this->claims->list(['status' => 'pending'])['total']);
     }