diff --git a/.dockerignore b/.dockerignore index fe1152bdb..3729ff0cd 100644 --- a/.dockerignore +++ b/.dockerignore @@ -22,9 +22,4 @@ **/secrets.dev.yaml **/values.dev.yaml LICENSE -README.md -!**/.gitignore -!.git/HEAD -!.git/config -!.git/packed-refs -!.git/refs/heads/** \ No newline at end of file +README.md \ No newline at end of file diff --git a/.gitignore b/.gitignore index d8fa85417..29afdb79a 100644 --- a/.gitignore +++ b/.gitignore @@ -360,6 +360,5 @@ MigrationBackup/ # Fody - auto-generated XML schema FodyWeavers.xsd -/Infrastructure/WebAPIBasic/Properties - -*.lscache +/WebApi/files +/AdminApi/wwwroot/uploads diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 4ac99709b..000000000 --- a/AGENTS.md +++ /dev/null @@ -1,246 +0,0 @@ -# AGENTS.md - -## 1. 文档目的 - -本规范用于约束 AI Agent 在 `NetEngine` 仓库中的分析、修改、验证与协作方式 - -本仓库是一个基于 .NET 10 的分层解决方案,核心能力包括 Web API、Blazor WASM 管理端、任务调度、EF Core、基础设施组件与源码生成器 - -Agent 在本仓库中工作时,必须遵守以下原则 - -- 保持现有分层结构稳定 -- 优先复用仓库中已存在的实现模式 -- 优先做小范围、可验证、可维护的修改 -- 代码风格尽量贴近当前仓库以及微软官方 ASP.NET Core 与 EF Core 常见写法 - -## 2. 解决方案结构 - -### 2.1 Application - -- `Application.Interface` - - 应用层接口定义 -- `Application.Model` - - DTO、请求模型、返回模型 -- `Application.Service` - - 业务编排与业务逻辑实现 - -### 2.2 Repository - -- `Repository` - - EF Core 实体、`DatabaseContext`、拦截器、数据库访问逻辑 -- `Repository.Tool` - - EF Core 迁移与数据库工具宿主 - -### 2.3 Infrastructure - -- 各类基础设施实现 -- 包括缓存、分布式锁、日志、短信、文件存储、LLM、ID 生成等能力 - -### 2.4 ProjectCore - -- 公共宿主能力 -- 包括 `WebAPI.Core` 与 `TaskService.Core` - -### 2.5 Presentation - -- 宿主与表现层项目 -- 包括 `Client.WebAPI`、`Admin.WebAPI`、`Admin.App`、`TaskService` - -### 2.6 SourceGenerator - -- 编译期源码生成器与其运行时支持代码 - -### 2.7 InitData - -- 初始化数据文件 - -## 3. 分层与职责规范 - -### 3.1 总体原则 - -- 依赖方向必须保持稳定 -- 表现层负责宿主组装、请求接入、参数传递与结果返回 -- 应用层负责业务逻辑、业务编排与用例实现 -- 数据访问层负责 EF Core、实体模型、数据库相关逻辑 -- 基础设施层负责第三方组件和外部服务接入 - -### 3.2 Presentation 层约束 - -- 业务逻辑不允许写在 `Presentation` 层 -- 包括各个 API 项目与 `TaskService` 项目 -- `Presentation` 层应尽可能只保留对 `Application` 层的调用 -- `Presentation` 层可以保留宿主启动、依赖注入、请求接收、鉴权、中间件、参数适配、返回包装等表现层职责 -- 不允许在 Controller、Razor 页面、宿主启动代码中堆放核心业务判断 -- 不允许把数据库访问逻辑直接写入 `Presentation` 层 - -### 3.3 Application 层约束 - -- 业务逻辑优先放在 `Application.Service` -- 新功能优先扩展现有 Service、DTO、接口,而不是平行新增一套风格不同的实现 -- 不要把宿主启动相关逻辑、HTTP 细节、页面细节混入应用层 -- 如果某个应用服务所在的应用类库会被多个宿主共同引用,并导致部分宿主为了启动不报错而被迫注册本宿主根本不用的基础设施能力,应优先将该宿主特化服务拆分到独立应用类库 -- 遇到上述情况时,优先参考 `Application.Service.LLM` 与 `Application.Service.SMS` 的拆分方式,而不是继续使用可空依赖、空实现或让所有宿主补齐注册 - -### 3.4 Repository 层约束 - -- `Repository` 负责实体、`DatabaseContext`、EF Core 映射、拦截器与持久化相关代码 -- 数据库结构变更时,应优先修改 `Repository` 中的实体与相关配置 -- 不要在无关层中写数据库方言相关代码 - -### 3.5 Infrastructure 层约束 - -- 第三方平台、云服务、缓存、锁、日志、文件、短信、LLM 等能力应放在 `Infrastructure` -- 不要把基础设施实现细节泄漏到 DTO、Controller 或页面代码中 - -## 4. Source Generator 规范 - -本仓库通过 `Directory.Build.props` 自动接入源码生成器 - -- 服务注册优先通过 `BatchRegisterServices()` -- 后台服务注册优先通过 `BatchRegisterBackgroundServices()` -- 代理拦截能力由 `SourceGenerator.Runtime` 支持 -- 软删除过滤器与 JSON 列映射优先使用生成代码能力 - -涉及注册、拦截、代理相关代码时,优先遵守以下规则 - -- 先检查是否已有基于特性的生成式做法 -- 能复用生成器时,不手写重复的 DI 注册代码 -- 需要代理拦截的方法,保持与现有模式一致,通常应保留 `virtual` -- 不要破坏启动项目现有的生成注册链路 - -## 5. 代码风格规范 - -### 5.1 通用要求 - -- 大多数项目目标框架为 `net10.0` -- 部分共享项目或运行时项目同时使用 `net10.0-browser` -- 全仓库启用了 Nullable 与 ImplicitUsings -- 编写代码时必须考虑空引用安全 -- 代码风格优先服从当前目录下已有代码 -- 优先选择直接、清晰、低包装的实现方式 -- 无明确必要时,不引入新的抽象层 - -### 5.2 注释要求 - -- Agent 新增或修改的 `class` 应补充中文注释 -- Agent 新增或修改的方法应补充中文注释 -- Agent 新增或修改的属性应补充中文注释 -- 注释结尾不要使用句号、顿号、分号、冒号等标点 -- 注释内容应直接说明职责、用途或语义,不写空话 - -### 5.3 排版要求 - -- 方法参数必须写在同一行 -- 即使参数有多个,也不要主动换行 -- 方法与方法之间保留两个空行 -- 类中的属性与属性之间保留两个空行 -- 类体内部开头与结尾相对外层大括号各保留一个空行 -- 方法体内部开头与结尾相对外层大括号各保留一个空行 -- 如用户未明确要求,不主动采用与当前规范冲突的自动格式化风格 - -### 5.4 注释与字符要求 - -- 注释优先使用中文 -- 新建和修改文件默认使用 UTF-8 without BOM 字符编码 -- 如无特殊原因,不使用 GBK、ANSI 等其他本地编码 -- 如果文件本身已包含中文内容,或业务语义必须使用中文,可继续保持中文 - -## 6. Web 与宿主规范 - -- Web 宿主启动代码通常位于 `Presentation/*/Program.cs` -- 公共中间件与宿主扩展能力通常位于 `ProjectCore/WebAPI.Core` -- 健康检查路径为 `/healthz` -- Swagger 默认按开发环境使用理解,除非现有代码明确说明其他行为 - -修改 API 行为时,至少同时检查以下位置 - -- 对应的 Controller -- 对应的 Application Service -- 对应的 DTO 或请求模型 - -## 7. 数据库与 EF Core 规范 - -- 默认数据库提供程序为 PostgreSQL -- `DatabaseContext` 与相关拦截器位于 `Repository` -- 涉及数据库结构变更时,应同步考虑实体、上下文、映射、拦截器及相关调用点 -- 如任务涉及 EF Core 结构调整,Agent 只负责代码修改 -- 除非用户明确提出,否则不要帮用户生成 EF Migration -- 除非用户明确提出,否则不要帮用户执行 EF Migration -- 涉及迁移相关问题时,优先检查 `Repository.Tool` - -## 8. 配置规范 - -- 新增配置项前,先检查对应宿主下的 `appsettings.json` 与 `appsettings.Development.json` -- 优先复用现有配置节名称、绑定方式与 Options 模式 -- 不提交真实密钥、真实连接串或真实密码 -- 仓库中的密钥类配置默认视为示例值或本地开发值 - -## 9. TaskService 规范 - -- 任务宿主主要位于 `Presentation/TaskService` -- 任务基础能力位于 `ProjectCore/TaskService.Core` -- 队列任务与定时任务应优先复用现有 Builder、Attribute 与注册方式 -- Debug 模式下 `TaskService` 存在交互式启用流程 -- 修改任务宿主时,不要轻易破坏该调试行为 - -## 10. 前端规范 - -- `Presentation/Admin.App` 为 Blazor WebAssembly 项目 -- `Presentation/Admin.WebAPI` 负责管理端 API 与静态资源相关宿主职责 -- 修改 `.razor` 页面时,优先保持当前项目现有组件风格与结构 -- 如无明确要求,不做大规模界面风格重写 - -## 11. 工作方式规范 - -### 11.1 修改前 - -- 先阅读将要修改的文件及其相邻调用链 -- 至少向上或向下多看一层 -- 先搜索仓库内是否已有相同问题的现成实现 -- 优先确认是否已有生成器、扩展方法或共享基础能力可复用 - -### 11.2 修改时 - -- 修改范围尽量收敛 -- 不做与当前任务无关的大型重构 -- 跨层功能变更时,应同步处理 Service、DTO、Controller、配置等相关内容 -- 优先沿用当前层已经存在的实现模式 -- 如果问题本质是宿主引用范围过大导致无关基础设施依赖外溢,优先通过拆分宿主特化应用类库来收敛注册范围 - -### 11.3 修改后 - -- 先做最小必要验证 -- 如果改动影响多个项目或启动组合,再考虑解决方案级别构建验证 -- 除非用户明确要求,否则默认不主动编写单元测试代码 -- 不要并行执行多个 `dotnet build`,应串行构建,避免 `SourceGenerator.Core.dll` 被 `VBCSCompiler` 占用导致构建失败 - -## 12. 验证命令 - -以下命令可在仓库根目录按需执行 - -```powershell -dotnet restore -dotnet build NetEngine.slnx -dotnet run --project Presentation/Client.WebAPI/Client.WebAPI.csproj -dotnet run --project Presentation/Admin.WebAPI/Admin.WebAPI.csproj -dotnet run --project Presentation/Admin.App/Admin.App.csproj -dotnet run --project Presentation/TaskService/TaskService.csproj -``` - -## 13. 决策优先级 - -面对多个实现方案时,按以下顺序决策 - -1. 复用同层中已经存在的实现模式 -2. 复用 `ProjectCore`、`Infrastructure` 或源码生成器提供的公共能力 -3. 添加最小必要代码 -4. 最后才考虑引入新的抽象 - -## 14. 明确禁止事项 - -- 不要新增无必要的包装层 -- 不要绕过现有生成式 DI 注册链路,除非确有必要 -- 不要把业务逻辑写进 Controller、Razor 页面、API 宿主或任务宿主 -- 不要把数据库访问直接塞进表现层 -- 不要把基础设施实现细节混入应用层模型或表现层代码 -- 不要进行与任务无关的大规模风格化重写 diff --git a/AdminApi/AdminApi.csproj b/AdminApi/AdminApi.csproj new file mode 100644 index 000000000..80f82691b --- /dev/null +++ b/AdminApi/AdminApi.csproj @@ -0,0 +1,50 @@ + + + + net6.0 + True + embedded + enable + enable + + + + 1591;8618 + True + + + + 1591;8618 + True + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/AdminApi/Controllers/ArticleController.cs b/AdminApi/Controllers/ArticleController.cs new file mode 100644 index 000000000..ecf3edf3b --- /dev/null +++ b/AdminApi/Controllers/ArticleController.cs @@ -0,0 +1,607 @@ +using AdminApi.Filters; +using AdminApi.Libraries; +using AdminApi.Services; +using AdminShared.Models; +using AdminShared.Models.Article; +using Common; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Repository.Database; + +namespace AdminApi.Controllers +{ + [SignVerifyFilter] + [Route("[controller]")] + [Authorize] + [ApiController] + public class ArticleController : ControllerBase + { + + + private readonly DatabaseContext db; + private readonly IConfiguration configuration; + private readonly SnowflakeHelper snowflakeHelper; + + private readonly ArticleService articleService; + + private readonly long userId; + + + + public ArticleController(DatabaseContext db, IConfiguration configuration, SnowflakeHelper snowflakeHelper, IHttpContextAccessor httpContextAccessor, ArticleService articleService) + { + this.db = db; + this.configuration = configuration; + this.snowflakeHelper = snowflakeHelper; + + var userIdStr = httpContextAccessor.HttpContext?.GetClaimByAuthorization("userId"); + + if (userIdStr != null) + { + userId = long.Parse(userIdStr); + } + + this.articleService = articleService; + } + + + + /// + /// 获取频道列表 + /// + /// 页码 + /// 单页数量 + /// 搜索关键词 + /// + [HttpGet("GetChannelList")] + public DtoPageList GetChannelList(int pageNum, int pageSize, string? searchKey) + { + var data = new DtoPageList(); + + int skip = (pageNum - 1) * pageSize; + + var query = db.TChannel.Where(t => t.IsDelete == false); + + if (!string.IsNullOrEmpty(searchKey)) + { + query = query.Where(t => t.Name.Contains(searchKey)); + } + + data.Total = query.Count(); + + data.List = query.OrderByDescending(t => t.CreateTime).Select(t => new DtoChannel + { + Id = t.Id, + Name = t.Name, + Remarks = t.Remarks, + Sort = t.Sort, + CreateTime = t.CreateTime + }).Skip(skip).Take(pageSize).ToList(); + + return data; + } + + + + /// + /// 获取频道KV列表 + /// + /// + [HttpGet("GetChannelKVList")] + public List GetChannelKVList() + { + var list = db.TChannel.Where(t => t.IsDelete == false).OrderBy(t => t.Sort).ThenBy(t => t.CreateTime).Select(t => new DtoKeyValue + { + Key = t.Id, + Value = t.Name + }).ToList(); + + return list; + } + + + + /// + /// 通过频道Id 获取频道信息 + /// + /// 频道ID + /// + [HttpGet("GetChannel")] + public DtoChannel? GetChannel(long channelId) + { + var channel = db.TChannel.Where(t => t.IsDelete == false && t.Id == channelId).Select(t => new DtoChannel + { + Id = t.Id, + Name = t.Name, + Remarks = t.Remarks, + Sort = t.Sort, + CreateTime = t.CreateTime + }).FirstOrDefault(); + + return channel; + } + + + + + /// + /// 创建频道 + /// + /// + /// + [HttpPost("CreateChannel")] + public long CreateChannel(DtoEditChannel createChannel) + { + TChannel channel = new() + { + Id = snowflakeHelper.GetId(), + Name = createChannel.Name, + CreateTime = DateTime.UtcNow, + CreateUserId = userId, + + Remarks = createChannel.Remarks, + Sort = createChannel.Sort + }; + + db.TChannel.Add(channel); + + db.SaveChanges(); + + return channel.Id; + } + + + + + /// + /// 更新频道信息 + /// + /// + /// + /// + [HttpPost("UpdateChannel")] + public bool UpdateChannel(long channelId, DtoEditChannel updateChannel) + { + var channel = db.TChannel.Where(t => t.IsDelete == false && t.Id == channelId).FirstOrDefault(); + + if (channel != null) + { + channel.Name = updateChannel.Name; + channel.Remarks = updateChannel.Remarks; + channel.Sort = updateChannel.Sort; + + db.SaveChanges(); + + return true; + } + else + { + return false; + } + + } + + + + /// + /// 删除频道 + /// + /// + /// + [HttpDelete("DeleteChannel")] + public bool DeleteChannel(long id) + { + var channel = db.TChannel.Where(t => t.IsDelete == false && t.Id == id).FirstOrDefault(); + + if (channel != null) + { + channel.IsDelete = true; + channel.DeleteTime = DateTime.UtcNow; + channel.DeleteUserId = userId; + + db.SaveChanges(); + + return true; + } + else + { + return false; + } + } + + + + + /// + /// 获取栏目列表 + /// + /// 频道ID + /// 页码 + /// 单页数量 + /// 搜索关键词 + /// + [HttpGet("GetCategoryList")] + public DtoPageList GetCategoryList(long channelId, int pageNum, int pageSize, string? searchKey) + { + var data = new DtoPageList(); + + int skip = (pageNum - 1) * pageSize; + + var query = db.TCategory.Where(t => t.IsDelete == false && t.ChannelId == channelId); + + if (!string.IsNullOrEmpty(searchKey)) + { + query = query.Where(t => t.Name.Contains(searchKey)); + } + + data.Total = query.Count(); + + data.List = query.OrderByDescending(t => t.CreateTime).Select(t => new DtoCategory + { + Id = t.Id, + Name = t.Name, + Remarks = t.Remarks, + Sort = t.Sort, + ParentId = t.ParentId, + ParentName = t.Parent!.Name, + CreateTime = t.CreateTime + }).Skip(skip).Take(pageSize).ToList(); + + return data; + } + + + + /// + /// 获取栏目树形列表 + /// + /// 频道ID + /// + [HttpGet("GetCategoryTreeList")] + public List GetCategoryTreeList(long channelId) + { + var list = db.TCategory.Where(t => t.IsDelete == false && t.ChannelId == channelId && t.ParentId == null).Select(t => new DtoKeyValueChild + { + Key = t.Id, + Value = t.Name + }).ToList(); + + foreach (var item in list) + { + item.ChildList = articleService.GetCategoryChildList(Convert.ToInt64(item.Key)); + } + + return list; + } + + + + /// + /// 通过栏目Id 获取栏目信息 + /// + /// 栏目ID + /// + [HttpGet("GetCategory")] + public DtoCategory? GetCategory(long categoryId) + { + var category = db.TCategory.Where(t => t.IsDelete == false && t.Id == categoryId).Select(t => new DtoCategory + { + Id = t.Id, + Name = t.Name, + Remarks = t.Remarks, + Sort = t.Sort, + ParentId = t.ParentId, + ParentName = t.Parent!.Name, + CreateTime = t.CreateTime + }).FirstOrDefault(); + + return category; + } + + + + + /// + /// 创建栏目 + /// + /// + /// + [HttpPost("CreateCategory")] + public long CreateCategory(DtoEditCategory createCategory) + { + TCategory category = new() + { + Id = snowflakeHelper.GetId(), + CreateTime = DateTime.UtcNow, + CreateUserId = userId, + Name = createCategory.Name, + ChannelId = createCategory.ChannelId, + ParentId = createCategory.ParentId, + Remarks = createCategory.Remarks, + Sort = createCategory.Sort + }; + + db.TCategory.Add(category); + + db.SaveChanges(); + + return category.Id; + } + + + + + /// + /// 更新栏目信息 + /// + /// + /// + /// + [HttpPost("UpdateCategory")] + public bool UpdateCategory(long categoryId, DtoEditCategory updateCategory) + { + var category = db.TCategory.Where(t => t.IsDelete == false && t.Id == categoryId).FirstOrDefault(); + + if (category != null) + { + category.Name = updateCategory.Name; + category.ParentId = updateCategory.ParentId; + category.Remarks = updateCategory.Remarks; + category.Sort = updateCategory.Sort; + + db.SaveChanges(); + + return true; + } + else + { + return false; + } + } + + + + /// + /// 删除栏目 + /// + /// + /// + [HttpDelete("DeleteCategory")] + public bool DeleteCategory(long id) + { + var category = db.TCategory.Where(t => t.IsDelete == false && t.Id == id).FirstOrDefault(); + + if (category != null) + { + category.IsDelete = true; + category.DeleteTime = DateTime.UtcNow; + category.DeleteUserId = userId; + + db.SaveChanges(); + + return true; + } + else + { + return false; + } + + } + + + + + /// + /// 获取文章列表 + /// + /// 频道ID + /// 页码 + /// 单页数量 + /// 搜索关键词 + /// + [HttpGet("GetArticleList")] + public DtoPageList GetArticleList(long channelId, int pageNum, int pageSize, string? searchKey) + { + var data = new DtoPageList(); + + int skip = (pageNum - 1) * pageSize; + + var query = db.TArticle.Where(t => t.IsDelete == false && t.Category.ChannelId == channelId); + + if (!string.IsNullOrEmpty(searchKey)) + { + query = query.Where(t => t.Title.Contains(searchKey)); + } + + data.Total = query.Count(); + + var fileServerUrl = configuration["FileServerUrl"].ToString(); + + data.List = query.OrderByDescending(t => t.CreateTime).Select(t => new DtoArticle + { + Id = t.Id, + CategoryId = t.CategoryId, + CategoryName = t.Category.Name, + Title = t.Title, + Content = t.Content, + Digest = t.Digest, + IsRecommend = t.IsRecommend, + IsDisplay = t.IsDisplay, + Sort = t.Sort, + ClickCount = t.ClickCount, + CreateTime = t.CreateTime, + CoverImageList = db.TFile.Where(f => f.IsDelete == false && f.Sign == "cover" && f.Table == "TArticle" && f.TableId == t.Id).Select(f => new DtoKeyValue + { + Key = f.Id, + Value = fileServerUrl + f.Path + }).ToList() + }).Skip(skip).Take(pageSize).ToList(); + + return data; + } + + + + + + /// + /// 通过文章ID 获取文章信息 + /// + /// 文章ID + /// + [HttpGet("GetArticle")] + public DtoArticle? GetArticle(long articleId) + { + var fileServerUrl = configuration["FileServerUrl"].ToString(); + + + var article = db.TArticle.Where(t => t.IsDelete == false && t.Id == articleId).Select(t => new DtoArticle + { + Id = t.Id, + CategoryId = t.CategoryId, + CategoryName = t.Category.Name, + Title = t.Title, + Content = t.Content, + Digest = t.Digest, + IsRecommend = t.IsRecommend, + IsDisplay = t.IsDisplay, + Sort = t.Sort, + ClickCount = t.ClickCount, + CreateTime = t.CreateTime, + CoverImageList = db.TFile.Where(f => f.IsDelete == false && f.Sign == "cover" && f.Table == "TArticle" && f.TableId == t.Id).Select(f => new DtoKeyValue + { + Key = f.Id, + Value = fileServerUrl + f.Path + }).ToList() + }).FirstOrDefault(); + + return article; + } + + + + + /// + /// 创建文章 + /// + /// + /// 文件key + /// + [HttpPost("CreateArticle")] + public long CreateArticle(DtoEditArticle createArticle, long fileKey) + { + TArticle article = new() + { + Id = snowflakeHelper.GetId(), + CreateTime = DateTime.UtcNow, + CreateUserId = userId, + Title = createArticle.Title, + Content = createArticle.Content, + CategoryId = createArticle.CategoryId, + IsRecommend = createArticle.IsRecommend, + IsDisplay = createArticle.IsDisplay, + Sort = createArticle.Sort, + ClickCount = createArticle.ClickCount + }; + + if (string.IsNullOrEmpty(createArticle.Digest) && !string.IsNullOrEmpty(createArticle.Content)) + { + string content = StringHelper.RemoveHtml(createArticle.Content); + article.Digest = content.Length > 255 ? content[..255] : content; + } + else + { + article.Digest = createArticle.Digest!; + } + + db.TArticle.Add(article); + + + var fileList = db.TFile.Where(t => t.IsDelete == false && t.Table == "TArticle" && t.TableId == fileKey).ToList(); + + foreach (var file in fileList) + { + file.TableId = article.Id; + } + + db.SaveChanges(); + + return article.Id; + } + + + + + /// + /// 更新文章信息 + /// + /// + /// + /// + [HttpPost("UpdateArticle")] + public bool UpdateArticle(long articleId, DtoEditArticle updateArticle) + { + var article = db.TArticle.Where(t => t.IsDelete == false && t.Id == articleId).FirstOrDefault(); + + if (article != null) + { + article.CategoryId = updateArticle.CategoryId; + article.Title = updateArticle.Title; + article.Content = updateArticle.Content; + article.IsRecommend = updateArticle.IsRecommend; + article.IsDisplay = updateArticle.IsDisplay; + article.Sort = updateArticle.Sort; + article.ClickCount = updateArticle.ClickCount; + + if (string.IsNullOrEmpty(updateArticle.Digest) && !string.IsNullOrEmpty(updateArticle.Content)) + { + string content = StringHelper.RemoveHtml(updateArticle.Content); + article.Digest = content.Length > 255 ? content[..255] : content; + } + else + { + article.Digest = updateArticle.Digest; + } + + db.SaveChanges(); + + return true; + } + else + { + return false; + } + } + + + + /// + /// 删除文章 + /// + /// + /// + [HttpDelete("DeleteArticle")] + public bool DeleteArticle(long id) + { + var article = db.TArticle.Where(t => t.IsDelete == false && t.Id == id).FirstOrDefault(); + + if (article != null) + { + article.IsDelete = true; + article.DeleteTime = DateTime.UtcNow; + article.DeleteUserId = userId; + + db.SaveChanges(); + + return true; + } + else + { + return false; + } + + } + + + } +} diff --git a/AdminApi/Controllers/AuthorizeController.cs b/AdminApi/Controllers/AuthorizeController.cs new file mode 100644 index 000000000..9a46a7366 --- /dev/null +++ b/AdminApi/Controllers/AuthorizeController.cs @@ -0,0 +1,107 @@ +using AdminApi.Filters; +using AdminApi.Libraries; +using AdminApi.Services; +using AdminShared.Models; +using AdminShared.Models.Authorize; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Cryptography.KeyDerivation; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using Repository.Database; +using System.Text; + +namespace AdminApi.Controllers +{ + + + /// + /// 系统访问授权模块 + /// + [Route("[controller]")] + [ApiController] + public class AuthorizeController : ControllerBase + { + + + private readonly DatabaseContext db; + + private readonly AuthorizeService authorizeService; + + private readonly long userId; + + + + public AuthorizeController(DatabaseContext db, AuthorizeService authorizeService, IHttpContextAccessor httpContextAccessor) + { + this.db = db; + + this.authorizeService = authorizeService; + + var userIdStr = httpContextAccessor.HttpContext?.GetClaimByAuthorization("userId"); + if (userIdStr != null) + { + userId = long.Parse(userIdStr); + } + } + + + + + + /// + /// 获取Token认证信息 + /// + /// 登录信息集合 + /// + [HttpPost("GetToken")] + public string? GetToken(DtoLogin login) + { + var userList = db.TUser.Where(t => t.IsDelete == false && (t.Name == login.Name || t.Phone == login.Name || t.Email == login.Name)).Select(t => new { t.Id, t.PassWord }).ToList(); + + var user = userList.Where(t => t.PassWord == Convert.ToBase64String(KeyDerivation.Pbkdf2(login.PassWord, Encoding.UTF8.GetBytes(t.Id.ToString()), KeyDerivationPrf.HMACSHA256, 1000, 32))).FirstOrDefault(); + + if (user != null) + { + return authorizeService.GetTokenByUserId(user.Id); + } + else + { + HttpContext.Response.StatusCode = 400; + HttpContext.Items.Add("errMsg", "用户名或密码错误"); + + return default; + } + + } + + + + + + /// + /// 获取授权功能列表 + /// + /// 模块标记 + /// + [SignVerifyFilter] + [Authorize] + [CacheDataFilter(TTL = 60, UseToken = true)] + [HttpGet("GetFunctionList")] + public List GetFunctionList(string sign) + { + + var roleIds = db.TUserRole.AsNoTracking().Where(t => t.IsDelete == false && t.UserId == userId).Select(t => t.RoleId).ToList(); + + var kvList = db.TFunctionAuthorize.Where(t => t.IsDelete == false && (roleIds.Contains(t.RoleId!.Value) || t.UserId == userId) && t.Function.Parent!.Sign == sign).Select(t => new DtoKeyValue + { + Key = t.Function.Sign, + Value = t.Function.Name + }).ToList(); + + return kvList; + } + + + + } +} diff --git a/AdminApi/Controllers/BaseController.cs b/AdminApi/Controllers/BaseController.cs new file mode 100644 index 000000000..797acdc88 --- /dev/null +++ b/AdminApi/Controllers/BaseController.cs @@ -0,0 +1,137 @@ +using AdminApi.Filters; +using AdminShared.Models; +using Common; +using Microsoft.AspNetCore.Mvc; +using Repository.Database; + +namespace AdminApi.Controllers +{ + /// + /// 系统基础方法控制器 + /// + [SignVerifyFilter] + [Route("[controller]")] + [ApiController] + public class BaseController : ControllerBase + { + + private readonly DatabaseContext db; + private readonly SnowflakeHelper snowflakeHelper; + + + + public BaseController(DatabaseContext db, SnowflakeHelper snowflakeHelper) + { + this.db = db; + this.snowflakeHelper = snowflakeHelper; + } + + + + /// + /// 获取省市级联地址数据 + /// + /// 省份ID + /// 城市ID + /// + /// 不传递任何参数返回省份数据,传入省份ID返回城市数据,传入城市ID返回区域数据 + [HttpGet("GetRegion")] + public List GetRegion(int provinceId, int cityId) + { + var list = new List(); + + if (provinceId == 0 && cityId == 0) + { + list = db.TRegionProvince.Select(t => new DtoKeyValue { Key = t.Id, Value = t.Province }).ToList(); + } + + if (provinceId != 0) + { + list = db.TRegionCity.Where(t => t.ProvinceId == provinceId).Select(t => new DtoKeyValue { Key = t.Id, Value = t.City }).ToList(); + } + + if (cityId != 0) + { + list = db.TRegionArea.Where(t => t.CityId == cityId).Select(t => new DtoKeyValue { Key = t.Id, Value = t.Area }).ToList(); + } + + return list; + } + + + + /// + /// 获取全部省市级联地址数据 + /// + /// + [HttpGet("GetRegionAll")] + public List GetRegionAll() + { + + var list = db.TRegionProvince.Select(t => new DtoKeyValueChild + { + Key = t.Id, + Value = t.Province, + ChildList = t.TRegionCity!.Select(c => new DtoKeyValueChild + { + Key = c.Id, + Value = c.City, + ChildList = c.TRegionArea!.Select(a => new DtoKeyValueChild + { + Key = a.Id, + Value = a.Area + }).ToList() + }).ToList() + }).ToList(); + + return list; + } + + + + /// + /// 自定义二维码生成方法 + /// + /// 数据内容 + /// + [HttpGet("GetQrCode")] + public FileResult GetQrCode(string text) + { + var image = ImgHelper.GetQrCode(text); + return File(image, "image/png"); + } + + + + /// + /// 获取指定组ID的KV键值对 + /// + /// + /// + [HttpGet("GetValueList")] + public List GetValueList(long groupId) + { + + var list = db.TAppSetting.Where(t => t.IsDelete == false && t.Module == "Dictionary" && t.GroupId == groupId).Select(t => new DtoKeyValue + { + Key = t.Key, + Value = t.Value + }).ToList(); + + return list; + } + + + + /// + /// 获取一个雪花ID + /// + /// + [HttpGet("GetSnowflakeId")] + public long GetSnowflakeId() + { + return snowflakeHelper.GetId(); + } + + } +} \ No newline at end of file diff --git a/AdminApi/Controllers/FileController.cs b/AdminApi/Controllers/FileController.cs new file mode 100644 index 000000000..d0377235b --- /dev/null +++ b/AdminApi/Controllers/FileController.cs @@ -0,0 +1,313 @@ +using AdminApi.Filters; +using AdminApi.Libraries; +using Common; +using FileStorage; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.StaticFiles; +using Microsoft.EntityFrameworkCore; +using Repository.Database; +using SkiaSharp; + +namespace AdminApi.Controllers +{ + + /// + /// 文件上传控制器 + /// + [SignVerifyFilter] + [Authorize] + [Route("[controller]")] + [ApiController] + public class FileController : ControllerBase + { + + + private readonly DatabaseContext db; + private readonly IConfiguration configuration; + private readonly SnowflakeHelper snowflakeHelper; + private readonly IFileStorage? fileStorage; + + private readonly string rootPath; + + private readonly long userId; + + + public FileController(DatabaseContext db, IConfiguration configuration, SnowflakeHelper snowflakeHelper, IWebHostEnvironment webHostEnvironment, IHttpContextAccessor httpContextAccessor, IFileStorage? fileStorage = null) + { + this.db = db; + this.configuration = configuration; + this.snowflakeHelper = snowflakeHelper; + this.fileStorage = fileStorage; + + rootPath = webHostEnvironment.WebRootPath.Replace("\\", "/"); + + var userIdStr = httpContextAccessor.HttpContext?.GetClaimByAuthorization("userId"); + + if (userIdStr != null) + { + userId = long.Parse(userIdStr); + } + } + + + + /// + /// 单文件上传接口 + /// + /// 业务领域 + /// 记录值 + /// 自定义标记 + /// file + /// 文件ID + [DisableRequestSizeLimit] + [HttpPost("UploadFile")] + public long UploadFile([FromQuery] string business, [FromQuery] long key, [FromQuery] string sign, IFormFile file) + { + + string basepath = "/uploads/" + DateTime.UtcNow.ToString("yyyy/MM/dd"); + string filepath = rootPath + basepath; + + Directory.CreateDirectory(filepath); + + var fileName = snowflakeHelper.GetId(); + var fileExtension = Path.GetExtension(file.FileName).ToLowerInvariant(); + var fullFileName = string.Format("{0}{1}", fileName, fileExtension); + + string path; + + var isSuccess = false; + + if (file != null && file.Length > 0) + { + path = filepath + "/" + fullFileName; + + using (FileStream fs = System.IO.File.Create(path)) + { + file.CopyTo(fs); + fs.Flush(); + } + + if (fileStorage != null) + { + + var upload = fileStorage.FileUpload(path, "uploads/" + DateTime.UtcNow.ToString("yyyy/MM/dd"), file.FileName); + + if (upload) + { + IOHelper.DeleteFile(path); + + path = "/uploads/" + DateTime.UtcNow.ToString("yyyy/MM/dd") + "/" + fullFileName; + isSuccess = true; + } + } + else + { + path = basepath + "/" + fullFileName; + isSuccess = true; + } + + if (isSuccess) + { + + TFile f = new() + { + Id = fileName, + Name = file.FileName, + Path = path, + Table = business, + Sign = sign, + TableId = key, + CreateUserId = userId, + CreateTime = DateTime.UtcNow + }; + db.TFile.Add(f); + db.SaveChanges(); + + return fileName; + } + + } + + HttpContext.Response.StatusCode = 400; + HttpContext.Items.Add("errMsg", "文件上传失败"); + return default; + } + + + + + /// + /// 通过文件ID获取文件 + /// + /// 文件ID + /// + [AllowAnonymous] + [HttpGet("GetFile")] + public FileResult? GetFile(long fileid) + { + + var file = db.TFile.Where(t => t.Id == fileid).FirstOrDefault(); + + if (file != null) + { + string path = rootPath + file.Path; + + + //读取文件入流 + var stream = System.IO.File.OpenRead(path); + + //获取文件后缀 + string fileExt = Path.GetExtension(path); + + //获取系统常规全部mime类型 + var provider = new FileExtensionContentTypeProvider(); + + //通过文件后缀寻找对呀的mime类型 + var memi = provider.Mappings.ContainsKey(fileExt) ? provider.Mappings[fileExt] : provider.Mappings[".zip"]; + + + return File(stream, memi, file.Name); + } + else + { + return default; + } + } + + + + + /// + /// 通过图片文件ID获取图片 + /// + /// 图片ID + /// 宽度 + /// 高度 + /// + /// 不指定宽高参数,返回原图 + [AllowAnonymous] + [HttpGet("GetImage")] + public FileResult? GetImage(long fileId, int width, int height) + { + var file = db.TFile.Where(t => t.Id == fileId).FirstOrDefault(); + + if (file != null) + { + var path = rootPath + file.Path; + + string fileExt = Path.GetExtension(path); + var provider = new FileExtensionContentTypeProvider(); + var memi = provider.Mappings[fileExt]; + + using var fileStream = System.IO.File.OpenRead(path); + + if (width == 0 && height == 0) + { + return File(fileStream, memi, file.Name); + } + else + { + + using var original = SKBitmap.Decode(path); + if (original.Width < width || original.Height < height) + { + return File(fileStream, memi, file.Name); + } + else + { + + if (width != 0 && height == 0) + { + var percent = width / (float)original.Width; + + width = (int)(original.Width * percent); + height = (int)(original.Height * percent); + } + + if (width == 0 && height != 0) + { + var percent = height / (float)original.Height; + + width = (int)(original.Width * percent); + height = (int)(original.Height * percent); + } + + using var resizeBitmap = original.Resize(new SKImageInfo(width, height), SKFilterQuality.High); + using var image = SKImage.FromBitmap(resizeBitmap); + using var imageData = image.Encode(SKEncodedImageFormat.Png, 100); + return File(imageData.ToArray(), "image/png"); + } + } + } + else + { + return default; + } + + } + + + + /// + /// 通过文件ID获取文件静态访问路径 + /// + /// 文件ID + /// + [HttpGet("GetFilePath")] + public string? GetFilePath(long fileid) + { + + var file = db.TFile.AsNoTracking().Where(t => t.IsDelete == false && t.Id == fileid).FirstOrDefault(); + + if (file != null) + { + var fileServerUrl = configuration["FileServerUrl"].ToString(); + + string fileUrl = fileServerUrl + file.Path; + + return fileUrl; + } + else + { + HttpContext.Response.StatusCode = 400; + HttpContext.Items.Add("errMsg", "通过指定的文件ID未找到任何文件"); + + return default; + } + + } + + + + + /// + /// 通过文件ID删除文件方法 + /// + /// 文件ID + /// + [HttpDelete("DeleteFile")] + public bool DeleteFile(long id) + { + + var file = db.TFile.Where(t => t.IsDelete == false && t.Id == id).FirstOrDefault(); + + if (file != null) + { + file.IsDelete = true; + file.DeleteTime = DateTime.UtcNow; + + db.SaveChanges(); + + return true; + } + else + { + return false; + } + + } + + + } +} \ No newline at end of file diff --git a/AdminApi/Controllers/LinkController.cs b/AdminApi/Controllers/LinkController.cs new file mode 100644 index 000000000..85a0e8cbe --- /dev/null +++ b/AdminApi/Controllers/LinkController.cs @@ -0,0 +1,182 @@ +using AdminApi.Filters; +using AdminApi.Libraries; +using AdminShared.Models; +using AdminShared.Models.Link; +using Common; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Repository.Database; + +namespace AdminApi.Controllers +{ + [SignVerifyFilter] + [Route("[controller]")] + [Authorize] + [ApiController] + public class LinkController : ControllerBase + { + + private readonly DatabaseContext db; + private readonly SnowflakeHelper snowflakeHelper; + + private readonly long userId; + + + + public LinkController(DatabaseContext db, SnowflakeHelper snowflakeHelper, IHttpContextAccessor httpContextAccessor) + { + this.db = db; + this.snowflakeHelper = snowflakeHelper; + + var userIdStr = httpContextAccessor.HttpContext?.GetClaimByAuthorization("userId"); + if (userIdStr != null) + { + userId = long.Parse(userIdStr); + } + } + + + + /// + /// 获取友情链接列表 + /// + /// 页码 + /// 单页数量 + /// 搜索关键词 + /// + [HttpGet("GetLinkList")] + public DtoPageList GetLinkList(int pageNum, int pageSize, string? searchKey) + { + var data = new DtoPageList(); + + int skip = (pageNum - 1) * pageSize; + + var query = db.TLink.Where(t => t.IsDelete == false); + + if (!string.IsNullOrEmpty(searchKey)) + { + query = query.Where(t => t.Name.Contains(searchKey)); + } + + data.Total = query.Count(); + + data.List = query.OrderByDescending(t => t.CreateTime).Select(t => new DtoLink + { + Id = t.Id, + Name = t.Name, + Url = t.Url, + Sort = t.Sort, + CreateTime = t.CreateTime + }).Skip(skip).Take(pageSize).ToList(); + + return data; + } + + + + /// + /// 获取友情链接 + /// + /// 链接ID + /// + [HttpGet("GetLink")] + public DtoLink? GetLink(long linkId) + { + var link = db.TLink.Where(t => t.IsDelete == false && t.Id == linkId).Select(t => new DtoLink + { + Id = t.Id, + Name = t.Name, + Url = t.Url, + Sort = t.Sort, + CreateTime = t.CreateTime + }).FirstOrDefault(); + + return link; + } + + + + + /// + /// 创建友情链接 + /// + /// + /// + [HttpPost("CreateLink")] + public long CreateLink(DtoEditLink createLink) + { + TLink link = new() + { + Id = snowflakeHelper.GetId(), + Name = createLink.Name, + Url = createLink.Url, + CreateTime = DateTime.UtcNow, + CreateUserId = userId, + + Sort = createLink.Sort + }; + + db.TLink.Add(link); + + db.SaveChanges(); + + return link.Id; + } + + + + + /// + /// 更新友情链接 + /// + /// + /// + /// + [HttpPost("UpdateLink")] + public bool UpdateLink(long linkId, DtoEditLink updateLink) + { + var link = db.TLink.Where(t => t.IsDelete == false && t.Id == linkId).FirstOrDefault(); + + if (link != null) + { + link.Name = updateLink.Name; + link.Url = updateLink.Url; + link.Sort = updateLink.Sort; + + db.SaveChanges(); + } + + return true; + } + + + + /// + /// 删除友情链接 + /// + /// + /// + [HttpDelete("DeleteLink")] + public bool DeleteLink(long id) + { + var link = db.TLink.Where(t => t.IsDelete == false && t.Id == id).FirstOrDefault(); + + if (link != null) + { + link.IsDelete = true; + link.DeleteTime = DateTime.UtcNow; + link.DeleteUserId = userId; + + db.SaveChanges(); + + return true; + } + else + { + return false; + } + } + + + } +} diff --git a/AdminApi/Controllers/SiteController.cs b/AdminApi/Controllers/SiteController.cs new file mode 100644 index 000000000..7aa7e1f52 --- /dev/null +++ b/AdminApi/Controllers/SiteController.cs @@ -0,0 +1,154 @@ +using AdminApi.Filters; +using AdminApi.Services; +using AdminShared.Models; +using AdminShared.Models.Site; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Repository.Database; + +namespace AdminApi.Controllers +{ + + /// + /// 站点控制器 + /// + [SignVerifyFilter] + [Authorize] + [Route("[controller]")] + [ApiController] + public class SiteController : ControllerBase + { + + private readonly DatabaseContext db; + + private readonly SiteService siteService; + + + + public SiteController(DatabaseContext db, SiteService siteService) + { + this.db = db; + + this.siteService = siteService; + + } + + + + /// + /// 获取站点信息 + /// + /// + [HttpGet("GetSite")] + public DtoSite GetSite() + { + var kvList = db.TAppSetting.Where(t => t.IsDelete == false && t.Module == "Site").Select(t => new + { + t.Key, + t.Value + }).ToList(); + + DtoSite site = new() + { + WebUrl = kvList.Where(t => t.Key == "WebUrl").Select(t => t.Value).FirstOrDefault(), + ManagerName = kvList.Where(t => t.Key == "ManagerName").Select(t => t.Value).FirstOrDefault(), + ManagerAddress = kvList.Where(t => t.Key == "ManagerAddress").Select(t => t.Value).FirstOrDefault(), + ManagerPhone = kvList.Where(t => t.Key == "ManagerPhone").Select(t => t.Value).FirstOrDefault(), + ManagerEmail = kvList.Where(t => t.Key == "ManagerEmail").Select(t => t.Value).FirstOrDefault(), + RecordNumber = kvList.Where(t => t.Key == "RecordNumber").Select(t => t.Value).FirstOrDefault(), + SeoTitle = kvList.Where(t => t.Key == "SeoTitle").Select(t => t.Value).FirstOrDefault(), + SeoKeyWords = kvList.Where(t => t.Key == "SeoKeyWords").Select(t => t.Value).FirstOrDefault(), + SeoDescription = kvList.Where(t => t.Key == "SeoDescription").Select(t => t.Value).FirstOrDefault(), + FootCode = kvList.Where(t => t.Key == "FootCode").Select(t => t.Value).FirstOrDefault() + }; + + return site; + } + + + + + /// + /// 编辑站点信息 + /// + /// + /// + [HttpPost("EditSite")] + public bool EditSite(DtoSite editSite) + { + var query = db.TAppSetting.Where(t => t.IsDelete == false && t.Module == "Site"); + + siteService.SetSiteInfo("WebUrl", editSite.WebUrl); + siteService.SetSiteInfo("ManagerName", editSite.ManagerName); + siteService.SetSiteInfo("ManagerAddress", editSite.ManagerAddress); + siteService.SetSiteInfo("ManagerPhone", editSite.ManagerPhone); + siteService.SetSiteInfo("ManagerEmail", editSite.ManagerEmail); + siteService.SetSiteInfo("RecordNumber", editSite.RecordNumber); + siteService.SetSiteInfo("SeoTitle", editSite.SeoTitle); + siteService.SetSiteInfo("SeoKeyWords", editSite.SeoKeyWords); + siteService.SetSiteInfo("SeoDescription", editSite.SeoDescription); + siteService.SetSiteInfo("FootCode", editSite.FootCode); + + return true; + } + + + + /// + /// 获取服务器信息 + /// + /// + [HttpGet("GetServerInfo")] + public List GetServerInfo() + { + var list = new List + { + new DtoKeyValue + { + Key = "服务器名称", + Value = Environment.MachineName + }, + + new DtoKeyValue + { + Key = "服务器IP", + Value = HttpContext.Connection.LocalIpAddress!.ToString() + }, + + new DtoKeyValue + { + Key = "操作系统", + Value = Environment.OSVersion.ToString() + }, + + new DtoKeyValue + { + Key = "外部端口", + Value = HttpContext.Connection.LocalPort.ToString() + }, + + new DtoKeyValue + { + Key = "目录物理路径", + Value = Environment.CurrentDirectory + }, + + new DtoKeyValue + { + Key = "服务器CPU", + Value = Environment.ProcessorCount.ToString() + "核" + }, + + new DtoKeyValue + { + Key = "本网站占用内存", + Value = ((double)GC.GetTotalMemory(false) / 1048576).ToString("N2") + "M" + } + }; + + return list; + } + + + } +} \ No newline at end of file diff --git a/AdminApi/Controllers/UeditorController.cs b/AdminApi/Controllers/UeditorController.cs new file mode 100644 index 000000000..30df318a2 --- /dev/null +++ b/AdminApi/Controllers/UeditorController.cs @@ -0,0 +1,73 @@ +using AdminApi.Libraries.Ueditor; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace AdminApi.Controllers +{ + [Authorize] + [Route("[controller]")] + [ApiController] + public class UeditorController : ControllerBase + { + + private readonly IWebHostEnvironment webHostEnvironment; + private readonly IConfiguration configuration; + + public UeditorController(IWebHostEnvironment webHostEnvironment, IConfiguration configuration) + { + this.webHostEnvironment = webHostEnvironment; + this.configuration = configuration; + } + + + [DisableRequestSizeLimit] + [HttpGet("ProcessRequest")] + [HttpPost("ProcessRequest")] + public string ProcessRequest() + { + string rootPath = webHostEnvironment.WebRootPath.Replace("\\", "/"); + + string fileServerUrl = configuration["FileServerUrl"].ToString(); + + Handler action = (Request.Query["action"].Count != 0 ? Request.Query["action"].ToString() : "") switch + { + "config" => new ConfigHandler(), + "uploadimage" => new UploadHandler(new UploadConfig() + { + AllowExtensions = Config.GetStringList("imageAllowFiles", fileServerUrl), + PathFormat = Config.GetString("imagePathFormat", fileServerUrl), + SizeLimit = Config.GetInt("imageMaxSize", fileServerUrl), + UploadFieldName = Config.GetString("imageFieldName", fileServerUrl) + }, rootPath, HttpContext), + "uploadscrawl" => new UploadHandler(new UploadConfig() + { + AllowExtensions = new string[] { ".png" }, + PathFormat = Config.GetString("scrawlPathFormat", fileServerUrl), + SizeLimit = Config.GetInt("scrawlMaxSize", fileServerUrl), + UploadFieldName = Config.GetString("scrawlFieldName", fileServerUrl), + Base64 = true, + Base64Filename = "scrawl.png" + }, rootPath, HttpContext), + "uploadvideo" => new UploadHandler(new UploadConfig() + { + AllowExtensions = Config.GetStringList("videoAllowFiles", fileServerUrl), + PathFormat = Config.GetString("videoPathFormat", fileServerUrl), + SizeLimit = Config.GetInt("videoMaxSize", fileServerUrl), + UploadFieldName = Config.GetString("videoFieldName", fileServerUrl) + }, rootPath, HttpContext), + "uploadfile" => new UploadHandler(new UploadConfig() + { + AllowExtensions = Config.GetStringList("fileAllowFiles", fileServerUrl), + PathFormat = Config.GetString("filePathFormat", fileServerUrl), + SizeLimit = Config.GetInt("fileMaxSize", fileServerUrl), + UploadFieldName = Config.GetString("fileFieldName", fileServerUrl) + }, rootPath, HttpContext), + "catchimage" => new CrawlerHandler(rootPath, HttpContext), + _ => new NotSupportedHandler(), + }; + return action.Process(fileServerUrl); + } + + + } +} \ No newline at end of file diff --git a/AdminApi/Controllers/UserController.cs b/AdminApi/Controllers/UserController.cs new file mode 100644 index 000000000..f2b5b7be2 --- /dev/null +++ b/AdminApi/Controllers/UserController.cs @@ -0,0 +1,217 @@ +using AdminApi.Filters; +using AdminApi.Libraries; +using AdminShared.Models; +using AdminShared.Models.User; +using Common; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Cryptography.KeyDerivation; +using Microsoft.AspNetCore.Mvc; +using Repository.Database; +using System.Text; + +namespace AdminApi.Controllers +{ + + + /// + /// 用户数据操作控制器 + /// + [SignVerifyFilter] + [Route("[controller]")] + [Authorize] + [ApiController] + public class UserController : ControllerBase + { + + private readonly DatabaseContext db; + private readonly SnowflakeHelper snowflakeHelper; + + private readonly long userId; + + + public UserController(DatabaseContext db, SnowflakeHelper snowflakeHelper, IHttpContextAccessor httpContextAccessor) + { + this.db = db; + this.snowflakeHelper = snowflakeHelper; + + var userIdStr = httpContextAccessor.HttpContext?.GetClaimByAuthorization("userId"); + if (userIdStr != null) + { + userId = long.Parse(userIdStr); + } + } + + + + /// + /// 获取用户列表 + /// + /// + /// + /// + /// + [HttpGet("GetUserList")] + public DtoPageList GetUserList(int pageNum, int pageSize, string? searchKey) + { + var data = new DtoPageList(); + + int skip = (pageNum - 1) * pageSize; + + var query = db.TUser.Where(t => t.IsDelete == false); + + if (!string.IsNullOrEmpty(searchKey)) + { + query = query.Where(t => t.Name.Contains(searchKey) || t.NickName.Contains(searchKey) || t.Phone.Contains(searchKey)); + } + + + data.Total = query.Count(); + + data.List = query.OrderByDescending(t => t.CreateTime).Select(t => new DtoUser + { + Id = t.Id, + Name = t.Name, + NickName = t.NickName, + Phone = t.Phone, + Email = t.Email, + Roles = string.Join(",", db.TUserRole.Where(r => r.IsDelete == false && r.UserId == t.Id).Select(r => r.Role.Name).ToList()), + CreateTime = t.CreateTime + }).Skip(skip).Take(pageSize).ToList(); + + return data; + } + + + + + /// + /// 通过 UserId 获取用户信息 + /// + /// 用户ID + /// + [HttpGet("GetUser")] + public DtoUser? GetUser(long? userId) + { + + if (userId == null) + { + userId = this.userId; + } + + var user = db.TUser.Where(t => t.Id == userId && t.IsDelete == false).Select(t => new DtoUser + { + Id = t.Id, + Name = t.Name, + NickName = t.NickName, + Phone = t.Phone, + Email = t.Email, + Roles = string.Join(",", db.TUserRole.Where(r => r.IsDelete == false && r.UserId == t.Id).Select(r => r.Role.Name).ToList()), + CreateTime = t.CreateTime + }).FirstOrDefault(); + + return user; + } + + + + + /// + /// 创建用户 + /// + /// + /// + [HttpPost("CreateUser")] + public long CreateUser(DtoEditUser createUser) + { + TUser user = new() + { + Id = snowflakeHelper.GetId(), + Name = createUser.Name, + NickName = createUser.NickName, + Phone = createUser.Phone + }; + user.PassWord = Convert.ToBase64String(KeyDerivation.Pbkdf2(createUser.PassWord, Encoding.UTF8.GetBytes(user.Id.ToString()), KeyDerivationPrf.HMACSHA256, 1000, 32)); + user.CreateTime = DateTime.UtcNow; + user.CreateUserId = userId; + + user.Email = createUser.Email; + + db.TUser.Add(user); + + db.SaveChanges(); + + return user.Id; + } + + + + + /// + /// 更新用户信息 + /// + /// + /// + /// + [HttpPost("UpdateUser")] + public bool UpdateUser(long userId, DtoEditUser updateUser) + { + var user = db.TUser.Where(t => t.IsDelete == false && t.Id == userId).FirstOrDefault(); + + if (user != null) + { + user.UpdateTime = DateTime.UtcNow; + user.UpdateUserId = this.userId; + + user.Name = updateUser.Name; + user.NickName = updateUser.NickName; + user.Phone = updateUser.Phone; + user.Email = updateUser.Email; + + if (updateUser.PassWord != "default") + { + user.PassWord = Convert.ToBase64String(KeyDerivation.Pbkdf2(updateUser.PassWord, Encoding.UTF8.GetBytes(user.Id.ToString()), KeyDerivationPrf.HMACSHA256, 1000, 32)); + } + + db.SaveChanges(); + + return true; + } + else + { + return false; + } + } + + + + /// + /// 删除用户 + /// + /// + /// + [HttpDelete("DeleteUser")] + public bool DeleteUser(long id) + { + var user = db.TUser.Where(t => t.IsDelete == false && t.Id == id).FirstOrDefault(); + + if (user != null) + { + user.IsDelete = true; + user.DeleteTime = DateTime.UtcNow; + user.DeleteUserId = userId; + + db.SaveChanges(); + + return true; + } + else + { + return false; + } + + } + + + + } +} \ No newline at end of file diff --git a/AdminApi/Filters/CacheDataFilter.cs b/AdminApi/Filters/CacheDataFilter.cs new file mode 100644 index 000000000..471a589aa --- /dev/null +++ b/AdminApi/Filters/CacheDataFilter.cs @@ -0,0 +1,102 @@ +using Common; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.Extensions.Caching.Distributed; + +namespace AdminApi.Filters +{ + + + /// + /// 缓存过滤器 + /// + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] + public class CacheDataFilter : Attribute, IActionFilter + { + + /// + /// 缓存时效有效期,单位 秒 + /// + public int TTL { get; set; } + + + + /// + /// 是否使用 Token + /// + public bool UseToken { get; set; } + + + void IActionFilter.OnActionExecuting(ActionExecutingContext context) + { + string key = ""; + + if (UseToken) + { + var token = context.HttpContext.Request.Headers.Where(t => t.Key == "Authorization").Select(t => t.Value).FirstOrDefault(); + + key = context.ActionDescriptor.DisplayName + "_" + context.HttpContext.Request.QueryString + "_" + token; + } + else + { + key = context.ActionDescriptor.DisplayName + "_" + context.HttpContext.Request.QueryString; + } + + key = "CacheData_" + CryptoHelper.GetMD5(key); + + try + { + var distributedCache = context.HttpContext.RequestServices.GetRequiredService(); + var cacheInfo = distributedCache.GetObject(key); + + if (cacheInfo != null) + { + context.Result = new ObjectResult(cacheInfo); + } + } + catch (Exception ex) + { + var logger = context.HttpContext.RequestServices.GetRequiredService>(); + logger.LogError(ex, "缓存模块异常-In"); + } + } + + + void IActionFilter.OnActionExecuted(ActionExecutedContext context) + { + try + { + if (context.Result is ObjectResult objectResult && objectResult.Value != null) + { + string key = ""; + + if (UseToken) + { + var token = context.HttpContext.Request.Headers.Where(t => t.Key == "Authorization").Select(t => t.Value).FirstOrDefault(); + + key = context.ActionDescriptor.DisplayName + "_" + context.HttpContext.Request.QueryString + "_" + token; + } + else + { + key = context.ActionDescriptor.DisplayName + "_" + context.HttpContext.Request.QueryString; + } + + key = "CacheData_" + CryptoHelper.GetMD5(key); + + if (objectResult.Value != null) + { + var distributedCache = context.HttpContext.RequestServices.GetRequiredService(); + distributedCache.SetObject(key, objectResult.Value, TimeSpan.FromSeconds(TTL)); + } + + } + } + catch (Exception ex) + { + var logger = context.HttpContext.RequestServices.GetRequiredService>(); + logger.LogError(ex, "缓存模块异常-Out"); + } + + } + } +} diff --git a/AdminApi/Filters/GlobalFilter.cs b/AdminApi/Filters/GlobalFilter.cs new file mode 100644 index 000000000..3b8fdcde8 --- /dev/null +++ b/AdminApi/Filters/GlobalFilter.cs @@ -0,0 +1,32 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; + +namespace AdminApi.Filters +{ + + /// + /// 全局过滤器 + /// + [AttributeUsage(AttributeTargets.Event)] + public class GlobalFilter : Attribute, IActionFilter + { + + void IActionFilter.OnActionExecuting(ActionExecutingContext context) + { + + } + + + void IActionFilter.OnActionExecuted(ActionExecutedContext context) + { + if (context.HttpContext.Response.StatusCode == 400) + { + string errMsg = context.HttpContext.Items["errMsg"]!.ToString()!; + + context.Result = new JsonResult(new { errMsg }); + } + } + + + } +} diff --git a/AdminApi/Filters/QueueLimitFilter.cs b/AdminApi/Filters/QueueLimitFilter.cs new file mode 100644 index 000000000..f6fa111ab --- /dev/null +++ b/AdminApi/Filters/QueueLimitFilter.cs @@ -0,0 +1,110 @@ +using AdminApi.Libraries; +using Common; +using DistributedLock; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; + +namespace AdminApi.Filters +{ + + + /// + /// 队列过滤器 + /// + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] + public class QueueLimitFilter : Attribute, IActionFilter + { + + + /// + /// 是否使用 参数 + /// + public bool UseParameter { get; set; } + + + /// + /// 是否使用 Token + /// + public bool UseToken { get; set; } + + + /// + /// 是否阻断重复请求 + /// + public bool IsBlock { get; set; } + + + private IDisposable? LockHandle { get; set; } + + + + + void IActionFilter.OnActionExecuting(ActionExecutingContext context) + { + string key = context.ActionDescriptor.DisplayName!; + + if (UseToken) + { + var token = context.HttpContext.Request.Headers.Where(t => t.Key == "Authorization").Select(t => t.Value).FirstOrDefault(); + key = key + "_" + token; + } + + if (UseParameter) + { + var parameter = JsonHelper.ObjectToJson(context.HttpContext.GetParameter()); + key = key + "_" + parameter; + } + + key = "QueueLimit_" + CryptoHelper.GetMD5(key); + + try + { + var distLock = context.HttpContext.RequestServices.GetRequiredService(); + + while (true) + { + var handle = distLock.TryLock(key); + if (handle != null) + { + LockHandle = handle; + break; + } + else + { + if (IsBlock) + { + context.Result = new BadRequestObjectResult(new { errMsg = "请勿频繁操作" }); + break; + } + else + { + Thread.Sleep(200); + } + } + } + } + catch (Exception ex) + { + var logger = context.HttpContext.RequestServices.GetRequiredService>(); + logger.LogError(ex, "队列限制模块异常-In"); + } + } + + + + void IActionFilter.OnActionExecuted(ActionExecutedContext context) + { + try + { + LockHandle?.Dispose(); + } + catch (Exception ex) + { + var logger = context.HttpContext.RequestServices.GetRequiredService>(); + logger.LogError(ex, "队列限制模块异常-Out"); + } + } + + + } +} diff --git a/AdminApi/Filters/SignVerifyFilter.cs b/AdminApi/Filters/SignVerifyFilter.cs new file mode 100644 index 000000000..01f3ac879 --- /dev/null +++ b/AdminApi/Filters/SignVerifyFilter.cs @@ -0,0 +1,105 @@ +using AdminApi.Libraries; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Cryptography; + +namespace AdminApi.Filters +{ + + /// + /// 签名过滤器 + /// + [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] + public class SignVerifyFilter : Attribute, IActionFilter + { + + + /// + /// 是否跳过签名验证,可用于控制器下单个接口指定跳过签名验证 + /// + public bool IsSkip { get; set; } + + + void IActionFilter.OnActionExecuting(ActionExecutingContext context) + { + SignVerifyFilter filter = (SignVerifyFilter)context.Filters.Where(t => t.ToString() == (typeof(SignVerifyFilter).Assembly.GetName().Name + ".Filters.SignVerifyFilter")).ToList().LastOrDefault()!; + + if (!filter.IsSkip) + { + var token = context.HttpContext.Request.Headers["Token"].ToString(); + + var remoteIpAddress = context.HttpContext.Connection.RemoteIpAddress!.ToString(); + + if (!remoteIpAddress.Contains("127.0.0.1") && !remoteIpAddress.Contains("::1")) + { + var timeStr = context.HttpContext.Request.Headers["Time"].ToString(); + var time = DateTimeOffset.FromUnixTimeMilliseconds(long.Parse(timeStr)); + + if (time.AddSeconds(10) > DateTime.UtcNow) + { + + var authorizationStr = context.HttpContext.Request.Headers["Authorization"].ToString().Replace("Bearer ", ""); + var securityToken = new JwtSecurityToken(authorizationStr); + + string privateKey = securityToken.RawSignature; + + string dataStr = privateKey + timeStr; + + var requestUrl = context.HttpContext.Request.Path + context.HttpContext.Request.QueryString; + + dataStr += requestUrl; + + if (!context.HttpContext.Request.HasFormContentType) + { + string body = context.HttpContext.GetRequestBody(); + dataStr += body; + } + else + { + var fromlist = context.HttpContext.Request.Form.OrderBy(t => t.Key).ToList(); + + foreach (var fm in fromlist) + { + string fmv = fm.Value.ToString(); + dataStr = dataStr + fm.Key + fmv; + } + + var files = context.HttpContext.Request.Form.Files.OrderBy(t => t.Name).ToList(); + + foreach (var file in files) + { + using var fileStream = file.OpenReadStream(); + using var sha256 = SHA256.Create(); + + var fileSign = Convert.ToHexString(sha256.ComputeHash(fileStream)); + + dataStr = dataStr + file.Name + fileSign; + } + } + + string tk = Common.CryptoHelper.GetSHA256(dataStr); + + if (token != tk) + { + context.HttpContext.Response.StatusCode = 401; + + context.Result = new JsonResult(new { errMsg = "非法 Token" }); + } + } + else + { + context.HttpContext.Response.StatusCode = 401; + context.Result = new JsonResult(new { errMsg = "Token 已过期" }); + } + } + } + } + + + void IActionFilter.OnActionExecuted(ActionExecutedContext context) + { + + } + } +} diff --git a/AdminApi/Libraries/GlobalError.cs b/AdminApi/Libraries/GlobalError.cs new file mode 100644 index 000000000..a66c7eb63 --- /dev/null +++ b/AdminApi/Libraries/GlobalError.cs @@ -0,0 +1,61 @@ +using Common; +using Microsoft.AspNetCore.Diagnostics; + +namespace AdminApi.Libraries +{ + + + public class GlobalError + { + + + public static Task ErrorEvent(HttpContext httpContext) + { + var feature = httpContext.Features.Get(); + var error = feature?.Error; + + var ret = new + { + errMsg = "系统全局内部异常" + }; + + + string path = httpContext.GetUrl(); + + var parameter = httpContext.GetParameter(); + + var parameterStr = JsonHelper.ObjectToJson(parameter); + + if (parameterStr.Length > 102400) + { + _ = parameterStr[..102400]; + } + + var authorization = httpContext.Request.Headers["Authorization"].ToString(); + + var content = new + { + path, + parameter, + authorization, + error = new + { + error?.Source, + error?.Message, + error?.StackTrace + } + }; + + + var logger = httpContext.RequestServices.GetRequiredService>(); + + logger.LogError("全局异常:{content}", content); + + httpContext.Response.StatusCode = 400; + + return httpContext.Response.WriteAsJsonAsync(ret); + } + + + } +} diff --git a/AdminApi/Libraries/HttpContextExtension.cs b/AdminApi/Libraries/HttpContextExtension.cs new file mode 100644 index 000000000..bb4c68400 --- /dev/null +++ b/AdminApi/Libraries/HttpContextExtension.cs @@ -0,0 +1,163 @@ +using AdminShared.Models; +using System.IdentityModel.Tokens.Jwt; +using System.IO.Compression; +using System.Text; + +namespace AdminApi.Libraries +{ + + /// + /// HttpContext扩展方法 + /// + public static class HttpContextExtension + { + + + /// + /// 获取当前HttpContext + /// + /// + /// + public static HttpContext Current(this HttpContext httpContext) + { + httpContext.Request.Body.Position = 0; + return httpContext; + } + + + /// + /// 通过Authorization获取Claim + /// + /// + /// Claim关键字 + /// + public static string? GetClaimByAuthorization(this HttpContext httpContext, string key) + { + try + { + var authorization = httpContext.Request.Headers["Authorization"].ToString().Replace("Bearer ", ""); + + var securityToken = new JwtSecurityToken(authorization); + + var value = securityToken.Claims.ToList().Where(t => t.Type == key).FirstOrDefault()?.Value; + + return value; + } + catch + { + return null; + } + } + + + /// + /// 获取完整Url信息 + /// + /// + public static string GetUrl(this HttpContext httpContext) + { + return httpContext.GetBaseUrl() + $"{httpContext.Request.Path}{httpContext.Request.QueryString}"; + } + + + /// + /// 获取基础Url信息 + /// + /// + public static string GetBaseUrl(this HttpContext httpContext) + { + var url = $"{httpContext.Request.Scheme}://{httpContext.Request.Host.Host}"; + + if (httpContext.Request.Host.Port != null) + { + url += $":{httpContext.Request.Host.Port}"; + } + + return url; + } + + + + /// + /// 获取RequestBody中的内容 + /// + public static string GetRequestBody(this HttpContext httpContext) + { + httpContext.Request.Body.Position = 0; + + var requestContent = ""; + + var contentEncoding = httpContext.Request.Headers.ContentEncoding.FirstOrDefault(); + + if (contentEncoding != null && contentEncoding.Equals("gzip", System.StringComparison.OrdinalIgnoreCase)) + { + using Stream requestBody = new MemoryStream(); + httpContext.Request.Body.CopyTo(requestBody); + httpContext.Request.Body.Position = 0; + + requestBody.Position = 0; + + using GZipStream decompressedStream = new(requestBody, CompressionMode.Decompress); + using StreamReader sr = new(decompressedStream, Encoding.UTF8); + requestContent = sr.ReadToEnd(); + } + else + { + using Stream requestBody = new MemoryStream(); + httpContext.Request.Body.CopyTo(requestBody); + httpContext.Request.Body.Position = 0; + + requestBody.Position = 0; + + using var requestReader = new StreamReader(requestBody); + requestContent = requestReader.ReadToEnd(); + } + + return requestContent; + } + + + + /// + /// 获取Http请求中的全部参数 + /// + public static List GetParameter(this HttpContext httpContext) + { + + var context = httpContext; + + var parameters = new List(); + + if (context.Request.Method == "POST") + { + string body = httpContext.GetRequestBody(); + + if (!string.IsNullOrEmpty(body)) + { + parameters.Add(new DtoKeyValue { Key = "body", Value = body }); + } + else if (context.Request.HasFormContentType) + { + var fromlist = context.Request.Form.OrderBy(t => t.Key).ToList(); + + foreach (var fm in fromlist) + { + parameters.Add(new DtoKeyValue { Key = fm.Key, Value = fm.Value.ToString() }); + } + } + } + else if (context.Request.Method == "GET") + { + var queryList = context.Request.Query.ToList(); + + foreach (var query in queryList) + { + parameters.Add(new DtoKeyValue { Key = query.Key, Value = query.Value }); + } + } + + return parameters; + } + + } +} diff --git a/AdminApi/Libraries/IdentityVerification.cs b/AdminApi/Libraries/IdentityVerification.cs new file mode 100644 index 000000000..22363bb21 --- /dev/null +++ b/AdminApi/Libraries/IdentityVerification.cs @@ -0,0 +1,178 @@ +using AdminApi.Models.AppSetting; +using Common; +using DistributedLock; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc.Controllers; +using Microsoft.IdentityModel.Tokens; +using Repository.Database; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Security.Cryptography; + +namespace AdminApi.Libraries +{ + + /// + /// 认证模块静态方法 + /// + public class IdentityVerification + { + + + /// + /// 权限校验 + /// + /// + /// + public static bool Authorization(AuthorizationHandlerContext authorizationHandlerContext) + { + + if (authorizationHandlerContext.User.Identity!.IsAuthenticated) + { + + if (authorizationHandlerContext.Resource is HttpContext httpContext) + { + + IssueNewToken(httpContext); + + var module = "webapi"; + + Endpoint endpoint = httpContext.GetEndpoint()!; + + ControllerActionDescriptor actionDescriptor = endpoint.Metadata.GetMetadata()!; + + var controller = actionDescriptor.ControllerName.ToLower(); + var action = actionDescriptor.ActionName.ToLower(); + + var db = httpContext.RequestServices.GetRequiredService(); + + var userId = long.Parse(httpContext.GetClaimByAuthorization("userId")!); + var roleIds = db.TUserRole.Where(t => t.IsDelete == false && t.UserId == userId).Select(t => t.RoleId).ToList(); + + var functionId = db.TFunctionAction.Where(t => t.IsDelete == false && t.Module.ToLower() == module && t.Controller.ToLower() == controller && t.Action.ToLower() == action).Select(t => t.FunctionId).FirstOrDefault(); + + if (functionId != default) + { + var functionAuthorizeId = db.TFunctionAuthorize.Where(t => t.IsDelete == false && t.FunctionId == functionId && (roleIds.Contains(t.RoleId!.Value) || t.UserId == userId)).Select(t => t.Id).FirstOrDefault(); + + if (functionAuthorizeId != default) + { + return true; + } + else + { + return false; + } + } + else + { + return true; + } + } + + return true; + } + else + { + return false; + } + + + } + + + + /// + /// 签发新Token + /// + /// + private static void IssueNewToken(HttpContext httpContext) + { + + SnowflakeHelper snowflakeHelper = httpContext.RequestServices.GetRequiredService(); + + var db = httpContext.RequestServices.GetRequiredService(); + + var nbf = Convert.ToInt64(httpContext.GetClaimByAuthorization("nbf")); + var exp = Convert.ToInt64(httpContext.GetClaimByAuthorization("exp")); + + var nbfTime = DateTimeOffset.FromUnixTimeSeconds(nbf); + var expTime = DateTimeOffset.FromUnixTimeSeconds(exp); + + //当前Token过期前15分钟开始签发新的Token + if (expTime < DateTime.UtcNow.AddMinutes(15)) + { + + var tokenId = long.Parse(httpContext.GetClaimByAuthorization("tokenId")!); + var userId = long.Parse(httpContext.GetClaimByAuthorization("userId")!); + + + string key = "IssueNewToken" + tokenId; + + var distLock = httpContext.RequestServices.GetRequiredService(); + if (distLock.TryLock(key) != null) + { + var newToken = db.TUserToken.Where(t => t.IsDelete == false && t.LastId == tokenId && t.CreateTime > nbfTime).FirstOrDefault(); + + if (newToken == null) + { + var tokenInfo = db.TUserToken.Where(t => t.Id == tokenId).FirstOrDefault(); + + if (tokenInfo != null) + { + + TUserToken userToken = new() + { + Id = snowflakeHelper.GetId(), + UserId = userId, + LastId = tokenId, + CreateTime = DateTime.UtcNow + }; + + var claims = new Claim[]{ + new Claim("tokenId",userToken.Id.ToString()), + new Claim("userId",userId.ToString()) + }; + + + var configuration = httpContext.RequestServices.GetRequiredService(); + var jwtSetting = configuration.GetSection("JWT").Get(); + var jwtPrivateKey = ECDsa.Create(); + jwtPrivateKey.ImportECPrivateKey(Convert.FromBase64String(jwtSetting.PrivateKey), out _); + var creds = new SigningCredentials(new ECDsaSecurityKey(jwtPrivateKey), SecurityAlgorithms.EcdsaSha256); + var jwtSecurityToken = new JwtSecurityToken(jwtSetting.Issuer, jwtSetting.Audience, claims, DateTime.UtcNow, DateTime.UtcNow + jwtSetting.Expiry, creds); + + var token = new JwtSecurityTokenHandler().WriteToken(jwtSecurityToken); + + userToken.Token = token; + + db.TUserToken.Add(userToken); + + if (distLock.TryLock("ClearExpireToken") != null) + { + var clearTime = DateTime.UtcNow.AddDays(-7); + var clearList = db.TUserToken.Where(t => t.CreateTime < clearTime).ToList(); + db.TUserToken.RemoveRange(clearList); + } + + db.SaveChanges(); + + + httpContext.Response.Headers.Add("NewToken", token); + httpContext.Response.Headers.Add("Access-Control-Expose-Headers", "NewToken"); //解决 Ionic 取不到 Header中的信息问题 + } + } + else + { + httpContext.Response.Headers.Add("NewToken", newToken.Token); + httpContext.Response.Headers.Add("Access-Control-Expose-Headers", "NewToken"); //解决 Ionic 取不到 Header中的信息问题 + } + } + } + + } + + + + } +} diff --git a/AdminApi/Libraries/Ueditor/Config.cs b/AdminApi/Libraries/Ueditor/Config.cs new file mode 100644 index 000000000..5578ecd5f --- /dev/null +++ b/AdminApi/Libraries/Ueditor/Config.cs @@ -0,0 +1,108 @@ +using System.Text.Json; + +namespace AdminApi.Libraries.Ueditor +{ + /// + /// Config 的摘要说明 + /// + public static class Config + { + private readonly static bool noCache = true; + + private static JsonDocument BuildItems(string fileServerUrl) + { + var json = @"{ + /* 上传图片配置项 */ + ""imageActionName"": ""uploadimage"", /* 执行上传图片的action名称 */ + ""imageFieldName"": ""upfile"", /* 提交的图片表单名称 */ + ""imageMaxSize"": 2147483647, /* 上传大小限制,单位B */ + ""imageAllowFiles"": [ "".png"", "".jpg"", "".jpeg"", "".gif"", "".bmp"" ], /* 上传图片格式显示 */ + ""imageCompressEnable"": true, /* 是否压缩图片,默认是true */ + ""imageCompressBorder"": 1600, /* 图片压缩最长边限制 */ + ""imageInsertAlign"": ""none"", /* 插入的图片浮动方式 */ + ""imageUrlPrefix"": ""FileServerUrl"", /* 图片访问路径前缀 */ + ""imagePathFormat"": ""/uploads/{yyyy}/{mm}/{dd}/{time}{rand:6}"", /* 上传保存路径,可以自定义保存路径和文件名格式 */ + + /* 涂鸦图片上传配置项 */ + ""scrawlActionName"": ""uploadscrawl"", /* 执行上传涂鸦的action名称 */ + ""scrawlFieldName"": ""upfile"", /* 提交的图片表单名称 */ + ""scrawlPathFormat"": ""/uploads/{yyyy}/{mm}/{dd}/{time}{rand:6}"", /* 上传保存路径,可以自定义保存路径和文件名格式 */ + ""scrawlMaxSize"": 2147483647, /* 上传大小限制,单位B */ + ""scrawlUrlPrefix"": ""FileServerUrl"", /* 图片访问路径前缀 */ + ""scrawlInsertAlign"": ""none"", + + /* 截图工具上传 */ + ""snapscreenActionName"": ""uploadimage"", /* 执行上传截图的action名称 */ + ""snapscreenPathFormat"": ""/uploads/{yyyy}/{mm}/{dd}/{time}{rand:6}"", /* 上传保存路径,可以自定义保存路径和文件名格式 */ + ""snapscreenUrlPrefix"": ""FileServerUrl"", /* 图片访问路径前缀 */ + ""snapscreenInsertAlign"": ""none"", /* 插入的图片浮动方式 */ + + /* 抓取远程图片配置 */ + ""catcherLocalDomain"": [ ""127.0.0.1"", ""localhost"", ""img.baidu.com"" ], + ""catcherActionName"": ""catchimage"", /* 执行抓取远程图片的action名称 */ + ""catcherFieldName"": ""source"", /* 提交的图片列表表单名称 */ + ""catcherPathFormat"": ""/uploads/{yyyy}/{mm}/{dd}/{time}{rand:6}"", /* 上传保存路径,可以自定义保存路径和文件名格式 */ + ""catcherUrlPrefix"": ""FileServerUrl"", /* 图片访问路径前缀 */ + ""catcherMaxSize"": 2147483647, /* 上传大小限制,单位B */ + ""catcherAllowFiles"": [ "".png"", "".jpg"", "".jpeg"", "".gif"", "".bmp"" ], /* 抓取图片格式显示 */ + + /* 上传视频配置 */ + ""videoActionName"": ""uploadvideo"", /* 执行上传视频的action名称 */ + ""videoFieldName"": ""upfile"", /* 提交的视频表单名称 */ + ""videoPathFormat"": ""/uploads/{yyyy}/{mm}/{dd}/{time}{rand:6}"", /* 上传保存路径,可以自定义保存路径和文件名格式 */ + ""videoUrlPrefix"": ""FileServerUrl"", /* 视频访问路径前缀 */ + ""videoMaxSize"": 2147483647, /* 上传大小限制,单位B,默认100MB */ + ""videoAllowFiles"": ["".flv"","".swf"","".mkv"","".avi"","".rm"","".rmvb"","".mpeg"","".mpg"","".ogg"","".ogv"","".mov"","".wmv"","".mp4"","".webm"","".mp3"","".wav"","".mid""], /* 上传视频格式显示 */ + + /* 上传文件配置 */ + ""fileActionName"": ""uploadfile"", /* controller里,执行上传视频的action名称 */ + ""fileFieldName"": ""upfile"", /* 提交的文件表单名称 */ + ""filePathFormat"": ""/uploads/{yyyy}/{mm}/{dd}/{time}{rand:6}"", /* 上传保存路径,可以自定义保存路径和文件名格式 */ + ""fileUrlPrefix"": ""FileServerUrl"", /* 文件访问路径前缀 */ + ""fileMaxSize"": 2147483647, /* 上传大小限制,单位B,默认50MB */ + ""fileAllowFiles"": [ "".png"", "".jpg"", "".jpeg"", "".gif"", "".bmp"", "".flv"", "".swf"", "".mkv"", "".avi"", "".rm"", "".rmvb"", "".mpeg"", "".mpg"", "".ogg"", "".ogv"", "".mov"", "".wmv"", "".mp4"", "".webm"", "".mp3"", "".wav"", "".mid"", "".rar"", "".zip"", "".tar"", "".gz"", "".7z"", "".bz2"", "".cab"", "".iso"", "".doc"", "".docx"", "".xls"", "".xlsx"", "".ppt"", "".pptx"", "".pdf"", "".txt"", "".md"", "".xml"" ] /* 上传文件格式显示 */ + }"; + + + + json = json.Replace("FileServerUrl", fileServerUrl); + + var options = new JsonDocumentOptions + { + CommentHandling = JsonCommentHandling.Skip, + }; + + return JsonDocument.Parse(json, options); + } + + public static JsonDocument Items(string fileServerUrl) + { + if (noCache || _Items == null) + { + _Items = BuildItems(fileServerUrl); + } + return _Items; + } + + private static JsonDocument? _Items; + + + + + public static string[] GetStringList(string key, string fileServerUrl) + { + return Items(fileServerUrl).RootElement.Clone().GetProperty(key).Deserialize()!; + } + + public static string GetString(string key, string fileServerUrl) + { + return Items(fileServerUrl).RootElement.Clone().GetProperty(key).GetString()!; + } + + public static int GetInt(string key, string fileServerUrl) + { + return Items(fileServerUrl).RootElement.Clone().GetProperty(key).GetInt32(); + } + } + +} \ No newline at end of file diff --git a/AdminApi/Libraries/Ueditor/ConfigHandler.cs b/AdminApi/Libraries/Ueditor/ConfigHandler.cs new file mode 100644 index 000000000..65dbbf572 --- /dev/null +++ b/AdminApi/Libraries/Ueditor/ConfigHandler.cs @@ -0,0 +1,17 @@ +namespace AdminApi.Libraries.Ueditor +{ + + /// + /// Config 的摘要说明 + /// + public class ConfigHandler : Handler + { + + + public override string Process(string fileServerUrl) + { + return WriteJson(Config.Items(fileServerUrl)); + } + } + +} \ No newline at end of file diff --git a/AdminApi/Libraries/Ueditor/CrawlerHandler.cs b/AdminApi/Libraries/Ueditor/CrawlerHandler.cs new file mode 100644 index 000000000..7205fb8cb --- /dev/null +++ b/AdminApi/Libraries/Ueditor/CrawlerHandler.cs @@ -0,0 +1,193 @@ +using FileStorage; +using System.Net; + +namespace AdminApi.Libraries.Ueditor +{ + /// + /// Crawler 的摘要说明 + /// + public class CrawlerHandler : Handler + { + private string[]? Sources; + private Crawler[]? Crawlers; + + private readonly string rootPath; + private readonly HttpContext httpContext; + + public CrawlerHandler(string rootPath, HttpContext httpContext) + { + this.rootPath = rootPath; + this.httpContext = httpContext; + } + + public override string Process(string fileServerUrl) + { + Sources = httpContext.Current().Request.Form["source[]"]; + if (Sources == null || Sources.Length == 0) + { + return WriteJson(new + { + state = "参数错误:没有指定抓取源" + }); + } + + var fileStorage = httpContext.RequestServices.GetService(); + + Crawlers = Sources.Select(x => new Crawler(x, rootPath, fileServerUrl).Fetch(fileStorage)).ToArray(); + return WriteJson(new + { + state = "SUCCESS", + list = Crawlers.Select(x => new + { + state = x.State, + source = x.SourceUrl, + url = x.ServerUrl + }) + }); + } + } + + public class Crawler + { + public string? SourceUrl { get; set; } + public string? ServerUrl { get; set; } + public string? State { get; set; } + + private readonly string rootPath; + + private readonly string fileServerUrl; + + + + public Crawler(string sourceUrl, string rootPath, string fileServerUrl) + { + SourceUrl = sourceUrl; + this.rootPath = rootPath; + this.fileServerUrl = fileServerUrl; + } + + public Crawler Fetch(IFileStorage? fileStorage) + { + if (!IsExternalIPAddress(SourceUrl!)) + { + State = "INVALID_URL"; + return this; + } + + + using HttpClient client = new(); + client.DefaultRequestVersion = new Version("2.0"); + using var httpResponse = client.GetAsync(SourceUrl).Result; + if (httpResponse.StatusCode != HttpStatusCode.OK) + { + State = "Url returns " + httpResponse.StatusCode; + return this; + } + + + if (httpResponse.Content.Headers.ContentType?.MediaType?.Contains("image") == false) + { + State = "Url is not an image"; + return this; + } + ServerUrl = PathFormatter.Format(Path.GetFileName(SourceUrl!), Config.GetString("catcherPathFormat", fileServerUrl)); + var savePath = rootPath + ServerUrl; + if (!Directory.Exists(Path.GetDirectoryName(savePath))) + { + Directory.CreateDirectory(Path.GetDirectoryName(savePath)!); + } + try + { + + File.WriteAllBytes(savePath, httpResponse.Content.ReadAsByteArrayAsync().Result); + + + + if (fileStorage != null) + { + + var upload = fileStorage.FileUpload(savePath, "uploads/" + DateTime.UtcNow.ToString("yyyy/MM/dd"), Path.GetFileName(SourceUrl!)); + + if (upload) + { + Common.IOHelper.DeleteFile(savePath); + + ServerUrl = "/uploads/" + DateTime.UtcNow.ToString("yyyy/MM/dd") + "/" + Path.GetFileName(savePath); + State = "SUCCESS"; + } + else + { + State = "抓取错误:文件存储转存失败"; + } + } + else + { + State = "SUCCESS"; + } + + } + catch (Exception e) + { + State = "抓取错误:" + e.Message; + } + return this; + + } + + private static bool IsExternalIPAddress(string url) + { + var uri = new Uri(url); + switch (uri.HostNameType) + { + case UriHostNameType.Dns: + var ipHostEntry = Dns.GetHostEntry(uri.DnsSafeHost); + foreach (IPAddress ipAddress in ipHostEntry.AddressList) + { + _ = ipAddress.GetAddressBytes(); + if (ipAddress.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork) + { + if (!IsPrivateIP(ipAddress)) + { + return true; + } + } + } + break; + + case UriHostNameType.IPv4: + return !IsPrivateIP(IPAddress.Parse(uri.DnsSafeHost)); + } + return false; + } + + private static bool IsPrivateIP(IPAddress myIPAddress) + { + if (IPAddress.IsLoopback(myIPAddress)) return true; + if (myIPAddress.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork) + { + byte[] ipBytes = myIPAddress.GetAddressBytes(); + // 10.0.0.0/24 + if (ipBytes[0] == 10) + { + return true; + } + // 172.16.0.0/16 + else if (ipBytes[0] == 172 && ipBytes[1] == 16) + { + return true; + } + // 192.168.0.0/16 + else if (ipBytes[0] == 192 && ipBytes[1] == 168) + { + return true; + } + // 169.254.0.0/16 + else if (ipBytes[0] == 169 && ipBytes[1] == 254) + { + return true; + } + } + return false; + } + } +} diff --git a/AdminApi/Libraries/Ueditor/Handler.cs b/AdminApi/Libraries/Ueditor/Handler.cs new file mode 100644 index 000000000..e21f6343b --- /dev/null +++ b/AdminApi/Libraries/Ueditor/Handler.cs @@ -0,0 +1,22 @@ +using Common; + +namespace AdminApi.Libraries.Ueditor +{ + + /// + /// Handler 的摘要说明 + /// + public abstract class Handler + { + + + public abstract string Process(string fileServerUrl); + + protected string WriteJson(object response) + { + string json = JsonHelper.ObjectToJson(response); + return json; + } + } + +} \ No newline at end of file diff --git a/AdminApi/Libraries/Ueditor/NotSupportedHandler.cs b/AdminApi/Libraries/Ueditor/NotSupportedHandler.cs new file mode 100644 index 000000000..93a2480b1 --- /dev/null +++ b/AdminApi/Libraries/Ueditor/NotSupportedHandler.cs @@ -0,0 +1,19 @@ +namespace AdminApi.Libraries.Ueditor +{ + + /// + /// NotSupportedHandler 的摘要说明 + /// + public class NotSupportedHandler : Handler + { + + + public override string Process(string fileServerUrl) + { + return WriteJson(new + { + state = "action 参数为空或者 action 不被支持。" + }); + } + } +} \ No newline at end of file diff --git a/AdminApi/Libraries/Ueditor/PathFormater.cs b/AdminApi/Libraries/Ueditor/PathFormater.cs new file mode 100644 index 000000000..f60ee0147 --- /dev/null +++ b/AdminApi/Libraries/Ueditor/PathFormater.cs @@ -0,0 +1,50 @@ +using System.Text.RegularExpressions; + + +namespace AdminApi.Libraries.Ueditor +{ + + /// + /// PathFormater 的摘要说明 + /// + public static class PathFormatter + { + public static string Format(string originFileName, string pathFormat) + { + if (String.IsNullOrWhiteSpace(pathFormat)) + { + pathFormat = "{filename}{rand:6}"; + } + + var invalidPattern = new Regex(@"[\\\/\:\*\?\042\<\>\|]"); + originFileName = invalidPattern.Replace(originFileName, ""); + + string extension = Path.GetExtension(originFileName); + string filename = Path.GetFileNameWithoutExtension(originFileName); + + pathFormat = pathFormat.Replace("{filename}", filename); + pathFormat = new Regex(@"\{rand(\:?)(\d+)\}", RegexOptions.Compiled).Replace(pathFormat, new MatchEvaluator(delegate (Match match) + { + var digit = 6; + if (match.Groups.Count > 2) + { + digit = Convert.ToInt32(match.Groups[2].Value); + } + var rand = new Random(); + return rand.Next((int)Math.Pow(10, digit), (int)Math.Pow(10, digit + 1)).ToString(); + })); + + pathFormat = pathFormat.Replace("{time}", DateTime.UtcNow.Ticks.ToString()); + pathFormat = pathFormat.Replace("{yyyy}", DateTime.UtcNow.Year.ToString()); + pathFormat = pathFormat.Replace("{yy}", (DateTime.UtcNow.Year % 100).ToString("D2")); + pathFormat = pathFormat.Replace("{mm}", DateTime.UtcNow.Month.ToString("D2")); + pathFormat = pathFormat.Replace("{dd}", DateTime.UtcNow.Day.ToString("D2")); + pathFormat = pathFormat.Replace("{hh}", DateTime.UtcNow.Hour.ToString("D2")); + pathFormat = pathFormat.Replace("{ii}", DateTime.UtcNow.Minute.ToString("D2")); + pathFormat = pathFormat.Replace("{ss}", DateTime.UtcNow.Second.ToString("D2")); + + return pathFormat + extension; + } + } + +} \ No newline at end of file diff --git a/AdminApi/Libraries/Ueditor/UploadHandler.cs b/AdminApi/Libraries/Ueditor/UploadHandler.cs new file mode 100644 index 000000000..2f037c042 --- /dev/null +++ b/AdminApi/Libraries/Ueditor/UploadHandler.cs @@ -0,0 +1,275 @@ +using FileStorage; + + + +namespace AdminApi.Libraries.Ueditor +{ + /// + /// UploadHandler 的摘要说明 + /// + public class UploadHandler : Handler + { + + public UploadConfig UploadConfig { get; private set; } + public UploadResult Result { get; private set; } + + private readonly string rootPath; + + private readonly HttpContext httpContext; + + private readonly IFileStorage? fileStorage; + + + public UploadHandler(UploadConfig config, string rootPath, HttpContext httpContext) : base() + { + this.UploadConfig = config; + this.Result = new UploadResult() { State = UploadState.Unknown }; + + this.rootPath = rootPath; + this.httpContext = httpContext; + + this.fileStorage = httpContext.RequestServices.GetService(); + } + + public override string Process(string fileServerUrl) + { + + string value = ""; + string uploadFileName; + if (UploadConfig.Base64) + { + uploadFileName = UploadConfig.Base64Filename!; + byte[] uploadFileBytes = Convert.FromBase64String(httpContext.Current().Request.Form[UploadConfig.UploadFieldName!]); + + var savePath = PathFormatter.Format(uploadFileName, UploadConfig.PathFormat!); + var localPath = rootPath + savePath; + + try + { + + if (!Directory.Exists(Path.GetDirectoryName(localPath))) + { + Directory.CreateDirectory(Path.GetDirectoryName(localPath)!); + } + File.WriteAllBytes(localPath, uploadFileBytes); + + + + + if (fileStorage != null) + { + + var upload = fileStorage.FileUpload(localPath, "uploads/" + DateTime.UtcNow.ToString("yyyy/MM/dd"), Path.GetFileName(localPath)); + + if (upload) + { + Common.IOHelper.DeleteFile(localPath); + + Result.Url = "/uploads/" + DateTime.UtcNow.ToString("yyyy/MM/dd") + "/" + Path.GetFileName(localPath); + Result.State = UploadState.Success; + } + else + { + Result.State = UploadState.FileAccessError; + Result.ErrorMessage = "文件存储转存失败"; + } + } + else + { + Result.Url = savePath; + Result.State = UploadState.Success; + } + + } + catch (Exception e) + { + Result.State = UploadState.FileAccessError; + Result.ErrorMessage = e.Message; + } + finally + { + value = WriteResult(); + } + } + else + { + var file = httpContext.Current().Request.Form.Files[UploadConfig.UploadFieldName!]!; + uploadFileName = file.FileName; + + if (!CheckFileType(uploadFileName)) + { + Result.State = UploadState.TypeNotAllow; + value = WriteResult(); + } + + int filelength = Convert.ToInt32(file.Length); + + if (!CheckFileSize(filelength)) + { + Result.State = UploadState.SizeLimitExceed; + value = WriteResult(); + } + + _ = new byte[file.Length]; + try + { + file.OpenReadStream(); + + string savePath = PathFormatter.Format(uploadFileName, UploadConfig.PathFormat!); + string localPath = rootPath + savePath; + + try + { + + + + if (!Directory.Exists(Path.GetDirectoryName(localPath))) + { + Directory.CreateDirectory(Path.GetDirectoryName(localPath)!); + } + using (FileStream fs = System.IO.File.Create(localPath)) + { + file.CopyTo(fs); + fs.Flush(); + } + + + if (fileStorage != null) + { + + var upload = fileStorage.FileUpload(localPath, "uploads/" + DateTime.UtcNow.ToString("yyyy/MM/dd"), file.FileName); + + if (upload) + { + Common.IOHelper.DeleteFile(localPath); + + Result.Url = "/uploads/" + DateTime.UtcNow.ToString("yyyy/MM/dd") + "/" + Path.GetFileName(localPath); + Result.State = UploadState.Success; + } + else + { + Result.State = UploadState.FileAccessError; + Result.ErrorMessage = "文件存储转存失败"; + } + } + else + { + Result.Url = savePath; + Result.State = UploadState.Success; + } + + } + catch (Exception e) + { + Result.State = UploadState.FileAccessError; + Result.ErrorMessage = e.Message; + } + finally + { + value = WriteResult(); + } + + } + catch (Exception) + { + Result.State = UploadState.NetworkError; + WriteResult(); + } + } + + + + + return value; + } + + private string WriteResult() + { + return this.WriteJson(new + { + state = GetStateMessage(Result.State), + url = Result.Url, + title = Result.OriginFileName, + original = Result.OriginFileName, + error = Result.ErrorMessage + }); + } + + private static string GetStateMessage(UploadState state) + { + return state switch + { + UploadState.Success => "SUCCESS", + UploadState.FileAccessError => "文件访问出错,请检查写入权限", + UploadState.SizeLimitExceed => "文件大小超出服务器限制", + UploadState.TypeNotAllow => "不允许的文件格式", + UploadState.NetworkError => "网络错误", + _ => "未知错误", + }; + } + + private bool CheckFileType(string filename) + { + var fileExtension = Path.GetExtension(filename).ToLower(); + return UploadConfig.AllowExtensions!.Select(x => x.ToLower()).Contains(fileExtension); + } + + private bool CheckFileSize(int size) + { + return size < UploadConfig.SizeLimit; + } + } + + public class UploadConfig + { + /// + /// 文件命名规则 + /// + public string? PathFormat { get; set; } + + /// + /// 上传表单域名称 + /// + public string? UploadFieldName { get; set; } + + /// + /// 上传大小限制 + /// + public int SizeLimit { get; set; } + + /// + /// 上传允许的文件格式 + /// + public string[]? AllowExtensions { get; set; } + + /// + /// 文件是否以 Base64 的形式上传 + /// + public bool Base64 { get; set; } + + /// + /// Base64 字符串所表示的文件名 + /// + public string? Base64Filename { get; set; } + } + + public class UploadResult + { + public UploadState State { get; set; } + public string? Url { get; set; } + public string? OriginFileName { get; set; } + + public string? ErrorMessage { get; set; } + } + + public enum UploadState + { + Success = 0, + SizeLimitExceed = -1, + TypeNotAllow = -2, + FileAccessError = -3, + NetworkError = -4, + Unknown = 1, + } + +} \ No newline at end of file diff --git a/AdminApi/Models/AppSetting/JWTSetting.cs b/AdminApi/Models/AppSetting/JWTSetting.cs new file mode 100644 index 000000000..c93563a4f --- /dev/null +++ b/AdminApi/Models/AppSetting/JWTSetting.cs @@ -0,0 +1,47 @@ +namespace AdminApi.Models.AppSetting +{ + + + /// + /// JWT 配置信息 + /// + public class JWTSetting + { + + + /// + /// token是谁颁发的 + /// + public string Issuer { get; set; } + + + + /// + /// token可以给哪些客户端使用 + /// + public string Audience { get; set; } + + + + /// + /// 私钥 + /// + public string PrivateKey { get; set; } + + + + /// + /// 公钥 + /// + public string PublicKey { get; set; } + + + + /// + /// 失效时长 + /// + public TimeSpan Expiry { get; set; } + + + } +} diff --git a/AdminApi/Program.cs b/AdminApi/Program.cs new file mode 100644 index 000000000..7797ab3cc --- /dev/null +++ b/AdminApi/Program.cs @@ -0,0 +1,354 @@ +using AdminApi.Filters; +using AdminApi.Libraries; +using AdminApi.Models.AppSetting; +using Common; +using DistributedLock.Redis; +using Logger.DataBase; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http.Features; +using Microsoft.AspNetCore.HttpOverrides; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Server.Kestrel.Core; +using Microsoft.EntityFrameworkCore; +using Microsoft.IdentityModel.Tokens; +using Microsoft.OpenApi.Models; +using Swashbuckle.AspNetCore.SwaggerGen; +using System.Security.Cryptography; + +namespace AdminApi +{ + public class Program + { + public static void Main(string[] args) + { + EnvironmentHelper.ChangeDirectory(args); + + var builder = WebApplication.CreateBuilder(args); + + builder.Host.UseWindowsService(); + + #region 启用 Kestrel Https 并绑定证书 + + //builder.WebHost.UseKestrel(options => + //{ + // options.ConfigureHttpsDefaults(options => + // { + // options.ServerCertificate = new System.Security.Cryptography.X509Certificates.X509Certificate2(Path.Combine(AppContext.BaseDirectory, "xxxx.pfx"), "123456"); + // }); + //}); + //builder.WebHost.UseUrls("https://*"); + + #endregion + + builder.Services.AddDbContextPool(options => + { + options.UseSqlServer(builder.Configuration.GetConnectionString("dbConnection")); + }, 100); + + + #region 基础 Server 配置 + + builder.Services.Configure(options => + { + options.MultipartBodyLengthLimit = long.MaxValue; + }); + + builder.Services.Configure(options => + { + options.AllowSynchronousIO = true; + }); + + builder.Services.Configure(options => + { + options.AllowSynchronousIO = true; + }); + + builder.Services.AddHsts(options => + { + options.MaxAge = TimeSpan.FromDays(365); + }); + + builder.Services.Configure(options => + { + options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto; + }); + + builder.Services.AddResponseCompression(options => + { + options.EnableForHttps = true; + }); + + #endregion + + #region 注册 JWT 认证机制 + + + builder.Services.AddAuthentication(options => + { + options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; + options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; + }) + .AddJwtBearer(options => + { + var jwtSetting = builder.Configuration.GetSection("JWT").Get(); + var issuerSigningKey = ECDsa.Create(); + issuerSigningKey.ImportSubjectPublicKeyInfo(Convert.FromBase64String(jwtSetting.PublicKey), out int i); + + options.TokenValidationParameters = new TokenValidationParameters + { + ValidIssuer = jwtSetting.Issuer, + ValidAudience = jwtSetting.Audience, + IssuerSigningKey = new ECDsaSecurityKey(issuerSigningKey) + }; + }); + + builder.Services.AddAuthorization(options => + { + options.DefaultPolicy = new AuthorizationPolicyBuilder().RequireAuthenticatedUser().RequireAssertion(context => IdentityVerification.Authorization(context)).Build(); + }); + + #endregion + + + //注册HttpContext + builder.Services.AddSingleton(); + + + //注册全局过滤器 + builder.Services.AddMvc(options => options.Filters.Add(new GlobalFilter())); + + + //注册跨域信息 + builder.Services.AddCors(options => + { + options.AddPolicy("cors", policy => + { + policy.SetIsOriginAllowed(origin => true) + .AllowAnyHeader() + .AllowAnyMethod() + .AllowCredentials(); + }); + }); + + + #region 注册 Json 序列化配置 + + builder.Services.AddControllers().AddJsonOptions(options => + { + options.JsonSerializerOptions.Converters.Add(new Common.JsonConverter.LongConverter()); + }); + + #endregion + + #region 注册 Swagger + builder.Services.AddSwaggerGen(options => + { + options.SwaggerDoc("v1", null); + + var modelPrefix = "AdminShared.Models."; + options.SchemaGeneratorOptions = new SchemaGeneratorOptions { SchemaIdSelector = type => (type.ToString()[(type.ToString().IndexOf("Models.") + 7)..]).Replace(modelPrefix, "").Replace("`1", "").Replace("+", ".") }; + + options.MapType(() => new OpenApiSchema { Type = "string", Format = "long" }); + + options.IncludeXmlComments(Path.Combine(AppContext.BaseDirectory, $"{typeof(Program).Assembly.GetName().Name}.xml"), true); + + + //开启 Swagger JWT 鉴权模块 + options.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme() + { + Description = "在下框中输入请求头中需要添加Jwt授权Token:Bearer Token", + Name = "Authorization", + In = ParameterLocation.Header, + Type = SecuritySchemeType.ApiKey, + BearerFormat = "JWT", + Scheme = "Bearer" + }); + options.AddSecurityRequirement(new OpenApiSecurityRequirement + { + { + new OpenApiSecurityScheme + { + Reference = new OpenApiReference + { + Type = ReferenceType.SecurityScheme, + Id = "Bearer" + } + }, + Array.Empty() + } + }); + }); + #endregion + + + //注册统一模型验证 + builder.Services.Configure(options => + { + options.InvalidModelStateResponseFactory = actionContext => + { + + //获取验证失败的模型字段 + var errors = actionContext.ModelState.Where(e => e.Value?.Errors.Count > 0).Select(e => e.Value?.Errors.First().ErrorMessage).ToList(); + + var dataStr = string.Join(" | ", errors); + + //设置返回内容 + var result = new + { + errMsg = dataStr + }; + + return new BadRequestObjectResult(result); + }; + }); + + + //注册雪花ID算法 + builder.Services.AddSingleton(new Common.SnowflakeHelper(0, 0)); + + + //注册分布式锁 Redis模式 + builder.Services.AddRedisLock(options => + { + options.Configuration = builder.Configuration.GetConnectionString("redisConnection"); + options.InstanceName = "lock"; + }); + + + #region 注册缓存服务 + + //注册缓存服务 内存模式 + builder.Services.AddDistributedMemoryCache(); + + + //注册缓存服务 Redis模式 + //builder.Services.AddStackExchangeRedisCache(options => + //{ + // options.Configuration = builder.Configuration.GetConnectionString("redisConnection"); + // options.InstanceName = "cache"; + //}); + + #endregion + + + #region 注册HttpClient + + builder.Services.AddHttpClient("", options => + { + options.DefaultRequestVersion = new Version("2.0"); + }).ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler + { + AllowAutoRedirect = false + }); + + + #endregion + + builder.Services.BatchRegisterServices(); + + + + #region 注册文件服务 + + + //builder.Services.AddTencentCloudSMS(options => + //{ + // var settings = builder.Configuration.GetSection("TencentCloudSMS").Get(); + // options.AppId = settings.AppId; + // options.SecretId = settings.SecretId; + // options.SecretKey = settings.SecretKey; + //}); + + + //builder.Services.AddAliCloudSMS(options => + //{ + // var settings = builder.Configuration.GetSection("AliCloudSMS").Get(); + // options.AccessKeyId = settings.AccessKeyId; + // options.AccessKeySecret = settings.AccessKeySecret; + //}); + + #endregion + + + #region 注册日志服务 + + //注册数据库日志服务 + builder.Logging.AddDataBaseLogger(options => { }); + + //注册本地文件日志服务 + //builder.Logging.AddLocalFileLogger(options => { }); + + #endregion + + var app = builder.Build(); + + app.UseForwardedHeaders(); + + app.UseResponseCompression(); + + //开启倒带模式允许多次读取 HttpContext.Body 中的内容 + app.Use(async (context, next) => + { + context.Request.EnableBuffering(); + await next.Invoke(); + }); + + if (app.Environment.IsDevelopment()) + { + app.UseDeveloperExceptionPage(); + } + else + { + //注册全局异常处理机制 + app.UseExceptionHandler(builder => builder.Run(async context => await GlobalError.ErrorEvent(context))); + } + + app.UseHsts(); + + + //注册跨域信息 + app.UseCors("cors"); + + //强制重定向到Https + app.UseHttpsRedirection(); + + app.UseStaticFiles(); + + app.UseRouting(); + + //注册用户认证机制,必须放在 UseCors UseRouting 之后 + app.UseAuthentication(); + app.UseAuthorization(); + + //注入 adminapp 项目 + //app.MapWhen(ctx => ctx.Request.Path.Value.ToLower().Contains("/admin"), adminapp => + //{ + // adminapp.UseStaticFiles("/admin"); + // adminapp.UseBlazorFrameworkFiles("/admin"); + + // adminapp.UseEndpoints(endpoints => + // { + // endpoints.MapFallbackToFile("/admin/{*path:nonfile}", "admin/index.html"); + // }); + //}); + + app.MapControllers(); + + + #region 启用 Swagger + app.UseSwagger(); + + app.UseSwaggerUI(options => + { + options.SwaggerEndpoint($"/swagger/v1/swagger.json", null); + }); + #endregion + + app.Run(); + + } + + + } +} diff --git a/AdminApi/Properties/launchSettings.json b/AdminApi/Properties/launchSettings.json new file mode 100644 index 000000000..de1c7c879 --- /dev/null +++ b/AdminApi/Properties/launchSettings.json @@ -0,0 +1,13 @@ +{ + "profiles": { + "AdminApi": { + "commandName": "Project", + "launchBrowser": true, + "launchUrl": "swagger/index.html", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "https://localhost:9833" + } + } +} \ No newline at end of file diff --git a/AdminApi/Services/ArticleService.cs b/AdminApi/Services/ArticleService.cs new file mode 100644 index 000000000..9183162a1 --- /dev/null +++ b/AdminApi/Services/ArticleService.cs @@ -0,0 +1,38 @@ +using AdminShared.Models; +using Common; +using Repository.Database; + +namespace AdminApi.Services +{ + + [Service(Lifetime = ServiceLifetime.Scoped)] + public class ArticleService + { + + private readonly DatabaseContext db; + + public ArticleService(DatabaseContext db) + { + this.db = db; + } + + + + public List? GetCategoryChildList(long categoryId) + { + var list = db.TCategory.Where(t => t.IsDelete == false && t.ParentId == categoryId).Select(t => new DtoKeyValueChild + { + Key = t.Id, + Value = t.Name, + }).ToList(); + + foreach (var item in list) + { + item.ChildList = GetCategoryChildList(Convert.ToInt64(item.Key)); + } + + return list; + } + + } +} diff --git a/AdminApi/Services/AuthorizeService.cs b/AdminApi/Services/AuthorizeService.cs new file mode 100644 index 000000000..1ad05091c --- /dev/null +++ b/AdminApi/Services/AuthorizeService.cs @@ -0,0 +1,65 @@ +using AdminApi.Models.AppSetting; +using Common; +using Microsoft.IdentityModel.Tokens; +using Repository.Database; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Security.Cryptography; + +namespace AdminApi.Services +{ + + [Service(Lifetime = ServiceLifetime.Scoped)] + public class AuthorizeService + { + + private readonly DatabaseContext db; + private readonly SnowflakeHelper snowflakeHelper; + private readonly IConfiguration configuration; + + public AuthorizeService(DatabaseContext db, SnowflakeHelper snowflakeHelper, IConfiguration configuration) + { + this.db = db; + this.snowflakeHelper = snowflakeHelper; + this.configuration = configuration; + } + + + + /// + /// 通过用户id获取 token + /// + /// + /// + public string GetTokenByUserId(long userId) + { + + TUserToken userToken = new() + { + Id = snowflakeHelper.GetId(), + UserId = userId, + CreateTime = DateTime.UtcNow + }; + + db.TUserToken.Add(userToken); + db.SaveChanges(); + + var claims = new Claim[] + { + new Claim("tokenId",userToken.Id.ToString()), + new Claim("userId",userId.ToString()) + }; + + var jwtSetting = configuration.GetSection("JWT").Get(); + + var jwtPrivateKey = ECDsa.Create(); + jwtPrivateKey.ImportECPrivateKey(Convert.FromBase64String(jwtSetting.PrivateKey), out _); + var creds = new SigningCredentials(new ECDsaSecurityKey(jwtPrivateKey), SecurityAlgorithms.EcdsaSha256); + var jwtSecurityToken = new JwtSecurityToken(jwtSetting.Issuer, jwtSetting.Audience, claims, DateTime.UtcNow, DateTime.UtcNow + jwtSetting.Expiry, creds); + + return new JwtSecurityTokenHandler().WriteToken(jwtSecurityToken); + } + + + } +} diff --git a/AdminApi/Services/SiteService.cs b/AdminApi/Services/SiteService.cs new file mode 100644 index 000000000..316a0dc42 --- /dev/null +++ b/AdminApi/Services/SiteService.cs @@ -0,0 +1,53 @@ +using Common; +using Repository.Database; + +namespace AdminApi.Services +{ + + [Service(Lifetime = ServiceLifetime.Scoped)] + public class SiteService + { + + + private readonly DatabaseContext db; + private readonly SnowflakeHelper snowflakeHelper; + + public SiteService(DatabaseContext db, SnowflakeHelper snowflakeHelper) + { + this.db = db; + this.snowflakeHelper = snowflakeHelper; + } + + + public bool SetSiteInfo(string key, string? value) + { + + if (value != null) + { + + var appSetting = db.TAppSetting.Where(t => t.IsDelete == false && t.Module == "Site" && t.Key == key).FirstOrDefault(); + + if (appSetting == null) + { + appSetting = new() + { + Id = snowflakeHelper.GetId(), + Module = "Site", + Key = key, + Value = value, + CreateTime = DateTime.UtcNow + }; + db.TAppSetting.Add(appSetting); + } + else + { + appSetting.Value = value; + } + + db.SaveChanges(); + } + + return true; + } + } +} diff --git a/Presentation/Admin.WebAPI/appsettings.Development.json b/AdminApi/appsettings.Development.json similarity index 70% rename from Presentation/Admin.WebAPI/appsettings.Development.json rename to AdminApi/appsettings.Development.json index b8fb9b713..855c56ecf 100644 --- a/Presentation/Admin.WebAPI/appsettings.Development.json +++ b/AdminApi/appsettings.Development.json @@ -7,21 +7,26 @@ "System.Net.Http.HttpClient": "Warning" } }, - "Cors": { - "AllowedOriginList": [ - "localhost" - ] - }, + "AllowedHosts": "*", "JWT": { "Issuer": "*", "Audience": "*", "PrivateKey": "MHcCAQEEIM46BrFOgAQYogx27I1vhqUnoX7dy3NND/yNSIXl8OLDoAoGCCqGSM49AwEHoUQDQgAEWIDxFn39cqNZdikMuwqF097X8BlIejvhb7QMp3fzXeGY+kcd7oku0dOQB34e5uOhYiHtzkDM/k+xdubSpp0dNA==", "PublicKey": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEWIDxFn39cqNZdikMuwqF097X8BlIejvhb7QMp3fzXeGY+kcd7oku0dOQB34e5uOhYiHtzkDM/k+xdubSpp0dNA==", - "Expiry": "0:6:0:0" + "Expiry": "0:0:30:0" }, "ConnectionStrings": { - "dbConnection": "Host=127.0.0.1;Database=webcore;Username=postgres;Password=123456;Maximum Pool Size=30", - "redisConnection": "127.0.0.1,Password=123456,DefaultDatabase=0,abortConnect=false" + "dbConnection": "Data Source=127.0.0.1;Initial Catalog=webcore;User ID=sa;Password=123456;Max Pool Size=100;Encrypt=True", + "redisConnection": "127.0.0.1,Password=123456,DefaultDatabase=0" + }, + "TencentCloudSMS": { + "AppId": "", + "SecretId": "", + "SecretKey": "" + }, + "AliCloudSMS": { + "AccessKeyId": "", + "AccessKeySecret": "" }, "TencentCloudFileStorage": { "AppId": "", @@ -31,11 +36,10 @@ "BucketName": "" }, "AliCloudFileStorage": { - "Region": "cn-shanghai", - "UseInternalEndpoint": false, + "Endpoint": "", "AccessKeyId": "", "AccessKeySecret": "", "BucketName": "" }, - "FileServerUrl": "https://localhost:9833/" + "FileServerUrl": "https://localhost:9833" } diff --git a/Presentation/Admin.WebAPI/appsettings.json b/AdminApi/appsettings.json similarity index 58% rename from Presentation/Admin.WebAPI/appsettings.json rename to AdminApi/appsettings.json index 22ad254a9..2b8e7ca11 100644 --- a/Presentation/Admin.WebAPI/appsettings.json +++ b/AdminApi/appsettings.json @@ -1,21 +1,4 @@ { - //"Kestrel": { - // "Endpoints": { - // "Http": { - // "Url": "http://*:8080" - // }, - // "Https": { - // "Url": "https://*:8081" - // } - // }, - // "Certificates": { - // "Default": { - // "Path": "", //pfx or pem or crt path - // "Password": "", //pfx need - // "KeyPath": "" //"pem or crt need" - // } - // } - //}, "Logging": { "LogLevel": { "Default": "Information", @@ -25,21 +8,25 @@ } }, "AllowedHosts": "*", - "Cors": { - "AllowedOriginList": [ - "*" - ] - }, "JWT": { "Issuer": "*", "Audience": "*", "PrivateKey": "MHcCAQEEIM46BrFOgAQYogx27I1vhqUnoX7dy3NND/yNSIXl8OLDoAoGCCqGSM49AwEHoUQDQgAEWIDxFn39cqNZdikMuwqF097X8BlIejvhb7QMp3fzXeGY+kcd7oku0dOQB34e5uOhYiHtzkDM/k+xdubSpp0dNA==", "PublicKey": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEWIDxFn39cqNZdikMuwqF097X8BlIejvhb7QMp3fzXeGY+kcd7oku0dOQB34e5uOhYiHtzkDM/k+xdubSpp0dNA==", - "Expiry": "0:12:0:0" + "Expiry": "0:0:30:0" }, "ConnectionStrings": { - "dbConnection": "Host=127.0.0.1;Database=webcore;Username=postgres;Password=123456;Maximum Pool Size=30", - "redisConnection": "127.0.0.1,Password=123456,DefaultDatabase=0,abortConnect=false" + "dbConnection": "Data Source=127.0.0.1;Initial Catalog=webcore;User ID=sa;Password=123456;Max Pool Size=100;Encrypt=True;TrustServerCertificate=True", + "redisConnection": "127.0.0.1,Password=123456,DefaultDatabase=0" + }, + "TencentCloudSMS": { + "AppId": "", + "SecretId": "", + "SecretKey": "" + }, + "AliCloudSMS": { + "AccessKeyId": "", + "AccessKeySecret": "" }, "TencentCloudFileStorage": { "AppId": "", @@ -49,11 +36,10 @@ "BucketName": "" }, "AliCloudFileStorage": { - "Region": "cn-shanghai", - "UseInternalEndpoint": true, + "Endpoint": "", "AccessKeyId": "", "AccessKeySecret": "", "BucketName": "" }, - "FileServerUrl": "https://localhost:9833/" + "FileServerUrl": "https://localhost:9833" } diff --git a/AdminApi/web.config b/AdminApi/web.config new file mode 100644 index 000000000..fd9eb2bca --- /dev/null +++ b/AdminApi/web.config @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git "a/Presentation/Admin.WebAPI/wwwroot/\346\226\207\344\273\266\345\244\271\345\215\240\344\275\215.txt" "b/AdminApi/wwwroot/\346\226\207\344\273\266\345\244\271\345\215\240\344\275\215.txt" similarity index 100% rename from "Presentation/Admin.WebAPI/wwwroot/\346\226\207\344\273\266\345\244\271\345\215\240\344\275\215.txt" rename to "AdminApi/wwwroot/\346\226\207\344\273\266\345\244\271\345\215\240\344\275\215.txt" diff --git a/Presentation/Admin.App/Admin.App.csproj b/AdminApp/AdminApp.csproj similarity index 54% rename from Presentation/Admin.App/Admin.App.csproj rename to AdminApp/AdminApp.csproj index b3bbd3d6e..d14f09e68 100644 --- a/Presentation/Admin.App/Admin.App.csproj +++ b/AdminApp/AdminApp.csproj @@ -1,13 +1,14 @@  - net10.0-browser + net6.0 true - + true + true + embedded enable enable - true @@ -21,19 +22,16 @@ - - - - - - - + + + + + + - - - + diff --git a/AdminApp/App.razor b/AdminApp/App.razor new file mode 100644 index 000000000..3a710e490 --- /dev/null +++ b/AdminApp/App.razor @@ -0,0 +1,12 @@ + + + + + + +

