游戏运营
1. 游戏活动系统
游戏活动系统是游戏运营的核心组件,负责各类活动配置、触发、执行和结算的全生命周期管理。
1.1 活动配置体系
1.1.1 活动类型
| 活动类型 | 说明 | 典型场景 |
|---|---|---|
| 签到活动 | 每日签到领取奖励,支持连续签到加成 | 月签到、七日签到 |
| 充值活动 | 累计充值达到指定档位领取奖励 | 首充双倍、累计充值返利 |
| 限时活动 | 在指定时间段内开放的特殊玩法 | 限时折扣、限时副本 |
| 抽卡活动 | 消耗道具进行随机抽取 | 卡池UP、限定招募 |
| BOSS活动 | 全服或公会挑战世界BOSS | 世界BOSS、个人BOSS |
| 公会战 | 公会之间进行周期性的对抗 | 公会联赛、公会攻城 |
| 跨服战 | 跨服务器玩家之间的竞技活动 | 跨服竞技场、跨服战场 |
| PVP赛季 | 周期性PVP竞技,按赛季结算奖励 | 天梯赛季、竞技场赛季 |
| 新手活动 | 针对新用户引导和成长的系列活动 | 新手任务、开服冲刺 |
| 返利活动 | 消费后按比例返还资源 | 充值返利、消费返利 |
| 节日活动 | 配合节日上线的主题活动 | 春节活动、周年庆 |
| 兑换码活动 | 通过输入兑换码领取奖励 | 礼包码、渠道福利码 |
| 转盘活动 | 消耗抽奖次数转动转盘随机获得奖励 | 幸运转盘、大转盘抽奖 |
| 任务活动 | 完成指定任务链领取阶段性奖励 | 每日任务、成就任务、限时任务 |
| 拼团活动 | 多名玩家组队达到条件后共同领取奖励 | 团购礼包、组队任务 |
1.1.2 活动触发条件
时间触发
yaml
# 活动时间配置示例
ActivityTimeTrigger:
start_time: "2026-07-15 00:00:00" # 活动开始时间
end_time: "2026-07-22 23:59:59" # 活动结束时间
server_timezone: "Asia/Shanghai" # 服务器时区
use_local_time: false # 是否使用玩家本地时间
time_segments: # 每日时间段配置(可选)
- begin: "10:00:00"
end: "12:00:00"
- begin: "19:00:00"
end: "22:00:00"
weekly_schedule: [1, 3, 5, 7] # 每周几开启(1=周一, 7=周日)条件触发
yaml
# 条件触发配置
ConditionTrigger:
type: "player_level" # 触发条件类型
operator: "GTE" # GTE/LTE/EQ/IN/RANGE
value: 30 # 目标值
# 其他条件类型示例
# type: "vip_level" value: 5
# type: "total_recharge" value: 1000
# type: "login_days" value: 7
# type: "signin_days" value: 3
# type: "completed_tasks" value: 50手动触发:运营人员通过GM后台手动开启或关闭活动,适用于紧急活动或灰度测试。
服务器时间 vs 玩家本地时间
- 服务器时间:以游戏服务器时间为准,所有活动统一时间轴,避免跨时区时间不一致问题。适用于全服性活动(BOSS战、跨服战、限时折扣)。
- 玩家本地时间:以玩家设备本地时间为准,使用玩家所在时区进行结算。适用于个人活动(签到、每日任务),提升用户体验。
- 混合策略:活动开始和结束以服务器时间为准,活动内日常刷新使用玩家本地时间。
java
// 时间计算示例
public class ActivityTimeUtil {
/**
* 计算活动当前状态
*/
public static ActivityStatus calculateStatus(ActivityConfig config, long serverTime, long playerLocalTime) {
long startTime = config.getStartTime();
long endTime = config.getEndTime();
long preheatTime = config.getPreheatTime(); // 预热时间
if (serverTime < startTime) {
if (preheatTime > 0 && serverTime >= preheatTime) {
return ActivityStatus.PREHEAT;
}
return ActivityStatus.NOT_STARTED;
}
if (serverTime >= startTime && serverTime < endTime) {
return ActivityStatus.IN_PROGRESS;
}
return ActivityStatus.ENDED;
}
/**
* 检查每日刷新(使用玩家本地时间)
*/
public static boolean isDailyRefresh(long lastRefreshTime, long playerLocalTime) {
LocalDate lastDate = Instant.ofEpochMilli(lastRefreshTime)
.atZone(ZoneId.systemDefault()).toLocalDate();
LocalDate currentDate = Instant.ofEpochMilli(playerLocalTime)
.atZone(ZoneId.systemDefault()).toLocalDate();
return !lastDate.equals(currentDate);
}
}1.1.3 活动模板
活动模板系统支持通过配置化方式快速创建活动,无需修改服务器代码。
JSON 模板示例
json
{
"activity_id": 1001,
"template_type": "signin",
"template_version": "1.0",
"rules": {
"cycle_type": "monthly",
"max_days": 30,
"miss_allow": 3,
"bonus_multiplier": {
"consecutive_3": 1.5,
"consecutive_7": 2.0,
"consecutive_15": 3.0,
"consecutive_30": 5.0
},
"rewards": [
{"day": 1, "items": [{"id": "gold", "count": 100}]},
{"day": 7, "items": [{"id": "item_1001", "count": 1}]},
{"day": 30, "items": [{"id": "skin_001", "count": 1}]}
]
}
}YAML 模板示例
yaml
template_type: recharge
cycle_type: single
rules:
tiers:
- amount: 6
rewards:
- { id: gold, count: 60 }
- { id: item_2001, count: 1 }
- amount: 30
rewards:
- { id: gold, count: 330 }
- { id: item_2002, count: 3 }
- amount: 98
rewards:
- { id: gold, count: 1100 }
- { id: item_2003, count: 5 }
- amount: 328
rewards:
- { id: gold, count: 3600 }
- { id: item_2004, count: 10 }
first_double: true
repeatable: false1.1.4 活动状态机
活动生命周期通过状态机进行管理,确保状态流转清晰可控。
┌──────────────────────────────────────┐
| 活动状态机 |
└──────────────────────────────────────┘
未开始 ──→ 预热 ──→ 进行中 ──→ 结束 ──→ 发奖 ──→ 关闭
↑ | | | | |
| | | | | |
└─────────┴─────────┴─────────┴─────────┴─────────┘
(状态不可逆)
状态说明:
- 未开始(NOT_STARTED): 活动已创建,但未到开始时间
- 预热(PREHEAT): 活动即将开始,可展示活动预告,不可参与
- 进行中(IN_PROGRESS): 活动开放,玩家可正常参与
- 结束(ENDED): 活动参与截止,未领取奖励的玩家仍可领取
- 发奖(AWARDING): 系统自动结算并发放排行类奖励
- 关闭(CLOSED): 活动完全关闭,所有数据归档java
public enum ActivityStatus {
NOT_STARTED(0, "未开始"),
PREHEAT(1, "预热"),
IN_PROGRESS(2, "进行中"),
ENDED(3, "结束"),
AWARDING(4, "发奖中"),
CLOSED(5, "关闭");
private final int code;
private final String desc;
private static final Map<ActivityStatus, Set<ActivityStatus>> TRANSITIONS = Map.of(
NOT_STARTED, Set.of(PREHEAT, IN_PROGRESS),
PREHEAT, Set.of(IN_PROGRESS),
IN_PROGRESS, Set.of(ENDED),
ENDED, Set.of(AWARDING, CLOSED),
AWARDING, Set.of(CLOSED),
CLOSED, Set.of()
);
public boolean canTransitionTo(ActivityStatus target) {
return TRANSITIONS.getOrDefault(this, Collections.emptySet()).contains(target);
}
}1.2 活动数据模型
1.2.1 核心数据库表设计
活动主表 (activity)
sql
CREATE TABLE `activity` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '活动ID',
`name` VARCHAR(128) NOT NULL COMMENT '活动名称',
`type` TINYINT NOT NULL COMMENT '活动类型 1-签到 2-充值 3-限时 4-抽卡 5-BOSS 6-公会战 7-跨服战 8-PVP赛季 9-新手 10-返利 11-节日 12-兑换码 13-转盘 14-任务 15-拼团',
`template_type` VARCHAR(32) NOT NULL COMMENT '模板类型 signin/recharge/gacha/boss/...',
`config_json` JSON NOT NULL COMMENT '活动配置JSON',
`start_time` DATETIME NOT NULL COMMENT '活动开始时间',
`end_time` DATETIME NOT NULL COMMENT '活动结束时间',
`preheat_time` DATETIME DEFAULT NULL COMMENT '预热开始时间',
`server_timezone` VARCHAR(32) DEFAULT 'Asia/Shanghai' COMMENT '服务器时区',
`priority` INT DEFAULT 0 COMMENT '活动优先级,数值越大优先级越高',
`status` TINYINT DEFAULT 0 COMMENT '活动状态 0-未开始 1-预热 2-进行中 3-结束 4-发奖中 5-关闭',
`version` INT DEFAULT 0 COMMENT '乐观锁版本号',
`created_by` VARCHAR(64) DEFAULT NULL COMMENT '创建人',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `idx_type_status` (`type`, `status`),
KEY `idx_time_range` (`start_time`, `end_time`),
KEY `idx_status_priority` (`status`, `priority`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='活动配置表';活动规则表 (activity_rule)
sql
CREATE TABLE `activity_rule` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '规则ID',
`activity_id` BIGINT NOT NULL COMMENT '活动ID',
`rule_type` VARCHAR(32) NOT NULL COMMENT '规则类型 threshold/times/condition/group',
`rule_key` VARCHAR(64) NOT NULL COMMENT '规则键名',
`rule_value` TEXT NOT NULL COMMENT '规则值JSON',
`group_id` INT DEFAULT 0 COMMENT '规则分组,同一组内满足任一规则即可',
`priority` INT DEFAULT 0 COMMENT '规则优先级',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`),
KEY `idx_activity_id` (`activity_id`),
KEY `idx_rule_type` (`rule_type`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='活动规则表';玩家参与表 (activity_player)
sql
CREATE TABLE `activity_player` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`activity_id` BIGINT NOT NULL COMMENT '活动ID',
`player_id` BIGINT NOT NULL COMMENT '玩家ID',
`server_id` INT NOT NULL COMMENT '服务器ID',
`progress` JSON DEFAULT NULL COMMENT '活动进度JSON,如签到天数、充值金额等',
`status` TINYINT DEFAULT 0 COMMENT '参与状态 0-参与中 1-已完成 2-已领奖',
`ext_data` JSON DEFAULT NULL COMMENT '扩展数据',
`first_join_at` DATETIME DEFAULT NULL COMMENT '首次参与时间',
`last_join_at` DATETIME DEFAULT NULL COMMENT '最后参与时间',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_activity_player` (`activity_id`, `player_id`),
KEY `idx_player_id` (`player_id`),
KEY `idx_server_id` (`server_id`),
KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='玩家活动参与表';奖励领取记录表 (activity_reward_log)
sql
CREATE TABLE `activity_reward_log` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`activity_id` BIGINT NOT NULL COMMENT '活动ID',
`player_id` BIGINT NOT NULL COMMENT '玩家ID',
`reward_id` VARCHAR(64) NOT NULL COMMENT '奖励ID,对应规则中的奖励标识',
`reward_type` TINYINT NOT NULL COMMENT '奖励类型 1-单次 2-每日 3-每周 4-累计',
`reward_json` JSON NOT NULL COMMENT '奖励内容JSON',
`status` TINYINT DEFAULT 1 COMMENT '发放状态 1-已发放 2-已领取 3-发放失败',
`source` VARCHAR(32) DEFAULT NULL COMMENT '来源,auto/manual',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`),
KEY `idx_activity_player` (`activity_id`, `player_id`),
UNIQUE KEY `uk_reward_id` (`activity_id`, `player_id`, `reward_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='活动奖励领取记录表';活动日志表 (activity_log)
sql
CREATE TABLE `activity_log` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`activity_id` BIGINT NOT NULL COMMENT '活动ID',
`player_id` BIGINT DEFAULT NULL COMMENT '玩家ID(非必须)',
`action` VARCHAR(64) NOT NULL COMMENT '操作类型 join/leave/claim/refresh/auto_award',
`detail` JSON DEFAULT NULL COMMENT '操作详情',
`result` TINYINT DEFAULT 1 COMMENT '结果 1-成功 0-失败',
`ip` VARCHAR(64) DEFAULT NULL COMMENT '操作IP',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`),
KEY `idx_activity_id` (`activity_id`),
KEY `idx_player_id` (`player_id`),
KEY `idx_action_time` (`action`, `created_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='活动日志表';1.2.2 字段设计说明
| 字段 | 说明 |
|---|---|
| activity_id | 活动全局唯一ID,建议使用分布式ID生成器,避免冲突 |
| name | 活动名称,支持多语言时使用key引用i18n配置 |
| type | 活动大类,用于分类查询和运营统计 |
| template_type | 活动模板标识,用于匹配对应的活动处理器 |
| config_json | 完整的活动配置,包括规则、奖励、UI配置等 |
| status | 活动状态,结合状态机管理活动生命周期 |
| priority | 多个活动同时生效时的优先级,优先级高的活动奖励优先计算 |
1.3 活动引擎
活动引擎负责活动条件判断、奖励计算和发放的核心逻辑。
1.3.1 条件判断
java
// 条件判断接口
public interface ConditionEvaluator {
boolean evaluate(PlayerContext context, ConditionConfig config);
}
// 等级判断实现
public class LevelConditionEvaluator implements ConditionEvaluator {
@Override
public boolean evaluate(PlayerContext context, ConditionConfig config) {
int playerLevel = context.getPlayerLevel();
int targetValue = Integer.parseInt(config.getValue());
return switch (config.getOperator()) {
case "GTE" -> playerLevel >= targetValue;
case "LTE" -> playerLevel <= targetValue;
case "EQ" -> playerLevel == targetValue;
case "RANGE" -> {
String[] parts = config.getValue().split("-");
int min = Integer.parseInt(parts[0]);
int max = Integer.parseInt(parts[1]);
yield playerLevel >= min && playerLevel <= max;
}
default -> false;
};
}
}
// VIP等级判断实现
public class VipConditionEvaluator implements ConditionEvaluator {
@Override
public boolean evaluate(PlayerContext context, ConditionConfig config) {
return context.getVipLevel() >= Integer.parseInt(config.getValue());
}
}
// 充值金额判断
public class RechargeConditionEvaluator implements ConditionEvaluator {
@Override
public boolean evaluate(PlayerContext context, ConditionConfig config) {
long totalRecharge = context.getTotalRecharge();
return totalRecharge >= Long.parseLong(config.getValue());
}
}支持的判断条件类型:
| 条件类型 | 说明 | 配置示例 |
|---|---|---|
| 时间范围 | 判断是否在活动时间段内 | {type:"time_range",value:"2026-07-15/2026-07-22"} |
| 玩家等级 | 判断玩家等级是否达到要求 | {type:"player_level",operator:"GTE",value:"30"} |
| VIP等级 | 判断VIP等级是否达标 | {type:"vip_level",operator:"GTE",value:"5"} |
| 充值金额 | 累计充值金额判断 | {type:"total_recharge",operator:"GTE",value:"1000"} |
| 登录天数 | 累计登录天数判断 | {type:"login_days",operator:"GTE",value:"7"} |
| 签到天数 | 累计签到天数判断 | {type:"signin_days",operator:"GTE",value:"3"} |
| 完成任务数 | 完成特定任务数量判断 | {type:"completed_tasks",operator:"GTE",value:"20"} |
1.3.2 奖励发放
奖励配置化
yaml
# 奖励规则配置
rewards:
- reward_id: "daily_signin_1"
type: "daily" # 发放周期: once/daily/weekly/accumulate
condition:
type: "signin_day"
value: 1
items:
- { id: "gold", amount: 1000, bind: true }
- { id: "item_3001", amount: 1, bind: true }
- reward_id: "accumulate_7"
type: "accumulate" # 累计奖励(只能领一次)
condition:
type: "signin_days"
operator: "GTE"
value: 7
items:
- { id: "diamond", amount: 50, bind: true }
- { id: "card_001", amount: 1, bind: false } # 非绑定
- reward_id: "topup_100"
type: "single"
condition:
type: "recharge_amount"
operator: "GTE"
value: 100
items:
- { id: "gold", amount: 1100, bind: true }
- { id: "crystal", amount: 10, bind: true }奖励发放实现
java
// 奖励发放服务
@Service
public class RewardService {
@Autowired
private RewardTemplateLoader templateLoader;
@Autowired
private RewardDedupService dedupService;
@Autowired
private MailService mailService;
@Autowired
private BackpackService backpackService;
/**
* 发放奖励
*/
public RewardResult deliverReward(PlayerContext context, RewardConfig rewardConfig) {
// 1. 奖励去重检查
if (!dedupService.checkDedup(context, rewardConfig)) {
return RewardResult.duplicate("奖励已领取");
}
// 2. 加载奖励模板
RewardTemplate template = templateLoader.load(rewardConfig.getTemplateId());
// 3. 概率计算(如果是概率奖励)
RewardItem finalItem = applyProbability(template.getItems());
// 4. 根据发放渠道发放
switch (template.getDeliveryChannel()) {
case "mail":
mailService.sendMail(context.getPlayerId(), template.getMailTitle(),
template.getMailContent(), finalItem);
break;
case "backpack":
backpackService.addItems(context.getPlayerId(), finalItem);
break;
case "direct":
// 直接修改玩家属性(金币、钻石等)
context.addResource(finalItem.getId(), finalItem.getAmount());
break;
}
// 5. 记录发放日志
saveRewardLog(context, rewardConfig, finalItem);
return RewardResult.success(finalItem);
}
/**
* 概率奖励计算
*/
private RewardItem applyProbability(List<RewardItem> items) {
double roll = ThreadLocalRandom.current().nextDouble(0, 100);
double cumulative = 0;
for (RewardItem item : items) {
cumulative += item.getProbability();
if (roll < cumulative) {
return item;
}
}
// 保底:返回第一个
return items.get(0);
}
/**
* 发放周期检查
*/
public boolean checkCycleLimit(PlayerContext context, RewardConfig config) {
return switch (config.getCycle()) {
case "once" -> !dedupService.hasClaimed(context, config); // 单次
case "daily" -> !dedupService.hasClaimedToday(context, config); // 每日
case "weekly" -> !dedupService.hasClaimedThisWeek(context, config); // 每周
case "accumulate" -> !dedupService.hasClaimed(context, config); // 累计(一次)
default -> true;
};
}
}奖励模板结构
java
@Data
public class RewardTemplate {
private String templateId; // 奖励模板ID
private List<RewardItem> items; // 奖励物品列表
private String deliveryChannel; // 发放渠道 mail/backpack/direct
private String mailTitle; // 邮件标题(邮件渠道时使用)
private String mailContent; // 邮件内容(邮件渠道时使用)
}
@Data
public class RewardItem {
private String id; // 物品ID
private int amount; // 数量
private boolean bind; // 是否绑定
private double probability; // 概率(0-100),总和为100则为确定奖励
private boolean isCustom; // 是否为自定义奖励(由运营配置具体内容)
}1.3.3 奖励去重
java
// 奖励去重服务
@Service
public class RewardDedupService {
@Autowired
private StringRedisTemplate redisTemplate;
private static final String DEDUP_KEY_PREFIX = "activity:dedup:";
/**
* 检查是否已领取(幂等性检查)
*/
public boolean checkDedup(PlayerContext context, RewardConfig config) {
String key = buildDedupKey(context, config);
Boolean existed = redisTemplate.opsForValue().setIfAbsent(key, "1",
Duration.ofDays(config.getCycleDays()));
return Boolean.TRUE.equals(existed);
}
/**
* 每日领取检查
*/
public boolean hasClaimedToday(PlayerContext context, RewardConfig config) {
String key = buildDedupKey(context, config) + ":" + LocalDate.now();
return Boolean.TRUE.equals(redisTemplate.hasKey(key));
}
private String buildDedupKey(PlayerContext context, RewardConfig config) {
return DEDUP_KEY_PREFIX + config.getRewardId() + ":" + context.getPlayerId();
}
}2. 排行榜系统
排行榜是游戏运营中用于激励玩家竞争、提升留存的核心模块。
2.1 排行榜类型
| 排行榜类型 | 实现方式 | 更新频率 | 典型场景 |
|---|---|---|---|
| 实时排行 | Redis Zset | 实时更新 | 战力排行、等级排行 |
| 定时排行 | 离线结算 T+1 | 每日/每周结算 | 充值排行、消费排行 |
| 分桶排行 | Legacy 分桶统计 | 定时更新 | 历史排行、全服累计排行 |
| ELO 天梯 | 分段排行算法 | 实时更新 | 竞技场、天梯赛 |
| 分类排行 | 按范围筛选 | 混合更新 | 好友排行、公会排行、全服排行、跨服排行 |
常见的排行榜维度:
- 战力排行:按玩家总战力排序
- 等级排行:按玩家等级+经验排序
- 充值排行:按累计充值金额排序
- 竞技场排行:按竞技场积分排序
- 关卡排行:按通关关卡进度排序
- 公会排行:按公会总战力或活跃度排序
- 跨服排行:跨服范围内的综合排名
2.2 Redis Zset 实现
Redis 的有序集合(Zset)是实现实时排行榜的核心数据结构,提供 O(log N) 的插入和查询性能。
2.2.1 核心命令
java
// Redis Zset 排行榜操作
@Service
public class LeaderboardService {
@Autowired
private StringRedisTemplate redisTemplate;
private static final String LB_KEY_PREFIX = "lb:";
/**
* 更新玩家分数
* ZADD: 添加或更新成员分数
*/
public void updateScore(String leaderboardId, long playerId, double score) {
String key = buildKey(leaderboardId);
redisTemplate.opsForZSet().add(key, String.valueOf(playerId), score);
}
/**
* 增加玩家分数(增量更新)
* ZINCRBY: 原子增加分数
*/
public void incrementScore(String leaderboardId, long playerId, double increment) {
String key = buildKey(leaderboardId);
redisTemplate.opsForZSet().incrementScore(key, String.valueOf(playerId), increment);
}
/**
* 获取排行榜(带分页)
* ZREVRANGE: 按分数从高到低获取排名
*/
public List<LeaderboardEntry> getTopN(String leaderboardId, int page, int pageSize) {
String key = buildKey(leaderboardId);
int start = (page - 1) * pageSize;
int end = start + pageSize - 1;
Set<ZSetOperations.TypedTuple<String>> tuples =
redisTemplate.opsForZSet().reverseRangeWithScores(key, start, end);
List<LeaderboardEntry> entries = new ArrayList<>();
if (tuples == null) return entries;
long rank = (long) start + 1;
for (ZSetOperations.TypedTuple<String> tuple : tuples) {
if (tuple.getValue() == null) continue;
entries.add(LeaderboardEntry.builder()
.rank(rank++)
.playerId(Long.parseLong(tuple.getValue()))
.score(tuple.getScore().longValue())
.build());
}
return entries;
}
/**
* 获取玩家排名
* ZREVRANK: 获取指定成员排名(从0开始,需要+1)
*/
public long getPlayerRank(String leaderboardId, long playerId) {
String key = buildKey(leaderboardId);
Long rank = redisTemplate.opsForZSet().reverseRank(key, String.valueOf(playerId));
return rank != null ? rank + 1 : -1; // -1表示未上榜
}
/**
* 获取玩家分数
* ZSCORE: 获取指定成员分数
*/
public double getPlayerScore(String leaderboardId, long playerId) {
String key = buildKey(leaderboardId);
Double score = redisTemplate.opsForZSet().score(key, String.valueOf(playerId));
return score != null ? score : 0.0;
}
/**
* 获取排行榜总人数
* ZCARD: 获取有序集合基数
*/
public long getTotalCount(String leaderboardId) {
String key = buildKey(leaderboardId);
Long count = redisTemplate.opsForZSet().zCard(key);
return count != null ? count : 0;
}
}2.2.2 玩家信息 Hash 存储
为减少查询排行榜时的多次缓存命中,将玩家基本信息存储在 Hash 结构中:
java
/**
* 玩家信息缓存存储
* 使用 Hash 结构存储,方便批量获取
*/
@Service
public class PlayerInfoCacheService {
@Autowired
private StringRedisTemplate redisTemplate;
private static final String PLAYER_INFO_KEY_PREFIX = "player:info:";
/**
* 批量获取玩家信息
*/
public Map<Long, PlayerInfo> batchGetPlayerInfo(List<Long> playerIds) {
List<String> keys = playerIds.stream()
.map(id -> PLAYER_INFO_KEY_PREFIX + id)
.collect(Collectors.toList());
List<Object> rawData = redisTemplate.opsForValue().multiGet(keys);
Map<Long, PlayerInfo> result = new LinkedHashMap<>();
for (int i = 0; i < playerIds.size(); i++) {
Object data = rawData.get(i);
if (data != null) {
result.put(playerIds.get(i), JSON.parseObject((String) data, PlayerInfo.class));
}
}
return result;
}
/**
* 获取排行榜条目(含玩家信息)
*/
public List<LeaderboardEntry> getLeaderboardWithPlayerInfo(
String leaderboardId, int page, int pageSize) {
List<LeaderboardEntry> entries = leaderboardService.getTopN(leaderboardId, page, pageSize);
// 批量查询玩家信息
List<Long> playerIds = entries.stream()
.map(LeaderboardEntry::getPlayerId)
.collect(Collectors.toList());
Map<Long, PlayerInfo> playerInfoMap = batchGetPlayerInfo(playerIds);
// 填充玩家信息
entries.forEach(entry -> {
PlayerInfo info = playerInfoMap.get(entry.getPlayerId());
if (info != null) {
entry.setPlayerName(info.getPlayerName());
entry.setAvatar(info.getAvatar());
entry.setLevel(info.getLevel());
entry.setVipLevel(info.getVipLevel());
entry.setGuildName(info.getGuildName());
}
});
return entries;
}
}2.2.3 定时更新
java
/**
* 排行榜定时更新任务
*/
@Component
public class LeaderboardScheduledTask {
@Autowired
private LeaderboardService leaderboardService;
/**
* 全量重建排行榜(每日凌晨执行)
*/
@Scheduled(cron = "0 0 3 * * ?") // 凌晨3点
public void rebuildDailyLeaderboard() {
String leaderboardId = "power_daily";
// 1. 从数据库全量加载玩家数据
List<PlayerPower> playerPowers = playerPowerRepository.findAll();
// 2. 清空旧排行榜
redisTemplate.delete("lb:" + leaderboardId);
// 3. 批量写入新数据(使用 pipeline 提升性能)
redisTemplate.executePipelined((RedisCallback<Object>) connection -> {
String key = "lb:" + leaderboardId;
for (PlayerPower p : playerPowers) {
connection.zAdd(key.getBytes(),
p.getPower(),
String.valueOf(p.getPlayerId()).getBytes());
}
return null;
});
// 4. 设置过期时间
redisTemplate.expire("lb:" + leaderboardId, Duration.ofDays(1));
}
/**
* 定时更新增量数据(每5分钟)
*/
@Scheduled(fixedRate = 300000)
public void incrementalUpdate() {
// 读取增量变更队列
List<ScoreChangeEvent> changes = scoreChangeQueue.drainAll();
for (ScoreChangeEvent event : changes) {
leaderboardService.incrementScore(
event.getLeaderboardId(),
event.getPlayerId(),
event.getDelta()
);
}
}
}2.2.4 过期策略与持久化
java
/**
* 排行榜过期策略
*/
@Configuration
public class LeaderboardExpireConfig {
/**
* 为不同排行榜设置不同的过期时间
*/
@PostConstruct
public void initExpirePolicies() {
// 实时排行榜:写入时自动刷新TTL,不主动过期
// 每日排行榜:24小时后过期
// 每周排行榜:7天后过期
// 赛季排行榜:赛季结束后30天过期
}
/**
* Redis Key 过期事件监听,用于持久化排行榜数据
*/
@Component
public class LeaderboardExpiredListener extends KeyExpirationEventMessageListener {
public LeaderboardExpiredListener(RedisMessageListenerContainer listenerContainer) {
super(listenerContainer);
}
@Override
public void onMessage(Message message, byte[] pattern) {
String expiredKey = message.toString();
if (expiredKey.startsWith("lb:")) {
// 排行榜过期前,将数据持久化到数据库
String leaderboardId = expiredKey.substring(3);
persistLeaderboard(leaderboardId);
}
}
private void persistLeaderboard(String leaderboardId) {
List<LeaderboardEntry> entries = leaderboardService.getTopN(leaderboardId, 1, 10000);
leaderboardSnapshotRepository.saveSnapshot(leaderboardId, entries);
}
}
}2.3 排行榜优化
2.3.1 跳表 vs Zset 性能
Redis Zset 底层使用跳表(Skip List)和哈希表两种数据结构:
- 跳表:提供有序的成员遍历和范围查询,平均 O(log N) 复杂度
- 哈希表:提供 O(1) 的单成员分数查询(ZSCORE)
| 维度 | Redis Zset | 数据库跳表 | 内存跳表 |
|---|---|---|---|
| 插入性能 | O(log N) | O(log N) | O(log N) |
| 范围查询 | O(log N + M) | O(log N + M) | O(log N + M) |
| 分布式支持 | 原生支持 | 需自实现 | 不支持 |
| 持久化 | RDB/AOF | 自带 | 需自行实现 |
| 原子操作 | ZINCRBY 原子操作 | 需事务 | 需锁 |
| 数据量限制 | 内存限制 | 磁盘 | 内存限制 |
2.3.2 冷热数据与分段排行榜
对于大规模玩家(百万级),单个 Zset 可能导致性能瓶颈,采用分段排行榜策略:
java
/**
* 分段排行榜实现
* 将全服玩家按分数段划分为多个桶,减少单 Zset 大小
*/
@Component
public class SegmentedLeaderboardService {
@Autowired
private StringRedisTemplate redisTemplate;
// 分段时间,例如每1000分为一段
private static final long SEGMENT_SIZE = 1000;
private static final String SEGMENT_KEY_PREFIX = "lb:seg:";
/**
* 计算玩家所在分段
*/
private String getSegmentKey(String leaderboardId, double score) {
long segment = (long) (score / SEGMENT_SIZE);
return SEGMENT_KEY_PREFIX + leaderboardId + ":" + segment;
}
/**
* 更新分数,同时维护全局排行索引
*/
public void updateScore(String leaderboardId, long playerId, double score) {
// 1. 获取旧的分数和分段
String globalKey = "lb:" + leaderboardId;
Double oldScore = redisTemplate.opsForZSet().score(globalKey, String.valueOf(playerId));
// 2. 如果有旧分数,从旧分段中移除
if (oldScore != null) {
String oldSegmentKey = getSegmentKey(leaderboardId, oldScore);
redisTemplate.opsForZSet().remove(oldSegmentKey, String.valueOf(playerId));
}
// 3. 更新全局排行(用于快速查排名)
redisTemplate.opsForZSet().add(globalKey, String.valueOf(playerId), score);
// 4. 加入新分段
String newSegmentKey = getSegmentKey(leaderboardId, score);
redisTemplate.opsForZSet().add(newSegmentKey, String.valueOf(playerId), score);
// 5. 设置分段TTL(热数据保留,冷数据自动淘汰)
redisTemplate.expire(newSegmentKey, Duration.ofHours(1));
}
/**
* 获取全局排行(跨分段查询)
*/
public List<LeaderboardEntry> getGlobalTopN(String leaderboardId, int page, int pageSize) {
String globalKey = "lb:" + leaderboardId;
int start = (page - 1) * pageSize;
int end = start + pageSize - 1;
Set<ZSetOperations.TypedTuple<String>> tuples =
redisTemplate.opsForZSet().reverseRangeWithScores(globalKey, start, end);
List<LeaderboardEntry> result = new ArrayList<>();
if (tuples == null) return result;
long rank = start + 1;
for (ZSetOperations.TypedTuple<String> tuple : tuples) {
if (tuple.getValue() == null) continue;
result.add(LeaderboardEntry.builder()
.rank(rank++)
.playerId(Long.parseLong(tuple.getValue()))
.score(tuple.getScore().longValue())
.build());
}
return result;
}
/**
* 查找玩家附近的玩家("我附近"功能)
*/
public List<LeaderboardEntry> getNeighbors(String leaderboardId, long playerId, int range) {
String globalKey = "lb:" + leaderboardId;
Long rank = redisTemplate.opsForZSet().reverseRank(globalKey, String.valueOf(playerId));
if (rank == null) return Collections.emptyList();
int start = Math.max(0, (int) (rank - range));
int end = (int) (rank + range);
Set<ZSetOperations.TypedTuple<String>> tuples =
redisTemplate.opsForZSet().reverseRangeWithScores(globalKey, start, end);
List<LeaderboardEntry> result = new ArrayList<>();
if (tuples == null) return result;
long currentRank = start + 1;
for (ZSetOperations.TypedTuple<String> tuple : tuples) {
if (tuple.getValue() == null) continue;
result.add(LeaderboardEntry.builder()
.rank(currentRank++)
.playerId(Long.parseLong(tuple.getValue()))
.score(tuple.getScore().longValue())
.build());
}
return result;
}
}2.3.3 千分位与万分段
对于超大排行榜,使用千分位/万分段进一步优化:
java
/**
* 千分位分段排行榜
* 适用于百万级玩家的全服排行榜
*/
@Component
public class ThousandSegLeaderboard {
// 每个桶1000人
private static final int BUCKET_SIZE = 1000;
private static final String BUCKET_KEY = "lb:bucket:";
/**
* 获取玩家所属桶
*/
public int getBucketIndex(long rank) {
return (int) (rank / BUCKET_SIZE);
}
/**
* 按桶加载排行榜
*/
public List<LeaderboardEntry> getBucket(String leaderboardId, int bucketIndex) {
String key = BUCKET_KEY + leaderboardId + ":" + bucketIndex;
Set<ZSetOperations.TypedTuple<String>> tuples =
redisTemplate.opsForZSet().reverseRangeWithScores(key, 0, BUCKET_SIZE - 1);
// 解析并返回
return parseEntries(tuples, bucketIndex * BUCKET_SIZE + 1);
}
}2.3.4 缓存预热
java
/**
* 排行榜缓存预热
* 服务器启动或新排行榜创建时预热
*/
@Component
public class LeaderboardCacheWarmer {
@PostConstruct
public void warmUp() {
// 预热热门排行榜
List<String> hotLeaderboards = List.of("power", "level", "arena");
for (String lb : hotLeaderboards) {
warmUpLeaderboard(lb);
}
}
private void warmUpLeaderboard(String leaderboardId) {
// 加载TOP 1000到缓存
List<LeaderboardEntry> topEntries =
loadFromDatabase(leaderboardId, 0, 1000);
String key = "lb:" + leaderboardId;
redisTemplate.opsForZSet().add(key, topEntries);
redisTemplate.expire(key, Duration.ofHours(1));
}
}2.3.5 增量更新 vs 全量重建
| 策略 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| 增量更新 | 实时排行(战力、等级) | 实时性高,延迟低 | 需要可靠的变更事件流 |
| 全量重建 | 定时排行(充值、消费) | 数据一致性高 | 耗时较长,高峰期不宜执行 |
混合策略:实时排行使用增量更新 + 每日定时全量重建做数据校正。
2.3.6 榜单分桶
java
/**
* 榜单分桶策略
* 将大榜拆分为多个小榜,分别排序后合并
*/
@Service
public class BucketLeaderboardService {
// Level 1-50: 新手榜, 51-100: 高手榜, 100+: 传说榜
private static final Map<String, Range<Integer>> LEVEL_BUCKETS = Map.of(
"newbie", Range.closed(1, 50),
"advanced", Range.closed(51, 100),
"legend", Range.atLeast(101)
);
public List<LeaderboardEntry> getLevelBucketTopN(String bucketName, int n) {
String leaderboardId = "level_" + bucketName;
return leaderboardService.getTopN(leaderboardId, 1, n);
}
}2.3.7 排名变化通知
java
/**
* 排名变化通知
* 当玩家排名变化超过阈值时推送通知
*/
@Service
public class RankChangeNotifier {
private static final long NOTIFY_THRESHOLD = 10; // 变化超过10名才通知
public void onScoreChanged(String leaderboardId, long playerId, double newScore) {
long newRank = leaderboardService.getPlayerRank(leaderboardId, playerId);
String cacheKey = "rank:prev:" + leaderboardId + ":" + playerId;
String prevRankStr = redisTemplate.opsForValue().get(cacheKey);
if (prevRankStr != null) {
long prevRank = Long.parseLong(prevRankStr);
long change = prevRank - newRank; // 正数表示上升
if (Math.abs(change) >= NOTIFY_THRESHOLD) {
// 推送排名变化通知
notifyService.pushRankChange(playerId, leaderboardId, prevRank, newRank);
}
}
// 缓存当前排名
redisTemplate.opsForValue().set(cacheKey, String.valueOf(newRank), Duration.ofMinutes(10));
}
}2.3.8 排行奖励与结算
java
/**
* 排行奖励结算系统
*/
@Component
public class LeaderboardRewardScheduler {
/**
* 每日结算
*/
@Scheduled(cron = "0 0 0 * * ?") // 每天午夜
public void dailySettlement() {
settleLeaderboard("power_daily");
settleLeaderboard("level_daily");
}
/**
* 每周结算
*/
@Scheduled(cron = "0 0 0 * * MON") // 每周一
public void weeklySettlement() {
settleLeaderboard("arena_weekly");
settleLeaderboard("guild_weekly");
}
/**
* 赛季结算
*/
@Scheduled(cron = "0 0 0 1 * ?") // 每月1号
public void seasonSettlement() {
settleLeaderboard("pvp_season");
}
private void settleLeaderboard(String leaderboardId) {
// 1. 获取最终排行
List<LeaderboardEntry> finalRankings =
leaderboardService.getTopN(leaderboardId, 1, 10000);
// 2. 加载奖励配置
RewardTierConfig rewardConfig = rewardConfigLoader.load(leaderboardId);
// 3. 发放奖励
for (LeaderboardEntry entry : finalRankings) {
RewardTier tier = rewardConfig.findTier(entry.getRank());
if (tier != null) {
rewardService.deliverReward(
PlayerContext.of(entry.getPlayerId()),
tier.getRewardConfig()
);
}
}
// 4. 保存结算快照
leaderboardSnapshotRepository.saveSnapshot(leaderboardId, finalRankings);
}
}3. GM 工具系统
GM 工具是游戏运营人员进行日常管理、问题排查和紧急操作的核心平台。
3.1 GM 后台架构
3.1.1 整体架构
┌─────────────────────────────────────────────────────────────┐
│ GM 管理后台 (Web Admin) │
│ Vue/React + Ant Design / Element UI │
├─────────────────────────────────────────────────────────────┤
│ BFF 层 (Gateway) │
│ 认证鉴权 / 参数校验 / 频率限制 / 操作审计 │
├─────────────────────────────────────────────────────────────┤
│ 游戏服务器集群 │
│ Game Server 1 Game Server 2 Game Server N │
│ ┌────────────┐ ┌────────────┐ ┌────────────┐ │
│ │ GM Module │ │ GM Module │ │ GM Module │ │
│ └────────────┘ └────────────┘ └────────────┘ │
└─────────────────────────────────────────────────────────────┘3.1.2 RBAC 权限管理
数据库设计
sql
-- 管理员表
CREATE TABLE `gm_admin` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '管理员ID',
`username` VARCHAR(64) NOT NULL COMMENT '用户名',
`password` VARCHAR(256) NOT NULL COMMENT '密码(bcrypt)',
`real_name` VARCHAR(32) DEFAULT NULL COMMENT '真实姓名',
`email` VARCHAR(128) DEFAULT NULL COMMENT '邮箱',
`phone` VARCHAR(20) DEFAULT NULL COMMENT '手机号',
`status` TINYINT DEFAULT 1 COMMENT '状态 1-启用 0-禁用',
`mfa_secret` VARCHAR(64) DEFAULT NULL COMMENT 'OTP 密钥',
`ip_whitelist` JSON DEFAULT NULL COMMENT 'IP白名单',
`last_login` DATETIME DEFAULT NULL COMMENT '最后登录时间',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
`updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_username` (`username`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='GM管理员表';
-- 角色表
CREATE TABLE `gm_role` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '角色ID',
`name` VARCHAR(64) NOT NULL COMMENT '角色名称',
`description` VARCHAR(256) DEFAULT NULL COMMENT '角色描述',
`status` TINYINT DEFAULT 1 COMMENT '状态',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_name` (`name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='GM角色表';
-- 权限表
CREATE TABLE `gm_permission` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '权限ID',
`code` VARCHAR(128) NOT NULL COMMENT '权限代码,如 gm:mail:send',
`name` VARCHAR(64) NOT NULL COMMENT '权限名称',
`parent_id` BIGINT DEFAULT NULL COMMENT '父权限ID',
`type` TINYINT DEFAULT 1 COMMENT '权限类型 1-菜单 2-操作 3-命令',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_code` (`code`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='GM权限表';
-- 管理员-角色关联表
CREATE TABLE `gm_admin_role` (
`admin_id` BIGINT NOT NULL,
`role_id` BIGINT NOT NULL,
PRIMARY KEY (`admin_id`, `role_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='管理员角色关联表';
-- 角色-权限关联表
CREATE TABLE `gm_role_permission` (
`role_id` BIGINT NOT NULL,
`permission_id` BIGINT NOT NULL,
PRIMARY KEY (`role_id`, `permission_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='角色权限关联表';权限校验
java
/**
* GM 权限校验注解
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RequiresGMPermission {
String value(); // 权限代码,如 "gm:mail:send"
}
/**
* 权限校验切面
*/
@Aspect
@Component
public class GMPermissionAspect {
@Around("@annotation(requiresPermission)")
public Object checkPermission(ProceedingJoinPoint joinPoint,
RequiresGMPermission requiresPermission) throws Throwable {
// 获取当前操作的管理员
GMAdmin currentAdmin = GMContextHolder.getCurrentAdmin();
if (currentAdmin == null) {
throw new GMAuthException("未登录或会话已过期");
}
// 检查IP白名单
if (!checkIpWhitelist(currentAdmin, HttpContextHolder.getRequestIP())) {
throw new GMAuthException("IP不在白名单中");
}
// 检查权限
String permissionCode = requiresPermission.value();
if (!currentAdmin.hasPermission(permissionCode)) {
// 记录审计日志
auditService.log(joinPoint, "PERMISSION_DENIED");
throw new GMPermissionException("权限不足: " + permissionCode);
}
return joinPoint.proceed();
}
private boolean checkIpWhitelist(GMAdmin admin, String requestIP) {
if (admin.getIpWhitelist() == null || admin.getIpWhitelist().isEmpty()) {
return true; // 未配置IP白名单则不限制
}
return admin.getIpWhitelist().contains(requestIP);
}
}3.1.3 安全措施
yaml
# GM 安全配置
gm_security:
# 命令频率限制
rate_limit:
enabled: true
max_requests_per_minute: 60
burst: 10
# IP 白名单
ip_whitelist:
enabled: true
allowed_ips:
- "10.0.0.0/8"
- "172.16.0.0/12"
# OTP 二次验证
mfa:
enabled: true
issuer: "GameGM"
algorithm: "HmacSHA1"
digits: 6
period: 30
# 操作审计
audit:
enabled: true
log_all_operations: true
retention_days: 180
# 操作回滚
rollback:
enabled: true
max_rollback_hours: 723.2 GM 命令系统
3.2.1 命令格式与协议定义
protobuf
// gm_command.proto
syntax = "proto3";
package gm;
// GM 命令请求
message GMCommandRequest {
string command_id = 1; // 命令唯一ID
string command = 2; // 命令名称,如 "send_mail"
repeated Param params = 3; // 参数列表
Target target = 4; // 命令目标
int64 request_time = 5; // 请求时间戳
string operator = 6; // 操作人
string reason = 7; // 操作原因
}
message Param {
string key = 1;
string value = 2;
}
message Target {
oneof target_type {
int64 player_id = 1; // 指定玩家
int32 server_id = 2; // 指定服务器
string player_name = 3; // 指定玩家名
string condition = 4; // 条件表达式(批量操作)
}
}
// GM 命令响应
message GMCommandResponse {
string command_id = 1;
int32 code = 2; // 状态码 0-成功 非0-失败
string message = 3; // 结果描述
GMCommandResult result = 4; // 执行结果
}
message GMCommandResult {
int32 affected_count = 1; // 影响数量
repeated string details = 2; // 执行详情
bool can_rollback = 3; // 是否可回滚
string snapshot_id = 4; // 回滚快照ID
}3.2.2 命令注册与执行器架构
java
/**
* GM 命令执行器接口
* 采用 SPI 模式,每个命令对应一个执行器
*/
public interface GMCommandExecutor {
/**
* 命令名称
*/
String commandName();
/**
* 所需权限等级
*/
String requiredPermission();
/**
* 参数校验
*/
ValidateResult validate(GMCommandRequest request);
/**
* 执行命令
*/
GMCommandResponse execute(GMCommandRequest request);
/**
* 回滚命令(可选)
*/
default GMCommandResponse rollback(String snapshotId) {
throw new UnsupportedOperationException("此命令不支持回滚");
}
}
/**
* 命令注册器
*/
@Component
public class GMCommandRegistry implements ApplicationContextAware {
private final Map<String, GMCommandExecutor> executors = new ConcurrentHashMap<>();
@Override
public void setApplicationContext(ApplicationContext context) {
// 从 Spring 容器中获取所有 GMCommandExecutor 实现
Map<String, GMCommandExecutor> beans = context.getBeansOfType(GMCommandExecutor.class);
beans.values().forEach(executor -> {
String name = executor.commandName();
if (executors.containsKey(name)) {
throw new IllegalStateException("重复的GM命令: " + name);
}
executors.put(name, executor);
});
}
public GMCommandExecutor getExecutor(String commandName) {
GMCommandExecutor executor = executors.get(commandName);
if (executor == null) {
throw new GMCommandNotFoundException("未知命令: " + commandName);
}
return executor;
}
public List<String> getAllCommandNames() {
return new ArrayList<>(executors.keySet());
}
}
/**
* 命令执行调度器
*/
@Component
public class GMCommandDispatcher {
@Autowired
private GMCommandRegistry registry;
@Autowired
private GMAuditService auditService;
@Autowired
private RateLimiter rateLimiter;
public GMCommandResponse dispatch(GMCommandRequest request) {
// 1. 频率限制检查
rateLimiter.checkLimit(request.getOperator());
// 2. 获取命令执行器
GMCommandExecutor executor = registry.getExecutor(request.getCommand());
// 3. 权限检查
if (!hasPermission(request.getOperator(), executor.requiredPermission())) {
throw new GMPermissionException("权限不足");
}
// 4. 参数校验
ValidateResult validateResult = executor.validate(request);
if (!validateResult.isSuccess()) {
return GMCommandResponse.newBuilder()
.setCode(-1)
.setMessage("参数校验失败: " + validateResult.getErrorMessage())
.build();
}
// 5. 执行命令
long startTime = System.currentTimeMillis();
GMCommandResponse response = executor.execute(request);
long costTime = System.currentTimeMillis() - startTime;
// 6. 记录审计日志
auditService.log(GMAuditLog.builder()
.operator(request.getOperator())
.command(request.getCommand())
.params(request.getParamsList())
.resultCode(response.getCode())
.costTime(costTime)
.reason(request.getReason())
.build());
return response;
}
}3.2.3 通用命令实现
发送邮件
java
@Component
public class SendMailCommandExecutor implements GMCommandExecutor {
@Autowired
private MailService mailService;
@Override
public String commandName() { return "send_mail"; }
@Override
public String requiredPermission() { return "gm:mail:send"; }
@Override
public ValidateResult validate(GMCommandRequest request) {
Param title = findParam(request, "title");
Param content = findParam(request, "content");
Param items = findParam(request, "items");
if (title == null || title.getValue().isEmpty()) {
return ValidateResult.fail("邮件标题不能为空");
}
if (content == null || content.getValue().isEmpty()) {
return ValidateResult.fail("邮件内容不能为空");
}
return ValidateResult.success();
}
@Override
public GMCommandResponse execute(GMCommandRequest request) {
String title = getParamValue(request, "title");
String content = getParamValue(request, "content");
String itemsJson = getParamValue(request, "items");
List<RewardItem> items = parseItems(itemsJson);
if (request.getTarget().hasPlayerId()) {
mailService.sendMail(request.getTarget().getPlayerId(), title, content, items);
} else if (request.getTarget().hasServerId()) {
mailService.sendMailToServer(request.getTarget().getServerId(), title, content, items);
} else if (request.getTarget().hasCondition()) {
mailService.sendMailByCondition(request.getTarget().getCondition(), title, content, items);
}
return GMCommandResponse.newBuilder()
.setCode(0)
.setMessage("邮件发送成功")
.setResult(GMCommandResult.newBuilder()
.setAffectedCount(1)
.build())
.build();
}
private Param findParam(GMCommandRequest request, String key) {
return request.getParamsList().stream()
.filter(p -> p.getKey().equals(key))
.findFirst().orElse(null);
}
private String getParamValue(GMCommandRequest request, String key) {
Param param = findParam(request, key);
return param != null ? param.getValue() : "";
}
}发送物品
java
@Component
public class SendItemCommandExecutor implements GMCommandExecutor {
@Autowired
private BackpackService backpackService;
@Override
public String commandName() { return "send_item"; }
@Override
public String requiredPermission() { return "gm:item:send"; }
@Override
public ValidateResult validate(GMCommandRequest request) {
String itemId = getParamValue(request, "item_id");
String count = getParamValue(request, "count");
if (itemId.isEmpty()) return ValidateResult.fail("物品ID不能为空");
if (count.isEmpty()) return ValidateResult.fail("数量不能为空");
try {
if (Integer.parseInt(count) <= 0) return ValidateResult.fail("数量必须大于0");
if (Integer.parseInt(count) > 999999) return ValidateResult.fail("数量超过上限");
} catch (NumberFormatException e) {
return ValidateResult.fail("数量格式错误");
}
return ValidateResult.success();
}
@Override
public GMCommandResponse execute(GMCommandRequest request) {
long playerId = request.getTarget().getPlayerId();
String itemId = getParamValue(request, "item_id");
int count = Integer.parseInt(getParamValue(request, "count"));
boolean bind = Boolean.parseBoolean(getParamValue(request, "bind"));
backpackService.addItem(playerId, itemId, count, bind);
return GMCommandResponse.newBuilder()
.setCode(0)
.setMessage("物品发送成功")
.build();
}
}修改属性
java
@Component
public class ModifyAttributeCommandExecutor implements GMCommandExecutor {
@Override
public String commandName() { return "modify_attr"; }
@Override
public String requiredPermission() { return "gm:player:modify"; }
@Override
public ValidateResult validate(GMCommandRequest request) {
String attr = getParamValue(request, "attribute");
String value = getParamValue(request, "value");
if (attr.isEmpty()) return ValidateResult.fail("属性名不能为空");
if (value.isEmpty()) return ValidateResult.fail("属性值不能为空");
return ValidateResult.success();
}
@Override
public GMCommandResponse execute(GMCommandRequest request) {
long playerId = request.getTarget().getPlayerId();
String attr = getParamValue(request, "attribute");
String value = getParamValue(request, "value");
String operator = getParamValue(request, "operator"); // set/add/sub
// 创建快照用于回滚
String snapshotId = createSnapshot(playerId, attr);
playerAttributeService.modify(playerId, attr, value, operator);
return GMCommandResponse.newBuilder()
.setCode(0)
.setMessage("属性修改成功")
.setResult(GMCommandResult.newBuilder()
.setCanRollback(true)
.setSnapshotId(snapshotId)
.build())
.build();
}
@Override
public GMCommandResponse rollback(String snapshotId) {
// 从快照恢复
rollbackFromSnapshot(snapshotId);
return GMCommandResponse.newBuilder()
.setCode(0)
.setMessage("回滚成功")
.build();
}
}封禁与踢人
java
@Component
public class BanPlayerCommandExecutor implements GMCommandExecutor {
@Override
public String commandName() { return "ban_player"; }
@Override
public String requiredPermission() { return "gm:player:ban"; }
@Override
public ValidateResult validate(GMCommandRequest request) {
String duration = getParamValue(request, "duration");
if (duration.isEmpty()) return ValidateResult.fail("封禁时长不能为空");
return ValidateResult.success();
}
@Override
public GMCommandResponse execute(GMCommandRequest request) {
long playerId = request.getTarget().getPlayerId();
long durationHours = Long.parseLong(getParamValue(request, "duration"));
String reason = getParamValue(request, "reason");
banService.banPlayer(playerId, durationHours, reason, request.getOperator());
// 强制踢下线
playerSessionService.kickPlayer(playerId, "账号已被封禁");
return GMCommandResponse.newBuilder()
.setCode(0)
.setMessage("封禁成功")
.build();
}
}公告/停服/开服
java
@Component
public class AnnounceCommandExecutor implements GMCommandExecutor {
@Override
public String commandName() { return "announce"; }
@Override
public String requiredPermission() { return "gm:announce"; }
@Override
public GMCommandResponse execute(GMCommandRequest request) {
String message = getParamValue(request, "message");
int times = Integer.parseInt(getParamValueOrDefault(request, "times", "3"));
int interval = Integer.parseInt(getParamValueOrDefault(request, "interval", "5"));
// 向全服发送公告
broadcastService.announce(message, times, interval);
return GMCommandResponse.newBuilder()
.setCode(0)
.setMessage("公告发送成功")
.build();
}
}
@Component
public class ServerControlCommandExecutor implements GMCommandExecutor {
@Override
public String commandName() { return "server_control"; }
@Override
public String requiredPermission() { return "gm:server:control"; }
@Override
public GMCommandResponse execute(GMCommandRequest request) {
String action = getParamValue(request, "action"); // shutdown/startup/reload
String reason = getParamValue(request, "reason");
switch (action) {
case "shutdown":
// 停服:先发送通知
broadcastService.announce("服务器即将关闭维护,原因: " + reason, 5, 3);
serverManager.shutdown(reason);
break;
case "startup":
serverManager.startup();
break;
case "reload":
configManager.reload();
break;
default:
return GMCommandResponse.newBuilder()
.setCode(-1)
.setMessage("未知操作: " + action)
.build();
}
return GMCommandResponse.newBuilder()
.setCode(0)
.setMessage("服务器操作成功: " + action)
.build();
}
}3.2.4 命令分类汇总
| 命令分类 | 命令列表 | 所需权限 |
|---|---|---|
| 邮件类 | send_mail, send_mail_batch | gm:mail:send |
| 物品类 | send_item, send_item_batch | gm:item:send |
| 属性类 | modify_attr, query_attr | gm:player:modify |
| 封禁类 | ban_player, unban_player, kick_player | gm:player:ban |
| 公告类 | announce, notice | gm:announce |
| 服务器类 | server_shutdown, server_startup, config_reload | gm:server:control |
| 合服类 | merge_server, migrate_player | gm:server:merge |
| 数据类 | data_fix, data_validate, data_export | gm:data:admin |
| 查询类 | query_player, query_log, query_recharge | gm:query |
3.3 GM 命令设计
3.3.1 审计日志
sql
CREATE TABLE `gm_audit_log` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`operator` VARCHAR(64) NOT NULL COMMENT '操作人',
`operator_ip` VARCHAR(64) DEFAULT NULL COMMENT '操作人IP',
`command` VARCHAR(64) NOT NULL COMMENT '命令名称',
`params` JSON DEFAULT NULL COMMENT '命令参数',
`target` VARCHAR(128) DEFAULT NULL COMMENT '操作目标',
`result_code` INT DEFAULT 0 COMMENT '结果码',
`result_msg` VARCHAR(512) DEFAULT NULL COMMENT '结果描述',
`cost_time` INT DEFAULT 0 COMMENT '耗时(ms)',
`rollback_id` VARCHAR(64) DEFAULT NULL COMMENT '回滚操作ID',
`rollback_time` DATETIME DEFAULT NULL COMMENT '回滚时间',
`reason` VARCHAR(256) DEFAULT NULL COMMENT '操作原因',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_operator` (`operator`),
KEY `idx_command` (`command`),
KEY `idx_created_at` (`created_at`),
KEY `idx_rollback_id` (`rollback_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='GM操作审计日志表';java
/**
* GM 审计日志
*/
@Data
@Builder
public class GMAuditLog {
private String operator;
private String operatorIp;
private String command;
private List<Param> params;
private String target;
private int resultCode;
private String resultMsg;
private long costTime;
private String rollbackId;
private String reason;
}3.4 紧急操作
3.4.1 紧急操作清单
| 操作类型 | 说明 | 影响范围 | 紧急程度 |
|---|---|---|---|
| 全服公告 | 向所有在线玩家推送系统公告 | 全服 | 高 |
| 全服邮件 | 向全服或指定服务器玩家发送邮件 | 指定服务器 | 中 |
| 停服维护 | 关闭服务器进行紧急维护 | 全服 | 最高 |
| 合服操作 | 将多个服务器数据合并 | 合服涉及的服务器 | 高 |
| 开服操作 | 开启新服务器或恢复已停服服务器 | 指定服务器 | 中 |
| 踢下线 | 强制指定玩家下线 | 单个玩家 | 中 |
| 数据修复 | 修复玩家异常数据 | 单个/批量玩家 | 高 |
| 版本回退 | 服务器版本回退到上一个稳定版本 | 全服 | 最高 |
| 数据校验 | 对全服或部分数据进行一致性校验 | 全服/部分范围 | 中 |
3.5 玩家反馈处理
玩家反馈处理是GM工具的重要功能模块,覆盖日常运营中的各类玩家诉求。
3.5.1 常见反馈类型
| 反馈类型 | 处理流程 | 期望响应时间 |
|---|---|---|
| 物品丢失 | 查询操作日志 -> 确认丢失原因 -> 补发物品 | 2小时内 |
| 充值未到账 | 查询支付订单 -> 确认充值状态 -> 手动补单 | 1小时内 |
| 异常封禁 | 查询封禁记录 -> 复核违规证据 -> 解封/维持判罚 | 4小时内 |
| 账号申诉 | 验证玩家身份 -> 复核封禁原因 -> 申诉处理 | 24小时内 |
| 封禁解封 | 确认封禁到期时间 -> 手动解封或等待自动解封 | 2小时内 |
| 日志查询 | 按条件查询玩家操作日志 -> 反馈查询结果 | 4小时内 |
3.5.2 反馈处理流程
java
/**
* 玩家反馈处理服务
*/
@Service
public class PlayerFeedbackService {
@Autowired
private RechargeQueryService rechargeQueryService;
@Autowired
private BanService banService;
@Autowired
private PlayerLogService playerLogService;
@Autowired
private MailService mailService;
@Autowired
private BackpackService backpackService;
/**
* 处理物品丢失补偿
*/
public FeedbackResult handleItemLoss(FeedbackRequest request) {
// 1. 查询玩家操作日志,确认丢失原因
List<PlayerOperationLog> logs = playerLogService.queryPlayerLogs(
request.getPlayerId(),
request.getEventTime().minusHours(2),
request.getEventTime().plusHours(2)
);
// 2. 分析异常原因
LossAnalysisResult analysis = analyzeLossCause(logs, request.getLostItems());
if (!analysis.isValidLoss()) {
return FeedbackResult.fail("经核查未发现异常丢失情况");
}
// 3. 补发物品
for (LostItem item : analysis.getConfirmedLostItems()) {
backpackService.addItem(request.getPlayerId(), item.getItemId(), item.getCount(), true);
}
// 4. 发送补偿通知邮件
mailService.sendMail(request.getPlayerId(),
"物品补偿通知",
"经核查,您丢失的物品已补发,请查收。",
analysis.getConfirmedLostItems()
);
// 5. 记录处理结果
saveFeedbackRecord(request, "COMPENSATED", analysis);
return FeedbackResult.success("已补发 " + analysis.getConfirmedLostItems().size() + " 件物品");
}
/**
* 处理充值未到账
*/
public FeedbackResult handleRechargeMissed(FeedbackRequest request) {
// 1. 查询支付订单
PaymentOrder order = rechargeQueryService.queryOrder(
request.getPlayerId(),
request.getOrderId()
);
if (order == null) {
return FeedbackResult.fail("未找到对应订单,请确认订单号是否正确");
}
// 2. 检查订单状态
if ("SUCCESS".equals(order.getStatus())) {
// 订单已成功但道具未发放,手动补单
rechargeQueryService.manualSettle(order);
return FeedbackResult.success("充值补单成功");
} else if ("PENDING".equals(order.getStatus())) {
// 订单处理中,等待第三方回调
return FeedbackResult.fail("充值正在处理中,请稍后再查");
} else {
return FeedbackResult.fail("订单状态异常: " + order.getStatus());
}
}
/**
* 处理封禁申诉
*/
public FeedbackResult handleBanAppeal(FeedbackRequest request) {
// 1. 查询封禁记录
BanRecord banRecord = banService.getBanRecord(request.getPlayerId());
if (banRecord == null) {
return FeedbackResult.fail("该账号未被封禁");
}
// 2. 复核封禁证据
AppealReviewResult review = reviewBanEvidence(banRecord, request.getAppealReason());
if (review.isWrongBan()) {
// 误封:立即解封并补偿
banService.unbanPlayer(request.getPlayerId(), "误封解封");
mailService.sendMail(request.getPlayerId(),
"封禁解封通知",
"经过复核,您账号的封禁已被解除,并附上补偿,请查收。",
review.getCompensationItems()
);
return FeedbackResult.success("已解封并发放补偿");
} else {
// 非误封:维持判罚
return FeedbackResult.fail("经过复核,封禁决定正确,维持原判罚。剩余封禁时间: "
+ banRecord.getRemainingTime());
}
}
}3.5.3 封禁日志查询
java
/**
* 封禁日志查询
*/
@Service
public class BanLogService {
public PageResult<BanRecord> queryBanHistory(Long playerId, String playerName,
Integer serverId, PageRequest page) {
// 支持按玩家ID、玩家名、服务器ID多种方式查询
// 返回封禁历史记录,包括封禁原因、操作人、封禁时长、解封时间等
return banRepository.queryHistory(playerId, playerName, serverId, page);
}
}4. 活动数据与监控
4.1 活动参与度
4.1.1 核心指标
| 指标 | 定义 | 计算公式 |
|---|---|---|
| DAU | 每日活跃用户数 | 当日参与活动的独立用户数 |
| 参与率 | 活动参与用户占活跃用户比例 | 活动参与人数 / DAU * 100% |
| 付费率 | 活动中付费用户占比 | 活动付费人数 / 活动参与人数 * 100% |
| ARPU | 每用户平均收入 | 活动总收入 / 活动参与人数 |
| ARPPU | 每付费用户平均收入 | 活动总收入 / 活动付费人数 |
4.1.2 漏斗分析
活动曝光 -> 活动点击 -> 活动参与 -> 活动付费 -> 活动分享
100% 65% 42% 8% 3%
各阶段转化率:
曝光->点击: 65%
点击->参与: 64.6%
参与->付费: 19.0%
付费->分享: 37.5%
整体转化率: 3.0%java
/**
* 活动漏斗分析
*/
@Component
public class ActivityFunnelAnalysis {
public FunnelReport analyzeFunnel(Long activityId, Date startDate, Date endDate) {
long exposure = eventService.countEvent(activityId, "exposure", startDate, endDate);
long click = eventService.countEvent(activityId, "click", startDate, endDate);
long participate = eventService.countEvent(activityId, "participate", startDate, endDate);
long payment = eventService.countEvent(activityId, "payment", startDate, endDate);
long share = eventService.countEvent(activityId, "share", startDate, endDate);
return FunnelReport.builder()
.steps(List.of(
FunnelStep.of("曝光", exposure, 1.0),
FunnelStep.of("点击", click, safeDivide(click, exposure)),
FunnelStep.of("参与", participate, safeDivide(participate, click)),
FunnelStep.of("付费", payment, safeDivide(payment, participate)),
FunnelStep.of("分享", share, safeDivide(share, payment))
))
.build();
}
private double safeDivide(long numerator, long denominator) {
return denominator == 0 ? 0 : (double) numerator / denominator;
}
}4.1.3 活动归因
活动归因用于分析不同活动对玩家行为和收入的贡献:
java
/**
* 活动归因模型
* 基于最后一次接触(Last Touch)和多点接触(Multi Touch)两种模型
*/
@Component
public class ActivityAttributionService {
/**
* 最后一次接触归因
*/
public Map<String, Double> lastTouchAttribution(long playerId, Date startDate, Date endDate) {
// 获取玩家在时间范围内的所有活动接触记录
List<ActivityTouch> touches = touchRepository.findByPlayerAndTimeRange(
playerId, startDate, endDate);
// 按时间排序,取最后一次接触的活动
Map<String, Integer> attribution = new HashMap<>();
if (!touches.isEmpty()) {
ActivityTouch lastTouch = touches.get(touches.size() - 1);
attribution.put(lastTouch.getActivityId(), 100); // 100%贡献
}
return attribution;
}
/**
* 多点接触归因(线性分配)
*/
public Map<String, Double> multiTouchAttribution(long playerId, Date startDate, Date endDate) {
List<ActivityTouch> touches = touchRepository.findByPlayerAndTimeRange(
playerId, startDate, endDate);
Map<String, Double> attribution = new HashMap<>();
double share = 1.0 / Math.max(touches.size(), 1);
for (ActivityTouch touch : touches) {
attribution.merge(touch.getActivityId(), share, Double::sum);
}
return attribution;
}
}4.2 活动效果评估
4.2.1 ROI 回收率
sql
-- 活动ROI分析
CREATE TABLE `activity_roi_report` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`activity_id` BIGINT NOT NULL COMMENT '活动ID',
`activity_name` VARCHAR(128) NOT NULL COMMENT '活动名称',
`total_revenue` DECIMAL(20,2) DEFAULT 0 COMMENT '活动总收入(元)',
`total_cost` DECIMAL(20,2) DEFAULT 0 COMMENT '活动总成本(元)',
`participant_count` INT DEFAULT 0 COMMENT '参与人数',
`payer_count` INT DEFAULT 0 COMMENT '付费人数',
`roi` DECIMAL(10,4) DEFAULT 0 COMMENT 'ROI = 收入/成本',
`arpu` DECIMAL(10,2) DEFAULT 0 COMMENT '每用户平均收入',
`arppu` DECIMAL(10,2) DEFAULT 0 COMMENT '每付费用户平均收入',
`report_date` DATE NOT NULL COMMENT '报表日期',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_activity_date` (`activity_id`, `report_date`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='活动ROI报表';ROI 计算示例
java
public class ROICalculator {
/**
* 计算活动ROI
*/
public ROIReport calculateROI(Long activityId) {
ActivityConfig activity = activityRepository.findById(activityId);
// 活动总收入(充值+消费)
BigDecimal totalRevenue = revenueRepository.sumRevenueByActivity(activityId);
// 活动总成本(奖励发放价值 + 运营人力 + 渠道推广)
BigDecimal rewardCost = rewardRepository.sumRewardCostByActivity(activityId);
BigDecimal operationCost = operationCostRepository.sumCostByActivity(activityId);
BigDecimal promotionCost = promotionRepository.sumCostByActivity(activityId);
BigDecimal totalCost = rewardCost.add(operationCost).add(promotionCost);
return ROIReport.builder()
.activityId(activityId)
.activityName(activity.getName())
.totalRevenue(totalRevenue)
.totalCost(totalCost)
.roi(totalCost.compareTo(BigDecimal.ZERO) > 0
? totalRevenue.divide(totalCost, 4, RoundingMode.HALF_UP)
: BigDecimal.ZERO)
.participantCount(participantRepository.countByActivity(activityId))
.payerCount(payerRepository.countByActivity(activityId))
.build();
}
}4.2.2 AARRR 海盗模型
Acquisition (获取) 活动曝光量、活动点击量
|
v
Activation (激活) 活动参与率、首次参与时间
|
v
Retention (留存) 活动次日留存、7日留存、30日留存
|
v
Revenue (收入) 活动付费率、ARPU、ARPPU
|
v
Referral (传播) 活动分享率、邀请转化率4.2.3 流失率与付费留存
java
/**
* 活动留存分析
*/
@Component
public class ActivityRetentionAnalysis {
/**
* 计算活动次日/7日/30日留存率
*/
public RetentionReport calculateRetention(Long activityId, Date activityEndDate) {
// 获取活动参与用户
List<Long> participants = participantRepository
.getParticipantIdsByActivity(activityId);
Set<Long> participantSet = new HashSet<>(participants);
// 次日留存:活动结束后第1天仍有登录的用户
double day1Retention = calculateRetentionRate(
participantSet, activityEndDate, 1);
// 7日留存
double day7Retention = calculateRetentionRate(
participantSet, activityEndDate, 7);
// 30日留存
double day30Retention = calculateRetentionRate(
participantSet, activityEndDate, 30);
return new RetentionReport(day1Retention, day7Retention, day30Retention);
}
private double calculateRetentionRate(Set<Long> participants,
Date baseDate, int daysAfter) {
Date targetDate = Date.from(baseDate.toInstant()
.plus(daysAfter, ChronoUnit.DAYS));
long activeCount = loginLogRepository
.countActiveUsersOnDate(participants, targetDate);
return participants.isEmpty() ? 0 : (double) activeCount / participants.size();
}
}4.3 运营监控
4.3.1 监控指标
yaml
# 运营监控配置
monitoring:
# 活动实时数据
activity_realtime:
- concurrent_participants # 活动同时在线人数
- participation_rate # 实时参与率
- reward_claim_rate # 奖励领取率
- error_count # 活动异常错误数
# 排行榜监控
leaderboard:
- refresh_latency # 排行榜刷新延迟(ms)
- total_entries # 排行榜总条目数
- query_qps # 排行榜查询QPS
# 奖励发放监控
reward_delivery:
- total_delivered # 总发放数量
- delivery_success_rate # 发放成功率
- avg_delivery_latency # 平均发放延迟
# 异常监控
anomaly:
- concurrent_online # 同时在线人数
- activity_concurrency # 活动并发数
- error_rate # 错误率
- slow_query_count # 慢查询次数4.3.2 告警规则
yaml
# 告警规则配置
alert_rules:
- name: "活动并发过高"
condition: "concurrent_participants > 10000"
duration: "1m"
severity: "warning"
channels: ["dingtalk", "wecom"]
- name: "奖励发放失败率过高"
condition: "delivery_success_rate < 99.5%"
duration: "5m"
severity: "critical"
channels: ["dingtalk", "wecom", "sms"]
- name: "排行榜刷新延迟过高"
condition: "refresh_latency > 5000"
duration: "3m"
severity: "warning"
channels: ["dingtalk", "email"]
- name: "活动异常错误"
condition: "error_count > 100"
duration: "1m"
severity: "critical"
channels: ["dingtalk", "wecom", "sms", "email"]4.3.3 告警通知实现
java
/**
* 告警通知分发器
*/
@Component
public class AlertNotifier {
@Autowired
private DingTalkNotifier dingTalkNotifier;
@Autowired
private WeComNotifier weComNotifier;
@Autowired
private EmailNotifier emailNotifier;
@Autowired
private SmsNotifier smsNotifier;
/**
* 发送告警通知
*/
public void notify(AlertEvent event) {
AlertRule rule = event.getRule();
for (String channel : rule.getChannels()) {
try {
switch (channel) {
case "dingtalk" -> dingTalkNotifier.send(buildDingTalkMessage(event));
case "wecom" -> weComNotifier.send(buildWeComMessage(event));
case "email" -> emailNotifier.send(buildEmailMessage(event));
case "sms" -> smsNotifier.send(buildSmsMessage(event));
default -> log.warn("未知告警渠道: {}", channel);
}
} catch (Exception e) {
log.error("告警通知发送失败, channel={}, eventId={}", channel, event.getId(), e);
}
}
}
private DingTalkMessage buildDingTalkMessage(AlertEvent event) {
return DingTalkMessage.markdown()
.title(String.format("[%s] %s", event.getSeverity(), event.getRule().getName()))
.text(formatAlertContent(event))
.build();
}
private String formatAlertContent(AlertEvent event) {
return String.format(
"""
### 告警通知
- **告警名称**: %s
- **严重级别**: %s
- **触发条件**: %s
- **当前值**: %.2f
- **触发时间**: %s
- **建议操作**: %s
""",
event.getRule().getName(),
event.getSeverity(),
event.getRule().getCondition(),
event.getCurrentValue(),
event.getTriggerTime(),
event.getRule().getSuggestion()
);
}
}4.3.4 活动实时面板设计
sql
-- 活动实时数据表
CREATE TABLE `activity_realtime_stats` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`activity_id` BIGINT NOT NULL COMMENT '活动ID',
`participant_count` INT DEFAULT 0 COMMENT '参与人数',
`payer_count` INT DEFAULT 0 COMMENT '付费人数',
`revenue_amount` DECIMAL(20,2) DEFAULT 0 COMMENT '实时收入',
`reward_claim_count` INT DEFAULT 0 COMMENT '奖励领取次数',
`concurrent_online` INT DEFAULT 0 COMMENT '同时在线',
`error_count` INT DEFAULT 0 COMMENT '错误次数',
`stats_time` DATETIME NOT NULL COMMENT '统计时间点',
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_activity_time` (`activity_id`, `stats_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='活动实时统计表';4.3.5 触发器与自动化
java
/**
* 活动触发器系统
* 当活动数据满足条件时自动执行操作
*/
@Component
public class ActivityTriggerEngine {
private final Map<String, ActivityTrigger> triggers = new ConcurrentHashMap<>();
/**
* 注册触发器
*/
public void registerTrigger(ActivityTrigger trigger) {
triggers.put(trigger.getTriggerId(), trigger);
}
/**
* 检查并触发
*/
public void evaluate(ActivityContext context) {
for (ActivityTrigger trigger : triggers.values()) {
if (trigger.evaluate(context)) {
trigger.execute(context);
}
}
}
}
/**
* 活动触发器接口
*/
public interface ActivityTrigger {
String getTriggerId();
/**
* 条件判断
*/
boolean evaluate(ActivityContext context);
/**
* 执行动作
*/
void execute(ActivityContext context);
}
/**
* 参与人数达标自动发放全服奖励触发器
*/
@Component
public class ParticipationMilestoneTrigger implements ActivityTrigger {
@Override
public String getTriggerId() { return "participation_milestone"; }
@Override
public boolean evaluate(ActivityContext context) {
int currentParticipants = context.getParticipantCount();
int milestone = context.getConfig().getInt("milestone_participants");
return currentParticipants >= milestone
&& !context.hasTriggered(getTriggerId());
}
@Override
public void execute(ActivityContext context) {
// 发放全服里程碑奖励
rewardService.deliverRewardToAllParticipants(
context.getActivityId(),
context.getConfig().getRewardConfig("milestone_reward")
);
context.markTriggered(getTriggerId());
}
}