1370 行
49 KiB
Markdown
1370 行
49 KiB
Markdown
# 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<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)
|
||
|
||
```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 <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 推送路由与降级策略
|
||
|
||
```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 |