4.登录流程

4.登录流程


4.1 登录流程图

graph TD
    subgraph 客户端请求鉴权服
        cli_click_login["点击“登录”按钮"]
        cli_select_auth_svr["选择鉴权服务器地址(根据用户名选择)"]
        cli_create_session["创建网络会话"]
        cli_send_login_request["发送登录请求 {用户名, 密码, 登录类型}"]
    end

    subgraph 鉴权服处理
        svr_handle_login_request["处理登录请求"]
        svr_validate_parameters["校验参数完整性"]
        svr_compute_position["计算服务器位置"]
        svr_position_match{"服务器位置匹配?"}
        svr_return_position_error["返回错误码 3(账号不应该在此服务器处理)"]
        svr_acquire_lock["获取协程锁"]
        svr_account_in_cache{"账号缓存存在?"}
        svr_return_cached_info["直接返回缓存信息 {错误码=0, 账号ID}"]
        svr_query_database["查询数据库,查找账号"]
        svr_account_exists{"账号存在?"}
        svr_return_not_registered_error["返回错误码 2(未注册或凭证错误)"]
        svr_update_login_time["更新登录时间,并保存到数据库"]
        svr_cache_account_info["缓存账号信息,并且要添加定时清理"]
        svr_generate_jwt["生成 JWT 令牌"]
        svr_return_login_response["返回登录响应 {令牌, 错误码=0, 账号ID}"]
        svr_get_gate_token["基于账户ID计算得到Gate的IP地址和端口,得到对应Gate的Token"]
    end

    subgraph 客户端接收鉴权服响应 尝试请求网关服
        cli_receive_login_response["接收登录响应"]
        cli_receive_check_has_token{"是否返回了令牌?"}
        cli_parse_token["解析令牌"]
        cli_token_parse_success{"令牌解析成功?"}
        cli_extract_gate_server["提取网关服务器地址"]
        cli_create_gate_session["创建新的网络会话,连接网关服务器"]
        cli_send_gate_login_request["发送网关登录请求 {令牌}"]
    end

    subgraph 网关服处理
        gate_verify_jwt["网关服务器:验证 JWT 签名"]
        gate_jwt_valid{"JWT 验证成功?"}
        gate_return_invalid_token_error["返回错误:令牌无效"]
        gate_parse_jwt["解析 JWT,提取用户信息"]
        gate_query_account_status["查询账号状态"]
        gate_check_account_cache{"账号在缓存中?"}
        gate_load_from_db["从数据库加载账号信息"]
        gate_create_new_account["创建新账号并保存到数据库"]
        gate_add_to_cache["将账号添加到缓存"]
        gate_update_session_id["更新SessionRunTimeId"]
        gate_set_account_flag["给Session添加账号标识组件"]
        gate_check_duplicate_session{"是否重复登录(SessionRunTimeId对比)"}
        gate_get_old_session["获取老的Session"]
        gate_check_get_old_session{"是否获取老的Session成功"}
        gate_clear_old_session_flag["清空老Session的账号标识组件数据"]
        gate_send_duplicate_login["给老Session发送重复登录消息"]
        gate_delay_disconnect_old_session["延迟3秒断开老Session"]
        gate_establish_session["建立会话并转发客户端至游戏服务器"]
        gate_return_login_response["返回网关登录响应"]
    end

    subgraph 客户端响应网关服
        cli_receive_gate_response["接收网关登录响应"]
        cli_handle_gate_response["处理网关登录响应,登录成功"]
        cli_handle_error["处理错误,退出登录流程"]
    end

    subgraph 网关服会话断开处理
        session_disconnect{"会话断开?"}
        session_get_account_id["获取账号ID"]
        session_check_cache{"账号在缓存中?(一般会在缓存中)"}
        session_get_account_session_id["获取账号的SessionRunTimeId"]
        session_check_session_exists{"Session是否存在?(一般都存在)"}
        session_cancel_timeout["取消可能存在的延迟下线定时器"]
        session_decide_disconnect{"立即下线 or 延迟下线?"}
        session_immediate_disconnect["立即下线(保存数据并移除缓存)"]
        session_delay_disconnect["设置延迟下线定时器"]
    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_compute_position --> svr_position_match
    
    svr_position_match -- 否 --> svr_return_position_error --> cli_receive_login_response

    svr_position_match -- 是 --> svr_acquire_lock --> svr_account_in_cache

    svr_account_in_cache -- 是 --> svr_return_cached_info --> svr_get_gate_token --> cli_receive_login_response
    svr_account_in_cache -- 否 --> svr_query_database --> svr_account_exists
    svr_account_exists -- 否 --> svr_return_not_registered_error --> cli_receive_login_response
    svr_account_exists -- 是 --> svr_update_login_time --> svr_cache_account_info --> svr_generate_jwt --> svr_return_login_response --> svr_get_gate_token

    cli_receive_login_response --> cli_receive_check_has_token
    cli_receive_check_has_token -- 是 --> cli_parse_token --> cli_token_parse_success
    cli_receive_check_has_token -- 否 --> cli_handle_error
    cli_token_parse_success -- 否 --> cli_handle_error
    cli_token_parse_success -- 是 --> cli_extract_gate_server --> cli_create_gate_session --> cli_send_gate_login_request --> gate_verify_jwt --> gate_jwt_valid
    gate_jwt_valid -- 否 --> gate_return_invalid_token_error --> cli_handle_error
    gate_jwt_valid -- 是 --> gate_parse_jwt --> gate_query_account_status --> gate_check_account_cache

    gate_check_account_cache -- 否 --> gate_load_from_db --> gate_account_exists{"账号在数据库中?"}
    gate_account_exists -- 否 --> gate_create_new_account --> gate_add_to_cache
    gate_account_exists -- 是 --> gate_add_to_cache
    gate_check_account_cache -- 是 --> gate_check_duplicate_session

    gate_check_duplicate_session -- 是 --> cli_handle_gate_response
    gate_check_duplicate_session -- 否 --> gate_get_old_session
    gate_get_old_session -->gate_check_get_old_session

    gate_check_get_old_session -- 是 --> gate_clear_old_session_flag --> gate_send_duplicate_login --> gate_delay_disconnect_old_session --> gate_update_session_id
    gate_check_get_old_session -- 否 --> gate_update_session_id

    gate_add_to_cache --> gate_update_session_id --> gate_set_account_flag --> gate_establish_session --> gate_return_login_response --> cli_receive_gate_response --> cli_handle_gate_response

    cli_handle_gate_response --> session_disconnect
    session_disconnect -- 是 --> session_get_account_id --> session_check_cache
    session_check_cache -- 是 --> session_get_account_session_id --> session_check_session_exists
    session_check_session_exists -- 是 --> session_cancel_timeout --> session_decide_disconnect
    session_decide_disconnect -- 立即下线 --> session_immediate_disconnect
    session_decide_disconnect -- 延迟下线 --> session_delay_disconnect
    session_check_cache -- 否 --> session_immediate_disconnect
    session_check_session_exists -- 否 --> session_immediate_disconnect

