From 9a33eb00a508394b917a153f7618c49202db8983 Mon Sep 17 00:00:00 2001 From: zzh <1368735060@qq.com> Date: Tue, 4 Aug 2026 13:54:10 +0800 Subject: [PATCH] feat: add knowledge base M1 APIs and workplace spend trend --- sql/20260804_kb_knowledge.sql | 144 +++++++ sql/20260804_kb_menu_notes.sql | 8 + src/Oms.Application/Dtos/Kb/KbDtos.cs | 138 +++++++ .../Orders/OrderWorkplaceSpendTrendOutput.cs | 58 +++ .../Services/Kb/KbArticleService.cs | 359 ++++++++++++++++++ .../Services/Kb/KbCategoryService.cs | 104 +++++ .../Services/Orders/OrderService.Workplace.cs | 65 ++++ .../KbAggregate/Entities/KbArticle.cs | 51 +++ .../KbAggregate/Entities/KbCategory.cs | 21 + .../Repositories/IKbArticleRepository.cs | 9 + .../Repositories/IKbCategoryRepository.cs | 11 + .../Repositories/IOrderRepository.cs | 9 + .../Kb/KbArticleRepository.cs | 13 + .../Kb/KbCategoryRepository.cs | 26 ++ .../Orders/OrderRepository.cs | 15 + .../Auth/XhsRolePermissionSeedContributor.cs | 11 +- 16 files changed, 1041 insertions(+), 1 deletion(-) create mode 100644 sql/20260804_kb_knowledge.sql create mode 100644 sql/20260804_kb_menu_notes.sql create mode 100644 src/Oms.Application/Dtos/Kb/KbDtos.cs create mode 100644 src/Oms.Application/Dtos/Orders/OrderWorkplaceSpendTrendOutput.cs create mode 100644 src/Oms.Application/Services/Kb/KbArticleService.cs create mode 100644 src/Oms.Application/Services/Kb/KbCategoryService.cs create mode 100644 src/Oms.Domain/KbAggregate/Entities/KbArticle.cs create mode 100644 src/Oms.Domain/KbAggregate/Entities/KbCategory.cs create mode 100644 src/Oms.Domain/KbAggregate/Repositories/IKbArticleRepository.cs create mode 100644 src/Oms.Domain/KbAggregate/Repositories/IKbCategoryRepository.cs create mode 100644 src/Oms.SqlSugarCore/Kb/KbArticleRepository.cs create mode 100644 src/Oms.SqlSugarCore/Kb/KbCategoryRepository.cs diff --git a/sql/20260804_kb_knowledge.sql b/sql/20260804_kb_knowledge.sql new file mode 100644 index 0000000..2077eca --- /dev/null +++ b/sql/20260804_kb_knowledge.sql @@ -0,0 +1,144 @@ +-- 物流业务知识库 M1:分类 + 文章 +-- 执行库:OMS 业务库 + +CREATE TABLE IF NOT EXISTS `t_kb_category` ( + `Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键', + `Code` VARCHAR(64) NOT NULL COMMENT '稳定键,如 pricing', + `Name` VARCHAR(128) NOT NULL COMMENT '展示名(中文)', + `SortOrder` INT NOT NULL DEFAULT 0 COMMENT '排序,越小越前', + `Enabled` TINYINT NOT NULL DEFAULT 1 COMMENT '是否启用:0-否,1-是', + `IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除:0-否,1-是', + `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()) COMMENT '并发戳', + `CreateUserId` INT NOT NULL DEFAULT 0 COMMENT '创建人', + `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `UpdateUserId` INT NULL COMMENT '更新人', + `UpdateTime` DATETIME NULL COMMENT '更新时间', + PRIMARY KEY (`Id`), + UNIQUE KEY `uk_kb_category_code` (`Code`), + KEY `idx_kb_category_enabled_sort` (`Enabled`, `SortOrder`) +) COMMENT='知识库分类'; + +CREATE TABLE IF NOT EXISTS `t_kb_article` ( + `Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键', + `CategoryId` INT NOT NULL COMMENT '分类 Id', + `Title` VARCHAR(200) NOT NULL COMMENT '标题(中文)', + `Summary` VARCHAR(500) NULL COMMENT '列表摘要', + `Content` LONGTEXT NOT NULL COMMENT 'Markdown 正文(中文)', + `Audience` VARCHAR(16) NOT NULL DEFAULT 'customer' COMMENT '受众 customer/internal', + `Status` VARCHAR(16) NOT NULL DEFAULT 'draft' COMMENT 'draft/published/offline', + `Tags` VARCHAR(256) NULL COMMENT '逗号分隔标签', + `RelatedPath` VARCHAR(256) NULL COMMENT '站内关联 path', + `RelatedLabel` VARCHAR(64) NULL COMMENT '关联按钮文案', + `IsPinned` TINYINT NOT NULL DEFAULT 0 COMMENT '置顶:0-否,1-是', + `SortOrder` INT NOT NULL DEFAULT 0 COMMENT '同类排序', + `PublishTime` DATETIME NULL COMMENT '发布时间', + `ExpireTime` DATETIME NULL COMMENT '过期时间', + `ViewCount` INT NOT NULL DEFAULT 0 COMMENT '浏览次数', + `IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除:0-否,1-是', + `ConcurrencyStamp` CHAR(36) NOT NULL DEFAULT (UUID()) COMMENT '并发戳', + `CreateUserId` INT NOT NULL DEFAULT 0 COMMENT '创建人', + `CreateTime` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `UpdateUserId` INT NULL COMMENT '更新人', + `UpdateTime` DATETIME NULL COMMENT '更新时间', + PRIMARY KEY (`Id`), + KEY `idx_kb_article_list` (`Status`, `Audience`, `CategoryId`, `IsPinned`, `SortOrder`), + KEY `idx_kb_article_publish` (`Status`, `Audience`, `PublishTime`), + KEY `idx_kb_article_hot` (`Status`, `Audience`, `IsPinned`, `ViewCount`) +) COMMENT='知识库文章'; + +-- 种子分类(幂等:Code 唯一) +INSERT INTO `t_kb_category` (`Code`, `Name`, `SortOrder`, `Enabled`) +SELECT 'pricing', '查价与计费', 10, 1 FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM `t_kb_category` WHERE `Code` = 'pricing' AND `IsDeleted` = 0); + +INSERT INTO `t_kb_category` (`Code`, `Name`, `SortOrder`, `Enabled`) +SELECT 'order', '下单与轨迹', 20, 1 FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM `t_kb_category` WHERE `Code` = 'order' AND `IsDeleted` = 0); + +INSERT INTO `t_kb_category` (`Code`, `Name`, `SortOrder`, `Enabled`) +SELECT 'exception', '问题件与售后', 30, 1 FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM `t_kb_category` WHERE `Code` = 'exception' AND `IsDeleted` = 0); + +INSERT INTO `t_kb_category` (`Code`, `Name`, `SortOrder`, `Enabled`) +SELECT 'customs', '禁限运与清关', 40, 1 FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM `t_kb_category` WHERE `Code` = 'customs' AND `IsDeleted` = 0); + +INSERT INTO `t_kb_category` (`Code`, `Name`, `SortOrder`, `Enabled`) +SELECT 'account', '账户与充值', 50, 1 FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM `t_kb_category` WHERE `Code` = 'account' AND `IsDeleted` = 0); + +INSERT INTO `t_kb_category` (`Code`, `Name`, `SortOrder`, `Enabled`) +SELECT 'dropship', '代发与店铺', 60, 1 FROM DUAL +WHERE NOT EXISTS (SELECT 1 FROM `t_kb_category` WHERE `Code` = 'dropship' AND `IsDeleted` = 0); + +-- 种子 FAQ(幂等:按 Title + Category Code) +INSERT INTO `t_kb_article` +(`CategoryId`, `Title`, `Summary`, `Content`, `Audience`, `Status`, `Tags`, `RelatedPath`, `RelatedLabel`, `IsPinned`, `SortOrder`, `PublishTime`, `ViewCount`) +SELECT c.`Id`, + '如何查价?计费重量怎么算?', + '说明计费重、体积重与卖价取整规则。', + '## 查价入口\n\n工作台或菜单进入「查价」,填写目的国、重量与尺寸即可预估运费。\n\n## 计费重量\n\n系统按 **实重** 与 **体积重** 取较大者作为计费重。体积重公式以销售产品配置为准。\n\n## 提示\n\n查价结果为预估,实际以出库计费为准。', + 'customer', 'published', '查价,计费', '/order/priceCheck', '去查价', 1, 10, NOW(), 0 +FROM `t_kb_category` c +WHERE c.`Code` = 'pricing' AND c.`IsDeleted` = 0 + AND NOT EXISTS ( + SELECT 1 FROM `t_kb_article` a + WHERE a.`Title` = '如何查价?计费重量怎么算?' AND a.`CategoryId` = c.`Id` AND a.`IsDeleted` = 0 + ); + +INSERT INTO `t_kb_article` +(`CategoryId`, `Title`, `Summary`, `Content`, `Audience`, `Status`, `Tags`, `RelatedPath`, `RelatedLabel`, `IsPinned`, `SortOrder`, `PublishTime`, `ViewCount`) +SELECT c.`Id`, + '下单后如何查看轨迹?', + '订单列表与轨迹查询入口说明。', + '## 查看轨迹\n\n1. 进入「我的订单」,找到目标运单。\n2. 点击轨迹图标或进入详情查看最新节点。\n\n上网后才会出现承运商轨迹;未上网前可能仅有内部操作节点。', + 'customer', 'published', '轨迹,订单', NULL, NULL, 1, 10, NOW(), 0 +FROM `t_kb_category` c +WHERE c.`Code` = 'order' AND c.`IsDeleted` = 0 + AND NOT EXISTS ( + SELECT 1 FROM `t_kb_article` a + WHERE a.`Title` = '下单后如何查看轨迹?' AND a.`CategoryId` = c.`Id` AND a.`IsDeleted` = 0 + ); + +INSERT INTO `t_kb_article` +(`CategoryId`, `Title`, `Summary`, `Content`, `Audience`, `Status`, `Tags`, `RelatedPath`, `RelatedLabel`, `IsPinned`, `SortOrder`, `PublishTime`, `ViewCount`) +SELECT c.`Id`, + '什么是问题件?如何处理?', + '问题件常见原因与处理路径。', + '## 什么是问题件\n\n因地址不全、禁限运、欠费、清关资料缺失等原因,包裹无法继续流转时会标记为问题件。\n\n## 怎么处理\n\n在订单列表筛选「问题件」,按提示补全资料或联系客服。处理完成后会重新放行。', + 'customer', 'published', '问题件,售后', NULL, NULL, 0, 20, NOW(), 0 +FROM `t_kb_category` c +WHERE c.`Code` = 'exception' AND c.`IsDeleted` = 0 + AND NOT EXISTS ( + SELECT 1 FROM `t_kb_article` a + WHERE a.`Title` = '什么是问题件?如何处理?' AND a.`CategoryId` = c.`Id` AND a.`IsDeleted` = 0 + ); + +INSERT INTO `t_kb_article` +(`CategoryId`, `Title`, `Summary`, `Content`, `Audience`, `Status`, `Tags`, `RelatedPath`, `RelatedLabel`, `IsPinned`, `SortOrder`, `PublishTime`, `ViewCount`) +SELECT c.`Id`, + '账户余额不足怎么办?', + '充值入口与余额扣费说明。', + '## 充值\n\n工作台余额卡片或财务菜单可发起充值。到账后余额可用于下单扣费。\n\n## 注意\n\n子账号余额归属主客户;扣费失败时订单可能无法提交或出库。', + 'customer', 'published', '充值,余额', NULL, NULL, 0, 10, NOW(), 0 +FROM `t_kb_category` c +WHERE c.`Code` = 'account' AND c.`IsDeleted` = 0 + AND NOT EXISTS ( + SELECT 1 FROM `t_kb_article` a + WHERE a.`Title` = '账户余额不足怎么办?' AND a.`CategoryId` = c.`Id` AND a.`IsDeleted` = 0 + ); + +INSERT INTO `t_kb_article` +(`CategoryId`, `Title`, `Summary`, `Content`, `Audience`, `Status`, `Tags`, `RelatedPath`, `RelatedLabel`, `IsPinned`, `SortOrder`, `PublishTime`, `ViewCount`) +SELECT c.`Id`, + '禁限运物品有哪些?', + '常见禁限运与申报注意点。', + '## 常见禁限运\n\n电池、液体、粉末、管制刀具、仿牌等通常受限或禁运,以承运商与目的国法规为准。\n\n## 申报建议\n\n如实填写品名与货值,避免笼统描述(如 gifts)。不确定时可先咨询客服。', + 'customer', 'published', '禁运,清关', NULL, NULL, 0, 10, NOW(), 0 +FROM `t_kb_category` c +WHERE c.`Code` = 'customs' AND c.`IsDeleted` = 0 + AND NOT EXISTS ( + SELECT 1 FROM `t_kb_article` a + WHERE a.`Title` = '禁限运物品有哪些?' AND a.`CategoryId` = c.`Id` AND a.`IsDeleted` = 0 + ); + diff --git a/sql/20260804_kb_menu_notes.sql b/sql/20260804_kb_menu_notes.sql new file mode 100644 index 0000000..64e6747 --- /dev/null +++ b/sql/20260804_kb_menu_notes.sql @@ -0,0 +1,8 @@ +-- 知识库 M1 菜单挂载说明 +-- 一级:知识库 route=/kb permissionCode=Knowledge +-- 在菜单管理对该一级点「同步子菜单及按钮」,将自动创建: +-- 分类管理 /kb/category permissionCode=Knowledge.Category +-- 三级:Knowledge.Category.Query / Knowledge.Category.Edit +-- 文章管理 /kb/article permissionCode=Knowledge.Article +-- 三级:Knowledge.Article.Query / Knowledge.Article.Edit / Knowledge.Article.Publish +-- Admin 角色启动时可自动合并上述权限(见 XhsRolePermissionSeedContributor.AdminKbPermissions) diff --git a/src/Oms.Application/Dtos/Kb/KbDtos.cs b/src/Oms.Application/Dtos/Kb/KbDtos.cs new file mode 100644 index 0000000..3c801d6 --- /dev/null +++ b/src/Oms.Application/Dtos/Kb/KbDtos.cs @@ -0,0 +1,138 @@ +using System.ComponentModel.DataAnnotations; + +namespace Oms.Application.Dtos.Kb; + +public static class KbAudienceCodes +{ + public const string Customer = "customer"; + public const string Internal = "internal"; +} + +public static class KbArticleStatusCodes +{ + public const string Draft = "draft"; + public const string Published = "published"; + public const string Offline = "offline"; +} + +public class KbCategoryOutput +{ + public int Id { get; set; } + public string Code { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public int SortOrder { get; set; } + public bool Enabled { get; set; } +} + +public class KbCategorySaveInput +{ + public int? Id { get; set; } + + [Required] + public string Code { get; set; } = string.Empty; + + [Required] + public string Name { get; set; } = string.Empty; + + public int SortOrder { get; set; } + + public bool Enabled { get; set; } = true; +} + +public class KbCategoryDeleteInput +{ + [Required] + public int Id { get; set; } +} + +public class KbArticleOutput +{ + public int Id { get; set; } + public int CategoryId { get; set; } + public string? CategoryName { get; set; } + public string Title { get; set; } = string.Empty; + public string? Summary { get; set; } + public string? Content { get; set; } + public string Audience { get; set; } = KbAudienceCodes.Customer; + public string Status { get; set; } = KbArticleStatusCodes.Draft; + public string? Tags { get; set; } + public string? RelatedPath { get; set; } + public string? RelatedLabel { get; set; } + public bool IsPinned { get; set; } + public int SortOrder { get; set; } + public DateTime? PublishTime { get; set; } + public DateTime? ExpireTime { get; set; } + public int ViewCount { get; set; } + public DateTime? UpdateTime { get; set; } +} + +public class KbArticleListInput +{ + public int Page { get; set; } = 1; + public int PageSize { get; set; } = 30; + public string? Keyword { get; set; } + public int? CategoryId { get; set; } + public string? Status { get; set; } + public string? Audience { get; set; } + public bool? IsPinned { get; set; } + public bool IncludeContent { get; set; } +} + +public class KbArticleSaveInput +{ + public int? Id { get; set; } + + [Required] + public int CategoryId { get; set; } + + [Required] + public string Title { get; set; } = string.Empty; + + public string? Summary { get; set; } + + [Required] + public string Content { get; set; } = string.Empty; + + public string Audience { get; set; } = KbAudienceCodes.Customer; + + public string Status { get; set; } = KbArticleStatusCodes.Draft; + + public string? Tags { get; set; } + + public string? RelatedPath { get; set; } + + public string? RelatedLabel { get; set; } + + public bool IsPinned { get; set; } + + public int SortOrder { get; set; } + + public DateTime? ExpireTime { get; set; } +} + +public class KbArticleIdInput +{ + [Required] + public int Id { get; set; } +} + +public class KbArticlePinInput +{ + [Required] + public int Id { get; set; } + + public bool IsPinned { get; set; } +} + +public class KbPortalArticleListInput +{ + public int Page { get; set; } = 1; + public int PageSize { get; set; } = 20; + public string? Keyword { get; set; } + public int? CategoryId { get; set; } +} + +public class KbPortalHotInput +{ + public int Top { get; set; } = 10; +} diff --git a/src/Oms.Application/Dtos/Orders/OrderWorkplaceSpendTrendOutput.cs b/src/Oms.Application/Dtos/Orders/OrderWorkplaceSpendTrendOutput.cs new file mode 100644 index 0000000..e85de5b --- /dev/null +++ b/src/Oms.Application/Dtos/Orders/OrderWorkplaceSpendTrendOutput.cs @@ -0,0 +1,58 @@ +namespace Oms.Application.Dtos.Orders; + +/// +/// 工作台费用趋势(按日/周/月)。 +/// +public class OrderWorkplaceSpendTrendOutput +{ + /// + /// 维度:day / week / month。 + /// + public string Dimension { get; set; } = "day"; + + /// + /// 区间合计:运费支出(客户应付)。 + /// + public decimal TotalSpend { get; set; } + + /// + /// 区间合计:订单数。 + /// + public int TotalOrders { get; set; } + + /// + /// 区间合计:单票均价。 + /// + public decimal AvgAmount { get; set; } + + /// + /// 趋势点(按时间正序)。 + /// + public List Points { get; set; } = []; +} + +/// +/// 单个时间桶的费用汇总。 +/// +public class OrderWorkplaceSpendTrendPointOutput +{ + /// + /// 展示标签。 + /// + public string Label { get; set; } = string.Empty; + + /// + /// 订单数(创建时间)。 + /// + public int OrderCount { get; set; } + + /// + /// 运费支出合计(Order.PayableAmount)。 + /// + public decimal SpendAmount { get; set; } + + /// + /// 单票均价。 + /// + public decimal AvgAmount { get; set; } +} diff --git a/src/Oms.Application/Services/Kb/KbArticleService.cs b/src/Oms.Application/Services/Kb/KbArticleService.cs new file mode 100644 index 0000000..8c6cd17 --- /dev/null +++ b/src/Oms.Application/Services/Kb/KbArticleService.cs @@ -0,0 +1,359 @@ +using System.Linq.Expressions; +using Fake.Application; +using Fake.Domain.Exceptions; +using Microsoft.AspNetCore.Mvc; +using Oms.Application.Dtos.Kb; +using Oms.Application.Dtos.Shared; +using Oms.Application.Shared; +using Oms.Domain.KbAggregate.Entities; +using Oms.Domain.KbAggregate.Repositories; + +namespace Oms.Application.Services.Kb; + +/// +/// 知识库文章:管理端 + 客户门户。 +/// +[ApiExplorerSettings(GroupName = "OMS-Kb")] +public class KbArticleService : ApplicationService +{ + private IKbArticleRepository ArticleRepository => + LazyServiceProvider.GetRequiredService(); + + private IKbCategoryRepository CategoryRepository => + LazyServiceProvider.GetRequiredService(); + + [HttpPost("/kb/admin/article/page")] + public async Task> AdminPageAsync(KbArticleListInput input) + { + var predicate = BuildAdminPredicate(input); + var pageIndex = OmsDocHelper.NormalizePage(input.Page); + var pageSize = OmsDocHelper.NormalizePageSize(input.PageSize); + var entities = await ArticleRepository.GetPagedListAsync( + predicate, + pageIndex, + pageSize, + new Dictionary + { + [nameof(KbArticle.IsPinned)] = false, + [nameof(KbArticle.SortOrder)] = true, + [nameof(KbArticle.Id)] = false + }); + var total = await ArticleRepository.CountAsync(predicate); + var categoryMap = await LoadCategoryNameMapAsync(entities.Select(x => x.CategoryId).Distinct()); + return new OmsPageResult + { + Total = total, + Items = entities.Select(x => Map(x, categoryMap, input.IncludeContent)).ToList() + }; + } + + [HttpGet("/kb/admin/article/{id:int}")] + public async Task AdminGetAsync(int id) + { + var entity = await GetArticleOrThrowAsync(id); + var categoryMap = await LoadCategoryNameMapAsync([entity.CategoryId]); + return Map(entity, categoryMap, includeContent: true); + } + + [HttpPost("/kb/admin/article/save")] + public async Task SaveAsync(KbArticleSaveInput input) + { + ValidateSave(input); + await EnsureCategoryExistsAsync(input.CategoryId); + + KbArticle? entity = null; + if (input.Id is > 0) + { + entity = await GetArticleOrThrowAsync(input.Id.Value); + } + + entity ??= new KbArticle(); + ApplySave(input, entity); + + if (string.Equals(entity.Status, KbArticleStatusCodes.Published, StringComparison.OrdinalIgnoreCase) + && entity.PublishTime == null) + { + entity.PublishTime = DateTime.Now; + } + + if (entity.Id > 0) + { + await ArticleRepository.UpdateAsync(entity); + } + else + { + await ArticleRepository.InsertAsync(entity); + } + + var categoryMap = await LoadCategoryNameMapAsync([entity.CategoryId]); + return Map(entity, categoryMap, includeContent: true); + } + + [HttpPost("/kb/admin/article/publish")] + public async Task PublishAsync(KbArticleIdInput input) + { + var entity = await GetArticleOrThrowAsync(input.Id); + entity.Status = KbArticleStatusCodes.Published; + entity.PublishTime ??= DateTime.Now; + await ArticleRepository.UpdateAsync(entity); + } + + [HttpPost("/kb/admin/article/offline")] + public async Task OfflineAsync(KbArticleIdInput input) + { + var entity = await GetArticleOrThrowAsync(input.Id); + entity.Status = KbArticleStatusCodes.Offline; + await ArticleRepository.UpdateAsync(entity); + } + + [HttpPost("/kb/admin/article/pin")] + public async Task PinAsync(KbArticlePinInput input) + { + var entity = await GetArticleOrThrowAsync(input.Id); + entity.IsPinned = input.IsPinned; + await ArticleRepository.UpdateAsync(entity); + } + + [HttpPost("/kb/admin/article/delete")] + public async Task DeleteAsync(KbArticleIdInput input) + { + var entity = await GetArticleOrThrowAsync(input.Id); + await ArticleRepository.DeleteAsync(entity); + } + + /// 门户:启用分类列表(需客户登录)。 + [HttpGet("/kb/portal/category/list")] + public async Task> PortalCategoryListAsync() + { + MarketingPortalSessionHelper.EnsureCustomerLogin(CurrentUser); + var items = await CategoryRepository.GetListAsync(x => x.Enabled); + var ordered = items.OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToList(); + return new OmsItemsResult + { + Items = ordered.Select(x => new KbCategoryOutput + { + Id = x.Id, + Code = x.Code, + Name = x.Name, + SortOrder = x.SortOrder, + Enabled = x.Enabled + }).ToList() + }; + } + + /// 门户:文章分页(需客户登录)。 + [HttpPost("/kb/portal/article/page")] + public async Task> PortalPageAsync(KbPortalArticleListInput input) + { + MarketingPortalSessionHelper.EnsureCustomerLogin(CurrentUser); + var predicate = BuildPortalPredicate(input.Keyword, input.CategoryId); + var pageIndex = OmsDocHelper.NormalizePage(input.Page); + var pageSize = OmsDocHelper.NormalizePageSize(input.PageSize); + var entities = await ArticleRepository.GetPagedListAsync( + predicate, + pageIndex, + pageSize, + new Dictionary + { + [nameof(KbArticle.IsPinned)] = false, + [nameof(KbArticle.SortOrder)] = true, + [nameof(KbArticle.PublishTime)] = false, + [nameof(KbArticle.Id)] = false + }); + var total = await ArticleRepository.CountAsync(predicate); + var categoryMap = await LoadCategoryNameMapAsync(entities.Select(x => x.CategoryId).Distinct()); + return new OmsPageResult + { + Total = total, + Items = entities.Select(x => Map(x, categoryMap, includeContent: false)).ToList() + }; + } + + /// 门户:热门(需客户登录)。 + [HttpGet("/kb/portal/article/hot")] + public async Task> PortalHotAsync([FromQuery] int top = 10) + { + MarketingPortalSessionHelper.EnsureCustomerLogin(CurrentUser); + top = Math.Clamp(top <= 0 ? 10 : top, 1, 50); + var now = DateTime.Now; + var all = await ArticleRepository.GetListAsync(x => + x.Status == KbArticleStatusCodes.Published + && x.Audience == KbAudienceCodes.Customer + && (x.ExpireTime == null || x.ExpireTime > now)); + var ordered = all + .OrderByDescending(x => x.IsPinned) + .ThenByDescending(x => x.ViewCount) + .ThenByDescending(x => x.PublishTime) + .ThenByDescending(x => x.Id) + .Take(top) + .ToList(); + var categoryMap = await LoadCategoryNameMapAsync(ordered.Select(x => x.CategoryId).Distinct()); + return new OmsItemsResult + { + Items = ordered.Select(x => Map(x, categoryMap, includeContent: false)).ToList() + }; + } + + /// 门户:详情(需客户登录;浏览量 +1)。 + [HttpGet("/kb/portal/article/{id:int}")] + public async Task PortalGetAsync(int id) + { + MarketingPortalSessionHelper.EnsureCustomerLogin(CurrentUser); + var entity = await GetArticleOrThrowAsync(id); + EnsurePortalVisible(entity); + entity.ViewCount += 1; + await ArticleRepository.UpdateAsync(entity); + var categoryMap = await LoadCategoryNameMapAsync([entity.CategoryId]); + return Map(entity, categoryMap, includeContent: true); + } + + private static Expression> BuildAdminPredicate(KbArticleListInput input) + { + var keyword = input.Keyword?.Trim(); + var status = NormalizeOptional(input.Status); + var audience = NormalizeOptional(input.Audience); + return x => + (!input.CategoryId.HasValue || x.CategoryId == input.CategoryId.Value) + && (status == null || x.Status == status) + && (audience == null || x.Audience == audience) + && (!input.IsPinned.HasValue || x.IsPinned == input.IsPinned.Value) + && (keyword == null + || x.Title.Contains(keyword) + || (x.Summary != null && x.Summary.Contains(keyword)) + || (x.Tags != null && x.Tags.Contains(keyword))); + } + + private static Expression> BuildPortalPredicate(string? keyword, int? categoryId) + { + var kw = NormalizeOptional(keyword); + var now = DateTime.Now; + return x => + x.Status == KbArticleStatusCodes.Published + && x.Audience == KbAudienceCodes.Customer + && (x.ExpireTime == null || x.ExpireTime > now) + && (!categoryId.HasValue || x.CategoryId == categoryId.Value) + && (kw == null + || x.Title.Contains(kw) + || (x.Summary != null && x.Summary.Contains(kw)) + || (x.Tags != null && x.Tags.Contains(kw))); + } + + private static void EnsurePortalVisible(KbArticle entity) + { + if (!string.Equals(entity.Audience, KbAudienceCodes.Customer, StringComparison.OrdinalIgnoreCase) + || !string.Equals(entity.Status, KbArticleStatusCodes.Published, StringComparison.OrdinalIgnoreCase) + || (entity.ExpireTime != null && entity.ExpireTime <= DateTime.Now)) + { + throw new BusinessException("文章不存在或未发布"); + } + } + + private async Task EnsureCategoryExistsAsync(int categoryId) + { + var exists = await CategoryRepository.AnyAsync(x => x.Id == categoryId && x.Enabled); + if (!exists) + { + throw new BusinessException("分类不存在或已停用"); + } + } + + private async Task GetArticleOrThrowAsync(int id) + { + var entity = await ArticleRepository.FirstOrDefaultAsync(x => x.Id == id); + if (entity == null) + { + throw new BusinessException("文章不存在"); + } + + return entity; + } + + private async Task> LoadCategoryNameMapAsync(IEnumerable ids) + { + var idList = ids.Where(x => x > 0).Distinct().ToList(); + if (idList.Count == 0) + { + return new Dictionary(); + } + + var categories = await CategoryRepository.GetListAsync(x => idList.Contains(x.Id)); + return categories.ToDictionary(x => x.Id, x => x.Name); + } + + private static void ValidateSave(KbArticleSaveInput input) + { + if (input.CategoryId <= 0) + { + throw new BusinessException("请选择分类"); + } + + if (string.IsNullOrWhiteSpace(input.Title)) + { + throw new BusinessException("标题不能为空"); + } + + if (string.IsNullOrWhiteSpace(input.Content)) + { + throw new BusinessException("正文不能为空"); + } + + var audience = (input.Audience ?? string.Empty).Trim().ToLowerInvariant(); + if (audience is not (KbAudienceCodes.Customer or KbAudienceCodes.Internal)) + { + throw new BusinessException("受众仅支持 customer / internal"); + } + + var status = (input.Status ?? string.Empty).Trim().ToLowerInvariant(); + if (status is not (KbArticleStatusCodes.Draft or KbArticleStatusCodes.Published or KbArticleStatusCodes.Offline)) + { + throw new BusinessException("状态仅支持 draft / published / offline"); + } + } + + private static void ApplySave(KbArticleSaveInput input, KbArticle entity) + { + entity.CategoryId = input.CategoryId; + entity.Title = input.Title.Trim(); + entity.Summary = NormalizeOptional(input.Summary); + entity.Content = input.Content ?? string.Empty; + entity.Audience = input.Audience.Trim().ToLowerInvariant(); + entity.Status = input.Status.Trim().ToLowerInvariant(); + entity.Tags = NormalizeOptional(input.Tags); + entity.RelatedPath = NormalizeOptional(input.RelatedPath); + entity.RelatedLabel = NormalizeOptional(input.RelatedLabel); + entity.IsPinned = input.IsPinned; + entity.SortOrder = input.SortOrder; + entity.ExpireTime = input.ExpireTime; + } + + private static KbArticleOutput Map( + KbArticle x, + IReadOnlyDictionary categoryMap, + bool includeContent) => + new() + { + Id = x.Id, + CategoryId = x.CategoryId, + CategoryName = categoryMap.TryGetValue(x.CategoryId, out var name) ? name : null, + Title = x.Title, + Summary = x.Summary, + Content = includeContent ? x.Content : null, + Audience = x.Audience, + Status = x.Status, + Tags = x.Tags, + RelatedPath = x.RelatedPath, + RelatedLabel = x.RelatedLabel, + IsPinned = x.IsPinned, + SortOrder = x.SortOrder, + PublishTime = x.PublishTime, + ExpireTime = x.ExpireTime, + ViewCount = x.ViewCount, + UpdateTime = x.UpdateTime + }; + + private static string? NormalizeOptional(string? value) + { + var trimmed = value?.Trim(); + return string.IsNullOrWhiteSpace(trimmed) ? null : trimmed; + } +} diff --git a/src/Oms.Application/Services/Kb/KbCategoryService.cs b/src/Oms.Application/Services/Kb/KbCategoryService.cs new file mode 100644 index 0000000..d045c1c --- /dev/null +++ b/src/Oms.Application/Services/Kb/KbCategoryService.cs @@ -0,0 +1,104 @@ +using Fake.Application; +using Fake.Domain.Exceptions; +using Microsoft.AspNetCore.Mvc; +using Oms.Application.Dtos.Kb; +using Oms.Application.Dtos.Shared; +using Oms.Domain.KbAggregate.Entities; +using Oms.Domain.KbAggregate.Repositories; + +namespace Oms.Application.Services.Kb; + +/// +/// 知识库分类管理。 +/// +[ApiExplorerSettings(GroupName = "OMS-Kb")] +public class KbCategoryService : ApplicationService +{ + private IKbCategoryRepository CategoryRepository => + LazyServiceProvider.GetRequiredService(); + + private IKbArticleRepository ArticleRepository => + LazyServiceProvider.GetRequiredService(); + + [HttpGet("/kb/admin/category/list")] + public async Task> AdminListAsync([FromQuery] bool? enabledOnly = null) + { + var items = await CategoryRepository.GetListAsync(x => + !enabledOnly.HasValue || x.Enabled == enabledOnly.Value); + var ordered = items.OrderBy(x => x.SortOrder).ThenBy(x => x.Id).ToList(); + return new OmsItemsResult + { + Items = ordered.Select(Map).ToList() + }; + } + + [HttpPost("/kb/admin/category/save")] + public async Task SaveAsync(KbCategorySaveInput input) + { + var code = input.Code.Trim(); + var name = input.Name.Trim(); + if (string.IsNullOrWhiteSpace(code) || string.IsNullOrWhiteSpace(name)) + { + throw new BusinessException("分类编码 / 名称不能为空"); + } + + KbCategory? entity = null; + if (input.Id is > 0) + { + entity = await CategoryRepository.FirstOrDefaultAsync(x => x.Id == input.Id.Value); + if (entity == null) + { + throw new BusinessException("分类不存在"); + } + } + + if (await CategoryRepository.ExistsCodeAsync(code, entity?.Id)) + { + throw new BusinessException($"分类编码已存在:{code}"); + } + + entity ??= new KbCategory(); + entity.Code = code; + entity.Name = name; + entity.SortOrder = input.SortOrder; + entity.Enabled = input.Enabled; + + if (entity.Id > 0) + { + await CategoryRepository.UpdateAsync(entity); + } + else + { + await CategoryRepository.InsertAsync(entity); + } + + return Map(entity); + } + + [HttpPost("/kb/admin/category/delete")] + public async Task DeleteAsync(KbCategoryDeleteInput input) + { + var entity = await CategoryRepository.FirstOrDefaultAsync(x => x.Id == input.Id); + if (entity == null) + { + throw new BusinessException("分类不存在"); + } + + var articleCount = await ArticleRepository.CountByCategoryIdAsync(entity.Id); + if (articleCount > 0) + { + throw new BusinessException($"该分类下仍有 {articleCount} 篇文章,无法删除"); + } + + await CategoryRepository.DeleteAsync(entity); + } + + private static KbCategoryOutput Map(KbCategory x) => new() + { + Id = x.Id, + Code = x.Code, + Name = x.Name, + SortOrder = x.SortOrder, + Enabled = x.Enabled + }; +} diff --git a/src/Oms.Application/Services/Orders/OrderService.Workplace.cs b/src/Oms.Application/Services/Orders/OrderService.Workplace.cs index dbfa3b8..191ba62 100644 --- a/src/Oms.Application/Services/Orders/OrderService.Workplace.cs +++ b/src/Oms.Application/Services/Orders/OrderService.Workplace.cs @@ -132,6 +132,71 @@ public partial class OrderService }; } + /// + /// 工作台费用趋势(运费支出 vs 单票均价)。 + /// + [HttpGet("/order/workplace-spend-trend")] + public async Task GetWorkplaceSpendTrendAsync( + [FromQuery] string? dimension = "day", + [FromQuery] int count = 7) + { + var dim = (dimension ?? "day").Trim().ToLowerInvariant(); + if (dim is not ("day" or "week" or "month")) + { + dim = "day"; + } + + count = dim switch + { + "week" => Math.Clamp(count, 1, 12), + "month" => Math.Clamp(count, 1, 12), + _ => Math.Clamp(count, 1, 31), + }; + + var filter = await BuildScopedListFilterAsync(); + var buckets = BuildTrendBuckets(dim, count); + var points = new List(buckets.Count); + + foreach (var bucket in buckets) + { + var orderCount = await OrderRepository.CountByFilterAndCreateTimeRangeAsync( + filter, + bucket.Start, + bucket.EndExclusive); + var spendAmount = await OrderRepository.SumPayableAmountByCreateTimeRangeAsync( + filter, + bucket.Start, + bucket.EndExclusive); + spendAmount = Math.Round(spendAmount, 2); + var avg = orderCount > 0 + ? Math.Round(spendAmount / orderCount, 2) + : 0m; + + points.Add(new OrderWorkplaceSpendTrendPointOutput + { + Label = bucket.Label, + OrderCount = orderCount, + SpendAmount = spendAmount, + AvgAmount = avg, + }); + } + + var totalOrders = points.Sum(x => x.OrderCount); + var totalSpend = Math.Round(points.Sum(x => x.SpendAmount), 2); + var totalAvg = totalOrders > 0 + ? Math.Round(totalSpend / totalOrders, 2) + : 0m; + + return new OrderWorkplaceSpendTrendOutput + { + Dimension = dim, + TotalSpend = totalSpend, + TotalOrders = totalOrders, + AvgAmount = totalAvg, + Points = points, + }; + } + private async Task BuildScopedListFilterAsync() { var customerId = await OmsSessionHelper.ResolveListCustomerIdAsync( diff --git a/src/Oms.Domain/KbAggregate/Entities/KbArticle.cs b/src/Oms.Domain/KbAggregate/Entities/KbArticle.cs new file mode 100644 index 0000000..db35c04 --- /dev/null +++ b/src/Oms.Domain/KbAggregate/Entities/KbArticle.cs @@ -0,0 +1,51 @@ +using Oms.Domain.Shared; +using SqlSugar; + +namespace Oms.Domain.KbAggregate.Entities; + +/// +/// 知识库文章。 +/// +[SugarTable("t_kb_article")] +public class KbArticle : OmsAuditedAggregateRoot +{ + public int CategoryId { get; set; } + + [SugarColumn(Length = 200)] + public string Title { get; set; } = string.Empty; + + [SugarColumn(IsNullable = true, Length = 500)] + public string? Summary { get; set; } + + [SugarColumn(ColumnDataType = "longtext")] + public string Content { get; set; } = string.Empty; + + /// customer / internal + [SugarColumn(Length = 16)] + public string Audience { get; set; } = "customer"; + + /// draft / published / offline + [SugarColumn(Length = 16)] + public string Status { get; set; } = "draft"; + + [SugarColumn(IsNullable = true, Length = 256)] + public string? Tags { get; set; } + + [SugarColumn(IsNullable = true, Length = 256)] + public string? RelatedPath { get; set; } + + [SugarColumn(IsNullable = true, Length = 64)] + public string? RelatedLabel { get; set; } + + public bool IsPinned { get; set; } + + public int SortOrder { get; set; } + + [SugarColumn(IsNullable = true)] + public DateTime? PublishTime { get; set; } + + [SugarColumn(IsNullable = true)] + public DateTime? ExpireTime { get; set; } + + public int ViewCount { get; set; } +} diff --git a/src/Oms.Domain/KbAggregate/Entities/KbCategory.cs b/src/Oms.Domain/KbAggregate/Entities/KbCategory.cs new file mode 100644 index 0000000..d3b70cf --- /dev/null +++ b/src/Oms.Domain/KbAggregate/Entities/KbCategory.cs @@ -0,0 +1,21 @@ +using Oms.Domain.Shared; +using SqlSugar; + +namespace Oms.Domain.KbAggregate.Entities; + +/// +/// 知识库分类。 +/// +[SugarTable("t_kb_category")] +public class KbCategory : OmsAuditedAggregateRoot +{ + [SugarColumn(Length = 64)] + public string Code { get; set; } = string.Empty; + + [SugarColumn(Length = 128)] + public string Name { get; set; } = string.Empty; + + public int SortOrder { get; set; } + + public bool Enabled { get; set; } = true; +} diff --git a/src/Oms.Domain/KbAggregate/Repositories/IKbArticleRepository.cs b/src/Oms.Domain/KbAggregate/Repositories/IKbArticleRepository.cs new file mode 100644 index 0000000..e579079 --- /dev/null +++ b/src/Oms.Domain/KbAggregate/Repositories/IKbArticleRepository.cs @@ -0,0 +1,9 @@ +using Fake.Domain.Repositories; +using Oms.Domain.KbAggregate.Entities; + +namespace Oms.Domain.KbAggregate.Repositories; + +public interface IKbArticleRepository : IRepository +{ + Task CountByCategoryIdAsync(int categoryId, CancellationToken cancellationToken = default); +} diff --git a/src/Oms.Domain/KbAggregate/Repositories/IKbCategoryRepository.cs b/src/Oms.Domain/KbAggregate/Repositories/IKbCategoryRepository.cs new file mode 100644 index 0000000..4fe86d9 --- /dev/null +++ b/src/Oms.Domain/KbAggregate/Repositories/IKbCategoryRepository.cs @@ -0,0 +1,11 @@ +using Fake.Domain.Repositories; +using Oms.Domain.KbAggregate.Entities; + +namespace Oms.Domain.KbAggregate.Repositories; + +public interface IKbCategoryRepository : IRepository +{ + Task GetByCodeAsync(string code, CancellationToken cancellationToken = default); + + Task ExistsCodeAsync(string code, int? excludeId = null, CancellationToken cancellationToken = default); +} diff --git a/src/Oms.Domain/OrdersAggregate/Repositories/IOrderRepository.cs b/src/Oms.Domain/OrdersAggregate/Repositories/IOrderRepository.cs index f720371..ceb1c11 100644 --- a/src/Oms.Domain/OrdersAggregate/Repositories/IOrderRepository.cs +++ b/src/Oms.Domain/OrdersAggregate/Repositories/IOrderRepository.cs @@ -98,6 +98,15 @@ public interface IOrderRepository : IRepository DateTime endExclusive, CancellationToken cancellationToken = default); + /// + /// 按创建时间汇总客户应付金额(Order.PayableAmount,营销后净额)。 + /// + Task SumPayableAmountByCreateTimeRangeAsync( + OrderListFilter filter, + DateTime startInclusive, + DateTime endExclusive, + CancellationToken cancellationToken = default); + Task SumFeeAmountByPayTimeRangeAsync( OrderListFilter filter, string feeType, diff --git a/src/Oms.SqlSugarCore/Kb/KbArticleRepository.cs b/src/Oms.SqlSugarCore/Kb/KbArticleRepository.cs new file mode 100644 index 0000000..3cd9697 --- /dev/null +++ b/src/Oms.SqlSugarCore/Kb/KbArticleRepository.cs @@ -0,0 +1,13 @@ +using Fake.DependencyInjection; +using Oms.Domain.KbAggregate.Entities; +using Oms.Domain.KbAggregate.Repositories; +using Oms.SqlSugarCore.Repositories; + +namespace Oms.SqlSugarCore.Kb; + +public class KbArticleRepository + : OmsSqlSugarRepository, IKbArticleRepository, IScopedDependency +{ + public Task CountByCategoryIdAsync(int categoryId, CancellationToken cancellationToken = default) => + CountAsync(x => x.CategoryId == categoryId, cancellationToken); +} diff --git a/src/Oms.SqlSugarCore/Kb/KbCategoryRepository.cs b/src/Oms.SqlSugarCore/Kb/KbCategoryRepository.cs new file mode 100644 index 0000000..f6fdc90 --- /dev/null +++ b/src/Oms.SqlSugarCore/Kb/KbCategoryRepository.cs @@ -0,0 +1,26 @@ +using Fake.DependencyInjection; +using Oms.Domain.KbAggregate.Entities; +using Oms.Domain.KbAggregate.Repositories; +using Oms.SqlSugarCore.Repositories; + +namespace Oms.SqlSugarCore.Kb; + +public class KbCategoryRepository + : OmsSqlSugarRepository, IKbCategoryRepository, IScopedDependency +{ + public Task GetByCodeAsync(string code, CancellationToken cancellationToken = default) => + FirstOrDefaultAsync(x => x.Code == code, cancellationToken); + + public async Task ExistsCodeAsync( + string code, + int? excludeId = null, + CancellationToken cancellationToken = default) + { + if (excludeId is > 0) + { + return await AnyAsync(x => x.Code == code && x.Id != excludeId.Value, cancellationToken); + } + + return await AnyAsync(x => x.Code == code, cancellationToken); + } +} diff --git a/src/Oms.SqlSugarCore/Orders/OrderRepository.cs b/src/Oms.SqlSugarCore/Orders/OrderRepository.cs index d0279ae..8c7388a 100644 --- a/src/Oms.SqlSugarCore/Orders/OrderRepository.cs +++ b/src/Oms.SqlSugarCore/Orders/OrderRepository.cs @@ -1074,6 +1074,21 @@ public class OrderRepository : OmsSqlSugarRepository, IOrderRepository, I return await query.CountAsync(cancellationToken); } + public async Task SumPayableAmountByCreateTimeRangeAsync( + OrderListFilter filter, + DateTime startInclusive, + DateTime endExclusive, + CancellationToken cancellationToken = default) + { + var db = await GetDbContextAsync(cancellationToken); + var query = ApplyListFilter(db.Queryable(), filter) + .Where(x => x.CreateTime >= startInclusive && x.CreateTime < endExclusive) + .Where(x => x.OrderStatus != OrderStatusCodes.Deleted) + .Where(x => x.PayableAmount != null); + + return await query.SumAsync(x => x.PayableAmount!.Value); + } + public async Task SumFeeAmountByPayTimeRangeAsync( OrderListFilter filter, string feeType, diff --git a/src/SimpleAdmin.Api/Auth/XhsRolePermissionSeedContributor.cs b/src/SimpleAdmin.Api/Auth/XhsRolePermissionSeedContributor.cs index 8050ec1..10abc06 100644 --- a/src/SimpleAdmin.Api/Auth/XhsRolePermissionSeedContributor.cs +++ b/src/SimpleAdmin.Api/Auth/XhsRolePermissionSeedContributor.cs @@ -28,6 +28,15 @@ public class XhsRolePermissionSeedContributor(RoleService roleService) : IDataSe "System.AiSkill.Save" ]; + public static readonly string[] AdminKbPermissions = + [ + "Knowledge.Category.Query", + "Knowledge.Category.Edit", + "Knowledge.Article.Query", + "Knowledge.Article.Edit", + "Knowledge.Article.Publish" + ]; + public async Task SeedAsync() { var roles = await roleService.GetAllRolesAsync(); @@ -42,7 +51,7 @@ public class XhsRolePermissionSeedContributor(RoleService roleService) : IDataSe string.Equals(x.Code, AdminRoleCode, StringComparison.OrdinalIgnoreCase)); if (admin != null) { - await MergePermissionsAsync(admin.Id, CustomerPermissions); + await MergePermissionsAsync(admin.Id, CustomerPermissions.Concat(AdminKbPermissions)); } }