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
|