feat: add knowledge base M1 APIs and workplace spend trend

This commit is contained in:
zzh 2026-08-04 13:54:10 +08:00
parent b2355104cf
commit 9a33eb00a5
16 changed files with 1041 additions and 1 deletions

View File

@ -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
);

View File

@ -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)

View File

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

View File

@ -0,0 +1,58 @@
namespace Oms.Application.Dtos.Orders;
/// <summary>
/// 工作台费用趋势(按日/周/月)。
/// </summary>
public class OrderWorkplaceSpendTrendOutput
{
/// <summary>
/// 维度:day / week / month。
/// </summary>
public string Dimension { get; set; } = "day";
/// <summary>
/// 区间合计:运费支出(客户应付)。
/// </summary>
public decimal TotalSpend { get; set; }
/// <summary>
/// 区间合计:订单数。
/// </summary>
public int TotalOrders { get; set; }
/// <summary>
/// 区间合计:单票均价。
/// </summary>
public decimal AvgAmount { get; set; }
/// <summary>
/// 趋势点(按时间正序)。
/// </summary>
public List<OrderWorkplaceSpendTrendPointOutput> Points { get; set; } = [];
}
/// <summary>
/// 单个时间桶的费用汇总。
/// </summary>
public class OrderWorkplaceSpendTrendPointOutput
{
/// <summary>
/// 展示标签。
/// </summary>
public string Label { get; set; } = string.Empty;
/// <summary>
/// 订单数(创建时间)。
/// </summary>
public int OrderCount { get; set; }
/// <summary>
/// 运费支出合计(Order.PayableAmount)。
/// </summary>
public decimal SpendAmount { get; set; }
/// <summary>
/// 单票均价。
/// </summary>
public decimal AvgAmount { get; set; }
}

View File

@ -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;
/// <summary>
/// 知识库文章:管理端 + 客户门户。
/// </summary>
[ApiExplorerSettings(GroupName = "OMS-Kb")]
public class KbArticleService : ApplicationService
{
private IKbArticleRepository ArticleRepository =>
LazyServiceProvider.GetRequiredService<IKbArticleRepository>();
private IKbCategoryRepository CategoryRepository =>
LazyServiceProvider.GetRequiredService<IKbCategoryRepository>();
[HttpPost("/kb/admin/article/page")]
public async Task<OmsPageResult<KbArticleOutput>> 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<string, bool>
{
[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<KbArticleOutput>
{
Total = total,
Items = entities.Select(x => Map(x, categoryMap, input.IncludeContent)).ToList()
};
}
[HttpGet("/kb/admin/article/{id:int}")]
public async Task<KbArticleOutput> 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<KbArticleOutput> 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);
}
/// <summary>门户:启用分类列表(需客户登录)。</summary>
[HttpGet("/kb/portal/category/list")]
public async Task<OmsItemsResult<KbCategoryOutput>> 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<KbCategoryOutput>
{
Items = ordered.Select(x => new KbCategoryOutput
{
Id = x.Id,
Code = x.Code,
Name = x.Name,
SortOrder = x.SortOrder,
Enabled = x.Enabled
}).ToList()
};
}
/// <summary>门户:文章分页(需客户登录)。</summary>
[HttpPost("/kb/portal/article/page")]
public async Task<OmsPageResult<KbArticleOutput>> 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<string, bool>
{
[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<KbArticleOutput>
{
Total = total,
Items = entities.Select(x => Map(x, categoryMap, includeContent: false)).ToList()
};
}
/// <summary>门户:热门(需客户登录)。</summary>
[HttpGet("/kb/portal/article/hot")]
public async Task<OmsItemsResult<KbArticleOutput>> 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<KbArticleOutput>
{
Items = ordered.Select(x => Map(x, categoryMap, includeContent: false)).ToList()
};
}
/// <summary>门户:详情(需客户登录;浏览量 +1)。</summary>
[HttpGet("/kb/portal/article/{id:int}")]
public async Task<KbArticleOutput> 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<Func<KbArticle, bool>> 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<Func<KbArticle, bool>> 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<KbArticle> GetArticleOrThrowAsync(int id)
{
var entity = await ArticleRepository.FirstOrDefaultAsync(x => x.Id == id);
if (entity == null)
{
throw new BusinessException("文章不存在");
}
return entity;
}
private async Task<Dictionary<int, string>> LoadCategoryNameMapAsync(IEnumerable<int> ids)
{
var idList = ids.Where(x => x > 0).Distinct().ToList();
if (idList.Count == 0)
{
return new Dictionary<int, string>();
}
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<int, string> 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;
}
}

View File

