4.登录流程
4.1 登录整体流程
登录流程比注册流程多了一层:客户端不是登录鉴权服以后就直接进游戏,而是先从鉴权服拿到一张临时凭证,再拿这张凭证去连接 Gate。
这套流程有点像进场前过两道口:
- 鉴权服 像售票处,负责确认账号密码是否正确,并签发一张短期门票。
- Gate 像真正的入口检票口,负责验票、绑定连接、处理顶号和断线重连。
- Token 像带防伪章的临时门票,客户端拿着它去 Gate,Gate 通过签名和业务字段判断这票能不能用。
这里最容易混淆的是:
鉴权服登录成功,只代表账号凭证通过了校验,不代表客户端已经进入游戏世界。
真正进入游戏入口,还需要客户端拿 Token 连接 Gate,并且 Gate 验签通过后,把当前 Session 和游戏账号绑定起来。
我自己看这段登录链路时,会先按这几段拆:
- 客户端根据用户名选择鉴权服,发送登录请求。
- 鉴权服校验参数、校验账号归属、加锁处理同账号并发登录。
- 鉴权服查询账号,验证用户名和密码,登录成功后生成 JWT。
- JWT 里携带账号 ID、Gate 地址、Gate 场景 ID 等信息。
- 客户端解析 JWT,拿到 Gate 地址,然后连接对应 Gate。
- Gate 验证 JWT 签名、发行者、接收方、场景 ID 等信息。
- Gate 根据账号 ID 加载或创建
GameAccount。 - Gate 处理重复登录、顶号、断线重连、延迟下线。
- Gate 这关过了之后,客户端才算真正接上游戏入口,后面才轮到角色、场景之类的流程。

