feat: 新增字典类型元数据表与类型备注 API

支持字典类型备注维护及左侧类型列表查询,创建字典项时自动补齐类型元数据。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zzh 2026-07-21 17:48:42 +08:00
parent dcf856568e
commit 93cf065165
10 changed files with 327 additions and 0 deletions

16
db.sql
View File

@ -57,6 +57,7 @@ DROP TABLE IF EXISTS `t_system_other_config`;
DROP TABLE IF EXISTS `t_warehouse_address`;
DROP TABLE IF EXISTS `t_notice_read`;
DROP TABLE IF EXISTS `t_notice`;
DROP TABLE IF EXISTS `t_dict_type`;
DROP TABLE IF EXISTS `t_dict`;
DROP TABLE IF EXISTS `t_country`;
DROP TABLE IF EXISTS `t_recharge_record`;
@ -1032,6 +1033,21 @@ CREATE TABLE `t_dict` (
KEY `idx_dict_type_sort` (`DictType`, `DictSort`, `Id`)
) COMMENT='系统字典表';
CREATE TABLE `t_dict_type` (
`Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键,自增,步长 1',
`DictType` VARCHAR(64) NOT NULL COMMENT '字典类型编码',
`Remark` VARCHAR(512) NULL COMMENT '字典类型备注',
`TypeSort` 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`),
UNIQUE KEY `uk_dict_type_code` (`DictType`)
) COMMENT='字典类型元数据表';
CREATE TABLE `t_system_other_config` (
`Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键,自增,步长 1',
`ConfigCode` VARCHAR(64) NOT NULL COMMENT '配置编码,如 track_service',

View File

@ -0,0 +1,23 @@
-- 已有库增量:字典类型元数据 t_dict_type(类型编码 + 备注)
-- 可重复执行(IF NOT EXISTS)
CREATE TABLE IF NOT EXISTS `t_dict_type` (
`Id` INT NOT NULL AUTO_INCREMENT COMMENT '主键,自增,步长 1',
`DictType` VARCHAR(64) NOT NULL COMMENT '字典类型编码',
`Remark` VARCHAR(512) NULL COMMENT '字典类型备注',
`TypeSort` 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`),
UNIQUE KEY `uk_dict_type_code` (`DictType`)
) COMMENT='字典类型元数据表';
-- 从现有字典项回填类型元数据(无备注)
INSERT IGNORE INTO `t_dict_type` (`DictType`, `Remark`, `TypeSort`, `CreateUserId`)
SELECT DISTINCT `DictType`, NULL, 0, 0
FROM `t_dict`
WHERE `IsDeleted` = 0 AND TRIM(`DictType`) <> '';

View File

@ -0,0 +1,32 @@
namespace Oms.Application.Dtos.System;
/// <summary>
/// 字典类型列表项。
/// </summary>
public class DictTypeOutput
{
/// <summary>
/// 元数据主键;仅存在于 t_dict_type 时有值。
/// </summary>
public int? Id { get; set; }
/// <summary>
/// 字典类型编码。
/// </summary>
public string DictType { get; set; } = string.Empty;
/// <summary>
/// 字典类型备注。
/// </summary>
public string? Remark { get; set; }
/// <summary>
/// 左侧菜单排序。
/// </summary>
public int TypeSort { get; set; }
/// <summary>
/// 该类型下字典项数量。
/// </summary>
public int ItemCount { get; set; }
}

View File

@ -0,0 +1,32 @@
using System.ComponentModel.DataAnnotations;
namespace Oms.Application.Dtos.System;
/// <summary>
/// 保存字典类型元数据入参(新增或更新备注)。
/// </summary>
public class DictTypeSaveInput
{
/// <summary>
/// 元数据主键;更新时传入。
/// </summary>
public int? Id { get; set; }
/// <summary>
/// 字典类型编码。
/// </summary>
[Required]
[StringLength(64)]
public string DictType { get; set; } = string.Empty;
/// <summary>
/// 字典类型备注。
/// </summary>
[StringLength(512)]
public string? Remark { get; set; }
/// <summary>
/// 左侧菜单排序。
/// </summary>
public int TypeSort { get; set; }
}

View File