@ -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;
/// <summary>
/// 知识库分类管理。
/// </summary>
[ApiExplorerSettings(GroupName = "OMS-Kb")]
public class KbCategoryService : ApplicationService
{
private IKbCategoryRepository CategoryRepository =>
LazyServiceProvider.GetRequiredService<IKbCategoryRepository>();
private IKbArticleRepository ArticleRepository =>
LazyServiceProvider.GetRequiredService<IKbArticleRepository>();
[HttpGet("/kb/admin/category/list")]
public async Task<OmsItemsResult<KbCategoryOutput>> 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<KbCategoryOutput>
{
Items = ordered.Select(Map).ToList()
};
}
[HttpPost("/kb/admin/category/save")]
public async Task<KbCategoryOutput> 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
};
}

View File

@ -132,6 +132,71 @@ public partial class OrderService
};
}
/// <summary>
/// 工作台费用趋势(运费支出 vs 单票均价)。
/// </summary>
[HttpGet("/order/workplace-spend-trend")]
public async Task<OrderWorkplaceSpendTrendOutput> 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<OrderWorkplaceSpendTrendPointOutput>(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<OrderListFilter> BuildScopedListFilterAsync()
{
var customerId = await OmsSessionHelper.ResolveListCustomerIdAsync(

View File

@ -0,0 +1,51 @@
using Oms.Domain.Shared;
using SqlSugar;
namespace Oms.Domain.KbAggregate.Entities;
/// <summary>
/// 知识库文章。
/// </summary>
[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;
/// <summary>customer / internal</summary>
[SugarColumn(Length = 16)]
public string Audience { get; set; } = "customer";
/// <summary>draft / published / offline</summary>
[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; }
}

View File

@ -0,0 +1,21 @@
using Oms.Domain.Shared;
using SqlSugar;
namespace Oms.Domain.KbAggregate.Entities;
/// <summary>
/// 知识库分类。
/// </summary>
[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;
}

View File

@ -0,0 +1,9 @@
using Fake.Domain.Repositories;
using Oms.Domain.KbAggregate.Entities;
namespace Oms.Domain.KbAggregate.Repositories;
public interface IKbArticleRepository : IRepository<KbArticle>
{
Task<int> CountByCategoryIdAsync(int categoryId, CancellationToken cancellationToken = default);
}

View File

@ -0,0 +1,11 @@
using Fake.Domain.Repositories;
using Oms.Domain.KbAggregate.Entities;
namespace Oms.Domain.KbAggregate.Repositories;
public interface IKbCategoryRepository : IRepository<KbCategory>
{
Task<KbCategory?> GetByCodeAsync(string code, CancellationToken cancellationToken = default);
Task<bool> ExistsCodeAsync(string code, int? excludeId = null, CancellationToken cancellationToken = default);
}

View File

@ -98,6 +98,15 @@ public interface IOrderRepository : IRepository<Order>
DateTime endExclusive,
CancellationToken cancellationToken = default);
/// <summary>
/// 按创建时间汇总客户应付金额(Order.PayableAmount,营销后净额)。
/// </summary>
Task<decimal> SumPayableAmountByCreateTimeRangeAsync(
OrderListFilter filter,
DateTime startInclusive,
DateTime endExclusive,
CancellationToken cancellationToken = default);
Task<decimal> SumFeeAmountByPayTimeRangeAsync(
OrderListFilter filter,
string feeType,

View File

@ -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<KbArticle>, IKbArticleRepository, IScopedDependency
{
public Task<int> CountByCategoryIdAsync(int categoryId, CancellationToken cancellationToken = default) =>
CountAsync(x => x.CategoryId == categoryId, cancellationToken);
}

View File

@ -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<KbCategory>, IKbCategoryRepository, IScopedDependency
{
public Task<KbCategory?> GetByCodeAsync(string code, CancellationToken cancellationToken = default) =>
FirstOrDefaultAsync(x => x.Code == code, cancellationToken);
public async Task<bool> 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);
}
}

View File

@ -1074,6 +1074,21 @@ public class OrderRepository : OmsSqlSugarRepository<Order>, IOrderRepository, I
return await query.CountAsync(cancellationToken);
}
public async Task<decimal> SumPayableAmountByCreateTimeRangeAsync(
OrderListFilter filter,
DateTime startInclusive,
DateTime endExclusive,
CancellationToken cancellationToken = default)
{
var db = await GetDbContextAsync(cancellationToken);
var query = ApplyListFilter(db.Queryable<Order>(), 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<decimal> SumFeeAmountByPayTimeRangeAsync(
OrderListFilter filter,
string feeType,

View File

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