上面这张图适合先看全貌。真要顺代码,把所有节点都塞进一张 Mermaid 反而不好读,所以我后面按职责拆成几张小图:
- 第一张看客户端到鉴权服的账号校验。
- 第二张看客户端拿 Token 后怎么连接 Gate。
- 第三张看 Gate 如何验签并加载
GameAccount。 - 第四张看 Session 绑定、顶号和断线下线处理。
这样每张图只盯一个阶段,后面回来看某段逻辑,也不用在一张超长流程图里翻节点。
鉴权服登录流程
这一段只关心一件事:用户名和密码能不能换到一个有效的登录 Token。
客户端会先根据用户名选择鉴权服。
鉴权服收到请求后,不会直接相信客户端选的节点,而是自己重新计算账号归属。
如果当前节点不负责这个账号,就直接返回错误。
账号归属没问题后,再进入同账号协程锁、登录缓存、数据库查询、更新登录时间、分配 Gate、生成 JWT 这些逻辑。
graph TD
subgraph client_auth["客户端请求鉴权服"]
cli_click_login["点击登录按钮"]
cli_select_auth_svr["根据用户名选择鉴权服务器"]
cli_create_session["创建鉴权服 Session"]
cli_send_login_request["发送登录请求 {用户名, 密码, 登录类型}"]
end
subgraph auth_server["鉴权服处理登录"]
svr_handle_login_request["处理登录请求"]
svr_validate_parameters{"参数是否合法?"}
svr_return_parameter_error["返回错误码 1:参数不完整"]
svr_compute_position["计算账号归属鉴权服"]
svr_position_match{"当前鉴权服是否匹配?"}
svr_return_position_error["返回错误码 3:账号不归当前节点处理"]
svr_acquire_lock["获取同账号协程锁"]
svr_account_in_cache{"登录缓存是否命中?"}
svr_return_cached_account["返回缓存中的账号 ID"]
svr_query_database["查询数据库"]
svr_account_exists{"账号是否存在且密码正确?"}
svr_return_not_registered_error["返回错误码 2:账号不存在或密码错误"]
svr_update_login_time["更新登录时间并保存"]
svr_cache_account_info["写入短期登录缓存"]
svr_select_gate["根据账号 ID 分配 Gate"]
svr_generate_jwt["生成 JWT 登录票据"]
svr_return_login_response["返回登录响应 {错误码, 账号ID, Token}"]
end
cli_click_login --> cli_select_auth_svr --> cli_create_session --> cli_send_login_request
cli_send_login_request --> svr_handle_login_request --> svr_validate_parameters
svr_validate_parameters -- 否 --> svr_return_parameter_error
svr_validate_parameters -- 是 --> svr_compute_position --> svr_position_match
svr_position_match -- 否 --> svr_return_position_error
svr_position_match -- 是 --> svr_acquire_lock --> svr_account_in_cache
svr_account_in_cache -- 是 --> svr_return_cached_account --> svr_select_gate
svr_account_in_cache -- 否 --> svr_query_database --> svr_account_exists
svr_account_exists -- 否 --> svr_return_not_registered_error
svr_account_exists -- 是 --> svr_update_login_time --> svr_cache_account_info --> svr_select_gate
svr_select_gate --> svr_generate_jwt --> svr_return_login_response
客户端拿 Token 连接 Gate
鉴权服返回成功后,客户端还没有真正进入游戏。
客户端只是拿到了一张短期登录票据。
这里客户端会做两件事:
- 检查鉴权服返回的错误码。
- 解析 Token 的 Payload,拿到 Gate 地址,然后连接 Gate。
这里要注意:客户端解析 Token 只是为了拿地址,不代表客户端完成了安全校验。
JWT 的 Payload 默认只是 Base64Url 编码,客户端能解析很正常。
真正判断 Token 有没有被篡改、是不是鉴权服签发的,要放在 Gate 上做。
graph TD
subgraph auth_response["客户端处理鉴权服响应"]
cli_receive_login_response["接收鉴权服登录响应"]
cli_check_error_code{"错误码是否为 0?"}
cli_handle_auth_error["处理鉴权错误,结束登录流程"]
cli_parse_token["解析 Token Payload"]
cli_token_parse_success{"解析是否成功?"}
cli_extract_gate_server["提取 Gate 地址"]
end
subgraph gate_connect["客户端连接 Gate"]
cli_create_gate_session["创建 Gate Session"]
cli_send_gate_login_request["发送 Gate 登录请求 {Token}"]
cli_wait_gate_response["等待 Gate 登录响应"]
end
cli_receive_login_response --> cli_check_error_code
cli_check_error_code -- 否 --> cli_handle_auth_error
cli_check_error_code -- 是 --> cli_parse_token
cli_parse_token --> cli_token_parse_success
cli_token_parse_success -- 否 --> cli_handle_auth_error
cli_token_parse_success -- 是 --> cli_extract_gate_server
cli_extract_gate_server --> cli_create_gate_session --> cli_send_gate_login_request --> cli_wait_gate_response
Gate 验签与账号接入
客户端连到 Gate 后,会把鉴权服签发的 Token 发给 Gate。
Gate 这里才是安全校验的关键位置。
它至少要检查几件事:
- Token 是否为空。
- JWT 签名是否正确。
issuer是否可信。audience是否符合当前服务。- Token 是否过期。
- Token 里的
SceneId是否匹配当前 Gate。 - Token 里的账号 ID 是否能加载到对应
GameAccount。
这一步可以理解成入口检票:
票面内容能看到不重要,重点是防伪章是不是真的、票有没有过期、这张票是不是发给当前入口的。
graph TD
subgraph gate_verify["Gate 验证 Token"]
gate_receive_login_request["接收 Gate 登录请求"]
gate_check_token_empty{"Token 是否为空?"}
gate_disconnect_empty_token["断开非法连接"]
gate_verify_jwt["验证 JWT 签名、发行者、接收方"]
gate_jwt_valid{"JWT 是否有效?"}
gate_disconnect_invalid_token["断开非法连接"]
gate_parse_jwt["解析账号 ID 和 SceneId"]
gate_check_scene_id{"SceneId 是否匹配当前 Gate?"}
gate_disconnect_wrong_gate["断开错误 Gate 连接"]
end
subgraph gate_account["Gate 加载 GameAccount"]
gate_get_account_cache["获取 GameAccountManageComponent"]
gate_check_account_cache{"账号是否已在 Gate 缓存中?"}
gate_load_from_db["从数据库加载 GameAccount"]
gate_account_in_db{"数据库中是否已有 GameAccount?"}
gate_create_new_account["首次进入时创建 GameAccount"]
gate_add_to_cache["加入 Gate 账号缓存"]
gate_continue_session_check["进入 Session 绑定和顶号判断"]
end
gate_receive_login_request --> gate_check_token_empty
gate_check_token_empty -- 是 --> gate_disconnect_empty_token
gate_check_token_empty -- 否 --> gate_verify_jwt
gate_verify_jwt --> gate_jwt_valid
gate_jwt_valid -- 否 --> gate_disconnect_invalid_token
gate_jwt_valid -- 是 --> gate_parse_jwt --> gate_check_scene_id
gate_check_scene_id -- 否 --> gate_disconnect_wrong_gate
gate_check_scene_id -- 是 --> gate_get_account_cache --> gate_check_account_cache
gate_check_account_cache -- 是 --> gate_continue_session_check
gate_check_account_cache -- 否 --> gate_load_from_db --> gate_account_in_db
gate_account_in_db -- 是 --> gate_add_to_cache
gate_account_in_db -- 否 --> gate_create_new_account --> gate_add_to_cache
gate_add_to_cache --> gate_continue_session_check
Session 绑定、顶号和断线处理
Gate 验签通过并拿到 GameAccount 后,才进入真正的连接状态管理。
这一段主要处理三个问题:
- 当前账号应该绑定到哪个 Session。
- 如果旧 Session 还活着,要不要顶号。
- 如果 Session 断了,账号是立即下线,还是延迟下线等重连。
这里的关键字段是 SessionRunTimeId。
- 如果当前 Session 的
RunTimeId和账号上记录的一样,说明是同一个连接重复发送登录请求。 - 如果不一样,说明有新连接进来,可能是断线重连,也可能是另一个客户端顶号。
- 如果旧 Session 还存在,就先清空旧 Session 的账号标识,再通知旧客户端被顶号,最后延迟断开旧 Session。
清空旧 Session 的账号标识很重要。
否则旧 Session 销毁时还会触发账号下线逻辑,可能把刚登录成功的新 Session 一起影响掉。
graph TD
subgraph session_bind["Session 绑定与顶号处理"]
gate_check_duplicate_session{"SessionRunTimeId 是否相同?"}
gate_return_repeat_request["重复登录请求,直接返回"]
gate_get_old_session["尝试获取旧 Session"]
gate_old_session_exists{"旧 Session 是否还存在?"}
gate_clear_old_session_flag["清空旧 Session 的账号标识"]
gate_send_repeat_login["通知旧客户端被顶号"]
gate_delay_disconnect_old_session["延迟 3 秒断开旧 Session"]
gate_bind_session["当前 Session 绑定账号标识组件"]
gate_update_session_id["更新账号 SessionRunTimeId"]
gate_return_gate_response["返回 Gate 登录响应"]
end
subgraph disconnect_process["Gate 会话断开处理"]
session_disconnect["Session 断开"]
session_flag_destroy["账号标识组件销毁"]
session_call_disconnect["调用账号 Disconnect"]
session_check_cache{"账号是否还在缓存?"}
session_immediate_disconnect["立即保存并移除缓存"]
session_check_session{"当前 Session 是否仍匹配?"}
session_cancel_timeout["取消旧的延迟下线定时器"]
session_decide_timeout{"立即下线还是延迟下线?"}
session_delay_disconnect["设置延迟下线定时器"]
end
gate_check_duplicate_session -- 是 --> gate_return_repeat_request
gate_check_duplicate_session -- 否 --> gate_get_old_session --> gate_old_session_exists
gate_old_session_exists -- 是 --> gate_clear_old_session_flag --> gate_send_repeat_login --> gate_delay_disconnect_old_session --> gate_bind_session
gate_old_session_exists -- 否 --> gate_bind_session
gate_bind_session --> gate_update_session_id --> gate_return_gate_response
session_disconnect --> session_flag_destroy --> session_call_disconnect --> session_check_cache
session_check_cache -- 否 --> session_immediate_disconnect
session_check_cache -- 是 --> session_check_session
session_check_session -- 否 --> session_immediate_disconnect
session_check_session -- 是 --> session_cancel_timeout --> session_decide_timeout
session_decide_timeout -- 立即下线 --> session_immediate_disconnect
session_decide_timeout -- 延迟下线 --> session_delay_disconnect
拆开以后,这篇主要盯三条线:
- 账号校验线:用户名密码是否正确,账号应该由哪个鉴权服处理。
- Token 跳转线:鉴权服签发 Token,客户端拿 Token 去连接指定 Gate。
- Session 状态线:Gate 把账号和 Session 绑定,处理重复登录、顶号、断线和延迟下线。
这三条线分清楚,后面读代码就不容易把职责看串。
鉴权服管的是“这个账号能不能登录、应该去哪个 Gate”;Gate 管的是“这张票能不能进来、进来后这个连接怎么挂到账号上”。
4.2 客户端登录前准备
客户端登录前需要准备两个能力:
- 根据用户名选择鉴权服务器。
- 解析鉴权服返回的 JWT,拿到 Gate 地址和场景信息。
// 添加认证服务器选择组件(一致性哈希算法选择服务器)
_scene.AddComponent<AuthenticationSelectComponent>();
// 添加JWT解析组件(用于处理身份验证令牌)
_scene.AddComponent<JWTParseComponent>();
这里和注册流程一样,客户端会根据用户名选择鉴权服。
不过这里要再强调一遍:客户端选择鉴权服只是第一层分流,不是最终可信判断。
客户端可能拿到旧配置,也可能被修改请求目标。
所以鉴权服收到请求后,必须用同一套规则重新计算账号归属。如果发现这个账号不应该由当前节点处理,就直接返回错误,不继续查数据库。
JWT 解析组件的作用也要分清楚:
- 客户端解析 JWT,主要是为了拿到 Gate 地址。
- 客户端本地解析 JWT,不代表它完成了安全校验。
- 真正的签名校验要放在 Gate 上做。
- 客户端看到的 Payload 只能当作“路由信息”,不能当作安全依据。
JWT 的 Payload 默认只是 Base64Url 编码,不是加密。
所以客户端能解析出来很正常,但能解析不代表可信。真正判断这张票是不是鉴权服签发的,要靠 Gate 的验签逻辑。
4.3 鉴权服处理登录请求
鉴权服这边需要两个组件:
AuthenticationComponent:负责注册、登录、账号归属判断、登录缓存等逻辑。AuthenticationJwtComponent:负责签发登录 Token。
// 用于鉴权服务器注册和登录相关逻辑的组件
scene.AddComponent<AuthenticationComponent>().UpdatePosition();
// 用于颁发Token证书相关的逻辑。
scene.AddComponent<AuthenticationJwtComponent>();
客户端发起登录时,期望拿到两个东西:
- 错误码:判断用户名密码是否正确、账号是否归当前鉴权服处理。
- Token:登录成功后,客户端拿这个 Token 去连接 Gate。
// 调用 AuthenticationHelper.Login 方法验证用户名和密码,并等待返回登录结果
var result = await AuthenticationHelper.Login(scene, request.Username, request.Password);
鉴权服接收登录消息的入口如下:
public class C2A_LoginRequrstHandler : MessageRPC<C2A_LoginRequrst, A2C_LoginResponse>
{
protected override async FTask Run(Session session, C2A_LoginRequrst request, A2C_LoginResponse response,
Action reply)
{
//...
}
}
这里的 C2A_LoginRequrstHandler 只是入口,真正的账号校验逻辑放在 AuthenticationComponent.Login 里。
// Login 方法实现账号登录逻辑,接受用户名和密码,返回错误码和账号ID
// 返回的错误码定义如下:
// 0:登录成功
// 1:参数不完整(用户名或密码为空)
// 2:账号不存在或密码错误(数据库中未找到匹配账号)
// 3:该账号不应在当前鉴权服务器处理(负载均衡判断失败)
internal static async FTask<(uint ErrorCode, long AccountId)> Login(this AuthenticationComponent self,
string userName, string password)
{
//...
}
这个方法返回得比较干净:
ErrorCode告诉外层是否登录成功。AccountId只在登录成功时有效。- Token 不在这个方法里直接生成,而是在登录成功后由外层根据账号 ID 继续分配 Gate 并签发。
这里拆开是有意义的:Login 方法只回答“账号是否能登录”,不要顺手把“玩家应该连哪个 Gate”也塞进去。入口分配放到外层,后面要换 Gate 分配策略也更好动。
参数校验和账号归属校验
登录首先要做基础参数校验。用户名或密码为空,没有必要继续走缓存和数据库。
// 检查用户名和密码是否为空,若任一为空则无法进行登录操作
if (string.IsNullOrEmpty(userName) || string.IsNullOrEmpty(password))
{
// 返回错误码1,表示参数不完整,同时账号ID返回0
return (1, 0);
}
// 利用哈希算法(MurmurHash3)计算用户名的哈希值,并对总鉴权服务器数取余
// 这个计算结果用于判断当前账号请求应该由哪台鉴权服务器处理
var position = HashCodeHelper.MurmurHash3(userName) % self.AuthenticationCount;
if (self.Position != position)
{
// 如果当前服务器的 Position 与计算得到的不匹配,
// 则说明此账号请求不该由当前服务器处理,返回错误码3
return (3, 0);
}
这里和注册流程是同一个思路:
- 客户端先根据用户名选鉴权服。
- 鉴权服收到请求后,自己再算一遍。
- 当前节点不匹配,就直接拒绝。
这个校验不能省。
因为客户端分流只能减轻服务端入口压力,不能作为服务端信任来源。
真放到线上,这里通常还会在入口前面补几层控制:
- 登录请求频率限制。
- Session 超时时间。
- IP 或设备维度的风控。
- 同账号短时间失败次数统计。
- 验证码或二次验证。
学习代码先把主流程跑通没问题,但正式入口不能只靠用户名密码校验硬扛所有流量。
同账号登录并发控制
登录和注册一样,也要考虑同一个账号短时间内并发请求的问题。
常见场景有:
- 玩家连续点击登录按钮。
- 网络抖动后客户端重试。
- 同一个账号在多个设备同时登录。
- 恶意脚本快速重复提交。
这类请求如果同时查缓存、查数据库、更新登录状态,很容易造成状态覆盖或者顶号顺序混乱。
所以这里按用户名加一把协程锁,让同一个账号的登录流程串行执行。
// 获取当前场景对象,方便后续调用数据库和缓存操作
var scene = self.Scene;
// 从场景中获取世界数据库对象,用于查询账号信息
var worldDateBase = scene.World.DateBase;
// 计算用户名的标准哈希码,用作协程锁的键值,防止同一账号并发请求
var usernameHashCode = userName.GetHashCode();
// 使用协程锁机制,确保同一账号的登录请求不会同时操作数据库或缓存
using (var @lock =
await scene.CoroutineLockComponent.Wait((int)LockType.AuthenticationLoginLock, usernameHashCode))
{
//...
}
这把锁的目标不是把所有登录请求串行化。
如果所有账号共用一把锁,那登录入口就废了。
这里要锁的是“同一个用户名”。不同账号之间仍然可以并行登录。
有点像银行柜台办业务:同一个账号的几张申请单要排队处理,但不同人的业务不应该互相卡住。
这里还有一个小边界:
userName.GetHashCode()在一些运行环境里可能不是稳定跨进程哈希。- 如果只是当前进程内做协程锁,一般问题不大。
- 如果锁 Key 要跨进程、跨机器共享,就应该用稳定哈希,比如 MurmurHash3,或者直接使用规范化后的用户名作为分布式锁 Key。
查询登录缓存和数据库
进入锁之后,才开始处理缓存和数据库。
当前代码里使用 userName + password 作为登录缓存 Key。
这个写法看流程很直观,但真项目里不建议这么做:
- 字符串直接拼接可能出现边界歧义,比如
ab + c和a + bc。 - 密码不应该以明文形式参与缓存 Key。
- 正式项目里应该存密码哈希,不应该用明文密码查数据库。
- 登录缓存也要考虑账号封禁、改密码、Token 版本变化等状态。
抛开这些工程细节,这段代码要表达的主线没问题:
先查短期缓存,没命中再查数据库,查到后更新登录时间并写入缓存。
// 使用协程锁机制,确保同一账号的登录请求不会同时操作数据库或缓存
using (var @lock =
await scene.CoroutineLockComponent.Wait((int)LockType.AuthenticationLoginLock, usernameHashCode))
{
/*
为了防止用户频繁提交登录请求对数据库造成过多压力,
采用缓存策略存储登录状态:
1. 将用户名和密码拼接成唯一的键 loginAccountsKey
2. 检查缓存中是否存在该账号的登录信息
3. 如果存在直接返回缓存中的账号信息,否则查询数据库
4. 为新查询的账号创建一个缓存实体,并设置超时自动清理,防止缓存无限增长
*/
Account account = null;
var loginAccountsKey = userName + password;
// 检查缓存中是否已有该账号的登录信息
if (self.LoginAccountCacheDic.TryGetValue(loginAccountsKey, out var accountCacheInfo))
{
// 尝试从缓存实体中获取 Account 组件
account = accountCacheInfo.GetComponent<Account>();
// 如果缓存中的账号数据无效(例如已被清理),返回错误码2
if (account == null)
{
return (2, 0);
}
// 成功从缓存中获取账号,返回错误码0及账号ID
return (0, account.Id);
}
// 如果缓存中没有找到对应账号,则初始化 result 变量并创建一个新的缓存实体
uint result = 0;
accountCacheInfo = Entity.Create<AccountCacheInfo>(scene, true, true);
// 从数据库中查询账号,条件为用户名和密码均匹配
account = await worldDateBase.First<Account>(d => d.Username == userName && d.Password == password);
// 若数据库中未查询到该账号,则将错误码设为2,表示账号不存在或密码错误
if (account == null)
{
result = 2;
}
// 若查询到账号,则更新账号的登录时间,并保存最新数据到数据库中
else
{
account.LoginTime = TimeHelper.Now;
await worldDateBase.Save(account);
// 调用 Deserialize 方法初始化账号实体(例如加载必要组件)
account.Deserialize(scene);
// 将查询到的账号数据添加到缓存实体中,方便后续快速登录验证
accountCacheInfo.AddComponent(account);
}
// 为缓存实体添加超时组件,设置缓存有效期为5000毫秒,到期后自动清理
accountCacheInfo.AddComponent<AccountCacheInfoTimeOut>().TimeOut(loginAccountsKey, 5000);
// 将缓存实体存入 AuthenticationComponent 的 LoginAccounts 字典中
self.LoginAccountCacheDic.Add(loginAccountsKey, accountCacheInfo);
// 如果之前查询账号过程中出现错误,则直接返回错误码及账号ID为0
if (result != 0)
{
return (result, 0);
}
// 若一切正常,返回错误码0以及账号的唯一ID,表示登录成功
return (0, account.Id);
}
这块我会记成下面几条:
- 登录缓存是为了挡住短时间重复请求,不是为了长期保存账号状态。
- 缓存时间设置得很短,比如这里的 5000 毫秒。
- 同账号请求先进锁,再查缓存,避免两个请求同时穿透到数据库。
- 数据库查不到账号时,返回统一错误码,不要区分“账号不存在”和“密码错误”太细,避免被撞库探测。
- 正式项目里密码应该走哈希校验,比如
PasswordHash + Salt或 BCrypt / Argon2 这类方案。
4.4 鉴权服分配 Gate 并签发 Token
账号登录成功后,鉴权服还要决定客户端接下来连哪个 Gate。
当前代码的分配逻辑比较简单:
根据账号 ID 对 Gate 数量取模,得到一个 Gate 下标。
// 如果登录成功(ErrorCode 为 0),则继续执行后续分配 Gate 服务器的逻辑
if (result.ErrorCode == 0)
{
// 从配置表或其他方式中获取所有 Gate 服务器的配置信息
var gates = SceneConfigData.Instance.GetSceneBySceneType(SceneType.Gate);
// 根据当前账号的 ID 通过取余计算分配的 Gate 服务器位置下标
var gatePosition = result.AccountId % gates.Count;
// 根据计算得到的下标获取具体的 Gate 服务器配置信息
var gateSceneConfig = gates[(int)gatePosition];
// 从 Gate 服务器的配置中获取外网端口信息
var outerPort = gateSceneConfig.OuterPort;
// 根据 Gate 服务器配置的 ProcessConfigId 获取进程配置信息
var processConfig = ProcessConfigData.Instance.Get(gateSceneConfig.ProcessConfigId);
// 根据进程配置的 MachineId 获取机器配置信息(包含外网 IP 地址等)
var machineConfig = MachineConfigData.Instance.Get(processConfig.MachineId);
// 利用 AuthenticationJwtHelper 生成 JWT 令牌,包含场景、账号、外网地址和 Gate 服务器 ID 等信息,并赋值给响应
response.Token = AuthenticationJwtHelper.GetToken(scene, result.AccountId,
$"{machineConfig.OuterIP}:{outerPort}", gateSceneConfig.Id);
}
// 将登录结果的错误码赋值给响应,供客户端判断登录是否成功
response.ErrorCode = result.ErrorCode;
// 输出调试日志,显示当前处理登录请求的服务器 SceneConfigId
Log.Debug($"Login 当前的服务器是:{scene.SceneConfigId}");
这段逻辑其实就是签一张“入场票”:
aId:账号 ID,Gate 后面用它加载GameAccount。Address:客户端接下来要连接的 Gate 外网地址。SceneId:目标 Gate 的场景配置 ID,Gate 验票时要确认这张票是不是发给自己的。exp:过期时间,避免登录票据长期有效。- 签名:证明这张票确实是鉴权服发的,客户端不能自己改。
当前示例里把 Gate 地址也放进了 JWT Payload。
这样写对学习来说很直观:客户端解析 Token 就能拿到连接地址。
但边界也要清楚:
- Payload 默认不是加密的,客户端能看到里面的地址。
- 客户端可以尝试篡改 Payload,但改完签名就会失效。
- 所以客户端解析地址只是为了连接,不能因为解析成功就认为 Token 可信。
- Gate 必须重新验签,并且检查
SceneId是否匹配当前 Gate。
生成 Token 的方法如下:
// 定义GetToken方法,用于生成JWT令牌
public static string GetToken(this AuthenticationJwtComponent self, long aId, string address, uint sceneId)
{
// 创建JWT的负载部分,包含自定义的声明
var jwtPayload = new JwtPayload()
{
{ "aId", aId }, // 添加账号ID声明
{ "Address", address }, // 添加地址声明
{ "SceneId", sceneId } // 添加场景ID声明
};
// 创建JWT安全令牌,设置发行者、受众、声明、过期时间和签名凭证
var jwtSecurityToken = new JwtSecurityToken(
issuer: "Tao", // 设置令牌的发行者
audience: "Tao", // 设置令牌的受众
claims: jwtPayload.Claims, // 设置令牌的声明
expires: DateTime.UtcNow.AddMilliseconds(3000), // 设置令牌的过期时间
signingCredentials: self.SigningCredentials); // 设置令牌的签名凭证
// 使用JwtSecurityTokenHandler将令牌对象序列化为字符串,并返回
return new JwtSecurityTokenHandler().WriteToken(jwtSecurityToken);
}
这里有几个坑最好顺手记一下。
expires: DateTime.UtcNow.AddMilliseconds(3000)表示这个 Token 只有 3 秒有效期。
这更像登录跳转票据,不是长期登录态。JWT 的过期时间应该使用 UTC 时间。
不要用本地时间,否则跨时区、服务器时钟不一致时容易出问题。issuer和audience现在都写成了"Tao"。
学习阶段没问题,正式项目里建议拆清楚,比如issuer = AuthServer,audience = GateServer。客户端解析方法里的形参写成了
toKen,这类大小写不统一虽然不影响运行,但后面查日志、搜代码、生成协议文档时都会有点别扭。
真项目里最好统一成token或Token,不要在同一条链路里混着来。
按“临时门票”这个比喻看:
aId是玩家身份。Address是入口地址。SceneId是指定入口编号。exp是门票有效期。- RSA 私钥签名是防伪章。
- Gate 上的公钥是验章模板。
4.5 客户端处理登录响应并连接 Gate
客户端收到鉴权服响应后,第一件事不是解析 Token,而是先看错误码。
// 发送登录的请求给鉴权服务器
var response = (A2C_LoginResponse)await _session.Call(new C2A_LoginRequrst()
{
Username = UsernameInput.text,
Password = PasswordInput.text,
LoginType = 1
});
// 如果登录失败,就直接返回了。
if (response.ErrorCode != 0)
{
Log.Error($"登录发生错误{response.ErrorCode}");
return;
}
// 登录成功,尝试解析Token
if (!_scene.GetComponent<JWTParseComponent>().Parse(response.Token, out var payload))
{
//解析失败后返回
return;
}
这个顺序不能反过来:
- 错误码不为 0,说明鉴权服没有签发有效 Token。
- 没有 Token,就不应该继续连接 Gate。
- Token 解析失败,说明客户端连 Gate 的地址都拿不到,也应该结束流程。
Payload 定义如下:
/// <summary>
/// JWT令牌负载数据结构
/// 用于存储和传输认证后的用户会话信息
/// </summary>
public sealed class Payload
{
/// <summary>
/// 账户唯一标识(自定义声明)
/// </summary>
public long aId { get; set; }
/// <summary>
/// 游戏服务器地址(自定义声明)
/// 格式示例:127.0.0.1:30001
/// </summary>
public string Address { get; set; }
/// <summary>
/// 游戏场景/地图ID(自定义声明)
/// </summary>
public uint SceneId { get; set; }
/// <summary>
/// 过期时间(标准JWT声明)
/// 使用Unix时间戳格式(秒数)
/// </summary>
public int exp { get; set; }
/// <summary>
/// 签发者(标准JWT声明)
/// 示例:AuthenticationServer
/// </summary>
public string iss { get; set; }
/// <summary>
/// 接收方(标准JWT声明)
/// 示例:GameServer
/// </summary>
public string aud { get; set; }
}
客户端解析 Token 的代码如下:
/// <summary>
/// JWT解析组件扩展系统
/// 实现JWT令牌的解析和基本验证功能
/// </summary>
public static class JWTParseComponentSystem
{
/// <summary>
/// 解析JWT令牌并验证基本格式
/// </summary>
/// <param name="self">组件实例</param>
/// <param name="toKen">待解析的JWT令牌字符串</param>
/// <param name="payloadData">输出解析后的负载数据</param>
/// <returns>是否解析成功</returns>
/// <remarks>
/// 示例令牌结构:
/// payload:{"aId":433552570364198912,"Address":"127.0.0.1:8080","exp":1729842327,"iss":"Tao","aud":"Tao"}
/// </remarks>
public static bool Parse(this JWTParseComponent self, string toKen, out Payload payloadData)
{
payloadData = null;
try
{
// 步骤1:验证JWT基础结构
// JWT标准结构:Header.Payload.Signature
var tokens = toKen.Split('.');
// 验证令牌分段数量
if (tokens.Length != 3)
{
Log.Error($"无效的JWT令牌结构,分段数量:{tokens.Length}");
return false;
}
// 步骤2:处理Base64URL编码
// JWT的Payload不是标准的Base64格式,因为咱们C#的Convert要求是一个标准的Base64格式。
// JWT的Payload是Base64URL格式,它里面的"-"替代成了"+","_"替代成了"/",需要把这些给还原成Base64格式
var payloadSegment = tokens[1]
.Replace('-', '+') // 替换URL安全字符
.Replace('_', '/'); // JWT规范中的Base64URL编码转换
// 步骤3:Base64填充处理
// 因为Base64的编码长度需要是4的倍数,如果不是的话咱们需要把这个长度用=来填充,使其长度符合要求
// 注意:case 0和1不需要处理:
// - case 0:长度正好是4的倍数
// - case 1:根据RFC4648标准,输入长度不可能产生余数1的情况
switch (payloadSegment.Length % 4)
{
// case 0:
// {
// // 如果这个余数是0,那就表示长度已经是4的倍数,那就没必要处理了。
// break;
// }
// case 1:
// {
// // 如果这个余数是1,那就表示这个Base64格式是不正确的,不是咱们发放的token。
// // 这样情况下,也不需要处理了。
// // 可以打印个报错
// break;
// }
case 2:
payloadSegment += "=="; // 补2个填充字符
break;
case 3:
payloadSegment += "="; // 补1个填充字符
break;
}
// 步骤4:解码和反序列化
// 把Base64编码的字符串反序列化为字节数组
var payloadBytes = Convert.FromBase64String(payloadSegment);
// 把字节数组反序列化为JSON字符串
var payloadJson = System.Text.Encoding.UTF8.GetString(payloadBytes);
// 使用自定义JSON反序列化器
// 这里使用的是Newtonsoft.Json
payloadData = JsonHelper.Deserialize<Payload>(payloadJson);
// 步骤5:基础验证(可扩展)
// 注意:当前实现仅验证令牌结构,未验证签名和过期时间!
return true;
}
catch (FormatException ex)
{
Log.Error($"Base64解码失败: {ex.Message}");
return false;
}
catch (JsonException ex)
{
Log.Error($"JSON解析失败: {ex.Message}");
return false;
}
catch (Exception ex)
{
Log.Error($"令牌解析异常: {ex}");
return false;
}
}
}
这段代码有一个很重要的注释:
// 注意:当前实现仅验证令牌结构,未验证签名和过期时间!
这点一定要分清。
客户端解析 JWT,只是在读取 Payload。
它没有验证签名,也没有验证过期时间,所以不能认为“客户端解析成功 = Token 合法”。
但放在当前流程里问题不大,因为客户端只是拿 Address 去连 Gate。
安全账要算在 Gate 这边:
- Token 被改过,Gate 验签失败。
- Token 过期,正式环境里 Gate 应该拒绝。
SceneId不匹配当前 Gate,Gate 应该拒绝。aId不存在或账号状态异常,Gate 也可以拒绝。
客户端拿到 Gate 地址后,创建新的 Gate Session,并把原始 Token 发给 Gate。
// 根据Token返回的Address登录到Gate服务器
_session = SessionHelper.CreateSession(_scene, payload.Address, OnConnectComplete, OnConnectFail,
OnConnectDisconnect);
// 发送登录请求到Gate服务器
var loginResponse = (G2C_LoginResponse)await _session.Call(new C2G_LoginRequest()
{
Token = response.Token
});
// 处理Gate服务器登录结果
if (loginResponse.ErrorCode != 0)
{
Log.Error($"Gate登录失败,错误码: {loginResponse.ErrorCode}");
return;
}
Log.Debug($"登录Gate服务器成功!登录时间: {loginResponse.GameAccountInfo.LoginTime} 创建时间: {loginResponse.GameAccountInfo.CreateTime}");
走到这里,客户端才算从鉴权服跳到了 Gate 登录阶段。
4.6 Gate 验证 Token
Gate 需要两个能力:
- 验证 JWT 是否可信。
- 管理当前 Gate 上的
GameAccount缓存。
// 用于验证JWT是否合法的组件
scene.AddComponent<GateJWTComponent>();
// 用于管理GameAccount的组件
scene.AddComponent<GameAccountManageComponent>();
GameAccount 和注册阶段的 Account 不是同一个概念。
Account更偏账号系统,保存用户名、密码、注册时间、登录时间等信息。GameAccount更偏游戏入口账号,和 Gate Session、断线重连、顶号处理相关。
当前 GameAccount 定义如下:
public sealed class GameAccount : Entity
{
// AuthenticationId: 用于存储身份验证的唯一标识符。
// 可以通过 Token 中传递的 AId 来设置此字段的值。
public long AuthenticationId;
// CreateTime: 记录账户创建的时间戳。
public long CreateTime;
// LoginTime: 记录账户最后一次登录的时间戳。
public long LoginTime;
// SessionRunTimeId: 用于标识会话的唯一运行时标识符。
// 使用 [BsonIgnore] 特性,表示在序列化时忽略此字段,
// 即该字段不会被保存到 MongoDB 数据库中,也不会参与查询操作。
[BsonIgnore] public long SessionRunTimeId;
}
SessionRunTimeId 不应该落库。
它只表示当前进程里的连接状态,不是账号档案。服务器一重启,这个 Session 本来也没了,存下来反而容易误导后续逻辑。
Gate 收到客户端登录请求后,先检查 Token 是否为空:
/// <summary>
/// 处理客户端登录请求的消息处理器
/// </summary>
public sealed class C2G_LoginRequestHandler : MessageRPC<C2G_LoginRequest, G2C_LoginResponse>
{
/// <summary>
/// 处理登录请求的核心方法
/// </summary>
/// <param name="session">客户端会话</param>
/// <param name="request">客户端发送的登录请求</param>
/// <param name="response">服务器返回的登录响应</param>
/// <param name="reply">回复客户端的回调函数</param>
protected override async FTask Run(Session session, C2G_LoginRequest request, G2C_LoginResponse response,
Action reply)
{
// 检查请求中的Token是否为空
if (string.IsNullOrEmpty(request.Token))
{
// 1、客户端漏传了 response.ErrorCode = 1;
// 2、恶意攻击导致的 session.Dispose();
session.Dispose();
return;
}
var scene = session.Scene;
// 验证Token的有效性,并获取账号ID
if (!GateJWTHelper.ValidateToken(scene, request.Token, out var accountId))
{
// 如果失败,表示肯定是恶意攻击、所以毫不犹疑,直接断开当前会话。
session.Dispose();
return;
}
//...
}
}
这里顺便说下错误处理策略。
学习代码里直接 session.Dispose() 很省事。
线上一般会分得细一点:
- Token 为空:可以返回错误码,也可以直接断开。
- Token 格式错误:更偏非法请求,可以断开。
- Token 过期:可以返回“登录已过期”,让客户端重新登录。
- Token 场景不匹配:可以断开,也可以返回错误码方便排查。
- 签名无效:通常直接断开,并记录安全日志。
不是所有失败都要弹给客户端看,尤其是明显非法请求。
但服务端日志一定要分清原因,否则线上只看到“登录失败”,基本没法定位。
验签和场景匹配
Gate 验证 Token 时,不只是验证签名,还要检查 Token 里写的 SceneId 是否等于当前 Gate 的 SceneConfigId。
/// <summary>
/// Gate 服务器的 JWT 帮助类,提供验证 JWT 令牌的功能。
/// </summary>
public static class GateJWTHelper
{
/// <summary>
/// 验证 JWT 令牌是否合法,并提取其中的账户 ID。
/// </summary>
/// <param name="scene">当前的场景对象。</param>
/// <param name="token">待验证的 JWT 令牌。</param>
/// <param name="accountId">输出参数,验证成功时返回账户 ID。</param>
/// <returns>如果验证成功且令牌中的场景 ID 与当前场景匹配,返回 true;否则返回 false。</returns>
public static bool ValidateToken(Scene scene, string token, out long accountId)
{
// 调用内部方法验证令牌,并获取其负载信息
if (!ValidateToken(scene, token, out JwtPayload payload))
{
// 如果令牌验证失败,设置 accountId 为 0,并返回 false
accountId = 0;
return false;
}
// 从令牌的负载中提取场景 ID 和账户 ID
var sceneId = Convert.ToInt64(payload["SceneId"]);
accountId = Convert.ToInt64(payload["aId"]);
// 检查令牌中的场景 ID 是否与当前场景的配置 ID 匹配
// 如果不匹配,表示该连接不应连接到当前的 Gate,返回 false
return sceneId == scene.SceneConfigId;
}
/// <summary>
/// 内部方法:验证 JWT 令牌的有效性,并提取其负载信息。
/// </summary>
/// <param name="token">待验证的 JWT 令牌。</param>
/// <param name="payload">输出参数,验证成功时返回令牌的负载信息。</param>
/// <returns>如果验证成功,返回 true;否则返回 false。</returns>
private static bool ValidateToken(Scene scene, string token, out JwtPayload payload)
{
// 调用 GateJWTComponent 组件的 ValidateToken 方法进行验证
return scene.GetComponent<GateJWTComponent>().ValidateToken(token, out payload);
}
}
SceneId 检查很关键。
否则客户端拿到一张发给 Gate A 的票,理论上可能尝试去连 Gate B。即使签名没被改,票面上写的入口也不对,Gate B 就应该拒绝。
这个逻辑还是检票那套思路:
- 票是真的。
- 票也没过期。
- 但票上写的是 A 入口。
- 玩家拿去 B 入口,B 入口也不能放行。
GateJWTComponent 初始化和验证
Gate 使用 RSA 公钥验证 Token。
// GateJWTComponentSystem 类用于处理与 JWT 相关的操作
public static class GateJWTComponentSystem
{
// Awake 方法用于初始化 GateJWTComponent 组件
public static void Awake(this GateJWTComponent self)
{
// 创建一个新的 RSA 实例,用于加密和解密
var rsa = RSA.Create();
// 使用从 Base64 编码的公钥字符串导入 RSA 公钥
rsa.ImportRSAPublicKey(Convert.FromBase64String(self.PublicKeyPem), out _);
// 创建签名凭据(SigningCredentials),使用 RSA 公钥和 SHA-256 算法
self.SigningCredentials = new SigningCredentials(new RsaSecurityKey(rsa), SecurityAlgorithms.RsaSha256);
// 配置验证参数(TokenValidationParameters),用于验证 JWT 令牌
self.TokenValidationParameters = new TokenValidationParameters
{
ValidateLifetime = false, // 禁用对令牌有效期的验证 实际项目中应该验证过期时间
ValidateIssuer = true, // 启用对令牌发行者的验证
ValidateAudience = true, // 启用对令牌受众的验证
ValidateIssuerSigningKey = true, // 启用对签名密钥的验证
ValidIssuer = "Tao", // 设置有效的令牌发行者为 "Tao"
ValidAudience = "Tao", // 设置有效的令牌受众为 "Tao"
IssuerSigningKey = new RsaSecurityKey(rsa) // 设置用于签名验证的 RSA 公钥
};
}
// ValidateToken 方法用于验证 JWT 令牌的合法性
public static bool ValidateToken(this GateJWTComponent self, string token, out JwtPayload payload)
{
payload = null; // 初始化 payload 为 null
try
{
// 创建 JwtSecurityTokenHandler 实例,用于处理和验证 JWT 令牌
var jwtSecurityTokenHandler = new JwtSecurityTokenHandler();
// 使用配置好的 TokenValidationParameters 验证令牌
jwtSecurityTokenHandler.ValidateToken(token, self.TokenValidationParameters, out _);
// 令牌验证成功,获取 JWT 令牌的有效载荷(Payload)
payload = jwtSecurityTokenHandler.ReadJwtToken(token).Payload;
return true; // 令牌验证通过,返回 true
}
catch (SecurityTokenInvalidAudienceException)
{
// 如果令牌的受众无效,输出错误信息并返回 false
Console.WriteLine("验证受众失败!");
return false;
}
catch (SecurityTokenInvalidIssuerException)
{
// 如果令牌的发行者无效,输出错误信息并返回 false
Console.WriteLine("验证发行者失败!");
return false;
}
catch (Exception e)
{
// 如果发生其他异常,输出异常信息并重新抛出异常
Console.WriteLine(e);
throw;
}
}
}
这里有个问题挺扎眼:
ValidateLifetime = false
这表示 Gate 当前不检查 Token 过期时间。
学 JWT 验签时先这么写方便调试,但放到登录链路里,这个值应该打开。
正式一点的配置至少要补这些:
ValidateLifetime = trueValidateIssuer = trueValidateAudience = trueValidateIssuerSigningKey = trueRequireExpirationTime = true- 合理设置
ClockSkew - 根据项目需要检查
tokenVersion、封号状态、顶号状态等业务状态
否则鉴权服生成了 3 秒过期的 Token,但 Gate 不校验过期时间,这个 exp 字段就只是写在票面上的数字,没有实际约束。
还有一点:ValidateToken 最后的 catch (Exception e) 重新 throw,异常会继续往外抛。
学习阶段这样能把问题暴露出来;线上更常见的是记录日志后返回 false,别让一个异常 Token 把消息处理流程打崩。
4.7 Gate 加载和缓存 GameAccount
Token 验证通过后,Gate 拿到了 accountId。
接下来要做的不是重新校验用户名密码,而是加载或创建游戏侧账号数据。
先拿账号管理组件:
// 获取账号管理组件 在缓存中检查该账号是否存在
var gameAccountManageComponent = scene.GetComponent<GameAccountManageComponent>();
Log.Debug("检查账号是否在缓存中");
GameAccountManageComponent 可以先理解成 Gate 上的一份账号运行时缓存。
// GameAccountManageComponent 继承自 Entity 类,表示游戏账户管理组件
public sealed class GameAccountManageComponent : Entity
{
// GameAccountCacheDic 字典用于缓存游戏账户信息
// 键(Key):long 类型的账户 ID
// 值(Value):GameAccount 类型的账户实体
public readonly Dictionary<long, GameAccount> GameAccountCacheDic = new();
}
它的基础增删查逻辑如下:
public sealed class GameAccountManageComponentDestroySystem : DestroySystem<GameAccountManageComponent>
{
protected override void Destroy(GameAccountManageComponent self)
{
// 遍历缓存字典中的所有 GameAccount 实体,调用 Dispose 方法释放资源。
foreach (var (_, gameAccount) in self.GameAccountCacheDic)
{
gameAccount.Dispose();
}
// 清空字典,确保没有留下任何对 GameAccount 实体的引用。
self.GameAccountCacheDic.Clear();
}
}
public static class GameAccountManageComponentSystem
{
// 向 GameAccountManageComponent 的缓存字典中添加一个新的 GameAccount 实体。
public static void Add(this GameAccountManageComponent self, GameAccount account)
{
// 使用账户的 Id 作为键,将 GameAccount 实体添加到字典中。
self.GameAccountCacheDic.Add(account.Id, account);
}
// 根据 accountId 从缓存字典中获取 GameAccount 实体,如果找不到则返回 null。
public static GameAccount? Get(this GameAccountManageComponent self, long accountId)
{
// 尝试获取字典中与 accountId 相关联的 GameAccount 实体,找不到时返回 null。
return self.GameAccountCacheDic.GetValueOrDefault(accountId);
}
// 尝试从缓存字典中获取 GameAccount 实体。如果找到则返回 true,并通过 out 参数返回账户对象,否则返回 false。
public static bool TryGet(this GameAccountManageComponent self, long accountId, out GameAccount? account)
{
// 尝试从字典中获取 GameAccount 实体,返回获取是否成功的结果,并通过 out 参数返回实体。
return self.GameAccountCacheDic.TryGetValue(accountId, out account);
}
// 从缓存字典中移除指定 accountId 的 GameAccount 实体,若 isDispose 为 true,还会销毁该实体。
public static void Remove(this GameAccountManageComponent self, long accountId, bool isDispose = true)
{
// 尝试从字典中移除 GameAccount 实体,如果移除失败(找不到指定 ID),则直接返回。
if (!self.GameAccountCacheDic.Remove(accountId, out var account))
{
return;
}
// 如果 isDispose 为 true,则销毁该 GameAccount 实体,释放其占用的资源。
if (isDispose)
{
account.Dispose();
}
}
}
为什么 Gate 要缓存 GameAccount?
主要是为了处理这些状态:
- 玩家当前绑定哪个 Session。
- 玩家是否正在延迟下线。
- 玩家断线重连时能不能复用账号对象。
- 新登录设备顶号时,能不能找到旧 Session。
- Gate 下线时能统一保存和清理账号数据。
如果每次 Gate 登录都查数据库,查完又不保留运行时状态,顶号和断线重连基本没法写顺。
缓存未命中时加载或创建账号
如果 Gate 缓存里没有这个账号,就先从数据库加载。
如果数据库里也没有,说明这是这个认证账号第一次进入游戏入口,需要创建 GameAccount。
// 尝试从缓存中获取账号信息
if (!gameAccountManageComponent.TryGet(accountId, out var account))
{
// 如果缓存中不存在,则从数据库加载 先要先到数据库中查询是否有这个账号
account = await GameAccountHelper.LoadDataBase(scene, accountId);
// 检查到账号没有在数据库中,需要创建一个新的账号并且保存到数据库中
if (account == null)
{
Log.Debug("检查到账号没有在数据库中,需要创建一个新的账号并且保存到数据库中");
// 如果没有,就要创建一个新的并且保存到数据库。
// 如果不存在,表示这是一个新的账号,需要创建一下这个账号。
account = await GameAccountHelper.Create(scene, accountId);
}
else
{
Log.Debug("检查到账号在数据库中");
}
Log.Debug("把当前账号添加到缓存中");
// 把创建完成的Account放入到缓存中
gameAccountManageComponent.Add(account);
}
// 缓存有这个账号
else
{
Log.Debug("检测到当前账号已经在缓存中了");
//...
}
创建和加载逻辑如下:
/// <summary>
/// 创建一个新的GameAccount实例。
/// </summary>
/// <param name="scene">当前的场景对象。</param>
/// <param name="aId">从Token令牌中传递过来的账号ID。</param>
/// <param name="isSaveDataBase">指示是否在创建过程中将数据保存到数据库,默认为true。</param>
/// <returns>返回创建的GameAccount实例。</returns>
public static async FTask<GameAccount> Create(Scene scene, long aId, bool isSaveDataBase = true)
{
// 创建一个新的GameAccount实体,传入场景、账号ID,以及是否需要保存到数据库的标志。
var gameAccount = Entity.Create<GameAccount>(scene, aId, false, false);
// 设置创建时间和登录时间为当前时间。
gameAccount.LoginTime = gameAccount.CreateTime = TimeHelper.Now;
// 如果需要保存到数据库,则异步保存GameAccount数据。
if (isSaveDataBase)
{
await gameAccount.SaveDataBase();
}
// 返回创建的GameAccount实例。
return gameAccount;
}
/// <summary>
/// 从数据库中读取GameAccount
/// </summary>
/// <param name="scene">数据库所在的Scene</param>
/// <param name="accountId">账号Id</param>
/// <returns></returns>
public static async FTask<GameAccount?> LoadDataBase(Scene scene, long accountId)
{
var account = await scene.World.DateBase.First<GameAccount>(d => d.Id == accountId);
if (account == null)
{
return null;
}
account.Deserialize(scene);
return account;
}
/// <summary>
/// 保存账号到数据库中
/// </summary>
/// <param name="self"></param>
public static async FTask SaveDataBase(this GameAccount self)
{
await self.Scene.World.DateBase.Save(self);
}
这里要先把边界划清楚:
Account是鉴权账号。GameAccount是游戏入口账号。- 当前用
Token里的aId作为GameAccount的 ID。 - 第一次登录 Gate 时,如果没有
GameAccount,就创建一条游戏账号记录。
如果后面要支持一个账号多个角色,就不要把 GameAccount 和角色数据搅在一起。
比较常见的拆法是:
Account:平台账号 / 登录账号。GameAccount:游戏账号入口状态。Role/Character:具体角色数据。Zone:区服维度。
这篇只看登录入口,角色系统先不展开。
4.8 Session 绑定、顶号和延迟下线
Gate 拿到 GameAccount 后,要把当前 Session 和账号绑定起来。
这个绑定不是随便记个字段,它是给后面的下线流程用的:Session 销毁时,要能反向找到账号。
// 给当前Session添加一个账号标识组件,当Session销毁的时候会销毁这个组件。
var accountFlagComponent = session.AddComponent<GameAccountFlagComponent>();
accountFlagComponent.AccountID = accountId;
accountFlagComponent.Account = account;
//每次登录成功,都要更新一下SessionRunTimeId
account.SessionRunTimeId = session.RunTimeId;
// 设置响应的账号信息
response.GameAccountInfo = account.GetGameAccountInfo();
Log.Debug($"当前的Gate服务器:{session.Scene.SceneConfigId} accountId:{accountId}");
GameAccountFlagComponent 的作用就是把 Session 和账号关联起来。
/// <summary>
/// 游戏账号标志组件,用于标识和关联游戏账号。
/// </summary>
public sealed class GameAccountFlagComponent : Entity
{
/// <summary>
/// 账号ID,用于唯一标识一个账号。
/// </summary>
public long AccountID;
/// <summary>
/// 关联的游戏账号实体引用。注意:当Account在其他地方被销毁时,这个引用可能仍然有效,但指向的可能是其他用户的账号。
/// </summary>
// 有一种可能,当在Account在其他地方被销毁
// 这时候因为这个Account是会回收到池子中,所以这个引用还是有效的
// 那这时候就会出现这个引用的Account可能是其他用户的了。
public EntityReference<GameAccount> Account;
}
这里用 EntityReference<GameAccount>,主要是防实体回收后的旧引用问题。
对象池场景下这个坑很隐蔽:引用不一定是 null,但它可能已经指向另一个复用后的实体。
Session 销毁时触发延迟下线
当客户端连接断开时,Session 会销毁。
Session 上的 GameAccountFlagComponent 也会销毁,于是可以在它的 DestroySystem 里触发账号下线逻辑。
/// <summary>
/// 处理 GameAccountFlagComponent 实体销毁时的逻辑。
/// </summary>
public sealed class GameAccountFlagComponentDestroySystem : DestroySystem<GameAccountFlagComponent>
{
/// <summary>
/// 当 GameAccountFlagComponent 实体被销毁时调用此方法。
/// </summary>
/// <param name="self">被销毁的 GameAccountFlagComponent 实体。</param>
protected override void Destroy(GameAccountFlagComponent self)
{
if (self.AccountID != 0)
{
// 执行下线过程,并要求在 5 分钟后完成缓存清理。
// 由于 5 分钟可能过长,建议在测试时将此时间缩短,例如改为 10 秒。
GameAccountHelper.Disconnect(self.Scene, self.AccountID, 1000 * 60 * 5).Coroutine();
// 重置 AccountID,以防止后续操作误用。
self.AccountID = 0;
}
// 解除对 Account 实体的引用,避免潜在的引用问题。
self.Account = null;
}
}
这里没有马上把账号从缓存里移除,而是走延迟下线。
目的就是给断线重连留窗口。
网络游戏里,客户端断开不一定代表玩家主动退出:
- 手机切后台。
- 网络闪断。
- Wi-Fi 和移动网络切换。
- 客户端短暂卡死。
- 心跳超时但玩家马上重连。
如果一断开就立刻保存、移除缓存,再重连时就要重新加载数据,体验和性能都不好。
延迟下线相当于给玩家留一个短暂缓冲,不要因为一次网络抖动就完整走下线、再登录、再加载。
Disconnect 的完整逻辑如下:
/// <summary>
/// 账号完整的断开逻辑,执行了这个接口后,该账号会完全下线。
/// </summary>
/// <param name="scene"></param>
/// <param name="accountId"></param>
/// <param name="timeOut"></param>
public static async FTask Disconnect(Scene scene, long accountId, int timeOut = 1000 * 60 * 3)
{
// 调用该方法有如下几种情况:
// 1、客户端主动断开,如:退出游戏、切换账号、等。 客户端会主动发送一个协议给服务器通知服务器断开。
// 2、客户端断线:无主动下线协议,由服务器心跳检测断开。
// 如果是心跳检测断开的Session,我怎么能拿到当前的这个账号来进行下线处理呢?
// 通过给当前的Session挂载一个组件,这个组件存AccountId,当销毁这个Session时候呢,也会销毁这个组件。
// 销毁这个组件时,调用这个AccountId的断开连接方法。
// 这样的话,是不是可以在登录的时候,给这个组件把AccountId存到这个组件呢?
// 要检查当前缓存中是否存在该账号的数据
var gameAccountManageComponent = scene.GetComponent<GameAccountManageComponent>();
if (!gameAccountManageComponent.TryGet(accountId, out var account))
{
// 如果缓存中没有、那表示已经下线或者根本不存在该账号,应该在打印一个警告,因为正常的情况下是不会出现的。
Log.Warning($"GameAccountHelper Disconnect accountId : {accountId} not found");
return;
}
// 为了防止逻辑的错误,加一个警告来排除下
if (!scene.TryGetEntity<Session>(account.SessionRunTimeId, out var session))
{
// 如果没有找到对应的Session,那只有一种可能就是当前的连接会话已经断开了,一般的情况下也不会出现的,所以咱们也要打印一个警告。
Log.Warning(
$"GameAccountHelper Disconnect accountId : {accountId} SessionRunTimeId : {account.SessionRunTimeId} not found");
return;
}
// 如果存在定时任务的组件,需要取消延迟下线
if (account.IsTimeOutComponent())
{
account.CancelTimeout();
}
// 立即下线处理
if (timeOut <= 0)
{
// 执行立即下线。
await account.Disconnect();
return;
}
// 不存在延迟下线组件且有timeOut时间 设置延迟下线
account.SetTimeout(timeOut, account.Disconnect);
}
/// <summary>
/// 执行该账号的断开逻辑,不要非必要不要使用这个接口,这个接口是内部使用。
/// </summary>
/// <param name="self"></param>
public static async FTask Disconnect(this GameAccount self)
{
// 保存该账号信息到数据库中。
await SaveDataBase(self);
// 在缓存中移除自己,并且执行自己的Dispose方法。
self.Scene.GetComponent<GameAccountManageComponent>().Remove(self.Id);
}
/// <summary>
/// 获得GameAccountInfo 这是为了返回给客户端的
///
/// </summary>
/// <param name="self"></param>
/// <returns></returns>
public static GameAccountInfo GetGameAccountInfo(this GameAccount self)
{
// 其实可以不用每次都NEW一个新的GameAccountInfo
// 可以在当前账号下创建一个GameAccountInfo,每次变动会提前通知这个GameAccountInfo
// 又或者每次调用该方法的时候,把值重新赋值一下。
return new GameAccountInfo()
{
CreateTime = self.CreateTime,
LoginTime = self.LoginTime
};
}
这段逻辑按执行顺序看就是:
- 先确认账号还在 Gate 缓存里。
- 再确认账号当前记录的
SessionRunTimeId还能找到 Session。 - 如果有旧的延迟下线定时器,先取消。
- 如果
timeOut <= 0,立即保存并移除缓存。 - 否则设置一个延迟下线定时器,到时间再真正保存和移除。
不过这里有个边界要留意:
Disconnect 里尝试通过 account.SessionRunTimeId 找 Session。
如果当前就是因为 Session 已经销毁才触发下线,这里可能找不到 Session。当前代码找不到就直接返回,会导致延迟下线没有设置成功。
这要看框架里 Destroy 的触发时机,以及 Session 实体当时还在不在。
如果测试时经常看到 SessionRunTimeId not found,这块就得改:断线流程不应该强依赖旧 Session 还能被查到,至少要能只根据账号缓存设置延迟下线。
缓存中已经存在账号的情况
Gate 缓存中已经存在账号,一般有三种情况:
- 同一客户端重复发送登录请求。
- 客户端断线后重新连接到这个 Gate。
- 另一个客户端登录同一个账号,也就是顶号。
这三种情况都要围绕 SessionRunTimeId 判断。
- 如果当前 Session 的
RunTimeId等于账号里记录的SessionRunTimeId,说明是同一个连接重复发登录请求,直接返回即可。 - 如果不一样,说明当前账号要绑定到新 Session。
- 如果旧 Session 还存在,要通知旧客户端被顶号,并延迟断开旧 Session。
- 绑定新 Session 前,要取消账号上的延迟下线定时器。
代码是这样走的:
// 尝试从缓存中获取账号信息
if (!gameAccountManageComponent.TryGet(accountId, out var account))
{
//...
}
// 缓存有这个账号
else
{
Log.Debug("检测到当前账号已经在缓存中了");
// 移除 如果有延迟下线的计划任务,那就先取消一下。
account.CancelTimeout();
// 如果在Gate的缓存中已经存在了该账号那只能以下几种可能:
// 1、同一客户端发送了重复登录的请求数据。
// 2、客户端经历的断线然后又重新连接到这个服务器上了(断线重连)。
// 3、多个客户端同时登录了这个账号(顶号)。
//思路是通过SessionRunTimeId 来判断
//如果SessionRunTimeId相同,同一客户端发送了重复登录的请求数据。
//客户端经历的断线然后又重新连接到这个服务器上了(断线重连) SessionRunTimeId是不同的
// 检查当前会话的运行时ID是否与账号的会话ID匹配
if (session.RunTimeId == account.SessionRunTimeId)
{
// 如果执行到这里,说明是客户端发送了多次登录的请求,这样的情况下,直接返回就可以了,不需要做任何操作。
return;
}
Log.Debug("检测到当前账号的Session不是同一个");
// 尝试获取旧的会话
if (scene.TryGetEntity<Session>(account.SessionRunTimeId, out var oldSession))
{
Log.Debug("当前账号的Session在当前的系统中,所以需要发送一个重复登录的命令,并且要断开这个Session");
// 如果这个Session在当前框架中可以查询到。
// 那表示就是当前的会话还是在存在的,有如下几个可能:
// 1、客户端断线重连,要给这个Session发送一个消息,通知它有人登录了。
// 2、其他的客户端登录了这个账号,要给这个Session发送一个消息,通知它有人登录了。
// 获取旧会话的账号标识组件
var gameAccountFlagComponent = oldSession.GetComponent<GameAccountFlagComponent>();
gameAccountFlagComponent.AccountID = 0;
gameAccountFlagComponent.Account = null;
// 给老的客户端Session发送一个重复登录的消息
// 如果新的客户端Session是自己上次登录的,发送也不会收到,因为老的客户端Session已经被销毁
// 如果新的客户端Session是用其他客户端登录的,发送会收到。因为老的客户端Session会在另一个机子上存在
oldSession.Send(new G2C_RepeatLogin());
// 给当前Session做一个定时销毁的任务,因为不做这个定时销毁,直接销毁的话,有可能消息还没有发送过去就销毁了
oldSession.SetTimeout(3000);
}
}
// 给当前Session添加一个账号标识组件,当Session销毁的时候会销毁这个组件。
var accountFlagComponent = session.AddComponent<GameAccountFlagComponent>();
accountFlagComponent.AccountID = accountId;
accountFlagComponent.Account = account;
//每次登录成功,都要更新一下SessionRunTimeId
account.SessionRunTimeId = session.RunTimeId;
// 设置响应的账号信息
response.GameAccountInfo = account.GetGameAccountInfo();
Log.Debug($"当前的Gate服务器:{session.Scene.SceneConfigId} accountId:{accountId}");
这里最关键的一步是清空旧 Session 上的账号标识组件:
gameAccountFlagComponent.AccountID = 0;
gameAccountFlagComponent.Account = null;
这个操作不是摆设。
如果不清空,旧 Session 被销毁时,它的 GameAccountFlagComponentDestroySystem 会继续触发 GameAccountHelper.Disconnect。
不清的话,后面很容易踩这个坑:
- 新 Session 已经登录成功。
- 旧 Session 延迟销毁。
- 旧 Session 销毁时又给同一个账号设置了延迟下线。
- 几分钟后,新 Session 还在线,但账号缓存被延迟下线逻辑清掉了。
这类问题线上很难排查,玩家看到的现象可能只是“登录几分钟后突然掉线”。
所以顶号时处理旧 Session 的顺序应该是:
- 找到旧 Session。
- 清空旧 Session 的账号标识,避免它再触发账号下线。
- 给旧客户端发
G2C_RepeatLogin。 - 延迟几秒断开旧 Session,尽量保证消息能发出去。
- 绑定新 Session。
- 更新账号的
SessionRunTimeId。
断线重连和顶号在服务端逻辑里很像。
主要区别在客户端侧:
- 断线重连时,旧客户端可能已经收不到消息。
- 顶号时,旧客户端还在线,能收到“账号在其他设备登录”的提示。
服务端可以走同一套换绑流程,关键是别让旧 Session 误触发新 Session 的下线逻辑。
4.9 总结
登录这块真正麻烦的地方,不是查一下账号密码,而是登录成功之后的状态交接。
这篇流程可以压成一句话:
鉴权服负责确认账号是谁,并签一张短期票;Gate 负责验票、接入连接、绑定账号运行时状态。
最后把容易忘的点收一下:
- 客户端根据用户名选择鉴权服,只是第一层分流。
- 鉴权服收到登录请求后,仍然要重新计算账号归属。
- 同一个用户名的登录请求需要加锁,避免并发查库和状态覆盖。
- 登录缓存只能挡短时间重复请求,不能代替账号状态系统。
- JWT Payload 默认不是加密,客户端能解析不代表可信。
- 客户端解析 Token 只是为了拿 Gate 地址。
- Gate 才是 Token 验签的主体。
- Gate 验 Token 时不仅要验签,还要检查
iss、aud、exp、SceneId等信息。 ValidateLifetime = false只能算调试写法,正式项目要打开过期时间校验。GameAccount是 Gate 上的运行时账号状态,不等同于注册阶段的Account。SessionRunTimeId用来判断当前账号绑定的是哪个连接。- 顶号时要清空旧 Session 的账号标识,避免旧 Session 销毁后误触发新账号下线。
- 延迟下线是为了断线重连,不是为了让账号永远挂在缓存里。
游戏服务器里常见的分工大概就是这样:
- Auth / Account 负责登录校验和 Token 签发。
- Client 负责拿 Token 去连目标入口。
- Gate 负责验签、接入、转发、连接状态管理。
- GameAccount 缓存 负责承接断线重连和顶号这种运行时状态。
如果后面继续往正式项目推进,还得补这些东西:
- 密码存储改成哈希加盐,不要明文保存和明文查询。
- Token 验证开启过期时间校验,并设置合理的
ClockSkew。 - Token 里加入
tokenVersion或jti,支持强制失效、顶号和黑名单。 - Gate 登录失败时区分错误类型,方便客户端提示和服务端排查。
- 登录链路加监控:成功率、失败码分布、Token 验签失败次数、平均耗时。
- 多 Gate 动态扩缩容时,账号到 Gate 的分配不能只靠简单
% Count。 - 断线重连和顶号最好有完整的自动化测试,否则这块很容易出现“旧 Session 清了新 Session”的问题。
做到这里,就不只是“能登录”了。
账号校验、Token 跳转、Gate 接入、会话换绑、断线缓冲、顶号通知,这几条线都接住,登录入口才算有点网络游戏服务端的样子。
转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。可以在下面评论区评论,也可以邮件至 785293209@qq.com