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 玩家偏好配置
{
"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 防刷屏机制
// 推送频率控制配置
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 在线状态判断
// 玩家在线状态检查
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 表
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 表
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 通知模板示例
-- 插入通知模板
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 客户端跳转处理
// 通知跳转处理
interface NotificationPayload {
jump_type: string;
jump_params: Record<string, any>;
}
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 <token>
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 <token>
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 <token>
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 <token>
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)
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)
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 厂商通道
// 华为 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 表
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刷新机制
// 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 <token>
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 推送路由与降级策略
// 推送路由器
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 传输加密
// 推送内容加密
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 去重策略
// 推送去重服务
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 推送统计表
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 统计指标
// 推送统计服务
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 集成
// 通知调度引擎(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 事件触发器
// 挂机结算触发通知
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 推送配置
# 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