4.2 登录时客户端应该具备的能力

客户端应该具备选择鉴权服务器地址和解析JWT的能力,登录得到Token时解析JWT

// 添加认证服务器选择组件(一致性哈希算法选择服务器)
_scene.AddComponent<AuthenticationSelectComponent>();

// 添加JWT解析组件(用于处理身份验证令牌)
_scene.AddComponent<JWTParseComponent>();

4.3 登录时鉴权服务器应该具备的能力

鉴权服务器应该具备处理登录逻辑和颁发ToKen证书的能力

// 用于鉴权服务器注册和登录相关逻辑的组件
scene.AddComponent<AuthenticationComponent>().UpdatePosition();
// 用于颁发ToKen证书相关的逻辑。
scene.AddComponent<AuthenticationJwtComponent>();

4.4 客户端发送登录请求给鉴权服务器

客户端发送登录请求,期望得到错误码和Token

// 调用 AuthenticationHelper.Login 方法验证用户名和密码,并等待返回登录结果
var result = await AuthenticationHelper.Login(scene, request.Username, request.Password);

4.5 鉴权服务器收到消息并进行处理

public class C2A_LoginRequrstHandler : MessageRPC<C2A_LoginRequrst, A2C_LoginResponse>
{
    protected override async FTask Run(Session session, C2A_LoginRequrst request, A2C_LoginResponse response,
        Action reply)
    {
        //...
    }
}