@ -17,6 +17,8 @@ public class DictService : ApplicationService
{
private IDictRepository DictRepository => LazyServiceProvider.GetRequiredService<IDictRepository>();
private IDictTypeRepository DictTypeRepository => LazyServiceProvider.GetRequiredService<IDictTypeRepository>();
/// <summary>
/// 查询字典列表。
/// </summary>
@ -42,6 +44,138 @@ public class DictService : ApplicationService
};
}
/// <summary>
/// 查询字典类型列表(含备注与项数)。
/// </summary>
/// <param name="keyword">可选:按类型编码或备注模糊搜索。</param>
/// <returns>字典类型列表。</returns>
[HttpGet("/system/dict/type/list")]
public async Task<List<DictTypeOutput>> GetTypeListAsync([FromQuery] string? keyword)
{
var kw = keyword?.Trim();
var metaRows = await DictTypeRepository.GetListAsync();
var countRows = await DictRepository.GetItemCountByTypeAsync();
var countMap = countRows.ToDictionary(x => x.DictType, x => x.ItemCount, StringComparer.OrdinalIgnoreCase);
var merged = new Dictionary<string, DictTypeOutput>(StringComparer.OrdinalIgnoreCase);
foreach (var meta in metaRows)
{
var type = meta.DictType.Trim();
if (string.IsNullOrWhiteSpace(type))
{
continue;
}
merged[type] = new DictTypeOutput
{
Id = meta.Id,
DictType = type,
Remark = meta.Remark,
TypeSort = meta.TypeSort,
ItemCount = countMap.TryGetValue(type, out var count) ? count : 0
};
}
foreach (var (dictType, itemCount) in countRows)
{
var type = dictType.Trim();
if (string.IsNullOrWhiteSpace(type) || merged.ContainsKey(type))
{
continue;
}
merged[type] = new DictTypeOutput
{
DictType = type,
ItemCount = itemCount
};
}
IEnumerable<DictTypeOutput> result = merged.Values;
if (!string.IsNullOrWhiteSpace(kw))
{
result = result.Where(x =>
x.DictType.Contains(kw, StringComparison.OrdinalIgnoreCase) ||
(!string.IsNullOrWhiteSpace(x.Remark) && x.Remark.Contains(kw, StringComparison.OrdinalIgnoreCase)));
}
return result
.OrderBy(x => x.TypeSort)
.ThenBy(x => x.DictType, StringComparer.OrdinalIgnoreCase)
.ToList();
}
/// <summary>
/// 保存字典类型元数据(新增或更新备注)。
/// </summary>
/// <param name="input">入参:字典类型元数据。</param>
/// <returns>无返回体。</returns>
[HttpPost("/system/dict/type/save")]
public async Task SaveTypeAsync(DictTypeSaveInput input)
{
var dictType = input.DictType?.Trim() ?? string.Empty;
if (string.IsNullOrWhiteSpace(dictType))
{
throw new BusinessException("字典类型不能为空");
}
var remark = string.IsNullOrWhiteSpace(input.Remark) ? null : input.Remark.Trim();
DictTypeMeta? entity = null;
if (input.Id.HasValue && input.Id.Value > 0)
{
entity = await DictTypeRepository.FirstOrDefaultAsync(x => x.Id == input.Id.Value);
}
entity ??= await DictTypeRepository.GetByDictTypeAsync(dictType);
if (entity == null)
{
var existedType = await DictTypeRepository.GetByDictTypeAsync(dictType);
if (existedType != null)
{
throw new BusinessException("字典类型已存在");
}
await DictTypeRepository.InsertAsync(new DictTypeMeta
{
DictType = dictType,
Remark = remark,
TypeSort = input.TypeSort
});
return;
}
if (!string.Equals(entity.DictType, dictType, StringComparison.OrdinalIgnoreCase))
{
var duplicate = await DictTypeRepository.GetByDictTypeAsync(dictType);
if (duplicate != null && duplicate.Id != entity.Id)
{
throw new BusinessException("字典类型已存在");
}
}
entity.DictType = dictType;
entity.Remark = remark;
entity.TypeSort = input.TypeSort;
await DictTypeRepository.UpdateAsync(entity);
}
/// <summary>
/// 删除字典类型元数据(不删除字典项)。
/// </summary>
/// <param name="input">入参:目标主键。</param>
/// <returns>无返回体。</returns>
[HttpPost("/system/dict/type/delete")]
public async Task DeleteTypeAsync(IdInput input)
{
var entity = await DictTypeRepository.FirstOrDefaultAsync(x => x.Id == input.Id);
if (entity == null)
{
throw new BusinessException("字典类型不存在");
}
await DictTypeRepository.DeleteAsync(entity);
}
/// <summary>
/// 新增字典。
/// </summary>
@ -51,6 +185,7 @@ public class DictService : ApplicationService
public async Task CreateAsync(DictCreateInput input)
{
await ValidateDictInputAsync(input.DictType, input.DictCode, input.DictName, null);
await EnsureDictTypeMetaAsync(input.DictType.Trim());
await DictRepository.InsertAsync(new Dict
{
DictType = input.DictType.Trim(),
@ -70,6 +205,7 @@ public class DictService : ApplicationService
{
var entity = await GetDictOrThrowAsync(input.Id);
await ValidateDictInputAsync(input.DictType, input.DictCode, input.DictName, entity.Id);
await EnsureDictTypeMetaAsync(input.DictType.Trim());
entity.DictType = input.DictType.Trim();
entity.DictSort = input.DictSort;
entity.DictCode = input.DictCode.Trim();
@ -148,4 +284,24 @@ public class DictService : ApplicationService
UpdateTime = OmsDocHelper.NullIfDefault(entity.UpdateTime)
};
}
private async Task EnsureDictTypeMetaAsync(string dictType)
{
if (string.IsNullOrWhiteSpace(dictType))
{
return;
}
var existed = await DictTypeRepository.GetByDictTypeAsync(dictType);
if (existed != null)
{
return;
}
await DictTypeRepository.InsertAsync(new DictTypeMeta
{
DictType = dictType,
TypeSort = 0
});
}
}

View File

@ -0,0 +1,27 @@
using Oms.Domain.Shared;
using SqlSugar;
namespace Oms.Domain.SystemAggregate.Entities;
/// <summary>
/// 字典类型元数据(类型编码 + 备注)。
/// </summary>
[SugarTable("t_dict_type")]
public class DictTypeMeta : OmsAuditedAggregateRoot
{
/// <summary>
/// 字典类型编码,与 t_dict.DictType 对应。
/// </summary>
public string DictType { get; set; } = string.Empty;
/// <summary>
/// 字典类型备注说明。
/// </summary>
[SugarColumn(IsNullable = true, Length = 512)]
public string? Remark { get; set; }
/// <summary>
/// 左侧菜单排序,值越小越靠前。
/// </summary>
public int TypeSort { get; set; }
}

View File

@ -6,4 +6,6 @@ namespace Oms.Domain.SystemAggregate.Repositories;
public interface IDictRepository : IRepository<Dict>
{
Task<Dict?> GetByTypeAndCodeAsync(string dictType, string dictCode, CancellationToken cancellationToken = default);
Task<List<(string DictType, int ItemCount)>> GetItemCountByTypeAsync(CancellationToken cancellationToken = default);
}

View File

@ -0,0 +1,9 @@
using Fake.Domain.Repositories;
using Oms.Domain.SystemAggregate.Entities;
namespace Oms.Domain.SystemAggregate.Repositories;
public interface IDictTypeRepository : IRepository<DictTypeMeta>
{
Task<DictTypeMeta?> GetByDictTypeAsync(string dictType, CancellationToken cancellationToken = default);
}

View File

@ -2,6 +2,7 @@ using Fake.DependencyInjection;
using Oms.Domain.SystemAggregate.Entities;
using Oms.Domain.SystemAggregate.Repositories;
using Oms.SqlSugarCore.Repositories;
using SqlSugar;
namespace Oms.SqlSugarCore.System;
@ -11,4 +12,19 @@ public class DictRepository : OmsSqlSugarRepository<Dict>, IDictRepository, ISco
{
return FirstOrDefaultAsync(x => x.DictType == dictType && x.DictCode == dictCode, cancellationToken);
}
public async Task<List<(string DictType, int ItemCount)>> GetItemCountByTypeAsync(CancellationToken cancellationToken = default)
{
var db = await GetDbContextAsync(cancellationToken);
var rows = await db.Queryable<Dict>()
.GroupBy(x => x.DictType)
.Select(x => new
{
DictType = x.DictType,
ItemCount = SqlFunc.AggregateCount(x.Id)
})
.ToListAsync(cancellationToken);
return rows.Select(x => (x.DictType, x.ItemCount)).ToList();
}
}

View File

@ -0,0 +1,14 @@
using Fake.DependencyInjection;
using Oms.Domain.SystemAggregate.Entities;
using Oms.Domain.SystemAggregate.Repositories;
using Oms.SqlSugarCore.Repositories;
namespace Oms.SqlSugarCore.System;
public class DictTypeRepository : OmsSqlSugarRepository<DictTypeMeta>, IDictTypeRepository, IScopedDependency
{
public Task<DictTypeMeta?> GetByDictTypeAsync(string dictType, CancellationToken cancellationToken = default)
{
return FirstOrDefaultAsync(x => x.DictType == dictType, cancellationToken);
}
}