# TDD-09 推送与通知系统设计 > 文档类型:技术设计文档(Technical Design Document) > 版本:1.0 > 日期:2026-07-02 > 关联文档:TDD-04《数据库表结构设计》、TDD-05《API接口设计》、TDD-06《离线挂机结算系统设计》、TDD-07《反作弊与安全设计》、GDD-07《帮派门派社交系统设计》、GDD-13《佣兵大厅与悬赏系统》、GDD-14《稀有宝物流转与拍卖系统》、GDD-22《开放世界随机事件》 --- ## 1. 文档信息 | 项目 | 说明 | |------|------| | 目标 | 为《洪荒大陆》挂机手游定义推送与通知系统的技术方案,覆盖游戏内通知中心、离线推送通道、推送策略控制、平台对接与内容安全。 | | 读者 | 服务端开发(Nakama/Go)、客户端开发(Cocos Creator 3.x)、运维、测试 | | 技术栈 | Nakama 3.x + Go插件 + PostgreSQL 16 + Valkey + Nacos 2.x + Cocos Creator 3.x | | 核心约束 | 无任务系统、无赛季重置、概率/机遇驱动、文字战报、ATB行动条、功法加持、能量体系(非体力) | | 游戏时间 | 现实:游戏 = 1:3 | --- ## 2. 系统总览 ### 2.1 推送与通知的核心架构 ``` ┌─────────────────────────────────────────────────────────────────────┐ │ 服务端事件源 │ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ │挂机结算 │ │社交系统 │ │经济系统 │ │世界事件 │ │风险检测 │ │ │ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ │ │ │ │ │ │ │ │ │ └────────────┴────────────┴────────────┴────────────┘ │ │ │ │ │ ┌─────────▼─────────┐ │ │ │ 通知调度引擎 │ │ │ │ (Nakama Runtime) │ │ │ └─────────┬─────────┘ │ │ │ │ │ ┌───────────────┼───────────────┐ │ │ ▼ ▼ ▼ │ │ ┌────────────┐ ┌────────────┐ ┌────────────┐ │ │ │ 游戏内通知 │ │ 离线推送 │ │ 通知存储 │ │ │ │ (WebSocket)│ │ (APNs/FCM) │ │ (PostgreSQL)│ │ │ └────────────┘ └────────────┘ └────────────┘ │ └─────────────────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────────────┐ │ 客户端 │ │ ┌────────────┐ ┌────────────┐ ┌────────────┐ │ │ │ 通知中心 │ │ 推送接收 │ │ 推送设置 │ │ │ │ (UI组件) │ │ (原生模块) │ │ (偏好管理) │ │ │ └────────────┘ └────────────┘ └────────────┘ │ └─────────────────────────────────────────────────────────────────────┘ ``` ### 2.2 设计原则 | 原则 | 说明 | |------|------| | **事件驱动** | 所有通知由游戏事件触发,不使用轮询机制 | | **优先级分级** | 风险警告 > 社交通知 > 挂机通知 > 系统公告 | | **在线优先** | 玩家在线时通过 WebSocket 实时推送,不发送离线推送 | | **防打扰** | 支持免打扰时段、频率控制、同类消息合并 | | **安全脱敏** | 离线推送内容不暴露游戏内敏感信息(如具体资源数量、坐标等) | --- ## 3. 推送类型分类 ### 3.1 推送类型定义表 | 类型编码 | 类型名称 | 优先级 | 触发场景 | 推送时机 | 示例内容 | |----------|----------|--------|----------|----------|----------| | `RISK_WANTED` | 被通缉 | P0-紧急 | 角色被其他玩家发布悬赏 | 立即 | "你已被悬赏追杀,赏金XXX灵石" | | `RISK_TRIBULATION` | 天罚预警 | P0-紧急 | 角色SAN值过低触发天罚倒计时 | 立即 | "天罚将在30分钟后降临" | | `RISK_INVASION` | 洞府被入侵 | P0-紧急 | 其他玩家入侵角色洞府 | 立即 | "你的洞府正被XXX入侵" | | `RISK_SAN_LOW` | SAN值过低 | P0-紧急 | 角色SAN值低于阈值 | 立即 | "你的精神状态岌岌可危" | | `SOCIAL_DAO_COMPANION` | 道侣护法请求 | P1-高 | 道侣请求护法协助突破 | 立即 | "你的道侣XXX请求护法" | | `SOCIAL_MASTER` | 师徒传功 | P1-高 | 师父发起传功邀请 | 立即 | "你的师父XXX邀请传功" | | `SOCIAL_GUILD_SUMMON` | 帮派召集 | P1-高 | 帮主发起帮派集结 | 立即 | "帮派紧急召集,速回" | | `SOCIAL_BOUNTY` | 追杀令 | P1-高 | 帮派发布追杀令 | 立即 | "帮派对XXX发布追杀令" | | `IDLE_RESOURCE_FULL` | 资源满 | P2-中 | 挂机资源达到上限 | 延迟5分钟 | "你的XXX资源已满" | | `IDLE_DISCIPLE_RETURN` | 弟子归来 | P2-中 | 弟子完成代派任务 | 延迟5分钟 | "弟子XXX完成任务归来" | | `IDLE_REALM_BREAK` | 修炼突破条件达成 | P2-中 | 修为/材料满足突破条件 | 延迟5分钟 | "你已满足突破至XXX的条件" | | `ECONOMY_AUCTION_WIN` | 拍卖成交 | P2-中 | 拍卖成功获得物品 | 结算后 | "你已成功拍得XXX" | | `ECONOMY_AUCTION_OUTBID` | 被超价 | P2-中 | 拍卖出价被超越 | 立即 | "你在XXX拍卖中已被超越" | | `ECONOMY_BOUNTY_SETTLE` | 悬赏结算 | P2-中 | 悬赏完成结算 | 结算后 | "悬赏XXX已结算" | | `ECONOMY_MARKET_SOLD` | 交易行售出 | P2-中 | 交易行物品被购买 | 立即 | "你的XXX已售出" | | `WORLD_APOCALYPSE` | 天启广播 | P2-中 | 天启事件触发 | 立即 | "天启降临,XXX" | | `WORLD_DIVINE_BEAST` | 神兽现世 | P2-中 | 神兽刷新 | 立即 | "神兽XXX现世于XXX" | | `WORLD_BOSS` | 世界Boss刷新 | P2-中 | 世界Boss刷新 | 立即 | "世界BossXXX已刷新" | | `SYSTEM_MAINTENANCE` | 维护公告 | P3-低 | 服务器维护通知 | 提前1小时 | "服务器将于XXX维护" | | `SYSTEM_UPDATE` | 版本更新 | P3-低 | 新版本发布 | 发布时 | "新版本XXX已发布" | | `SYSTEM_ACTIVITY` | 活动开始 | P3-低 | 活动开启 | 活动开始 | "活动XXX已开始" | ### 3.2 推送类型编码规则 ``` {CATEGORY}_{SPECIFIC_EVENT} CATEGORY: - RISK: 风险警告 - SOCIAL: 社交通知 - IDLE: 挂机通知 - ECONOMY: 经济通知 - WORLD: 世界事件 - SYSTEM: 系统通知 ``` --- ## 4. 推送策略 ### 4.1 优先级分级策略 | 优先级 | 级别 | 在线行为 | 离线行为 | 频率限制 | |--------|------|----------|----------|----------| | P0-紧急 | 立即 | WebSocket实时推送 + 游戏内弹窗 | APNs/FCM即时推送 | 无限制 | | P1-高 | 立即 | WebSocket实时推送 + 游戏内通知 | APNs/FCM即时推送 | 同类型5分钟/条 | | P2-中 | 延迟 | WebSocket实时推送 | APNs/FCM延迟推送(5分钟合并) | 同类型10分钟/条 | | P3-低 | 延迟 | WebSocket实时推送 | 不推送离线通知 | 同类型30分钟/条 | ### 4.2 免打扰时段设置 #### 4.2.1 玩家偏好配置 ```json { "notification_preferences": { "dnd_enabled": true, "dnd_start_time": "23:00", "dnd_end_time": "08:00", "dnd_override_p0": true, // P0紧急消息是否突破免打扰 "category_settings": { "RISK": { "enabled": true, "sound": true, "vibrate": true }, "SOCIAL": { "enabled": true, "sound": true, "vibrate": false }, "IDLE": { "enabled": true, "sound": false, "vibrate": false }, "ECONOMY": { "enabled": true, "sound": false, "vibrate": false }, "WORLD": { "enabled": true, "sound": true, "vibrate": false }, "SYSTEM": { "enabled": false, "sound": false, "vibrate": false } } } } ``` #### 4.2.2 免打扰逻辑 ``` IF 玩家处于免打扰时段: IF 优先级 == P0 AND dnd_override_p0 == true: 正常推送 ELSE IF 优先级 == P0 AND dnd_override_p0 == false: 存入通知中心,不发送离线推送 ELSE: 存入通知中心,不发送离线推送 ELSE: 正常推送 ``` ### 4.3 推送频率控制 #### 4.3.1 合并同类消息 | 合并策略 | 适用场景 | 合并窗口 | 合并方式 | |----------|----------|----------|----------| | 资源满合并 | 多种资源同时满 | 5分钟 | "你的XXX、YYY、ZZZ资源已满" | | 弟子归来合并 | 多个弟子同时归来 | 5分钟 | "弟子XXX、YYY完成任务归来" | | 交易行售出合并 | 多件物品售出 | 10分钟 | "你的XXX等N件物品已售出" | | 世界事件合并 | 多个世界事件 | 不合并 | 单独推送 | #### 4.3.2 防刷屏机制 ```go // 推送频率控制配置 type PushRateLimit struct { Category string `json:"category"` MaxPerMinute int `json:"max_per_minute"` MaxPerHour int `json:"max_per_hour"` Cooldown time.Duration `json:"cooldown"` } // 默认配置 var DefaultRateLimits = map[string]PushRateLimit{ "RISK": {MaxPerMinute: 10, MaxPerHour: 60, Cooldown: 0}, "SOCIAL": {MaxPerMinute: 5, MaxPerHour: 30, Cooldown: 30 * time.Second}, "IDLE": {MaxPerMinute: 2, MaxPerHour: 10, Cooldown: 5 * time.Minute}, "ECONOMY": {MaxPerMinute: 3, MaxPerHour: 20, Cooldown: 2 * time.Minute}, "WORLD": {MaxPerMinute: 5, MaxPerHour: 30, Cooldown: 1 * time.Minute}, "SYSTEM": {MaxPerMinute: 1, MaxPerHour: 5, Cooldown: 10 * time.Minute}, } ``` ### 4.4 在线状态判断 ```go // 玩家在线状态检查 type PlayerOnlineStatus struct { CharacterID string `json:"character_id"` IsOnline bool `json:"is_online"` LastHeartbeat time.Time `json:"last_heartbeat"` SessionID string `json:"session_id"` } // 推送决策逻辑 func shouldSendPush(status PlayerOnlineStatus, priority string) bool { if status.IsOnline && time.Since(status.LastHeartbeat) < 30*time.Second { // 玩家在线,通过WebSocket推送,不发送离线推送 return false } // 离线超过5分钟才发送离线推送(避免频繁切换) if time.Since(status.LastHeartbeat) < 5*time.Minute { return false } return true } ``` --- ## 5. 游戏内通知中心 ### 5.1 通知中心数据模型 #### 5.1.1 notifications 表 ```sql CREATE TABLE notifications ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), character_id UUID NOT NULL REFERENCES characters(id), type VARCHAR(50) NOT NULL, -- 推送类型编码 category VARCHAR(20) NOT NULL, -- 分类:RISK/SOCIAL/IDLE/ECONOMY/WORLD/SYSTEM priority SMALLINT NOT NULL DEFAULT 2, -- 优先级:0=P0, 1=P1, 2=P2, 3=P3 title VARCHAR(200) NOT NULL, -- 通知标题 content TEXT NOT NULL, -- 通知内容(支持模板变量) payload JSONB, -- 附加数据(跳转参数等) is_read BOOLEAN NOT NULL DEFAULT FALSE, -- 是否已读 read_at TIMESTAMPTZ, -- 阅读时间 created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), expires_at TIMESTAMPTZ, -- 过期时间(可选) -- 索引 INDEX idx_notifications_character_id (character_id), INDEX idx_notifications_category (category), INDEX idx_notifications_created_at (created_at), INDEX idx_notifications_unread (character_id, is_read) WHERE is_read = FALSE ); -- 按时间分区(可选,数据量大时) -- CREATE TABLE notifications_partitioned ( -- LIKE notifications INCLUDING ALL -- ) PARTITION BY RANGE (created_at); ``` #### 5.1.2 notification_templates 表 ```sql CREATE TABLE notification_templates ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), type VARCHAR(50) NOT NULL UNIQUE, -- 推送类型编码 category VARCHAR(20) NOT NULL, priority SMALLINT NOT NULL DEFAULT 2, title_template VARCHAR(200) NOT NULL, -- 标题模板 content_template TEXT NOT NULL, -- 内容模板 push_template TEXT, -- 离线推送内容模板(脱敏版) sound VARCHAR(50), -- 提示音 vibrate BOOLEAN DEFAULT FALSE, jump_type VARCHAR(50), -- 跳转类型 jump_params JSONB, -- 跳转参数模板 is_active BOOLEAN DEFAULT TRUE, created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW() ); ``` ### 5.2 通知模板示例 ```sql -- 插入通知模板 INSERT INTO notification_templates (type, category, priority, title_template, content_template, push_template, jump_type) VALUES ('RISK_WANTED', 'RISK', 0, '被通缉', '你已被{{issuer_name}}悬赏追杀,赏金{{amount}}灵石', '你已被悬赏追杀', 'bounty_detail'), ('SOCIAL_DAO_COMPANION', 'SOCIAL', 1, '道侣护法请求', '你的道侣{{companion_name}}请求护法协助突破{{realm}}', '道侣请求护法', 'companion_request'), ('IDLE_RESOURCE_FULL', 'IDLE', 2, '资源满', '你的{{resource_names}}资源已满', '资源已满请及时收取', 'resource_panel'), ('ECONOMY_AUCTION_WIN', 'ECONOMY', 2, '拍卖成交', '你已成功拍得{{item_name}},花费{{amount}}灵石', '拍卖成功', 'auction_detail'), ('WORLD_DIVINE_BEAST', 'WORLD', 2, '神兽现世', '神兽{{beast_name}}现世于{{location}}', '神兽现世', 'world_map'); ``` ### 5.3 通知操作与跳转 #### 5.3.1 跳转类型定义 | 跳转类型 | 说明 | 参数示例 | |----------|------|----------| | `bounty_detail` | 跳转到悬赏详情 | `{"bounty_id": "uuid"}` | | `companion_request` | 跳转到道侣请求 | `{"request_id": "uuid"}` | | `resource_panel` | 跳转到资源面板 | `{"resource_type": "spirit_stone"}` | | `auction_detail` | 跳转到拍卖详情 | `{"auction_id": "uuid"}` | | `world_map` | 跳转到世界地图 | `{"region_id": "uuid", "x": 100, "y": 200}` | | `guild_main` | 跳转到帮派主页 | `{"guild_id": "uuid"}` | | `market_order` | 跳转到交易行订单 | `{"order_id": "uuid"}` | | `character_panel` | 跳转到角色面板 | `{"character_id": "uuid"}` | #### 5.3.2 客户端跳转处理 ```typescript // 通知跳转处理 interface NotificationPayload { jump_type: string; jump_params: Record; } class NotificationManager { handleNotificationClick(notification: Notification): void { const payload = notification.payload as NotificationPayload; switch (payload.jump_type) { case 'bounty_detail': this.uiManager.openPanel('BountyDetailPanel', payload.jump_params); break; case 'companion_request': this.uiManager.openPanel('CompanionRequestPanel', payload.jump_params); break; case 'resource_panel': this.uiManager.openPanel('ResourcePanel', payload.jump_params); break; case 'auction_detail': this.uiManager.openPanel('AuctionDetailPanel', payload.jump_params); break; case 'world_map': this.uiManager.openPanel('WorldMapPanel', payload.jump_params); break; case 'guild_main': this.uiManager.openPanel('GuildMainPanel', payload.jump_params); break; case 'market_order': this.uiManager.openPanel('MarketOrderPanel', payload.jump_params); break; case 'character_panel': this.uiManager.openPanel('CharacterPanel', payload.jump_params); break; default: this.uiManager.openPanel('NotificationDetailPanel', { id: notification.id }); } } } ``` ### 5.4 通知中心API #### 5.4.1 获取通知列表 ``` GET /api/v1/notifications Authorization: Bearer Query Parameters: - category: string (可选,筛选分类) - is_read: boolean (可选,筛选已读/未读) - page: int (默认1) - page_size: int (默认20,最大50) Response: { "code": 0, "data": { "total": 150, "unread_count": 12, "notifications": [ { "id": "uuid", "type": "RISK_WANTED", "category": "RISK", "priority": 0, "title": "被通缉", "content": "你已被XXX悬赏追杀,赏金1000灵石", "payload": { "jump_type": "bounty_detail", "jump_params": {"bounty_id": "uuid"} }, "is_read": false, "created_at": "2026-07-02T10:30:00Z" } ] } } ``` #### 5.4.2 标记通知已读 ``` POST /api/v1/notifications/{id}/read Authorization: Bearer Response: { "code": 0, "data": { "id": "uuid", "is_read": true, "read_at": "2026-07-02T10:35:00Z" } } ``` #### 5.4.3 批量标记已读 ``` POST /api/v1/notifications/batch-read Authorization: Bearer Request Body: { "notification_ids": ["uuid1", "uuid2", "uuid3"] } Response: { "code": 0, "data": { "updated_count": 3 } } ``` #### 5.4.4 获取未读数量 ``` GET /api/v1/notifications/unread-count Authorization: Bearer Response: { "code": 0, "data": { "total_unread": 12, "by_category": { "RISK": 2, "SOCIAL": 3, "IDLE": 5, "ECONOMY": 1, "WORLD": 1, "SYSTEM": 0 } } } ``` --- ## 6. 平台对接 ### 6.1 推送平台架构 ``` ┌─────────────────────────────────────────────────────────────────────┐ │ 推送调度服务 │ │ ┌─────────────────────────────────────────────────────────────┐ │ │ │ PushRouter │ │ │ │ - 根据设备类型选择推送通道 │ │ │ │ - 失败重试与降级策略 │ │ │ └─────────────────────────────────────────────────────────────┘ │ │ │ │ │ ┌──────────────────────┼──────────────────────┐ │ │ ▼ ▼ ▼ │ │ ┌─────────┐ ┌─────────┐ ┌─────────┐ │ │ │ APNs │ │ FCM │ │ 厂商通道 │ │ │ │ (iOS) │ │(Android)│ │ (华为等) │ │ │ └─────────┘ └─────────┘ └─────────┘ │ └─────────────────────────────────────────────────────────────────────┘ ``` ### 6.2 推送通道配置 #### 6.2.1 APNs (iOS) ```go type APNsConfig struct { // 认证方式:P8证书(推荐)或P12证书 AuthKeyPath string `json:"auth_key_path"` // P8证书路径 KeyID string `json:"key_id"` // P8 Key ID TeamID string `json:"team_id"` // Apple Team ID BundleID string `json:"bundle_id"` // App Bundle ID // 环境配置 IsProduction bool `json:"is_production"` // true=生产环境,false=开发环境 // 连接配置 MaxIdleConns int `json:"max_idle_conns"` // 最大空闲连接数 IdleTimeout int `json:"idle_timeout"` // 空闲超时(秒) } // APNs 推送请求 type APNsRequest struct { DeviceToken string `json:"device_token"` Priority string `json:"priority"` // "10"=立即,"5"=省电模式 TTL int `json:"ttl"` // 消息存活时间(秒) Topic string `json:"topic"` // Bundle ID Payload APNsPayload `json:"payload"` } type APNsPayload struct { Aps APSObject `json:"aps"` // 自定义数据 CustomData map[string]interface{} `json:"custom_data,omitempty"` } type APSObject struct { Alert AlertObject `json:"alert"` Badge int `json:"badge,omitempty"` Sound string `json:"sound,omitempty"` ContentAvailable int `json:"content-available,omitempty"` MutableContent int `json:"mutable-content,omitempty"` } type AlertObject struct { Title string `json:"title"` Body string `json:"body"` LocKey string `json:"loc-key,omitempty"` LocArgs []string `json:"loc-args,omitempty"` } ``` #### 6.2.2 FCM (Android) ```go type FCMConfig struct { // 认证方式:服务账号JSON文件 ServiceAccountPath string `json:"service_account_path"` ProjectID string `json:"project_id"` // 连接配置 MaxIdleConns int `json:"max_idle_conns"` IdleTimeout int `json:"idle_timeout"` } // FCM 推送请求 type FCMRequest struct { Message FCMMessage `json:"message"` } type FCMMessage struct { Token string `json:"token"` Notification *FCMNotification `json:"notification,omitempty"` Data map[string]string `json:"data,omitempty"` Android *AndroidConfig `json:"android,omitempty"` APNs *FCMAPNsConfig `json:"apns,omitempty"` } type FCMNotification struct { Title string `json:"title"` Body string `json:"body"` ImageURL string `json:"image_url,omitempty"` } type AndroidConfig struct { Priority string `json:"priority"` // "high" 或 "normal" TTL string `json:"ttl"` // 消息存活时间 Notification *AndroidNotification `json:"notification,omitempty"` } type AndroidNotification struct { ChannelID string `json:"channel_id"` // 通知渠道ID Sound string `json:"sound"` ClickAction string `json:"click_action"` } ``` #### 6.2.3 华为/小米/OPPO/vivo 厂商通道 ```go // 华为 Push Kit type HuaweiPushConfig struct { AppID string `json:"app_id"` AppSecret string `json:"app_secret"` AuthURL string `json:"auth_url"` PushURL string `json:"push_url"` } // 小米推送 type XiaomiPushConfig struct { AppID string `json:"app_id"` AppKey string `json:"app_key"` AppSecret string `json:"app_secret"` PushURL string `json:"push_url"` } // OPPO 推送 type OPPOPushConfig struct { AppID string `json:"app_id"` AppKey string `json:"app_key"` MasterSecret string `json:"master_secret"` PushURL string `json:"push_url"` } // vivo 推送 type VivoPushConfig struct { AppID string `json:"app_id"` AppKey string `json:"app_key"` AppSecret string `json:"app_secret"` PushURL string `json:"push_url"` } // 鸿蒙 Push Kit type HarmonyPushConfig struct { AppID string `json:"app_id"` AppSecret string `json:"app_secret"` PushURL string `json:"push_url"` } ``` ### 6.3 推送Token管理 #### 6.3.1 device_push_tokens 表 ```sql CREATE TABLE device_push_tokens ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), player_id UUID NOT NULL REFERENCES players(id), character_id UUID NOT NULL REFERENCES characters(id), device_id VARCHAR(100) NOT NULL, -- 设备唯一标识 platform VARCHAR(20) NOT NULL, -- ios/android/harmonyos push_provider VARCHAR(20) NOT NULL, -- apns/fcm/huawei/xiaomi/oppo/vivo/harmony push_token TEXT NOT NULL, -- 推送Token device_model VARCHAR(100), -- 设备型号 os_version VARCHAR(20), -- 系统版本 app_version VARCHAR(20), -- 应用版本 is_active BOOLEAN DEFAULT TRUE, -- 是否有效 last_used_at TIMESTAMPTZ, -- 最后使用时间 created_at TIMESTAMPTZ DEFAULT NOW(), updated_at TIMESTAMPTZ DEFAULT NOW(), -- 唯一约束:同一设备同一推送提供者只有一个有效Token UNIQUE(device_id, push_provider, is_active) WHERE is_active = TRUE ); -- 索引 CREATE INDEX idx_device_push_tokens_player_id ON device_push_tokens(player_id); CREATE INDEX idx_device_push_tokens_character_id ON device_push_tokens(character_id); CREATE INDEX idx_device_push_tokens_active ON device_push_tokens(is_active) WHERE is_active = TRUE; ``` #### 6.3.2 Token刷新机制 ```go // Token刷新服务 type TokenRefreshService struct { db *sql.DB cache *valkey.Client } // 客户端上报Token func (s *TokenRefreshService) RegisterToken(ctx context.Context, req *RegisterTokenRequest) error { // 1. 验证Token有效性(调用各平台API验证) if err := s.validateToken(ctx, req.Platform, req.PushProvider, req.PushToken); err != nil { return fmt.Errorf("invalid push token: %w", err) } // 2. 使旧Token失效(同一设备同一提供者) _, err := s.db.ExecContext(ctx, ` UPDATE device_push_tokens SET is_active = FALSE, updated_at = NOW() WHERE device_id = $1 AND push_provider = $2 AND is_active = TRUE `, req.DeviceID, req.PushProvider) if err != nil { return err } // 3. 插入新Token _, err = s.db.ExecContext(ctx, ` INSERT INTO device_push_tokens (player_id, character_id, device_id, platform, push_provider, push_token, device_model, os_version, app_version, is_active, last_used_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, TRUE, NOW()) `, req.PlayerID, req.CharacterID, req.DeviceID, req.Platform, req.PushProvider, req.PushToken, req.DeviceModel, req.OSVersion, req.AppVersion) if err != nil { return err } // 4. 更新缓存 s.cache.Set(ctx, fmt.Sprintf("push_token:%s:%s", req.DeviceID, req.PushProvider), req.PushToken, 24*7*time.Hour) return nil } // 定时清理无效Token func (s *TokenRefreshService) CleanupInvalidTokens(ctx context.Context) error { // 清理30天未使用的Token _, err := s.db.ExecContext(ctx, ` UPDATE device_push_tokens SET is_active = FALSE, updated_at = NOW() WHERE last_used_at < NOW() - INTERVAL '30 days' AND is_active = TRUE `) return err } ``` #### 6.3.3 Token注册API ``` POST /api/v1/push-tokens Authorization: Bearer Request Body: { "device_id": "device-uuid", "platform": "ios", "push_provider": "apns", "push_token": "apns-device-token-here", "device_model": "iPhone 14 Pro", "os_version": "16.5", "app_version": "1.0.0" } Response: { "code": 0, "data": { "token_id": "uuid", "registered_at": "2026-07-02T10:30:00Z" } } ``` ### 6.4 推送路由与降级策略 ```go // 推送路由器 type PushRouter struct { providers map[string]PushProvider db *sql.DB cache *valkey.Client } // 推送提供者接口 type PushProvider interface { Send(ctx context.Context, token string, payload *PushPayload) (*PushResult, error) ValidateToken(ctx context.Context, token string) error } // 推送结果 type PushResult struct { Success bool `json:"success"` MessageID string `json:"message_id"` Error string `json:"error,omitempty"` Retryable bool `json:"retryable"` } // 推送路由逻辑 func (r *PushRouter) SendPush(ctx context.Context, characterID string, notification *Notification) error { // 1. 获取玩家所有有效设备Token tokens, err := r.getActiveTokens(ctx, characterID) if err != nil { return err } if len(tokens) == 0 { return nil // 无有效Token,跳过 } // 2. 构建推送内容(脱敏版) payload := r.buildPushPayload(notification) // 3. 按优先级发送(P0立即,P2延迟5分钟) if notification.Priority <= 1 { return r.sendImmediate(ctx, tokens, payload) } // 4. 延迟推送(放入队列) return r.enqueueDelayed(ctx, tokens, payload, 5*time.Minute) } // 立即发送 func (r *PushRouter) sendImmediate(ctx context.Context, tokens []*DeviceToken, payload *PushPayload) error { for _, token := range tokens { provider, ok := r.providers[token.PushProvider] if !ok { continue } result, err := provider.Send(ctx, token.PushToken, payload) if err != nil { // 记录失败,后续重试 r.recordFailure(ctx, token, err) continue } if !result.Success && result.Retryable { // 可重试的失败,放入重试队列 r.enqueueRetry(ctx, token, payload, 3) } } return nil } ``` --- ## 7. 推送内容安全 ### 7.1 消息加密 #### 7.1.1 传输加密 ```go // 推送内容加密 type PushEncryptor struct { key []byte // AES-256密钥 } // 加密推送内容 func (e *PushEncryptor) Encrypt(payload *PushPayload) (*EncryptedPushPayload, error) { // 1. 序列化为JSON data, err := json.Marshal(payload) if err != nil { return nil, err } // 2. AES-256-GCM加密 block, err := aes.NewCipher(e.key) if err != nil { return nil, err } gcm, err := cipher.NewGCM(block) if err != nil { return nil, err } nonce := make([]byte, gcm.NonceSize()) if _, err := io.ReadFull(rand.Reader, nonce); err != nil { return nil, err } ciphertext := gcm.Seal(nonce, nonce, data, nil) return &EncryptedPushPayload{ Data: base64.StdEncoding.EncodeToString(ciphertext), Timestamp: time.Now().Unix(), }, nil } ``` #### 7.1.2 内容脱敏策略 | 推送类型 | 脱敏规则 | 示例 | |----------|----------|------| | 被通缉 | 不暴露具体赏金数额 | "你已被悬赏追杀" | | 资源满 | 不暴露具体资源数量 | "你的资源已满" | | 拍卖成交 | 不暴露具体花费金额 | "拍卖成功" | | 洞府被入侵 | 不暴露入侵者信息 | "你的洞府正被入侵" | | 世界Boss | 保留Boss名称和位置 | "世界BossXXX已刷新" | ### 7.2 推送去重 #### 7.2.1 去重策略 ```go // 推送去重服务 type PushDeduplicator struct { cache *valkey.Client } // 去重键生成 func (d *PushDeduplicator) generateDedupeKey(characterID string, notificationType string, relatedID string) string { // 格式:push_dedupe:{character_id}:{type}:{related_id} return fmt.Sprintf("push_dedupe:%s:%s:%s", characterID, notificationType, relatedID) } // 检查是否重复 func (d *PushDeduplicator) IsDuplicate(ctx context.Context, characterID string, notification *Notification) bool { key := d.generateDedupeKey(characterID, notification.Type, notification.RelatedID) // 检查是否已发送 exists, err := d.cache.Exists(ctx, key).Result() if err != nil { return false // 缓存错误,允许发送 } return exists > 0 } // 标记已发送 func (d *PushDeduplicator) MarkSent(ctx context.Context, characterID string, notification *Notification, ttl time.Duration) error { key := d.generateDedupeKey(characterID, notification.Type, notification.RelatedID) return d.cache.Set(ctx, key, "1", ttl).Err() } ``` #### 7.2.2 去重规则 | 场景 | 去重键 | TTL | |------|--------|-----| | 同一悬赏重复通知 | `push_dedupe:{char_id}:RISK_WANTED:{bounty_id}` | 24小时 | | 同一弟子归来 | `push_dedupe:{char_id}:IDLE_DISCIPLE_RETURN:{mission_id}` | 1小时 | | 同一拍卖超价 | `push_dedupe:{char_id}:ECONOMY_AUCTION_OUTBID:{auction_id}` | 拍卖结束 | | 同一世界事件 | `push_dedupe:{char_id}:WORLD_BOSS:{boss_id}` | Boss存活期间 | ### 7.3 推送追踪 #### 7.3.1 推送统计表 ```sql CREATE TABLE push_statistics ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), notification_id UUID NOT NULL, character_id UUID NOT NULL, push_provider VARCHAR(20) NOT NULL, device_id VARCHAR(100) NOT NULL, -- 发送状态 sent_at TIMESTAMPTZ, delivered_at TIMESTAMPTZ, -- 到达时间(平台回调) clicked_at TIMESTAMPTZ, -- 点击时间 -- 错误信息 error_code VARCHAR(50), error_message TEXT, -- 追踪ID platform_message_id VARCHAR(100), -- 平台返回的消息ID created_at TIMESTAMPTZ DEFAULT NOW(), -- 索引 INDEX idx_push_statistics_character_id (character_id), INDEX idx_push_statistics_sent_at (sent_at), INDEX idx_push_statistics_platform_message_id (platform_message_id) ); ``` #### 7.3.2 统计指标 ```go // 推送统计服务 type PushStatisticsService struct { db *sql.DB } // 统计指标 type PushMetrics struct { TotalSent int64 `json:"total_sent"` TotalDelivered int64 `json:"total_delivered"` TotalClicked int64 `json:"total_clicked"` DeliveryRate float64 `json:"delivery_rate"` // 到达率 ClickRate float64 `json:"click_rate"` // 点击率 // 按平台统计 ByProvider map[string]*ProviderMetrics `json:"by_provider"` // 按类型统计 ByType map[string]*TypeMetrics `json:"by_type"` } type ProviderMetrics struct { Sent int64 `json:"sent"` Delivered int64 `json:"delivered"` Clicked int64 `json:"clicked"` ErrorRate float64 `json:"error_rate"` } type TypeMetrics struct { Sent int64 `json:"sent"` Delivered int64 `json:"delivered"` Clicked int64 `json:"clicked"` ClickRate float64 `json:"click_rate"` } // 获取统计指标 func (s *PushStatisticsService) GetMetrics(ctx context.Context, startTime, endTime time.Time) (*PushMetrics, error) { // 实现略 return nil, nil } ``` --- ## 8. 通知调度引擎 ### 8.1 Nakama Runtime 集成 ```go // 通知调度引擎(Nakama Go插件) type NotificationEngine struct { db *sql.DB cache *valkey.Client pushRouter *PushRouter deduplicator *PushDeduplicator rateLimiter *RateLimiter statistics *PushStatisticsService } // 初始化通知引擎 func InitNotificationEngine(db *sql.DB, cache *valkey.Client) *NotificationEngine { return &NotificationEngine{ db: db, cache: cache, pushRouter: NewPushRouter(db, cache), deduplicator: NewPushDeduplicator(cache), rateLimiter: NewRateLimiter(cache), statistics: NewPushStatisticsService(db), } } // 发送通知 func (e *NotificationEngine) SendNotification(ctx context.Context, req *SendNotificationRequest) error { // 1. 验证请求 if err := req.Validate(); err != nil { return err } // 2. 检查频率限制 if !e.rateLimiter.Allow(ctx, req.CharacterID, req.Type) { return fmt.Errorf("rate limit exceeded for %s", req.Type) } // 3. 检查去重 if e.deduplicator.IsDuplicate(ctx, req.CharacterID, req.Notification) { return nil // 重复消息,跳过 } // 4. 获取玩家在线状态 isOnline, err := e.isPlayerOnline(ctx, req.CharacterID) if err != nil { return err } // 5. 存储通知到数据库 notificationID, err := e.storeNotification(ctx, req) if err != nil { return err } // 6. 如果在线,通过WebSocket推送 if isOnline { e.sendWebSocket(ctx, req.CharacterID, req.Notification) } // 7. 如果离线,发送离线推送 if !isOnline && req.Priority <= 2 { if err := e.pushRouter.SendPush(ctx, req.CharacterID, req.Notification); err != nil { // 推送失败不阻塞主流程,记录日志 e.recordPushFailure(ctx, notificationID, err) } } // 8. 标记已发送 e.deduplicator.MarkSent(ctx, req.CharacterID, req.Notification, 24*time.Hour) // 9. 记录统计 e.statistics.RecordSent(ctx, notificationID, req.CharacterID) return nil } ``` ### 8.2 事件触发器 ```go // 挂机结算触发通知 func (e *NotificationEngine) OnIdleSettlement(ctx context.Context, characterID string, result *IdleSettlementResult) { // 资源满通知 if result.ResourceFull { e.SendNotification(ctx, &SendNotificationRequest{ CharacterID: characterID, Type: "IDLE_RESOURCE_FULL", Priority: 2, Notification: &Notification{ Title: "资源满", Content: fmt.Sprintf("你的%s资源已满", strings.Join(result.FullResources, "、")), Payload: map[string]interface{}{ "jump_type": "resource_panel", "jump_params": map[string]interface{}{"resource_type": result.FullResources[0]}, }, }, }) } // 弟子归来通知 if len(result.CompletedDisciples) > 0 { e.SendNotification(ctx, &SendNotificationRequest{ CharacterID: characterID, Type: "IDLE_DISCIPLE_RETURN", Priority: 2, Notification: &Notification{ Title: "弟子归来", Content: fmt.Sprintf("弟子%s完成任务归来", strings.Join(result.CompletedDisciples, "、")), Payload: map[string]interface{}{ "jump_type": "disciple_panel", "jump_params": map[string]interface{}{}, }, }, }) } } // 拍卖超价触发通知 func (e *NotificationEngine) OnAuctionOutbid(ctx context.Context, characterID string, auctionID string, currentPrice int64) { e.SendNotification(ctx, &SendNotificationRequest{ CharacterID: characterID, Type: "ECONOMY_AUCTION_OUTBID", Priority: 2, Notification: &Notification{ Title: "被超价", Content: "你在拍卖中已被超越", Payload: map[string]interface{}{ "jump_type": "auction_detail", "jump_params": map[string]interface{}{"auction_id": auctionID}, }, RelatedID: auctionID, }, }) } // 世界Boss刷新触发通知 func (e *NotificationEngine) OnWorldBossSpawn(ctx context.Context, bossID string, bossName string, location string) { // 获取所有在线玩家 onlinePlayers, err := e.getOnlinePlayers(ctx) if err != nil { return } // 批量发送通知 for _, playerID := range onlinePlayers { e.SendNotification(ctx, &SendNotificationRequest{ CharacterID: playerID, Type: "WORLD_BOSS", Priority: 2, Notification: &Notification{ Title: "世界Boss刷新", Content: fmt.Sprintf("世界Boss%s已刷新于%s", bossName, location), Payload: map[string]interface{}{ "jump_type": "world_map", "jump_params": map[string]interface{}{"boss_id": bossID}, }, RelatedID: bossID, }, }) } } ``` --- ## 9. Nacos 动态配置 ### 9.1 推送配置 ```yaml # Nacos 配置:push_config.yaml push: # 全局开关 enabled: true # 优先级配置 priority: p0_delay_seconds: 0 p1_delay_seconds: 0 p2_delay_seconds: 300 p3_delay_seconds: 1800 # 频率限制 rate_limit: risk: max_per_minute: 10 max_per_hour: 60 cooldown_seconds: 0 social: max_per_minute: 5 max_per_hour: 30 cooldown_seconds: 30 idle: max_per_minute: 2 max_per_hour: 10 cooldown_seconds: 300 economy: max_per_minute: 3 max_per_hour: 20 cooldown_seconds: 120 world: max_per_minute: 5 max_per_hour: 30 cooldown_seconds: 60 system: max_per_minute: 1 max_per_hour: 5 cooldown_seconds: 600 # 去重配置 deduplication: enabled: true default_ttl_hours: 24 # 推送通道配置 providers: apns: enabled: true is_production: false max_retries: 3 retry_delay_seconds: 5 fcm: enabled: true max_retries: 3 retry_delay_seconds: 5 huawei: enabled: true max_retries: 3 retry_delay_seconds: 5 xiaomi: enabled: true max_retries: 3 retry_delay_seconds: 5 oppo: enabled: true max_retries: 3 retry_delay_seconds: 5 vivo: enabled: true max_retries: 3 retry_delay_seconds: 5 harmony: enabled: true max_retries: 3 retry_delay_seconds: 5 # 免打扰配置 dnd: override_p0: true min_offline_minutes: 5 # 通知保留配置 retention: max_days: 30 cleanup_interval_hours: 24 ``` --- ## 10. 已确认决策记录表 | 决策编号 | 决策内容 | 状态 | 关联文档 | 备注 | |----------|----------|------|----------|------| | ✅P-01 | 推送类型分为6大类:RISK/SOCIAL/IDLE/ECONOMY/WORLD/SYSTEM | 已确认 | - | 基于游戏核心玩法 | | ✅P-02 | 优先级分为4级:P0紧急/P1高/P2中/P3低 | 已确认 | - | 风险警告最高优先 | | ✅P-03 | 在线玩家通过WebSocket实时推送,不发送离线推送 | 已确认 | TDD-05 | 避免重复推送 | | ✅P-04 | 离线推送延迟5分钟合并同类消息 | 已确认 | - | 减少打扰 | | ✅P-05 | 免打扰时段支持P0消息突破 | 已确认 | - | 紧急消息必须送达 | | ✅P-06 | 推送内容脱敏,不暴露敏感信息 | 已确认 | TDD-07 | 安全要求 | | ✅P-07 | 支持APNs/FCM/华为/小米/OPPO/vivo/鸿蒙7个推送通道 | 已确认 | - | 覆盖主流平台 | | ✅P-08 | 推送Token有效期30天,过期自动清理 | 已确认 | - | Token生命周期管理 | | ✅P-09 | 推送去重基于角色ID+类型+关联ID | 已确认 | - | 防止重复推送 | | ✅P-10 | 通知中心数据保留30天 | 已确认 | - | 存储成本考虑 | | ✅P-11 | 通知跳转支持8种跳转类型 | 已确认 | - | 覆盖主要场景 | | ✅P-12 | 推送统计记录到达率和点击率 | 已确认 | - | 运营分析需求 | | ✅P-13 | Nakama Runtime作为通知调度引擎 | 已确认 | TDD-05 | 与现有架构一致 | | ✅P-14 | Nacos动态配置推送参数 | 已确认 | - | 支持热更新 | | ✅P-15 | 推送频率限制:RISK 10条/分钟,SOCIAL 5条/分钟 | 已确认 | - | 防刷屏 | --- ## 11. 验收标准 ### 11.1 功能验收 | 验收项 | 验收标准 | 验证方法 | |--------|----------|----------| | 推送类型覆盖 | 6大类20种推送类型全部实现 | 单元测试 + 集成测试 | | 优先级分级 | P0/P1立即推送,P2延迟5分钟,P3不推送离线 | 测试不同优先级推送 | | 免打扰时段 | 免打扰时段内不推送离线通知(P0可突破) | 设置免打扰测试 | | 频率控制 | 同类型推送不超过频率限制 | 压力测试 | | 消息合并 | 同类消息在窗口期内合并 | 测试合并逻辑 | | 在线状态判断 | 在线玩家不发送离线推送 | 模拟在线/离线 | | 通知中心 | 通知存储、查询、已读、跳转功能正常 | 功能测试 | | Token管理 | Token注册、刷新、过期清理正常 | 功能测试 | | 多平台推送 | 7个推送通道全部可用 | 各平台测试 | | 内容脱敏 | 离线推送不包含敏感信息 | 代码审查 + 测试 | | 推送去重 | 同一通知不重复推送 | 测试去重逻辑 | | 推送统计 | 到达率、点击率统计正确 | 数据验证 | ### 11.2 性能验收 | 验收项 | 验收标准 | 验证方法 | |--------|----------|----------| | 通知写入延迟 | P99 < 50ms | 压力测试 | | 通知查询延迟 | P99 < 100ms | 压力测试 | | 推送发送延迟 | P99 < 200ms | 压力测试 | | 并发推送能力 | 支持1000条/秒 | 压力测试 | | 数据库查询 | 通知列表查询 < 50ms | 慢查询监控 | | 缓存命中率 | Token缓存命中率 > 95% | 监控统计 | ### 11.3 安全验收 | 验收项 | 验收标准 | 验证方法 | |--------|----------|----------| | Token安全 | Token传输加密,存储加密 | 安全审计 | | 内容加密 | 敏感推送内容加密传输 | 代码审查 | | 权限控制 | 玩家只能查看自己的通知 | 权限测试 | | 防刷屏 | 频率限制有效 | 压力测试 | | 数据保留 | 过期通知自动清理 | 定时任务验证 | ### 11.4 可靠性验收 | 验收项 | 验收标准 | 验证方法 | |--------|----------|----------| | 推送送达率 | > 99% | 统计监控 | | 通知不丢失 | 所有通知持久化到数据库 | 数据一致性检查 | | 故障恢复 | 推送服务故障不影响游戏主流程 | 故障注入测试 | | 重试机制 | 推送失败自动重试3次 | 测试重试逻辑 | | 降级策略 | 推送服务不可用时通知正常存储 | 故障测试 | --- ## 12. 附录 ### 12.1 推送类型完整列表 | 编码 | 名称 | 分类 | 优先级 | 在线推送 | 离线推送 | |------|------|------|--------|----------|----------| | RISK_WANTED | 被通缉 | RISK | P0 | 是 | 是 | | RISK_TRIBULATION | 天罚预警 | RISK | P0 | 是 | 是 | | RISK_INVASION | 洞府被入侵 | RISK | P0 | 是 | 是 | | RISK_SAN_LOW | SAN值过低 | RISK | P0 | 是 | 是 | | SOCIAL_DAO_COMPANION | 道侣护法请求 | SOCIAL | P1 | 是 | 是 | | SOCIAL_MASTER | 师徒传功 | SOCIAL | P1 | 是 | 是 | | SOCIAL_GUILD_SUMMON | 帮派召集 | SOCIAL | P1 | 是 | 是 | | SOCIAL_BOUNTY | 追杀令 | SOCIAL | P1 | 是 | 是 | | IDLE_RESOURCE_FULL | 资源满 | IDLE | P2 | 是 | 是 | | IDLE_DISCIPLE_RETURN | 弟子归来 | IDLE | P2 | 是 | 是 | | IDLE_REALM_BREAK | 修炼突破条件达成 | IDLE | P2 | 是 | 是 | | ECONOMY_AUCTION_WIN | 拍卖成交 | ECONOMY | P2 | 是 | 是 | | ECONOMY_AUCTION_OUTBID | 被超价 | ECONOMY | P2 | 是 | 是 | | ECONOMY_BOUNTY_SETTLE | 悬赏结算 | ECONOMY | P2 | 是 | 是 | | ECONOMY_MARKET_SOLD | 交易行售出 | ECONOMY | P2 | 是 | 是 | | WORLD_APOCALYPSE | 天启广播 | WORLD | P2 | 是 | 是 | | WORLD_DIVINE_BEAST | 神兽现世 | WORLD | P2 | 是 | 是 | | WORLD_BOSS | 世界Boss刷新 | WORLD | P2 | 是 | 是 | | SYSTEM_MAINTENANCE | 维护公告 | SYSTEM | P3 | 是 | 否 | | SYSTEM_UPDATE | 版本更新 | SYSTEM | P3 | 是 | 否 | | SYSTEM_ACTIVITY | 活动开始 | SYSTEM | P3 | 是 | 否 | ### 12.2 数据库表清单 | 表名 | 说明 | 分区 | 保留策略 | |------|------|------|----------| | notifications | 通知表 | 按月分区 | 30天 | | notification_templates | 通知模板表 | - | 永久 | | device_push_tokens | 设备推送Token表 | - | 30天未使用清理 | | push_statistics | 推送统计表 | 按天分区 | 90天 | ### 12.3 API接口清单 | 接口 | 方法 | 说明 | |------|------|------| | `/api/v1/notifications` | GET | 获取通知列表 | | `/api/v1/notifications/{id}/read` | POST | 标记通知已读 | | `/api/v1/notifications/batch-read` | POST | 批量标记已读 | | `/api/v1/notifications/unread-count` | GET | 获取未读数量 | | `/api/v1/push-tokens` | POST | 注册推送Token | | `/api/v1/push-tokens/{id}` | DELETE | 删除推送Token | | `/api/v1/push-tokens/refresh` | POST | 刷新推送Token | --- **文档维护人**:移动端开发 **最后更新**:2026-07-02 **版本**:1.0