Sorry, there's nothing at this address.

+
+
+
+ + diff --git a/AdminApp/Libraries/HttpCore.cs b/AdminApp/Libraries/HttpCore.cs new file mode 100644 index 000000000..29171721e --- /dev/null +++ b/AdminApp/Libraries/HttpCore.cs @@ -0,0 +1,40 @@ +using System.Net.Http.Json; +using System.Text.Json; + +namespace AdminApp.Libraries +{ + public static class HttpCore + { + + + + public static Task GetFromJsonAsync(this HttpClient client, string requestUri) + { + var jsonSerializerOptions = new JsonSerializerOptions(); + + jsonSerializerOptions.Converters.Add(new Json.DateTimeConverter()); + jsonSerializerOptions.Converters.Add(new Json.DateTimeNullConverter()); + jsonSerializerOptions.Converters.Add(new Json.LongConverter()); + jsonSerializerOptions.PropertyNameCaseInsensitive = true; + + return client.GetFromJsonAsync(requestUri, jsonSerializerOptions); + } + + + + public static TValue? ReadAsEntityAsync(this HttpContent httpContent) + { + var jsonSerializerOptions = new JsonSerializerOptions(); + + jsonSerializerOptions.Converters.Add(new Json.DateTimeConverter()); + jsonSerializerOptions.Converters.Add(new Json.DateTimeNullConverter()); + jsonSerializerOptions.Converters.Add(new Json.LongConverter()); + jsonSerializerOptions.PropertyNameCaseInsensitive = true; + + var result = httpContent.ReadAsStringAsync().Result; + + return JsonSerializer.Deserialize(result, jsonSerializerOptions); + } + + } +} diff --git a/AdminApp/Libraries/HttpHelper.cs b/AdminApp/Libraries/HttpHelper.cs new file mode 100644 index 000000000..fe0afa2c5 --- /dev/null +++ b/AdminApp/Libraries/HttpHelper.cs @@ -0,0 +1,270 @@ +using System.Net.Http.Headers; +using System.Reflection; +using System.Text; +using System.Web; + +namespace AdminApp.Libraries +{ + /// + /// 常用Http操作类集合 + /// + public class HttpHelper + { + + + /// + /// Get方式获取远程资源 + /// + /// 请求地址 + /// 自定义Header集合 + /// + public static string Get(string url, Dictionary? headers = default) + { + using HttpClientHandler handler = new(); + using HttpClient client = new(handler); + client.DefaultRequestVersion = new Version("2.0"); + + if (headers != default) + { + foreach (var header in headers) + { + client.DefaultRequestHeaders.Add(header.Key, header.Value); + } + } + + using var httpResponse = client.GetStringAsync(url); + return httpResponse.Result; + } + + + + + /// + /// Model对象转换为Uri网址参数形式 + /// + /// Model对象 + /// 前部分网址 + /// + public static string ModelToUriParam(object obj, string url = "") + { + PropertyInfo[] propertis = obj.GetType().GetProperties(); + StringBuilder sb = new(); + sb.Append(url); + sb.Append('?'); + foreach (var p in propertis) + { + var v = p.GetValue(obj, null); + if (v == null) + continue; + + sb.Append(p.Name); + sb.Append('='); + sb.Append(HttpUtility.UrlEncode(v.ToString())); + sb.Append('&'); + } + sb.Remove(sb.Length - 1, 1); + + return sb.ToString(); + } + + + + + /// + /// Post Json或XML 数据到指定url + /// + /// Url + /// 数据 + /// json,xml + /// 自定义Header集合 + /// + public static string Post(string url, string data, string type, Dictionary? headers = default) + { + + using HttpClientHandler handler = new(); + + using HttpClient client = new(handler); + client.DefaultRequestVersion = new Version("2.0"); + + if (headers != default) + { + foreach (var header in headers) + { + client.DefaultRequestHeaders.Add(header.Key, header.Value); + } + } + + using Stream dataStream = new MemoryStream(Encoding.UTF8.GetBytes(data)); + using HttpContent content = new StreamContent(dataStream); + + + if (type == "json") + { + content.Headers.ContentType = new MediaTypeHeaderValue("application/json"); + } + else if (type == "xml") + { + content.Headers.ContentType = new MediaTypeHeaderValue("text/xml"); + } + + content.Headers.ContentType!.CharSet = "utf-8"; + + using var httpResponse = client.PostAsync(url, content); + return httpResponse.Result.Content.ReadAsStringAsync().Result; + } + + + + + /// + /// Post Json或XML 数据到指定url,异步执行 + /// + /// Url + /// 数据 + /// json,xml + /// 自定义Header集合 + /// + public async static void PostAsync(string url, string data, string type, Dictionary? headers = default) + { + await Task.Run(() => + { + Post(url, data, type, headers); + }); + } + + + + + /// + /// Post表单数据到指定url + /// + /// + /// Post表单内容 + /// 自定义Header集合 + /// 是否跳过SSL验证 + /// + public static string PostForm(string url, Dictionary formItems, Dictionary? headers = default) + { + using HttpClientHandler handler = new(); + + using HttpClient client = new(handler); + client.DefaultRequestVersion = new Version("2.0"); + + if (headers != default) + { + foreach (var header in headers) + { + client.DefaultRequestHeaders.Add(header.Key, header.Value); + } + } + + using FormUrlEncodedContent formContent = new(formItems); + formContent.Headers.ContentType!.CharSet = "utf-8"; + + using var httpResponse = client.PostAsync(url, formContent); + return httpResponse.Result.Content.ReadAsStringAsync().Result; + } + + + + + /// + /// Post文件和数据到指定url + /// + /// + /// Post表单内容 + /// 自定义Header集合 + /// 是否跳过SSL验证 + /// + public static string PostFormData(string url, List formItems, Dictionary? headers = default) + { + using HttpClientHandler handler = new(); + + using HttpClient client = new(handler); + client.DefaultRequestVersion = new Version("2.0"); + + if (headers != default) + { + foreach (var header in headers) + { + client.DefaultRequestHeaders.Add(header.Key, header.Value); + } + } + + string boundary = "----" + DateTime.UtcNow.Ticks.ToString("x"); + + using MultipartFormDataContent formDataContent = new(boundary); + foreach (var item in formItems) + { + if (item.IsFile) + { + //上传文件 + formDataContent.Add(new StreamContent(item.FileContent!), item.Key!, item.FileName!); + } + else + { + //上传文本 + formDataContent.Add(new StringContent(item.Value!), item.Key!); + } + } + + using var httpResponse = client.PostAsync(url, formDataContent); + return httpResponse.Result.Content.ReadAsStringAsync().Result; + } + + + + /// + /// Post 提交 FromData 表单数据模型结构 + /// + public class PostFormDataItem + { + + /// + /// 表单键,request["key"] + /// + public string? Key { set; get; } + + + + /// + /// 表单值,上传文件时忽略,request["key"].value + /// + public string? Value { set; get; } + + + + /// + /// 是否是文件 + /// + public bool IsFile + { + get + { + if (FileContent == null || FileContent.Length == 0) + return false; + + if (FileContent != null && FileContent.Length > 0 && string.IsNullOrWhiteSpace(FileName)) + throw new Exception("上传文件时 FileName 属性值不能为空"); + return true; + } + } + + + + /// + /// 上传的文件名 + /// + public string? FileName { set; get; } + + + + /// + /// 上传的文件内容 + /// + public Stream? FileContent { set; get; } + + + } + } +} diff --git a/AdminApp/Libraries/HttpInterceptor.cs b/AdminApp/Libraries/HttpInterceptor.cs new file mode 100644 index 000000000..1ee67e919 --- /dev/null +++ b/AdminApp/Libraries/HttpInterceptor.cs @@ -0,0 +1,98 @@ +using AntDesign; +using Blazored.LocalStorage; +using Microsoft.AspNetCore.Components; +using System.Security.Cryptography; +using System.Text; + +namespace AdminApp.Libraries +{ + + + /// + /// Http请求拦截器 + /// + public class HttpInterceptor : DelegatingHandler + { + + private readonly ISyncLocalStorageService LocalStorage; + private readonly NavigationManager NavigationManager; + private readonly NotificationService Notice; + + public HttpInterceptor(ISyncLocalStorageService _LocalStorage, NavigationManager _NavigationManager, NotificationService _Notice) + { + LocalStorage = _LocalStorage; + NavigationManager = _NavigationManager; + Notice = _Notice; + InnerHandler = new HttpClientHandler(); + } + + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var authorization = LocalStorage.GetItemAsString("Authorization"); + + var isGetToken = request.RequestUri!.AbsolutePath.Contains("/api/Authorize/GetToken", System.StringComparison.OrdinalIgnoreCase); + + if (!string.IsNullOrEmpty(authorization) || isGetToken) + { + if (!isGetToken) + { + var timeStr = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds().ToString(); + var privateKey = authorization.Split(".").ToList().LastOrDefault(); + var requestUrl = request.RequestUri.PathAndQuery; + + var dataStr = privateKey + timeStr + requestUrl; + + var requestBody = request.Content?.ReadAsStringAsync(cancellationToken).Result; + + if (requestBody != null) + { + dataStr += requestBody; + } + + using SHA256 sha256 = SHA256.Create(); + string dataSign = Convert.ToHexString(sha256.ComputeHash(Encoding.UTF8.GetBytes(dataStr))); + + request.Headers.Add("Authorization", "Bearer " + authorization); + request.Headers.Add("Token", dataSign); + request.Headers.Add("Time", timeStr); + } + + var response = await base.SendAsync(request, cancellationToken); + + if ((int)response.StatusCode == 401) + { + NavigationManager.NavigateTo("login"); + } + + if ((int)response.StatusCode == 400) + { + var ret = await response.Content.ReadAsStringAsync(cancellationToken); + Notice.Open(new NotificationConfig() + { + Message = "异常", + Description = Json.JsonHelper.GetValueByKey(ret, "errMsg"), + NotificationType = NotificationType.Warning + }); + } + + if ((int)response.StatusCode == 200 && response.Headers.Contains("NewToken")) + { + var newToken = response.Headers.GetValues("NewToken").ToList().FirstOrDefault(); + + if (!string.IsNullOrEmpty(newToken)) + { + LocalStorage.SetItemAsString("Authorization", newToken); + } + } + + return response; + } + else + { + NavigationManager.NavigateTo("login", true); + return new HttpResponseMessage(); + } + } + } +} diff --git a/AdminApp/Libraries/Json/DateTimeConverter.cs b/AdminApp/Libraries/Json/DateTimeConverter.cs new file mode 100644 index 000000000..fa043845e --- /dev/null +++ b/AdminApp/Libraries/Json/DateTimeConverter.cs @@ -0,0 +1,35 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace AdminApp.Libraries.Json +{ + public class DateTimeConverter : JsonConverter + { + + + public DateTimeConverter() + { + + } + + + public override DateTime Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.String) + { + if (DateTime.TryParse(reader.GetString(), out DateTime date)) + { + return date; + } + } + return reader.GetDateTime(); + } + + + public override void Write(Utf8JsonWriter writer, DateTime value, JsonSerializerOptions options) + { + writer.WriteStringValue(value.ToString()); + } + } + +} diff --git a/AdminApp/Libraries/Json/DateTimeNullConverter.cs b/AdminApp/Libraries/Json/DateTimeNullConverter.cs new file mode 100644 index 000000000..79bda0bb1 --- /dev/null +++ b/AdminApp/Libraries/Json/DateTimeNullConverter.cs @@ -0,0 +1,29 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace AdminApp.Libraries.Json +{ + public class DateTimeNullConverter : JsonConverter + { + + + public DateTimeNullConverter() + { + + } + + + + public override DateTime? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + return string.IsNullOrEmpty(reader.GetString()) ? default(DateTime?) : DateTime.Parse(reader.GetString()!); + } + + + public override void Write(Utf8JsonWriter writer, DateTime? value, JsonSerializerOptions options) + { + writer.WriteStringValue(value?.ToString()); + } + } + +} diff --git a/AdminApp/Libraries/Json/JsonHelper.cs b/AdminApp/Libraries/Json/JsonHelper.cs new file mode 100644 index 000000000..bbf8b68b3 --- /dev/null +++ b/AdminApp/Libraries/Json/JsonHelper.cs @@ -0,0 +1,88 @@ +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Text.Json.Nodes; + +namespace AdminApp.Libraries.Json +{ + public class JsonHelper + { + + + /// + /// 通过 Key 获取 Value + /// + /// + public static string? GetValueByKey(string json, string key) + { + using JsonDocument doc = JsonDocument.Parse(json); + var jsonElement = doc.RootElement.Clone(); + + return jsonElement.GetProperty(key).GetString(); + } + + + + /// + /// 对象 转 Json + /// + /// 对象 + /// JSON格式的字符串 + public static string ObjectToJson(object obj) + { + JsonSerializerOptions options = new(); + options.Converters.Add(new DateTimeConverter()); + options.Converters.Add(new DateTimeNullConverter()); + options.Converters.Add(new LongConverter()); + + //关闭默认转义 + options.Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping; + + //启用驼峰格式 + options.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; + + //关闭缩进设置 + options.WriteIndented = false; + + return JsonSerializer.Serialize(obj, options); + } + + + + + /// + /// Json 转 对象 + /// + /// 类型 + /// JSON文本 + /// 指定类型的对象 + public static T? JsonToObject(string json) + { + JsonSerializerOptions options = new(); + options.Converters.Add(new DateTimeConverter()); + options.Converters.Add(new DateTimeNullConverter()); + options.Converters.Add(new LongConverter()); + + //启用大小写不敏感 + options.PropertyNameCaseInsensitive = true; + + return JsonSerializer.Deserialize(json, options); + } + + + + + /// + /// 没有 Key 的 Json 转 List + /// + /// + /// + public static JsonNode? JsonToArrayList(string json) + { + var jsonNode = JsonNode.Parse(json); + + return jsonNode; + } + + + } +} diff --git a/AdminApp/Libraries/Json/LongConverter.cs b/AdminApp/Libraries/Json/LongConverter.cs new file mode 100644 index 000000000..945e6a74b --- /dev/null +++ b/AdminApp/Libraries/Json/LongConverter.cs @@ -0,0 +1,28 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace AdminApp.Libraries.Json +{ + public class LongConverter : JsonConverter + { + + public override long Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.String) + { + if (long.TryParse(reader.GetString(), out long l)) + { + return l; + } + } + return reader.GetInt64(); + } + + + public override void Write(Utf8JsonWriter writer, long value, JsonSerializerOptions options) + { + writer.WriteStringValue(value.ToString()); + } + } + +} diff --git a/AdminApp/Models/DtoSelect.cs b/AdminApp/Models/DtoSelect.cs new file mode 100644 index 000000000..3eb9de751 --- /dev/null +++ b/AdminApp/Models/DtoSelect.cs @@ -0,0 +1,27 @@ +namespace AdminApp.Models +{ + public class DtoSelect + { + + + + /// + /// 值 + /// + public long Value { get; set; } + + + /// + /// 标签 + /// + public string? Label { get; set; } + + + /// + /// 是否禁用 + /// + public bool IsDisabled { get; set; } + + + } +} diff --git a/AdminApp/Models/DtoTreeSelect.cs b/AdminApp/Models/DtoTreeSelect.cs new file mode 100644 index 000000000..e0a49c3c0 --- /dev/null +++ b/AdminApp/Models/DtoTreeSelect.cs @@ -0,0 +1,18 @@ +using AntDesign; + +namespace AdminApp.Models +{ + + + public class DtoTreeSelect : ITreeData + { + public string Key { get; set; } + public DtoTreeSelect Value => this; + public string Title { get; set; } + public IEnumerable? Children { get; set; } + + + public bool IsDisabled { get; set; } + } + +} diff --git a/AdminApp/Pages/Article/Article.razor b/AdminApp/Pages/Article/Article.razor new file mode 100644 index 000000000..7b70ad18f --- /dev/null +++ b/AdminApp/Pages/Article/Article.razor @@ -0,0 +1,411 @@ +@page "/article/{channelId}" +@using AdminShared.Models.Article + + + + 返回上一页 + 首页 + 文章管理 + 所有文章 + + + +
+ +
+ + + + + + + + + + + + + 编辑 + + + + 删除 + + + + + + + + +
+ +
+
+ +
+ +
+
+ + +@{ + RenderFragment editFooter = @; +} + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
上传
+
+ +
+ + + + +
+ +
+ + + +