From b628f0d04ae6d671e55ef82a829cc4dbe9d6b09f Mon Sep 17 00:00:00 2001 From: "Bob.Song" Date: Wed, 1 Apr 2026 16:40:34 +0800 Subject: [PATCH] =?UTF-8?q?=E8=BA=AB=E4=BB=BD=E9=AA=8C=E8=AF=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 160 ++++++++++++++++++ Entity/Entity.csproj | 2 +- Hotfix/Api/Args/ErrorCode.cs | 17 ++ Hotfix/Api/Args/RequestArgs.cs | 6 + Hotfix/Api/Base/NBControllerBase.cs | 88 ++++++++++ Hotfix/Api/Base/ResponseData.cs | 9 + Hotfix/Api/Controllers/AuthController.cs | 129 ++++++++++++-- Hotfix/Api/Controllers/UserController.cs | 33 ++++ .../Api/Handler/GameHttpApplicationHandler.cs | 96 +++++------ Hotfix/Api/Handler/GameHttpServicesHandler.cs | 60 +++---- Hotfix/Api/Helper/ApiJwtHelper.cs | 17 ++ Hotfix/Api/Helper/DingTalkHelper.cs | 4 +- .../Api/Middlewares/ApiJwtGuardMiddleware.cs | 63 +++++++ Hotfix/Hotfix.csproj | 2 +- Main/Main.csproj | 2 +- Server.sln.DotSettings.user | 1 + ThirdParty/ThirdParty.csproj | 2 +- 17 files changed, 590 insertions(+), 101 deletions(-) create mode 100644 AGENTS.md create mode 100644 Hotfix/Api/Args/ErrorCode.cs create mode 100644 Hotfix/Api/Args/RequestArgs.cs create mode 100644 Hotfix/Api/Base/NBControllerBase.cs create mode 100644 Hotfix/Api/Base/ResponseData.cs create mode 100644 Hotfix/Api/Controllers/UserController.cs create mode 100644 Hotfix/Api/Helper/ApiJwtHelper.cs create mode 100644 Hotfix/Api/Middlewares/ApiJwtGuardMiddleware.cs diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..04be690 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,160 @@ +# Fishing2Server 协作说明 + +## 项目概览 + +这是一个基于 Fantasy.Net 的游戏服务器项目,主要包含四个工程: + +- `Main`:程序入口、启动流程、日志初始化、运行时配置加载。 +- `Entity`:实体定义、组件定义、共享模型、生成代码、稳定契约。 +- `Hotfix`:业务逻辑、消息处理、HTTP API、辅助类、工厂类、System 扩展,以及可发布/可热更新的逻辑层。 +- `ThirdParty`:本地第三方依赖代码。 + +以当前源码为准,不以旧文档为准。当前 `.csproj` 实际目标框架是 `net10.0`。 + +## 最重要的架构规则 + +这个仓库最核心的规则是: + +- `Entity` 负责定义。 +- `Hotfix` 负责实际业务逻辑。 +- 业务逻辑优先写成 `Hotfix` 中的静态扩展、`System`、`Helper`、`Factory`、`Handler`、工具类。 +- `Entity` 尽量保持稳定,因为 `Hotfix` 才是后续发布、替换、热更新的主要承载层。 + +具体落地时: + +- 新增或修改字段、组件、枚举、共享数据结构、协议承载类型,优先放在 `Entity`。 +- 新增或修改玩法流程、消息处理、校验、奖励结算、状态流转、跨模块编排,优先放在 `Hotfix`。 +- 不要把复杂业务直接塞进实体类实例方法里,除非有非常明确的理由,并且符合现有 Fantasy 项目模式。 + +## 目录职责 + +- `Main/Program.cs`:服务启动入口。 +- `Main/NLog.config`:日志配置。 +- `Main/configs.json`:配置相关源定义。 +- `Main/cfg/`:运行时配置表 JSON。 +- `Entity/Authentication`、`Entity/Game`、`Entity/Gate`、`Entity/Model`、`Entity/Modules`:实体和共享模型定义。 +- `Entity/Generate/NetworkProtocol`:协议生成代码。 +- `Entity/Generate/ConfigTable`:配置表生成代码。 +- `Hotfix/Authentication`、`Hotfix/Game`、`Hotfix/Gate`、`Hotfix/Common`、`Hotfix/Api`:业务逻辑实现,其中 HTTP API 相关逻辑统一放在 `Hotfix/Api`。 +- `Tools/NetworkProtocol`:协议定义源文件。 +- `Tools/ProtocolExportTool`:协议导出工具。 + +## 修改代码时的基本判断 + +处理需求时,先判断它属于哪一类: + +- 如果是“数据长什么样、有哪些字段、有哪些共享结构”,改 `Entity`。 +- 如果是“数据怎么流转、怎么校验、怎么处理、怎么响应”,改 `Hotfix`。 + +默认原则: + +- 业务变更默认落在 `Hotfix`。 +- 目录结构尽量与现有模块保持对称。 +- 如果实体在 `Entity/Game/Player`,对应逻辑通常应放在 `Hotfix/Game/Player`。 +- 优先复用现有模块,不要随意新起一套平行抽象。 + +## 命名和组织约定 + +遵循现有源码习惯: + +- `*System`:静态扩展逻辑、Fantasy 系统逻辑。 +- `*Helper`:流程编排、模块辅助逻辑。 +- `*Factory`:对象创建、初始化组装逻辑。 +- `*Handler`:消息处理、RPC 处理、HTTP 处理。 + +命名空间尽量沿用项目现有风格,主要是 `NB.*`。 + +## Entity 与 Hotfix 的边界 + +适合放在 `Entity` 的内容: + +- Entity / Component 类型定义。 +- 持久化字段和共享字段。 +- 枚举、共享模型、基础契约。 +- `Main` 和 `Hotfix` 都要依赖的公共类型。 +- 生成代码产物。 + +适合放在 `Hotfix` 的内容: + +- `MessageRPC<,>` 等消息处理器。 +- HTTP API 的 `Controller`、API `Handler`、API `Helper`、中间件及相关业务流程。 +- 对实体/组件的静态扩展方法。 +- 玩法规则、条件校验、奖励发放、状态变化。 +- 场景生命周期逻辑。 +- 登录/JWT/网关业务流程。 +- 工厂、帮助类、缓存协作、跨模块调度。 + +尽量避免: + +- 在 `Entity` 中直接编写大量玩法逻辑。 +- 直接手改 `Entity/Generate` 下的生成文件。 +- 一边改协议源文件,一边又手工改生成后的协议代码。 + +## 生成代码与源文件规则 + +以下内容默认视为“产物”而不是“手工主编辑区”: + +- 协议源文件:`Tools/NetworkProtocol/**` +- 协议生成输出:`Entity/Generate/NetworkProtocol/**` +- 配置表生成输出:`Entity/Generate/ConfigTable/**` +- 运行时配置 JSON:`Main/cfg/**` + +推荐流程: + +1. 先改源定义。 +2. 再执行生成。 +3. 只有在用户明确要求,或生成链路不可用时,才直接改生成产物。 + +## 常见任务处理方式 + +### 新增玩法或业务功能 + +1. 只有在数据结构必须变化时,才修改 `Entity`。 +2. 实际功能实现放到 `Hotfix`。 +3. 请求入口放在对应模块的 `Handler` 目录。 +4. 跨场景或跨模块编排优先写在 `Helper` 或 `System`,不要堆到 `Main`。 + +### 新增或修改 HTTP API + +1. HTTP API 相关实现统一放在 `Hotfix/Api`。 +2. 控制器放在 `Hotfix/Api/Controllers`。 +3. API 相关服务注册、处理入口、辅助逻辑分别沿用现有 `Handler`、`Helper`、`Middlewares` 目录结构。 +4. API 只是入口层,真正的业务规则仍应复用 `Hotfix` 内已有模块逻辑,不要在 Controller 中堆积大量业务代码。 + +### 新增或修改网络消息 + +1. 修改 `Tools/NetworkProtocol` 下的协议定义。 +2. 生成到 `Entity/Generate/NetworkProtocol`。 +3. 在 `Hotfix` 中补齐对应处理逻辑。 +4. 非必要不要直接维护 Opcode 或生成消息文件。 + +### 新增或修改配置表 + +1. 先修改配置源。 +2. 如有需要,重新生成 `Entity/Generate/ConfigTable`。 +3. 确保 `Main/cfg` 中的运行时 JSON 与生成类型一致。 + +## 构建与验证 + +仓库根目录常用命令: + +```powershell +dotnet build Server.sln +dotnet build Main/Main.csproj +dotnet build Entity/Entity.csproj +dotnet build Hotfix/Hotfix.csproj +dotnet run --project Main/Main.csproj +``` + +完成较大改动前,建议至少做这些检查: + +- 优先构建受影响最小的项目。 +- 如果公共契约变了,构建整个解决方案。 +- 如果动了协议或配置生成链路,确认生成代码和 `Hotfix` 使用方都能正常编译。 + +## 额外注意事项 + +- 工作区可能已经有用户自己的未提交改动,不要回滚无关内容。 +- 仓库中已有 `bin/`、`obj/` 等构建产物目录,除非任务明确要求,否则忽略它们。 +- `CODELY.md` 可以作为背景参考,但其中部分信息已经滞后;若与源码冲突,以源码和项目文件为准。 +- 当需求描述不够明确时,优先按本项目既有架构理解:稳定定义放 `Entity`,可变业务放 `Hotfix`。 diff --git a/Entity/Entity.csproj b/Entity/Entity.csproj index 398f5e7..e682bba 100644 --- a/Entity/Entity.csproj +++ b/Entity/Entity.csproj @@ -1,11 +1,11 @@ - net9.0 Entity latest enable enable + net10.0 diff --git a/Hotfix/Api/Args/ErrorCode.cs b/Hotfix/Api/Args/ErrorCode.cs new file mode 100644 index 0000000..1c9e3f1 --- /dev/null +++ b/Hotfix/Api/Args/ErrorCode.cs @@ -0,0 +1,17 @@ +namespace NBF; + +public enum ErrorCode +{ + /// + /// 成功 + /// + Success = 0, + + ArgsError = 1, + + Busy = 2, + /// + /// 安装包不存在 + /// + AppNotFound = 404 +} \ No newline at end of file diff --git a/Hotfix/Api/Args/RequestArgs.cs b/Hotfix/Api/Args/RequestArgs.cs new file mode 100644 index 0000000..8d81d7f --- /dev/null +++ b/Hotfix/Api/Args/RequestArgs.cs @@ -0,0 +1,6 @@ +namespace NBF; + +public class RequestArgs +{ + +} \ No newline at end of file diff --git a/Hotfix/Api/Base/NBControllerBase.cs b/Hotfix/Api/Base/NBControllerBase.cs new file mode 100644 index 0000000..76ce039 --- /dev/null +++ b/Hotfix/Api/Base/NBControllerBase.cs @@ -0,0 +1,88 @@ +using Microsoft.AspNetCore.Mvc; + +namespace NBF; + +public abstract class NBControllerBase : ControllerBase +{ + protected static readonly string BaseRootPath = $"{AppContext.BaseDirectory}wwwroot/"; + + + public IActionResult Html(string fileName) + { + var filePath = Path.Combine(BaseRootPath, $"{fileName}.html"); + + if (System.IO.File.Exists(filePath)) + { + return PhysicalFile(filePath, "text/html"); + } + + return Content($"{fileName}.html not found"); + } + + public OkObjectResult Error(ErrorCode code, string msg = "") + { + var res = new ResponseData + { + Code = (int)code, + Data = msg + }; + return Ok(res); + } + + public OkObjectResult Success() + { + var res = new ResponseData + { + Code = 0, + Data = string.Empty + }; + return Ok(res); + } + + public OkObjectResult Success(T data) + { + var res = new ResponseData + { + Code = 0, + Data = data + }; + return Ok(res); + } + + #region 工具方法 + + /// + /// 获得请求url参数 + /// + /// + protected Dictionary GetQuery() + { + Dictionary paramMap = new Dictionary(); + var request = HttpContext.Request; + foreach (var keyValuePair in request.Query) + paramMap.Add(keyValuePair.Key, keyValuePair.Value[0]); + + return paramMap; + } + + protected Dictionary GetHeaders() + { + Dictionary paramMap = new Dictionary(); + var request = HttpContext.Request; + foreach (var keyValuePair in request.Headers) + paramMap.Add(keyValuePair.Key, keyValuePair.Value[0]); + + return paramMap; + } + + protected static void TryCreateDir(string path) + { + if (string.IsNullOrEmpty(path)) return; + if (!Directory.Exists(path)) + { + Directory.CreateDirectory(path); + } + } + + #endregion +} \ No newline at end of file diff --git a/Hotfix/Api/Base/ResponseData.cs b/Hotfix/Api/Base/ResponseData.cs new file mode 100644 index 0000000..3388858 --- /dev/null +++ b/Hotfix/Api/Base/ResponseData.cs @@ -0,0 +1,9 @@ +using System.Text.Json.Serialization; + +namespace NBF; + +public class ResponseData +{ + [JsonPropertyName("code")] public int Code { get; set; } + [JsonPropertyName("data")] public T Data { get; set; } +} \ No newline at end of file diff --git a/Hotfix/Api/Controllers/AuthController.cs b/Hotfix/Api/Controllers/AuthController.cs index 5c0f5c2..08f6bb6 100644 --- a/Hotfix/Api/Controllers/AuthController.cs +++ b/Hotfix/Api/Controllers/AuthController.cs @@ -1,40 +1,139 @@ -using System.Threading; +using System.Collections.Concurrent; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Security.Cryptography; using Fantasy; using Fantasy.Async; using Fantasy.Network.HTTP; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; +using Microsoft.IdentityModel.Tokens; namespace NBF; [ApiController] [Route("api/[controller]")] [ServiceFilter(typeof(SceneContextFilter))] -public class AuthController : ControllerBase +public class AuthController : NBControllerBase { + private static readonly ConcurrentDictionary CaptchaCache = new(); + private static readonly TimeSpan CaptchaLifetime = TimeSpan.FromMinutes(10); private readonly Scene _scene; - /// - /// 构造函数依赖注入 - /// - /// public AuthController(Scene scene) { _scene = scene; } - + [HttpGet("login")] - public async FTask Login() + [AllowAnonymous] + public async FTask Login([FromQuery] string account, [FromQuery] string code) { - await DingTalkHelper.SendCAPTCHA("123456"); + if (string.IsNullOrWhiteSpace(account) || string.IsNullOrWhiteSpace(code)) + { + await FTask.CompletedTask; + return Error(ErrorCode.ArgsError, "account or code is empty"); + } + + var key = account.Trim(); + if (!CaptchaCache.TryGetValue(key, out var captchaInfo)) + { + await FTask.CompletedTask; + return Error(ErrorCode.ArgsError, "captcha not found"); + } + + if (captchaInfo.ExpireAtUtc <= DateTime.UtcNow) + { + CaptchaCache.TryRemove(key, out _); + await FTask.CompletedTask; + return Error(ErrorCode.ArgsError, "captcha expired"); + } + + if (!string.Equals(captchaInfo.Code, code.Trim(), StringComparison.Ordinal)) + { + await FTask.CompletedTask; + return Error(ErrorCode.ArgsError, "captcha invalid"); + } + + CaptchaCache.TryRemove(key, out _); + var (token, expireAtUtc) = GenerateJwtToken(key); + await FTask.CompletedTask; - return Ok($"Hello from the Fantasy controller! _scene.SceneType:{_scene.SceneType} _scene.SceneType:{_scene.SceneConfigId}"); + return Success(new + { + token, + expireAtUtc + }); } - + [HttpGet("code")] - public async FTask SendCode() + [AllowAnonymous] + public async FTask SendCode([FromQuery] string account) { - await DingTalkHelper.SendCAPTCHA("123456"); - await FTask.CompletedTask; - return Ok($"Hello from the Fantasy controller! _scene.SceneType:{_scene.SceneType} _scene.SceneType:{_scene.SceneConfigId}"); + if (string.IsNullOrWhiteSpace(account)) + { + await FTask.CompletedTask; + return Error(ErrorCode.ArgsError, "account is empty"); + } + + var key = account.Trim(); + var captchaCode = RandomNumberGenerator.GetInt32(0, 1_000_000).ToString("D6"); + var expireAtUtc = DateTime.UtcNow.Add(CaptchaLifetime); + + CaptchaCache[key] = new CaptchaInfo + { + Code = captchaCode, + ExpireAtUtc = expireAtUtc + }; + + await DingTalkHelper.SendCAPTCHA(account, captchaCode); + + return Success(); + } + + [HttpGet("me")] + [Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)] + public IActionResult Me() + { + var account = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? string.Empty; + return Success(new + { + account + }); + } + + private static (string Token, DateTime ExpireAtUtc) GenerateJwtToken(string account) + { + var now = DateTime.UtcNow; + var expireAtUtc = now.Add(ApiJwtHelper.TokenLifetime); + var tokenHandler = new JwtSecurityTokenHandler(); + + var claims = new[] + { + new Claim(ClaimTypes.NameIdentifier, account), + new Claim(ClaimTypes.Name, account), + new Claim(ClaimTypes.Role, "Player") + }; + + var token = tokenHandler.CreateToken(new SecurityTokenDescriptor + { + Subject = new ClaimsIdentity(claims), + Expires = expireAtUtc, + NotBefore = now, + Issuer = ApiJwtHelper.Issuer, + Audience = ApiJwtHelper.Audience, + SigningCredentials = new SigningCredentials( + ApiJwtHelper.CreateSigningKey(), + SecurityAlgorithms.HmacSha256) + }); + + return (tokenHandler.WriteToken(token), expireAtUtc); + } + + private sealed class CaptchaInfo + { + public string Code { get; init; } = string.Empty; + public DateTime ExpireAtUtc { get; init; } } } \ No newline at end of file diff --git a/Hotfix/Api/Controllers/UserController.cs b/Hotfix/Api/Controllers/UserController.cs new file mode 100644 index 0000000..384b8ba --- /dev/null +++ b/Hotfix/Api/Controllers/UserController.cs @@ -0,0 +1,33 @@ +using System.Threading; +using Fantasy; +using Fantasy.Async; +using Fantasy.Network.HTTP; +using Microsoft.AspNetCore.Mvc; + +namespace NBF; + +/// +/// 用户api +/// +[ApiController] +[Route("api/[controller]")] +[ServiceFilter(typeof(SceneContextFilter))] +public class UserController : NBControllerBase +{ + private readonly Scene _scene; + + /// + /// 构造函数依赖注入 + /// + /// + public UserController(Scene scene) + { + _scene = scene; + } + + [HttpGet("test")] + public async FTask SendCode() + { + return Success("test"); + } +} \ No newline at end of file diff --git a/Hotfix/Api/Handler/GameHttpApplicationHandler.cs b/Hotfix/Api/Handler/GameHttpApplicationHandler.cs index 0f30702..07b91c6 100644 --- a/Hotfix/Api/Handler/GameHttpApplicationHandler.cs +++ b/Hotfix/Api/Handler/GameHttpApplicationHandler.cs @@ -1,66 +1,60 @@ +using System; +using Fantasy; using Fantasy.Async; using Fantasy.Event; using Fantasy.Network.HTTP; using Microsoft.AspNetCore.Builder; -using System; -using Fantasy; -namespace NBF +namespace NBF; + +public class GameHttpApplicationHandler : AsyncEventSystem { - public class GameHttpApplicationHandler : AsyncEventSystem + protected override async FTask Handler(OnConfigureHttpApplication self) { - protected override async FTask Handler(OnConfigureHttpApplication self) + var app = self.Application; + + app.UseCors("GameClient"); + + app.Use(async (context, next) => { - var app = self.Application; + var requestId = Guid.NewGuid().ToString("N"); + context.Items["RequestId"] = requestId; - // 1. CORS(必须在认证之前) - app.UseCors("GameClient"); + var start = DateTime.UtcNow; + var method = context.Request.Method; + var path = context.Request.Path; + var ip = context.Connection.RemoteIpAddress?.ToString(); - // 2. 请求日志中间件 - app.Use(async (context, next) => + Log.Info($"[HTTP-{requestId}] {method} {path} - IP: {ip}"); + + try { - var requestId = Guid.NewGuid().ToString("N"); - context.Items["RequestId"] = requestId; - - var start = DateTime.UtcNow; - var method = context.Request.Method; - var path = context.Request.Path; - var ip = context.Connection.RemoteIpAddress?.ToString(); - - Log.Info($"[HTTP-{requestId}] {method} {path} - IP: {ip}"); - - try - { - await next.Invoke(); - - var duration = (DateTime.UtcNow - start).TotalMilliseconds; - var status = context.Response.StatusCode; - Log.Info($"[HTTP-{requestId}] {status} - {duration}ms"); - } - catch (Exception e) - { - Log.Error($"[HTTP-{requestId}] 异常: {e.Message}"); - throw; - } - }); - - // 3. 认证 - app.UseAuthentication(); - - // 4. 授权 - app.UseAuthorization(); - - // 5. 自定义响应头 - app.Use(async (context, next) => - { - context.Response.Headers.Add("X-Game-Server", "NBF"); - context.Response.Headers.Add("X-Server-Version", "1.0.0"); await next.Invoke(); - }); - Log.Info($"[HTTP] 游戏应用配置完成: Scene {self.Scene.SceneConfigId}"); + var duration = (DateTime.UtcNow - start).TotalMilliseconds; + var status = context.Response.StatusCode; + Log.Info($"[HTTP-{requestId}] {status} - {duration}ms"); + } + catch (Exception e) + { + Log.Error($"[HTTP-{requestId}] exception: {e.Message}"); + throw; + } + }); - await FTask.CompletedTask; - } + app.UseAuthentication(); + app.UseApiJwtGuard(); + + app.UseAuthorization(); + + app.Use(async (context, next) => + { + context.Response.Headers["X-Game-Server"] = "NBF"; + context.Response.Headers["X-Server-Version"] = "1.0.0"; + await next.Invoke(); + }); + + Log.Info($"[HTTP] game application configured: Scene {self.Scene.SceneConfigId}"); + await FTask.CompletedTask; } -} \ No newline at end of file +} diff --git a/Hotfix/Api/Handler/GameHttpServicesHandler.cs b/Hotfix/Api/Handler/GameHttpServicesHandler.cs index c81f46f..5dc2102 100644 --- a/Hotfix/Api/Handler/GameHttpServicesHandler.cs +++ b/Hotfix/Api/Handler/GameHttpServicesHandler.cs @@ -1,13 +1,14 @@ +using Fantasy; using Fantasy.Async; using Fantasy.Event; using Fantasy.Network.HTTP; using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.DependencyInjection; using Microsoft.IdentityModel.Tokens; -using System.Text; +using System.Linq; using System.Text.Json; using System.Text.Json.Serialization; -using Fantasy; namespace NBF; @@ -15,25 +16,39 @@ public class GameHttpServicesHandler : AsyncEventSystem { protected override async FTask Handler(OnConfigureHttpServices self) { - // 1. 配置 JSON 序列化 self.MvcBuilder.AddJsonOptions(options => { options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; options.JsonSerializerOptions.WriteIndented = true; - options.JsonSerializerOptions.DefaultIgnoreCondition = - JsonIgnoreCondition.WhenWritingNull; + options.JsonSerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull; options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()); }); - // 2. 添加全局过滤器 self.MvcBuilder.AddMvcOptions(options => { // options.Filters.Add(); // options.Filters.Add(); }); - // 3. 配置 JWT 认证 - var jwtSecret = "YourSuperSecretKeyForJwtTokenGeneration123!"; + self.MvcBuilder.ConfigureApiBehaviorOptions(options => + { + options.InvalidModelStateResponseFactory = context => + { + var errorText = string.Join("; ", context.ModelState + .Where(kv => kv.Value is { Errors.Count: > 0 }) + .Select(kv => $"{kv.Key}: {string.Join(", ", kv.Value!.Errors.Select(e => e.ErrorMessage))}")); + + var response = new ResponseData + { + Code = (int)ErrorCode.ArgsError, + Data = string.IsNullOrWhiteSpace(errorText) ? "args error" : errorText + }; + + // Keep API error style consistent with NBControllerBase.Error(): HTTP 200 + business code. + return new OkObjectResult(response); + }; + }); + self.Builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options => { @@ -43,26 +58,20 @@ public class GameHttpServicesHandler : AsyncEventSystem ValidateAudience = true, ValidateLifetime = true, ValidateIssuerSigningKey = true, - ValidIssuer = "GameServer", - ValidAudience = "GameClient", - IssuerSigningKey = new SymmetricSecurityKey( - Encoding.UTF8.GetBytes(jwtSecret)), + ValidIssuer = ApiJwtHelper.Issuer, + ValidAudience = ApiJwtHelper.Audience, + IssuerSigningKey = ApiJwtHelper.CreateSigningKey(), ClockSkew = TimeSpan.Zero }; }); - // 4. 配置授权策略 self.Builder.Services.AddAuthorization(options => { - options.AddPolicy("Player", policy => - policy.RequireRole("Player", "Admin")); - options.AddPolicy("Admin", policy => - policy.RequireRole("Admin")); - options.AddPolicy("VIP", policy => - policy.RequireClaim("VIPLevel")); + options.AddPolicy("Player", policy => policy.RequireRole("Player", "Admin")); + options.AddPolicy("Admin", policy => policy.RequireRole("Admin")); + options.AddPolicy("VIP", policy => policy.RequireClaim("VIPLevel")); }); - // 5. 配置 CORS self.Builder.Services.AddCors(options => { options.AddPolicy("GameClient", builder => @@ -77,14 +86,7 @@ public class GameHttpServicesHandler : AsyncEventSystem }); }); - // // 6. 注册游戏服务 - // self.Builder.Services.AddSingleton(); - // self.Builder.Services.AddSingleton(); - // self.Builder.Services.AddScoped(); - // self.Builder.Services.AddScoped(); - - Log.Info($"[HTTP] 游戏服务配置完成: Scene {self.Scene.SceneConfigId}"); - + Log.Info($"[HTTP] game service configured: Scene {self.Scene.SceneConfigId}"); await FTask.CompletedTask; } -} \ No newline at end of file +} diff --git a/Hotfix/Api/Helper/ApiJwtHelper.cs b/Hotfix/Api/Helper/ApiJwtHelper.cs new file mode 100644 index 0000000..7108e24 --- /dev/null +++ b/Hotfix/Api/Helper/ApiJwtHelper.cs @@ -0,0 +1,17 @@ +using System.Text; +using Microsoft.IdentityModel.Tokens; + +namespace NBF; + +public static class ApiJwtHelper +{ + public const string Issuer = "GameServer"; + public const string Audience = "GameClient"; + public const string Secret = "YourSuperSecretKeyForJwtTokenGeneration123!"; + public static readonly TimeSpan TokenLifetime = TimeSpan.FromDays(1); + + public static SymmetricSecurityKey CreateSigningKey() + { + return new SymmetricSecurityKey(Encoding.UTF8.GetBytes(Secret)); + } +} diff --git a/Hotfix/Api/Helper/DingTalkHelper.cs b/Hotfix/Api/Helper/DingTalkHelper.cs index 0c44523..118b98c 100644 --- a/Hotfix/Api/Helper/DingTalkHelper.cs +++ b/Hotfix/Api/Helper/DingTalkHelper.cs @@ -8,14 +8,14 @@ public static class DingTalkHelper { private static readonly HttpClient _httpClient = new HttpClient(); - public static async Task SendCAPTCHA(string code) + public static async Task SendCAPTCHA(string account, string code) { DingTalkMarkdownData dingTalkTestData = new DingTalkMarkdownData { markdown = new DingTalkMarkdownItem { title = "NB验证码", - text = $"验证码:{code}" + text = $"账号:{account}\n验证码:{code}" } }; await SendMessageAsync("23457f9c93ac0ae909e1cbf8bcfeb7e0573968ac2d4c4b2c3a961b2f0c9247cb", dingTalkTestData); diff --git a/Hotfix/Api/Middlewares/ApiJwtGuardMiddleware.cs b/Hotfix/Api/Middlewares/ApiJwtGuardMiddleware.cs new file mode 100644 index 0000000..0258413 --- /dev/null +++ b/Hotfix/Api/Middlewares/ApiJwtGuardMiddleware.cs @@ -0,0 +1,63 @@ +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; + +namespace NBF; + +public sealed class ApiJwtGuardMiddleware +{ + private readonly RequestDelegate _next; + + public ApiJwtGuardMiddleware(RequestDelegate next) + { + _next = next; + } + + public async Task InvokeAsync(HttpContext context) + { + if (HttpMethods.IsOptions(context.Request.Method)) + { + await _next(context); + return; + } + + var path = context.Request.Path.Value ?? string.Empty; + var normalizedPath = path.Length > 1 ? path.TrimEnd('/') : path; + if (!normalizedPath.StartsWith("/api/", StringComparison.OrdinalIgnoreCase)) + { + await _next(context); + return; + } + + // Endpoint carries AllowAnonymous metadata when action/controller has [AllowAnonymous]. + var endpoint = context.GetEndpoint(); + if (endpoint?.Metadata.GetMetadata() != null) + { + await _next(context); + return; + } + + if (context.User?.Identity?.IsAuthenticated != true) + { + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + await context.Response.WriteAsJsonAsync(new ResponseData + { + Code = StatusCodes.Status401Unauthorized, + Data = "unauthorized" + }); + return; + } + + await _next(context); + } +} + +public static class ApiJwtGuardMiddlewareExtensions +{ + public static IApplicationBuilder UseApiJwtGuard(this IApplicationBuilder app) + { + return app.UseMiddleware(); + } +} diff --git a/Hotfix/Hotfix.csproj b/Hotfix/Hotfix.csproj index 724ece9..9eced04 100644 --- a/Hotfix/Hotfix.csproj +++ b/Hotfix/Hotfix.csproj @@ -1,11 +1,11 @@ - net9.0 Hotfix latest enable enable + net10.0 diff --git a/Main/Main.csproj b/Main/Main.csproj index c9e56ef..fdc2a3e 100644 --- a/Main/Main.csproj +++ b/Main/Main.csproj @@ -2,13 +2,13 @@ Exe - net9.0 Main latest enable enable false true + net10.0 diff --git a/Server.sln.DotSettings.user b/Server.sln.DotSettings.user index 20588c1..9d724c6 100644 --- a/Server.sln.DotSettings.user +++ b/Server.sln.DotSettings.user @@ -5,6 +5,7 @@ ForceIncluded ForceIncluded ForceIncluded + ForceIncluded ForceIncluded ForceIncluded ForceIncluded diff --git a/ThirdParty/ThirdParty.csproj b/ThirdParty/ThirdParty.csproj index 0590c88..a444dae 100644 --- a/ThirdParty/ThirdParty.csproj +++ b/ThirdParty/ThirdParty.csproj @@ -1,10 +1,10 @@  - net8.0 enable disable true + net10.0