4.6 执行登录方法,返回错误码和账号ID

// Login 方法实现账号登录逻辑,接受用户名和密码,返回错误码和账号ID
// 返回的错误码定义如下:
//   0:登录成功
//   1:参数不完整(用户名或密码为空)
//   2:账号不存在或密码错误(数据库中未找到匹配账号)
//   3:该账号不应在当前鉴权服务器处理(负载均衡判断失败)
internal static async FTask<(uint ErrorCode, long AccountId)> Login(this AuthenticationComponent self,
string userName, string password)
{
    //...
}

4.7 校验登录参数及鉴权服务器位置

和注册时同理,需要校验登录参数及鉴权服务器位置。也可以自行添加登录请求间隔及会话超时时间。

// 检查用户名和密码是否为空,若任一为空则无法进行登录操作
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);
}

4.8 登录时高并发时原子逻辑

使用协程锁确保同一账号的登录请求不会同时操作数据库或缓存

// 获取当前场景对象,方便后续调用数据库和缓存操作
var scene = self.Scene;
// 从场景中获取世界数据库对象,用于查询账号信息
var worldDateBase = scene.World.DateBase;
// 计算用户名的标准哈希码,用作协程锁的键值,防止同一账号并发请求
var usernameHashCode = userName.GetHashCode();

// 使用协程锁机制,确保同一账号的登录请求不会同时操作数据库或缓存
using (var @lock =
        await scene.CoroutineLockComponent.Wait((int)LockType.AuthenticationLoginLock, usernameHashCode))
{
    //...
}

4.9 从鉴权服务器获得登录账户ID核心逻辑

  1. 采用缓存策略存储登录状态,避免频繁查数据库。
  2. 查询数据库,查找账号。
  3. 数据库中未查询到账号设置错误码返回。
  4. 数据库中查询到账号,更新登录账号登录时间并添加到缓存。缓存设置定时清理
// 使用协程锁机制,确保同一账号的登录请求不会同时操作数据库或缓存
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);
}

4.10 解析结果,计算网关和Token

如果登录成功,基于返回的用户ID计算选择Gate服务器。基于用户ID,网关服务器IP端口以及网关服务器ID计算Token并赋值返回。计算选择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}");

获得Token方法自定义的声明Payload部分

// 定义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);
}

4.11 客户端处理鉴权服务器的登录响应,并尝试解析Token

检查错误码。无错误则尝试解析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;
}

Payload定义如下,传入到解析ToKen方法中得到信息的

/// <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时分割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;
        }
    }
}

4.12 客户端给网关服务器发送请求

给网关服务器发送请求,并传入Token凭证

// 根据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}");

4.13 登录时网关服务器应该具备的能力

网关服务器具备验证JWT和管理GameAccount的能力。GameAccountManageComponent实际上就是缓存GameAccount,并且可以增删改查。使用缓存是因为方便断线重连和顶号使用。只有下线处理中才可以清除这个缓存。

    // 用于验证JWT是否合法的组件
    scene.AddComponent<GateJWTComponent>();
    // 用于管理GameAccount的组件
    scene.AddComponent<GameAccountManageComponent>();

GameAccount定义如下,关联ToKen传递的账户ID

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;
}

GameAccountManageComponent定义如下

// 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();
        }
    }
}

4.14 Gate服务器处理请求,对Token进行校验

检查请求中的Token是否为空,不为空则检查有效性并获取账号ID

/// <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;
        }

        //...
    }
}

检查时不仅要验证令牌合法性,还要令牌中的场景 ID 是否与当前场景的配置 ID 匹配。如果不匹配,表示该连接不应连接到当前的 Gate,验证失败。验证成功的话从令牌的负载中提取账户 ID,外部使用。

