feat(channel): 新增问题模板 CRUD 与 options API

支持按组织与问题类型维护通知模板,含建表脚本与仓储层唯一性校验。
This commit is contained in:
zzh 2026-07-06 14:58:37 +08:00
parent cde0ccb6b0
commit d49573fe9a
12 changed files with 584 additions and 0 deletions

19
db.sql
View File

@ -25,6 +25,7 @@ DROP TABLE IF EXISTS `t_label_print_record`;
DROP TABLE IF EXISTS `t_outbound_record`;
DROP TABLE IF EXISTS `t_inbound_record`;
DROP TABLE IF EXISTS `t_label_template`;
DROP TABLE IF EXISTS `t_issue_template`;
DROP TABLE IF EXISTS `t_auto_trigger`;
DROP TABLE IF EXISTS `t_weight_rounding_info`;
DROP TABLE IF EXISTS `t_weight_rounding`;
@ -653,6 +654,24 @@ CREATE TABLE `t_auto_trigger` (
KEY `idx_at_operation` (`Operation`)
) COMMENT='自动触发表';
CREATE TABLE `t_issue_template` (
`Id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
`OrganizationId` CHAR(36) NULL COMMENT '归属组织 GUID;NULL=平台通用',
`IssueType` VARCHAR(32) NOT NULL COMMENT '问题类型 code',
`Title` VARCHAR(128) NOT NULL COMMENT '问题标题',
`Content` TEXT NOT NULL COMMENT '问题内容',
`NotifyMethod` VARCHAR(64) NOT NULL COMMENT '通知方式,逗号分隔',
`Status` TINYINT NOT NULL DEFAULT 1 COMMENT '0-禁用 1-启用',
`IsDeleted` TINYINT NOT NULL DEFAULT 0 COMMENT '软删除:0-否,1-是',
`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_issue_tpl_org_type` (`OrganizationId`, `IssueType`, `IsDeleted`),
KEY `idx_issue_tpl_status` (`Status`, `IsDeleted`)
) COMMENT='渠道-问题模板';
CREATE TABLE `t_label_template` (
`Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键,自增,步长 1',
`Name` VARCHAR(128) NOT NULL COMMENT '模板名称',

View File

@ -0,0 +1,18 @@
-- 渠道-问题模板
CREATE TABLE IF NOT EXISTS `t_issue_template` (
`Id` BIGINT NOT NULL AUTO_INCREMENT,
`OrganizationId` CHAR(36) NULL COMMENT '归属组织 GUID;NULL=平台通用',
`IssueType` VARCHAR(32) NOT NULL COMMENT '问题类型 code',
`Title` VARCHAR(128) NOT NULL COMMENT '问题标题',
`Content` TEXT NOT NULL COMMENT '问题内容',
`NotifyMethod` VARCHAR(64) NOT NULL COMMENT '通知方式,逗号分隔',
`Status` TINYINT NOT NULL DEFAULT 1 COMMENT '0-禁用 1-启用',
`IsDeleted` TINYINT NOT NULL DEFAULT 0,
`CreateUserId` INT NOT NULL,
`CreateTime` DATETIME NOT NULL,
`UpdateUserId` INT NULL,
`UpdateTime` DATETIME NULL,
PRIMARY KEY (`Id`),
KEY `idx_issue_tpl_org_type` (`OrganizationId`, `IssueType`, `IsDeleted`),
KEY `idx_issue_tpl_status` (`Status`, `IsDeleted`)
) COMMENT='渠道-问题模板';

View File

@ -0,0 +1,19 @@
namespace Oms.Application.Dtos.Channels;
/// <summary>
/// 问题模板列表查询入参。
/// </summary>
public class IssueTemplateListInput
{
public int Page { get; set; } = 1;
public int PageSize { get; set; } = 20;
public string? OrganizationId { get; set; }
public string? IssueType { get; set; }
public string? Keyword { get; set; }
public byte? Status { get; set; }
}

View File

@ -0,0 +1,15 @@
namespace Oms.Application.Dtos.Channels;
/// <summary>
/// 问题模板下拉选项。
/// </summary>
public class IssueTemplateOptionOutput
{
public int Id { get; set; }
public string IssueType { get; set; } = string.Empty;
public string? IssueTypeName { get; set; }
public string Title { get; set; } = string.Empty;
}

View File

@ -0,0 +1,31 @@
namespace Oms.Application.Dtos.Channels;
/// <summary>
/// 问题模板出参。
/// </summary>
public class IssueTemplateOutput
{
public int Id { get; set; }
public string? OrganizationId { get; set; }
public string? OrganizationName { get; set; }
public string IssueType { get; set; } = string.Empty;
public string? IssueTypeName { get; set; }
public string Title { get; set; } = string.Empty;
public string Content { get; set; } = string.Empty;
public string NotifyMethod { get; set; } = string.Empty;
public List<string> NotifyMethods { get; set; } = [];
public byte Status { get; set; }
public DateTime CreateTime { get; set; }
public DateTime? UpdateTime { get; set; }
}

View File

@ -0,0 +1,23 @@
using Oms.Domain.Shared.Enums;
namespace Oms.Application.Dtos.Channels;
/// <summary>
/// 问题模板保存入参。
/// </summary>
public class IssueTemplateSaveInput
{
public int? Id { get; set; }
public string? OrganizationId { get; set; }
public string? IssueType { get; set; }
public string? Title { get; set; }
public string? Content { get; set; }
public string? NotifyMethod { get; set; }
public EnableStatus Status { get; set; } = EnableStatus.Enabled;
}

View File

@ -0,0 +1,303 @@
using System.Linq.Expressions;
using Fake.Application;
using Microsoft.AspNetCore.Mvc;
using Oms.Application.DataPerm;
using Oms.Application.Dtos.Channels;
using Oms.Application.Dtos.Shared;
using Oms.Application.Shared;
using Oms.Domain.ChannelsAggregate.Consts;
using Oms.Domain.ChannelsAggregate.Entities;
using Oms.Domain.ChannelsAggregate.Repositories;
using Oms.Domain.Shared.Enums;
namespace Oms.Application.Services.Channels;
/// <summary>
/// 问题模板 API 服务。
/// </summary>
[ApiExplorerSettings(GroupName = "OMS-Channel")]
public class IssueTemplateService : ApplicationService
{
private IIssueTemplateRepository IssueTemplateRepository =>
LazyServiceProvider.GetRequiredService<IIssueTemplateRepository>();
private DataPermRepository DataPermRepository =>
LazyServiceProvider.GetRequiredService<DataPermRepository>();
[HttpGet("/channel/issue-template/list")]
public async Task<OmsPageResult<IssueTemplateOutput>> GetListAsync([FromQuery] IssueTemplateListInput input)
{
var predicate = BuildListPredicate(input);
var pageIndex = OmsDocHelper.NormalizePage(input.Page);
var pageSize = OmsDocHelper.NormalizePageSize(input.PageSize);
var entities = await IssueTemplateRepository.GetPagedListAsync(
predicate,
pageIndex,
pageSize,
new Dictionary<string, bool> { [nameof(IssueTemplate.UpdateTime)] = false, [nameof(IssueTemplate.Id)] = false });
var total = await IssueTemplateRepository.CountAsync(predicate);
var orgNameMap = await ResolveOrganizationNameMapAsync(entities);
var items = entities.Select(x => MapOutput(x, orgNameMap)).ToList();
return new OmsPageResult<IssueTemplateOutput> { Items = items, Total = total };
}
[HttpGet("/channel/issue-template/detail")]
public async Task<IssueTemplateOutput> GetDetailAsync([FromQuery] int id)
{
var entity = await GetEntityOrThrowAsync(id);
var orgNameMap = await ResolveOrganizationNameMapAsync([entity]);
return MapOutput(entity, orgNameMap);
}
[HttpPost("/channel/issue-template/create")]
public async Task CreateAsync(IssueTemplateSaveInput input)
{
var normalized = await NormalizeSaveInputAsync(input);
var entity = new IssueTemplate
{
OrganizationId = normalized.OrganizationId,
IssueType = normalized.IssueType,
Title = normalized.Title,
Content = normalized.Content,
NotifyMethod = normalized.NotifyMethod,
Status = normalized.Status
};
await IssueTemplateRepository.InsertAsync(entity);
}
[HttpPost("/channel/issue-template/update")]
public async Task UpdateAsync(IssueTemplateSaveInput input)
{
var entity = await GetEntityOrThrowAsync(input.Id);
var normalized = await NormalizeSaveInputAsync(input, input.Id);
entity.OrganizationId = normalized.OrganizationId;
entity.IssueType = normalized.IssueType;
entity.Title = normalized.Title;
entity.Content = normalized.Content;
entity.NotifyMethod = normalized.NotifyMethod;
entity.Status = normalized.Status;
await IssueTemplateRepository.UpdateAsync(entity);
}
[HttpPost("/channel/issue-template/delete")]
public async Task DeleteAsync(IdInput input)
{
var entity = await GetEntityOrThrowAsync(input.Id);
await IssueTemplateRepository.DeleteAsync(entity);
}
[HttpPost("/channel/issue-template/toggleStatus")]
public async Task ToggleStatusAsync(ToggleStatusInput input)
{
var entity = await GetEntityOrThrowAsync(input.Id);
entity.Status = input.Status;
await IssueTemplateRepository.UpdateAsync(entity);
}
[HttpGet("/channel/issue-template/options")]
public async Task<OmsItemsResult<IssueTemplateOptionOutput>> GetOptionsAsync(
[FromQuery] string? organizationId = null,
[FromQuery] string? issueType = null)
{
var normalizedOrg = NormalizeOrganizationId(organizationId);
var normalizedType = issueType?.Trim();
var entities = await IssueTemplateRepository.GetListAsync(x =>
x.Status == EnableStatus.Enabled
&& (string.IsNullOrWhiteSpace(normalizedType) || x.IssueType == normalizedType)
&& (normalizedOrg == null
|| x.OrganizationId == normalizedOrg
|| x.OrganizationId == null
|| x.OrganizationId == string.Empty));
var grouped = entities
.GroupBy(x => x.IssueType, StringComparer.OrdinalIgnoreCase)
.Select(group =>
{
var preferred = group.FirstOrDefault(x => x.OrganizationId == normalizedOrg)
?? group.FirstOrDefault(x => string.IsNullOrWhiteSpace(x.OrganizationId))
?? group.First();
return new IssueTemplateOptionOutput
{
Id = preferred.Id,
IssueType = preferred.IssueType,
IssueTypeName = IssueTypeCodes.GetDisplayName(preferred.IssueType),
Title = preferred.Title
};
})
.OrderBy(x => x.IssueType, StringComparer.OrdinalIgnoreCase)
.ToList();
return new OmsItemsResult<IssueTemplateOptionOutput> { Items = grouped };
}
private static Expression<Func<IssueTemplate, bool>> BuildListPredicate(IssueTemplateListInput input)
{
var organizationId = NormalizeOrganizationId(input.OrganizationId);
var issueType = input.IssueType?.Trim();
var keyword = input.Keyword?.Trim();
var status = input.Status;
return entity =>
(organizationId == null || entity.OrganizationId == organizationId)
&& (string.IsNullOrWhiteSpace(issueType) || entity.IssueType == issueType)
&& (status == null || (byte)entity.Status == status.Value)
&& (string.IsNullOrWhiteSpace(keyword)
|| entity.Title.Contains(keyword)
|| entity.Content.Contains(keyword));
}
private async Task<NormalizedIssueTemplateSaveInput> NormalizeSaveInputAsync(
IssueTemplateSaveInput input,
int? excludeId = null)
{
var issueType = input.IssueType?.Trim().ToLowerInvariant();
if (string.IsNullOrWhiteSpace(issueType) || !IssueTypeCodes.All.Contains(issueType))
{
throw new BusinessException("问题类型不合法");
}
var title = input.Title?.Trim();
if (string.IsNullOrWhiteSpace(title))
{
throw new BusinessException("问题标题不能为空");
}
if (title.Length > 128)
{
throw new BusinessException("问题标题长度不能超过128");
}
var content = input.Content?.Trim();
if (string.IsNullOrWhiteSpace(content))
{
throw new BusinessException("问题内容不能为空");
}
if (content.Length > 5000)
{
throw new BusinessException("问题内容长度不能超过5000");
}
var notifyMethod = IssueNotifyMethodCodes.Normalize(input.NotifyMethod);
if (string.IsNullOrWhiteSpace(notifyMethod))
{
throw new BusinessException("请至少选择一种通知方式");
}
var organizationId = NormalizeOrganizationId(input.OrganizationId);
if (!string.IsNullOrWhiteSpace(organizationId))
{
if (!Guid.TryParse(organizationId, out var orgGuid))
{
throw new BusinessException("组织ID格式不正确");
}
if (!await DataPermRepository.OrganizationExistsAndEnabledAsync(orgGuid))
{
throw new BusinessException("组织不存在");
}
}
if (await IssueTemplateRepository.ExistsDuplicateAsync(organizationId, issueType, excludeId))
{
throw new BusinessException("同一组织下该问题类型已存在模板");
}
return new NormalizedIssueTemplateSaveInput
{
OrganizationId = organizationId,
IssueType = issueType,
Title = title,
Content = content,
NotifyMethod = notifyMethod,
Status = input.Status
};
}
private static string? NormalizeOrganizationId(string? organizationId)
{
var normalized = organizationId?.Trim();
return string.IsNullOrWhiteSpace(normalized) ? null : normalized;
}
private async Task<IssueTemplate> GetEntityOrThrowAsync(int? id)
{
if (!id.HasValue || id.Value <= 0)
{
throw new BusinessException("问题模板不存在");
}
var entity = await IssueTemplateRepository.FirstOrDefaultAsync(x => x.Id == id.Value);
if (entity == null)
{
throw new BusinessException("问题模板不存在");
}
return entity;
}
private async Task<Dictionary<Guid, string>> ResolveOrganizationNameMapAsync(
IReadOnlyCollection<IssueTemplate> entities)
{
var orgIds = entities
.Select(x => x.OrganizationId)
.Where(x => !string.IsNullOrWhiteSpace(x))
.Select(x => Guid.TryParse(x, out var guid) ? guid : Guid.Empty)
.Where(x => x != Guid.Empty)
.Distinct()
.ToList();
return orgIds.Count == 0
? []
: await DataPermRepository.GetOrganizationNamesAsync(orgIds);
}
private static IssueTemplateOutput MapOutput(
IssueTemplate entity,
IReadOnlyDictionary<Guid, string> orgNameMap)
{
string? organizationName = null;
if (!string.IsNullOrWhiteSpace(entity.OrganizationId)
&& Guid.TryParse(entity.OrganizationId, out var orgGuid)
&& orgNameMap.TryGetValue(orgGuid, out var name))
{
organizationName = name;
}
else if (string.IsNullOrWhiteSpace(entity.OrganizationId))
{
organizationName = "平台通用";
}
return new IssueTemplateOutput
{
Id = entity.Id,
OrganizationId = entity.OrganizationId,
OrganizationName = organizationName,
IssueType = entity.IssueType,
IssueTypeName = IssueTypeCodes.GetDisplayName(entity.IssueType),
Title = entity.Title,
Content = entity.Content,
NotifyMethod = entity.NotifyMethod,
NotifyMethods = IssueNotifyMethodCodes.ParseList(entity.NotifyMethod),
Status = (byte)entity.Status,
CreateTime = entity.CreateTime,
UpdateTime = OmsDocHelper.NullIfDefault(entity.UpdateTime)
};
}
private sealed class NormalizedIssueTemplateSaveInput
{
public string? OrganizationId { get; set; }
public string IssueType { get; set; } = string.Empty;
public string Title { get; set; } = string.Empty;
public string Content { get; set; } = string.Empty;
public string NotifyMethod { get; set; } = string.Empty;
public EnableStatus Status { get; set; }
}
}

View File

@ -0,0 +1,57 @@
namespace Oms.Domain.ChannelsAggregate.Consts;
public static class IssueNotifyMethodCodes
{
public const string InApp = "inApp";
public const string Email = "email";
public const string Sms = "sms";
public const string Feishu = "feishu";
public const string Wechat = "wechat";
public static readonly HashSet<string> All =
[
InApp,
Email,
Sms,
Feishu,
Wechat
];
public static string GetDisplayName(string? code) => code?.Trim().ToLowerInvariant() switch
{
InApp => "站内信",
Email => "邮件",
Sms => "短信",
Feishu => "飞书",
Wechat => "微信",
_ => code ?? string.Empty
};
public static string Normalize(string? raw)
{
if (string.IsNullOrWhiteSpace(raw))
{
return string.Empty;
}
var parts = raw
.Split([',', ';', ' '], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(x => x.ToLowerInvariant())
.Where(x => All.Contains(x))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
return string.Join(",", parts);
}
public static List<string> ParseList(string? raw)
{
var normalized = Normalize(raw);
if (string.IsNullOrWhiteSpace(normalized))
{
return [];
}
return normalized.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
}
}

View File

@ -0,0 +1,38 @@
namespace Oms.Domain.ChannelsAggregate.Consts;
public static class IssueTypeCodes
{
public const string Address = "address";
public const string Customs = "customs";
public const string Weight = "weight";
public const string Document = "document";
public const string Cargo = "cargo";
public const string Channel = "channel";
public const string Payment = "payment";
public const string Other = "other";
public static readonly HashSet<string> All =
[
Address,
Customs,
Weight,
Document,
Cargo,
Channel,
Payment,
Other
];
public static string GetDisplayName(string? code) => code?.Trim().ToLowerInvariant() switch
{
Address => "地址异常",
Customs => "清关问题",
Weight => "重量异常",
Document => "单证不全",
Cargo => "货物异常",
Channel => "渠道异常",
Payment => "费用异常",
Other => "其他",
_ => code ?? string.Empty
};
}

View File

@ -0,0 +1,21 @@
using Oms.Domain.Shared;
using Oms.Domain.Shared.Enums;
using SqlSugar;
namespace Oms.Domain.ChannelsAggregate.Entities;
[SugarTable("t_issue_template")]
public class IssueTemplate : OmsAuditedAggregateRoot
{
public string? OrganizationId { get; set; }
public string IssueType { get; set; } = string.Empty;
public string Title { get; set; } = string.Empty;
public string Content { get; set; } = string.Empty;
public string NotifyMethod { get; set; } = string.Empty;
public EnableStatus Status { get; set; } = EnableStatus.Enabled;
}

View File

@ -0,0 +1,13 @@
using Fake.Domain.Repositories;
using Oms.Domain.ChannelsAggregate.Entities;
namespace Oms.Domain.ChannelsAggregate.Repositories;
public interface IIssueTemplateRepository : IRepository<IssueTemplate>
{
Task<bool> ExistsDuplicateAsync(
string? organizationId,
string issueType,
int? excludeId = null,
CancellationToken cancellationToken = default);
}

View File

@ -0,0 +1,27 @@
using Fake.DependencyInjection;
using Oms.Domain.ChannelsAggregate.Entities;
using Oms.Domain.ChannelsAggregate.Repositories;
using Oms.SqlSugarCore.Repositories;
namespace Oms.SqlSugarCore.Channels;
public class IssueTemplateRepository : OmsSqlSugarRepository<IssueTemplate>, IIssueTemplateRepository, IScopedDependency
{
public async Task<bool> ExistsDuplicateAsync(
string? organizationId,
string issueType,
int? excludeId = null,
CancellationToken cancellationToken = default)
{
var normalizedOrg = string.IsNullOrWhiteSpace(organizationId) ? null : organizationId.Trim();
var normalizedType = issueType.Trim();
return await AnyAsync(
x =>
x.IssueType == normalizedType
&& ((normalizedOrg == null && (x.OrganizationId == null || x.OrganizationId == string.Empty))
|| x.OrganizationId == normalizedOrg)
&& (!excludeId.HasValue || x.Id != excludeId.Value),
cancellationToken);
}
}