跳转至

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