/// <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);
    }
}
// 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;
        }
    }
}

4.15 获取账号管理组件方便得到缓存

// 获取账号管理组件 在缓存中检查该账号是否存在
var gameAccountManageComponent = scene.GetComponent<GameAccountManageComponent>();
Log.Debug("检查账号是否在缓存中");

4.16 尝试从缓存获取账号,缓存未命中访问数据库

判断缓存中有没有当前有效的账户 ID。没有就要读数据库。注意这个数据库的集合不是注册时的Account,而是登录时的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("检测到当前账号已经在缓存中了");

    //...
}

创建时给定创建时间并保存到数据库中,从数据库中读取GameAccount时基于账户ID匹配

/// <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);
}

4.17 先不管缓存中存在账号的情况,每次通过Gate验证且得到账户后,要给Session添加标识组件且更新信息

给当前Session添加一个账号标识组件,记录账户ID和账户对象。这个组件的作用主要用于延迟下线,避免账户频繁上下线。当Session销毁的时候会销毁这个组件。同时要更新SessionRunTimeId和账户信息用于返回。

// 给当前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}");

游戏账号标志组件用于标识和关联游戏账号。

/// <summary>
/// 游戏账号标志组件,用于标识和关联游戏账号。
/// </summary>
public sealed class GameAccountFlagComponent : Entity
{
    /// <summary>
    /// 账号ID,用于唯一标识一个账号。
    /// </summary>
    public long AccountID;

    /// <summary>
    /// 关联的游戏账号实体引用。注意:当Account在其他地方被销毁时,这个引用可能仍然有效,但指向的可能是其他用户的账号。
    /// </summary>
    // 有一种可能,当在Account在其他地方被销毁
    // 这时候因为这个Account是会回收到池子中,所以这个引用还是有效的
    // 那这时候就会出现这个引用的Account可能是其他用户的了。
    public EntityReference<GameAccount> Account;
}

当和客户端的Session销毁时,会执行游戏账号标志组件的销毁。游戏账号标志组件的销毁主要用于延迟下线,避免在短时间内的重复登录给服务器造成的压力,并且能更加方便的处理断线重连的逻辑。

/// <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;
    }
}

当和客户端的Session销毁时,会调用一下Disconnect方法,传入账户ID,可能会有几种情况进行调用:

  1. 客户端主动断开,如:退出游戏、切换账号、等。 客户端会主动发送一个协议给服务器通知服务器断开。
  2. 客户端断断线 客户端不会主动发送一个协议给服务器,是由服务器的心跳来检测是否断开了。

首先检查Gate账户缓存中有没有这个账户ID,正常来说不会出现缓存中没有这个账户ID还下线的情况。因为只有下线时我们才清除Gate账户缓存的。如果能拿到就获取了account且能拿到account的SessionRunTimeId。

其次,基于当前account的SessionRunTimeId,尝试询问当前Gate服有没有这个Session。正常来说是有的。如果没有这个Session还尝试断开不符合逻辑。

然后,取消可能存在的定时器。根据timeOut设置新的延迟下线定时器或者直接执行。执行的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
    };
}

4.18 缓存中存在账号

缓存中存在账号 可能存在多种情况:

  1. 同一客户端发送了重复登录的请求数据。
  2. 客户端经历的断线然后又重新连接到这个服务器上了(断线重连)。
  3. 多个客户端同时登录了这个账号(顶号)

根据SessionRunTimeId是否一致判断是否是同一客户端发送了重复登录的请求数据。可能发生的情况是用户连续点击登录。客户端应该设置点击间隔或菊花图。

如果是相同客户端的断线重连,或者另一个客户端顶号登录,都会产生新的Session。那么基于老的SessionRunTimeId尝试获取老的Session。如果获取的到老的Session我们需要断开他。获取到老的Session后,先获取其中的账号标识组件,清空里面的数据。给客户端发送重复登录的消息,并且三秒后断开老的Session(延迟三秒是希望让给客户端发送重复登录的消息能够送达客户端)。

