4.登录流程

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 + ca + 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 时间。
    不要用本地时间,否则跨时区、服务器时钟不一致时容易出问题。

  • issueraudience 现在都写成了 "Tao"
    学习阶段没问题,正式项目里建议拆清楚,比如 issuer = AuthServeraudience = GateServer

  • 客户端解析方法里的形参写成了 toKen,这类大小写不统一虽然不影响运行,但后面查日志、搜代码、生成协议文档时都会有点别扭。
    真项目里最好统一成 tokenToken,不要在同一条链路里混着来。

按“临时门票”这个比喻看:

  • 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 = true
  • ValidateIssuer = true
  • ValidateAudience = true
  • ValidateIssuerSigningKey = true
  • RequireExpirationTime = 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 时不仅要验签,还要检查 issaudexpSceneId 等信息。
  • ValidateLifetime = false 只能算调试写法,正式项目要打开过期时间校验。
  • GameAccount 是 Gate 上的运行时账号状态,不等同于注册阶段的 Account
  • SessionRunTimeId 用来判断当前账号绑定的是哪个连接。
  • 顶号时要清空旧 Session 的账号标识,避免旧 Session 销毁后误触发新账号下线。
  • 延迟下线是为了断线重连,不是为了让账号永远挂在缓存里。

游戏服务器里常见的分工大概就是这样:

  • Auth / Account 负责登录校验和 Token 签发。
  • Client 负责拿 Token 去连目标入口。
  • Gate 负责验签、接入、转发、连接状态管理。
  • GameAccount 缓存 负责承接断线重连和顶号这种运行时状态。

如果后面继续往正式项目推进,还得补这些东西:

  • 密码存储改成哈希加盐,不要明文保存和明文查询。
  • Token 验证开启过期时间校验,并设置合理的 ClockSkew
  • Token 里加入 tokenVersionjti,支持强制失效、顶号和黑名单。
  • Gate 登录失败时区分错误类型,方便客户端提示和服务端排查。
  • 登录链路加监控:成功率、失败码分布、Token 验签失败次数、平均耗时。
  • 多 Gate 动态扩缩容时,账号到 Gate 的分配不能只靠简单 % Count
  • 断线重连和顶号最好有完整的自动化测试,否则这块很容易出现“旧 Session 清了新 Session”的问题。

做到这里,就不只是“能登录”了。
账号校验、Token 跳转、Gate 接入、会话换绑、断线缓冲、顶号通知,这几条线都接住,登录入口才算有点网络游戏服务端的样子。



转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。可以在下面评论区评论,也可以邮件至 785293209@qq.com

×

喜欢就点赞,疼爱就打赏