为什么要获取到老的Session后,要先获取其中的账号标识组件,清空里面的数据呢?因为如果不清空数据,老的Session销毁时,会走进GameAccountFlagComponent销毁方法。GameAccountFlagComponent销毁时会判断账户ID是否为0,如果不为0会尝试调用GameAccountHelper.Disconnect,会对这个账户ID进行延迟下线处理。这样新的Session刚进来,什么都没做,这个账户ID就被设置延迟下线。几分钟后可能就被踢了,不合理。我们希望是每次账户连接Gate成功后,取消该账户ID的延迟下线,Session断开连接后,开启该账户ID的延迟下线定时器。

可以观察到,断线重连和顶号我们走到是相同的逻辑。关于断线重连和顶号的区别,主要的区别是老的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}");

4.19 总结

本文详细解析了游戏登录系统的技术实现,通过分层架构设计(客户端、鉴权服、网关服)和关键技术(JWT、缓存、协程锁)实现了高并发场景下的安全登录与会话管理。以下是核心要点总结:

核心流程

  • 客户端:通过一致性哈希选择鉴权服务器,封装登录参数发送请求;解析JWT获取网关地址,建立网关连接。
  • 鉴权服务器处理
    • 前置校验:验证参数完整性,控制请求频率,设置会话超时。
    • 服务器位置匹配:通过用户名哈希计算目标服务器,拒绝非指定服务器请求。
    • 并发控制:获取协程锁,确保同一账号登录操作原子性。
    • 账号验证:先查登录缓存,未命中则查询数据库,验证密码匹配。
    • JWT生成:包含账号ID、网关地址、场景ID,设置过期时间。
  • 网关服务器处理
    • JWT验证:校验签名合法性,匹配场景ID,提取账号信息。
    • 账号管理:缓存查询账号(无则加载/创建数据库记录),更新登录时间。
    • 会话管理:通过SessionRunTimeId检测重复登录,旧会话延迟3秒断开;会话销毁时触发账号延迟下线。

核心设计

  1. 无状态认证

    • 通过 JWT 令牌在客户端与服务器间传递用户信息,实现无状态会话管理,降低服务端内存消耗。
    • 网关服验证 JWT 签名和场景 ID,确保请求合法性。
  2. 负载均衡与扩展性

    • 一致性哈希算法动态选择鉴权服务器,支持水平扩展。
    • 网关服通过账号 ID 计算分配策略,实现流量均衡。
  3. 高并发处理

    • 协程锁保证同一账号登录操作的原子性,避免数据库竞争。
    • 缓存机制(如鉴权服的账号缓存、网关服的 GameAccount 缓存)减少数据库压力。

技术亮点

  • 顶号处理:通过 SessionRunTimeId 检测重复登录,实现旧会话延迟断开(3 秒),确保新会话优先。
  • 延迟下线:会话断开后,账号缓存保留 5 分钟(可配置),优化断线重连体验。
  • 模块化设计:通过 AuthenticationComponentGameAccountManageComponent 等组件封装核心逻辑,便于功能扩展。

潜在问题与建议

  1. 安全增强

    • 启用 JWT 过期时间验证(ValidateLifetime),设置合理有效期(如 15 分钟)。
    • 密码存储应使用哈希加盐(如 BCrypt),避免明文传输和存储。
  2. 异常处理优化

    • 网关服在 Token 无效时返回明确错误码,便于客户端提示用户。
  3. 性能优化

    • 缩短延迟下线定时器(如 30 秒),减少僵尸会话资源占用。
    • 异步化数据库查询操作,提升系统吞吐量。

未来改进方向

  • 扩展登录方式:支持第三方登录(如微信、Google)。
  • 增强监控:添加登录成功率、延迟等指标监控,及时发现异常。
  • 数据库优化:引入读写分离或 NoSQL 存储(如 Redis)加速高频查询。

通过以上设计,系统在保证安全性与稳定性的同时,具备良好的扩展性与高并发处理能力,为游戏服务的核心模块奠定了坚实基础。



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

×

喜欢就点赞,疼爱就打赏