diff --git a/IOT_API/Controllers/Asset/QrCodeController.cs b/IOT_API/Controllers/Asset/QrCodeController.cs
index f77f454..562d462 100644
--- a/IOT_API/Controllers/Asset/QrCodeController.cs
+++ b/IOT_API/Controllers/Asset/QrCodeController.cs
@@ -1,14 +1,86 @@
using Microsoft.AspNetCore.Mvc;
+using Model;
+using Model.Dto.Asset;
+using Service.Interface;
+using SqlSugar;
+using WebAPI.Filters;
namespace WebAPI.Controllers
{
///
- /// 二维码
+ /// 设备二维码生成与打印(资产侧;生成/绑定 RFID 需 device:edit,列表/图片/打印页 device:view 可访问)
+ /// 扫码直达:二维码内容 = http://host/asset/ledger/equipment?id={equipmentId}
///
[ApiController]
[Route("api/asset/qrcode")]
public class QrCodeController : ControllerBase
{
- // TODO: 实现 二维码 相关接口
+ private readonly IQrCodeService _qrService;
+
+ public QrCodeController(IQrCodeService qrService)
+ {
+ _qrService = qrService;
+ }
+
+ // baseUrl 由当前请求构造(scheme://host[:port]);如需固定域名,改为读 sys_param site_base_url
+ private string BaseUrl => $"{Request.Scheme}://{Request.Host}";
+
+ /// 生成二维码 URL(写入设备 QrCodeUrl 字段,不返回图片)
+ [HttpPost("generate/{equipmentId}")]
+ [RequirePermission("device:edit")]
+ public async Task Generate(long equipmentId)
+ {
+ return Ok(await _qrService.GenerateAsync(equipmentId, BaseUrl));
+ }
+
+ /// 生成二维码 PNG 图片(直接返回 image/png 二进制)
+ [HttpGet("image/{equipmentId}")]
+ [RequirePermission("device:view")]
+ public async Task GetImage(long equipmentId)
+ {
+ var result = await _qrService.GenerateImageAsync(equipmentId, BaseUrl);
+ if (!result.IsSuccess || result.Data == null)
+ return Ok(Result.Error(result.Msg));
+ return File(result.Data, "image/png");
+ }
+
+ /// 批量生成二维码 URL
+ [HttpPost("batch-generate")]
+ [RequirePermission("device:edit")]
+ public async Task BatchGenerate([FromBody] long[] equipmentIds)
+ {
+ return Ok(await _qrService.BatchGenerateAsync(equipmentIds, BaseUrl));
+ }
+
+ /// 设备二维码列表(分页,qrOnly=1 仅看已生成)
+ [HttpGet("list")]
+ [RequirePermission("device:view")]
+ public async Task GetList(int pageIndex = 1, int pageSize = 10, string? keyword = null, [FromQuery] bool qrOnly = false)
+ {
+ RefAsync total = 0;
+ var result = await _qrService.GetListPagedAsync(pageIndex, pageSize, total, keyword, qrOnly);
+ Response.Headers["X-Total-Count"] = total.Value.ToString();
+ return result.IsSuccess
+ ? Ok(Result>.Success(result.Data))
+ : Ok(Result>.Error(result.Msg));
+ }
+
+ /// 打印页(返回 text/html,浏览器打开后自动调起打印)
+ [HttpGet("print/{equipmentId}")]
+ [RequirePermission("device:view")]
+ public async Task Print(long equipmentId)
+ {
+ var result = await _qrService.GetPrintHtmlAsync(equipmentId, BaseUrl);
+ if (!result.IsSuccess) return Ok(result);
+ return Content(result.Data!, "text/html; charset=utf-8");
+ }
+
+ /// 绑定 RFID 编号到设备(query 传 rfidCode)
+ [HttpPost("bind-rfid/{equipmentId}")]
+ [RequirePermission("device:edit")]
+ public async Task BindRfid(long equipmentId, [FromQuery] string rfidCode)
+ {
+ return Ok(await _qrService.BindRfidAsync(equipmentId, rfidCode));
+ }
}
}
diff --git a/IOT_API/Controllers/System/DictController.cs b/IOT_API/Controllers/System/DictController.cs
index abae2dd..e57d4e4 100644
--- a/IOT_API/Controllers/System/DictController.cs
+++ b/IOT_API/Controllers/System/DictController.cs
@@ -1,14 +1,121 @@
using Microsoft.AspNetCore.Mvc;
+using Model;
+using Model.Dto.System;
+using Service.Interface;
+using SqlSugar;
+using WebAPI.Filters;
namespace WebAPI.Controllers
{
///
- /// 数据字典管理
+ /// 数据字典管理(管理侧需 system:manage 权限;业务侧 options 接口无权限供其它模块调用)
///
[ApiController]
[Route("api/system/dict")]
public class DictController : ControllerBase
{
- // TODO: 实现 数据字典管理 相关接口
+ private readonly IDictService _dictService;
+
+ public DictController(IDictService dictService)
+ {
+ _dictService = dictService;
+ }
+
+ // ==================== 字典分类 ====================
+
+ /// 字典分类列表(分页)
+ [HttpGet("type/list")]
+ [RequirePermission("system:manage")]
+ public async Task GetTypeList(int pageIndex = 1, int pageSize = 10, string? keyword = null)
+ {
+ RefAsync total = 0;
+ var result = await _dictService.GetTypesPagedAsync(pageIndex, pageSize, total, keyword);
+ Response.Headers["X-Total-Count"] = total.Value.ToString();
+ return result.IsSuccess
+ ? Ok(Result>.Success(result.Data))
+ : Ok(Result>.Error(result.Msg));
+ }
+
+ /// 字典分类下拉(全部启用的分类)
+ [HttpGet("type/options")]
+ public async Task GetTypeOptions()
+ {
+ return Ok(await _dictService.GetTypesAllAsync());
+ }
+
+ /// 字典分类详情
+ [HttpGet("type/{id}")]
+ [RequirePermission("system:manage")]
+ public async Task GetTypeById(long id)
+ {
+ return Ok(await _dictService.GetTypeByIdAsync(id));
+ }
+
+ /// 新增字典分类
+ [HttpPost("type")]
+ [RequirePermission("system:manage")]
+ public async Task AddType([FromBody] DictTypeDto dto)
+ {
+ return Ok(await _dictService.AddTypeAsync(dto));
+ }
+
+ /// 修改字典分类
+ [HttpPut("type")]
+ [RequirePermission("system:manage")]
+ public async Task UpdateType([FromBody] DictTypeDto dto)
+ {
+ return Ok(await _dictService.UpdateTypeAsync(dto));
+ }
+
+ /// 删除字典分类(分类下有字典项时禁止删除)
+ [HttpDelete("type/{id}")]
+ [RequirePermission("system:manage")]
+ public async Task DeleteType(long id)
+ {
+ return Ok(await _dictService.DeleteTypeAsync(id));
+ }
+
+ // ==================== 字典项 ====================
+
+ /// 查询指定分类下的字典项
+ [HttpGet("item/list/{typeId}")]
+ [RequirePermission("system:manage")]
+ public async Task GetItemsByType(long typeId)
+ {
+ return Ok(await _dictService.GetItemsByTypeAsync(typeId));
+ }
+
+ /// 新增字典项
+ [HttpPost("item")]
+ [RequirePermission("system:manage")]
+ public async Task AddItem([FromBody] DictItemDto dto)
+ {
+ return Ok(await _dictService.AddItemAsync(dto));
+ }
+
+ /// 修改字典项
+ [HttpPut("item")]
+ [RequirePermission("system:manage")]
+ public async Task UpdateItem([FromBody] DictItemDto dto)
+ {
+ return Ok(await _dictService.UpdateItemAsync(dto));
+ }
+
+ /// 删除字典项
+ [HttpDelete("item/{id}")]
+ [RequirePermission("system:manage")]
+ public async Task DeleteItem(long id)
+ {
+ return Ok(await _dictService.DeleteItemAsync(id));
+ }
+
+ // ==================== 业务侧(无权限,供其它模块通过字典编码查询) ====================
+
+ /// 按字典编码查询启用项(带缓存,业务侧调用)
+ [HttpGet("options/{typeCode}")]
+ public async Task GetOptions(string typeCode)
+ {
+ return Ok(await _dictService.GetOptionsByCodeAsync(typeCode));
+ }
}
}
diff --git a/IOT_API/Controllers/System/FileStorageController.cs b/IOT_API/Controllers/System/FileStorageController.cs
index 98c401b..3d3a9aa 100644
--- a/IOT_API/Controllers/System/FileStorageController.cs
+++ b/IOT_API/Controllers/System/FileStorageController.cs
@@ -1,14 +1,118 @@
+using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
+using Model;
+using Model.Dto.System;
+using Service.Interface;
+using SqlSugar;
+using WebAPI.Filters;
namespace WebAPI.Controllers
{
///
- /// 文件存储管理
+ /// 文件存储管理(存储配置 CRUD + 连接测试 + 文件上传 + 文件记录查询,需 system:manage 权限)
///
[ApiController]
[Route("api/system/file-storage")]
+ [RequirePermission("system:manage")]
public class FileStorageController : ControllerBase
{
- // TODO: 实现 文件存储管理 相关接口
+ private readonly IFileStorageService _storageService;
+
+ public FileStorageController(IFileStorageService storageService)
+ {
+ _storageService = storageService;
+ }
+
+ // ==================== 存储配置 ====================
+
+ /// 存储配置列表(分页)
+ [HttpGet("list")]
+ public async Task GetList(int pageIndex = 1, int pageSize = 10, string? keyword = null)
+ {
+ RefAsync total = 0;
+ var result = await _storageService.GetListPagedAsync(pageIndex, pageSize, total, keyword);
+ Response.Headers["X-Total-Count"] = total.Value.ToString();
+ return result.IsSuccess
+ ? Ok(Result>.Success(result.Data))
+ : Ok(Result>.Error(result.Msg));
+ }
+
+ /// 存储配置详情
+ [HttpGet("{id}")]
+ public async Task GetById(long id)
+ {
+ return Ok(await _storageService.GetByIdAsync(id));
+ }
+
+ /// 新增存储配置
+ [HttpPost]
+ public async Task Add([FromBody] FileStorageConfigDto dto)
+ {
+ return Ok(await _storageService.AddAsync(dto));
+ }
+
+ /// 修改存储配置(AccessKey/SecretKey 为 **** 掩码时保留原值)
+ [HttpPut]
+ public async Task Update([FromBody] FileStorageConfigDto dto)
+ {
+ return Ok(await _storageService.UpdateAsync(dto));
+ }
+
+ /// 删除存储配置(默认通道禁止删除)
+ [HttpDelete("{id}")]
+ public async Task Delete(long id)
+ {
+ return Ok(await _storageService.DeleteAsync(id));
+ }
+
+ /// 设为默认存储通道
+ [HttpPost("{id}/default")]
+ public async Task SetDefault(long id)
+ {
+ return Ok(await _storageService.SetDefaultAsync(id));
+ }
+
+ /// 测试连接(按配置调用对应 Provider 的 TestConnectionAsync)
+ [HttpPost("{id}/test")]
+ public async Task Test(long id)
+ {
+ return Ok(await _storageService.TestAsync(id));
+ }
+
+ // ==================== 文件上传/记录 ====================
+
+ /// 上传文件(multipart/form-data,字段名 file;使用默认存储通道)
+ /// 文件
+ /// 业务类型(equipment_attachment/qrcode/generic 等,可选)
+ /// 业务Id(可选)
+ /// 上传人(可选,默认取当前登录用户)
+ [HttpPost("upload")]
+ [RequestSizeLimit(100 * 1024 * 1024)]
+ public async Task Upload(IFormFile file, [FromForm] string? bizType = null, [FromForm] string? bizId = null, [FromForm] string? uploader = null)
+ {
+ if (file == null || file.Length == 0)
+ return Ok(Result.Error("请选择要上传的文件"));
+ using var stream = file.OpenReadStream();
+ return Ok(await _storageService.UploadAsync(stream, file.FileName, file.Length, bizType, bizId, uploader));
+ }
+
+ /// 文件记录列表(分页,可按关键字和业务类型过滤)
+ [HttpGet("file/list")]
+ public async Task GetFileList(int pageIndex = 1, int pageSize = 10, string? keyword = null, string? bizType = null)
+ {
+ RefAsync total = 0;
+ var result = await _storageService.GetFileListPagedAsync(pageIndex, pageSize, total, keyword, bizType);
+ Response.Headers["X-Total-Count"] = total.Value.ToString();
+ return result.IsSuccess
+ ? Ok(Result>.Success(result.Data))
+ : Ok(Result>.Error(result.Msg));
+ }
+
+ /// 文件记录详情
+ [HttpGet("file/{id}")]
+ public async Task GetFileById(long id)
+ {
+ return Ok(await _storageService.GetFileByIdAsync(id));
+ }
}
}
diff --git a/IOT_API/Controllers/System/SystemParamController.cs b/IOT_API/Controllers/System/SystemParamController.cs
index 8822fe8..0089fdf 100644
--- a/IOT_API/Controllers/System/SystemParamController.cs
+++ b/IOT_API/Controllers/System/SystemParamController.cs
@@ -1,14 +1,95 @@
using Microsoft.AspNetCore.Mvc;
+using Model;
+using Model.Dto.System;
+using Service.Interface;
+using SqlSugar;
+using WebAPI.Filters;
namespace WebAPI.Controllers
{
///
- /// 系统参数配置
+ /// 系统参数配置(管理侧需 system:manage 权限;业务侧 value/values 接口无权限供其它模块调用)
///
[ApiController]
[Route("api/system/param")]
public class SystemParamController : ControllerBase
{
- // TODO: 实现 系统参数配置 相关接口
+ private readonly ISystemParamService _paramService;
+
+ public SystemParamController(ISystemParamService paramService)
+ {
+ _paramService = paramService;
+ }
+
+ // ==================== 管理侧 ====================
+
+ /// 参数列表(分页,可按关键字和分组过滤)
+ [HttpGet("list")]
+ [RequirePermission("system:manage")]
+ public async Task GetList(int pageIndex = 1, int pageSize = 10, string? keyword = null, string? group = null)
+ {
+ RefAsync total = 0;
+ var result = await _paramService.GetListPagedAsync(pageIndex, pageSize, total, keyword, group);
+ Response.Headers["X-Total-Count"] = total.Value.ToString();
+ return result.IsSuccess
+ ? Ok(Result>.Success(result.Data))
+ : Ok(Result>.Error(result.Msg));
+ }
+
+ /// 参数详情
+ [HttpGet("{id}")]
+ [RequirePermission("system:manage")]
+ public async Task GetById(long id)
+ {
+ return Ok(await _paramService.GetByIdAsync(id));
+ }
+
+ /// 新增参数
+ [HttpPost]
+ [RequirePermission("system:manage")]
+ public async Task Add([FromBody] SystemParamDto dto)
+ {
+ return Ok(await _paramService.AddAsync(dto));
+ }
+
+ /// 修改参数(内置参数仅允许改 Value/Remark)
+ [HttpPut]
+ [RequirePermission("system:manage")]
+ public async Task Update([FromBody] SystemParamDto dto)
+ {
+ return Ok(await _paramService.UpdateAsync(dto));
+ }
+
+ /// 删除参数(内置参数禁止删除)
+ [HttpDelete("{id}")]
+ [RequirePermission("system:manage")]
+ public async Task Delete(long id)
+ {
+ return Ok(await _paramService.DeleteAsync(id));
+ }
+
+ /// 清空参数缓存(运维用)
+ [HttpPost("cache/reload")]
+ [RequirePermission("system:manage")]
+ public async Task ReloadCache()
+ {
+ return Ok(await _paramService.ReloadCacheAsync());
+ }
+
+ // ==================== 业务侧(无权限,供其它模块按 Key 取值) ====================
+
+ /// 按 Key 取单个值
+ [HttpGet("value/{key}")]
+ public async Task GetValue(string key)
+ {
+ return Ok(await _paramService.GetValueAsync(key));
+ }
+
+ /// 按 Key 列表批量取值(query: ?keys=k1&k2&k3)
+ [HttpGet("values")]
+ public async Task GetValues([FromQuery] string[] keys)
+ {
+ return Ok(await _paramService.GetValuesAsync(keys));
+ }
}
}
diff --git a/IOT_API/DependencyInjection.cs b/IOT_API/DependencyInjection.cs
index b1bd0d9..7caa24b 100644
--- a/IOT_API/DependencyInjection.cs
+++ b/IOT_API/DependencyInjection.cs
@@ -54,6 +54,10 @@ namespace WebAPI
// 泛型基础服务(IBaseService<> -> BaseService<>)单独注册
services.AddScoped(typeof(Service.Interface.IBaseService<>), typeof(Service.Implement.BaseService<>));
+ // 文件存储 Provider:命名约定 XxxFileStorageProvider 不匹配自动注册,手动登记
+ // 新增 MinIO/OSS 实现时在此追加一行 services.AddScoped()
+ services.AddScoped();
+
return services;
}
diff --git a/IOT_API/Program.cs b/IOT_API/Program.cs
index 471a535..5b2d8c9 100644
--- a/IOT_API/Program.cs
+++ b/IOT_API/Program.cs
@@ -59,6 +59,9 @@ namespace WebAPI
// 自动注册业务服务(Service.Interface -> Service.Implement)
builder.Services.AddBusinessServices();
+ // 内存缓存:数据字典等查询频繁的基础数据按 typeCode 缓存,增删改时失效
+ builder.Services.AddMemoryCache();
+
// 当前用户上下文(从 JWT Claims 解析,供业务服务取操作人/做权限判断)
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped();
diff --git a/Model/Dto/Asset/EquipmentDto.cs b/Model/Dto/Asset/EquipmentDto.cs
index ee1adcb..88117fa 100644
--- a/Model/Dto/Asset/EquipmentDto.cs
+++ b/Model/Dto/Asset/EquipmentDto.cs
@@ -49,6 +49,12 @@ namespace Model.Dto.Asset
/// 二维码编号(一物一码)
public string? QrCode { get; set; }
+ /// 二维码扫码直达 URL(生成后写入,扫码跳转设备台账详情)
+ public string? QrCodeUrl { get; set; }
+
+ /// RFID 编号(绑定时写入)
+ public string? RfidCode { get; set; }
+
/// 设备描述
public string? Description { get; set; }
diff --git a/Model/Dto/Asset/QrCodeDto.cs b/Model/Dto/Asset/QrCodeDto.cs
new file mode 100644
index 0000000..3f7d2e5
--- /dev/null
+++ b/Model/Dto/Asset/QrCodeDto.cs
@@ -0,0 +1,38 @@
+namespace Model.Dto.Asset
+{
+ ///
+ /// 设备二维码信息(生成/列表/详情共用)
+ ///
+ public class QrCodeDto
+ {
+ /// 设备 Id(long → string)
+ public string EquipmentId { get; set; }
+ /// 设备编号
+ public string? EquipmentCode { get; set; }
+ /// 设备名称
+ public string? EquipmentName { get; set; }
+ /// 存放位置(打印标签用)
+ public string? Location { get; set; }
+ /// 二维码编号(一物一码,人工可读)
+ public string? QrCode { get; set; }
+ /// 扫码直达 URL(生成后写入;为空表示尚未生成)
+ public string? QrCodeUrl { get; set; }
+ /// RFID 编号(已绑定则有值)
+ public string? RfidCode { get; set; }
+ /// 是否已生成二维码(QrCodeUrl 非空)
+ public bool HasGenerated => !string.IsNullOrWhiteSpace(QrCodeUrl);
+ }
+
+ ///
+ /// 批量生成结果
+ ///
+ public class QrCodeBatchResultDto
+ {
+ public string EquipmentId { get; set; }
+ public string? EquipmentCode { get; set; }
+ public string? EquipmentName { get; set; }
+ public string? QrCodeUrl { get; set; }
+ public bool Success { get; set; }
+ public string? Message { get; set; }
+ }
+}
diff --git a/Model/Dto/System/DictDto.cs b/Model/Dto/System/DictDto.cs
new file mode 100644
index 0000000..3642862
--- /dev/null
+++ b/Model/Dto/System/DictDto.cs
@@ -0,0 +1,44 @@
+namespace Model.Dto.System
+{
+ ///
+ /// 数据字典分类 DTO
+ ///
+ public class DictTypeDto
+ {
+ public string Id { get; set; }
+ public string Code { get; set; }
+ public string Name { get; set; }
+ public byte Status { get; set; }
+ public int Sort { get; set; }
+ public string? Remark { get; set; }
+ public DateTime? CreateTime { get; set; }
+ /// 该分类下的字典项数量(列表展示用,由 Service 填充)
+ public int ItemCount { get; set; }
+ }
+
+ ///
+ /// 数据字典项 DTO
+ ///
+ public class DictItemDto
+ {
+ public string Id { get; set; }
+ public string TypeId { get; set; }
+ public string Code { get; set; }
+ public string Name { get; set; }
+ public int Sort { get; set; }
+ public byte Status { get; set; }
+ public byte IsDefault { get; set; }
+ public string? Remark { get; set; }
+ public DateTime? CreateTime { get; set; }
+ }
+
+ ///
+ /// 字典项下拉选项(业务侧通过 typeCode 查询用:仅返回编码+文本+是否默认)
+ ///
+ public class DictOptionDto
+ {
+ public string Code { get; set; }
+ public string Name { get; set; }
+ public bool IsDefault { get; set; }
+ }
+}
diff --git a/Model/Dto/System/FileStorageDto.cs b/Model/Dto/System/FileStorageDto.cs
new file mode 100644
index 0000000..2dab323
--- /dev/null
+++ b/Model/Dto/System/FileStorageDto.cs
@@ -0,0 +1,60 @@
+namespace Model.Dto.System
+{
+ ///
+ /// 文件存储配置 DTO
+ ///
+ public class FileStorageConfigDto
+ {
+ public string Id { get; set; }
+ public string Provider { get; set; }
+ public string Name { get; set; }
+ public string? Endpoint { get; set; }
+ /// 回显时掩码(仅显示前后各 2 字符);写入时若为掩码样式则保留原值不变
+ public string? AccessKey { get; set; }
+ public string? SecretKey { get; set; }
+ public string? Bucket { get; set; }
+ public string? Region { get; set; }
+ public string? BasePath { get; set; }
+ public int MaxSizeMB { get; set; }
+ public string? AllowedExts { get; set; }
+ public byte IsDefault { get; set; }
+ public byte Status { get; set; }
+ public string? Remark { get; set; }
+ public DateTime? CreateTime { get; set; }
+ }
+
+ ///
+ /// 文件上传记录 DTO
+ ///
+ public class FileRecordDto
+ {
+ public string Id { get; set; }
+ public string FileName { get; set; }
+ public string? OriginalName { get; set; }
+ public string? Url { get; set; }
+ public long Size { get; set; }
+ public string? Ext { get; set; }
+ public string? Provider { get; set; }
+ public string StorageId { get; set; }
+ public string? Uploader { get; set; }
+ public string? BizType { get; set; }
+ public string? BizId { get; set; }
+ public DateTime? CreateTime { get; set; }
+ }
+
+ ///
+ /// 上传结果(Provider 返回,Service 写入 sys_file_record 时用)
+ ///
+ public class FileUploadResultDto
+ {
+ /// 存储后的相对文件名(含路径,如 2026/09/18/snowflake.jpg)
+ public string FileName { get; set; }
+ /// 访问 URL
+ public string Url { get; set; }
+ /// 扩展名
+ public string? Ext { get; set; }
+ /// 文件大小(字节)
+ public long Size { get; set; }
+ public string Provider { get; set; }
+ }
+}
diff --git a/Model/Dto/System/SystemParamDto.cs b/Model/Dto/System/SystemParamDto.cs
new file mode 100644
index 0000000..d54f01f
--- /dev/null
+++ b/Model/Dto/System/SystemParamDto.cs
@@ -0,0 +1,35 @@
+namespace Model.Dto.System
+{
+ ///
+ /// 系统参数 DTO
+ ///
+ public class SystemParamDto
+ {
+ public string Id { get; set; }
+ public string ParamKey { get; set; }
+ public string ParamName { get; set; }
+ public string? ParamValue { get; set; }
+ /// 值类型(0=int, 1=text, 2=enum, 3=json)
+ public byte ParamType { get; set; }
+ /// 枚举选项(原始 JSON 字符串,由前端解析;仅 ParamType=enum 有值)
+ public string? ParamOptions { get; set; }
+ public string? Unit { get; set; }
+ public string? Group { get; set; }
+ public int Sort { get; set; }
+ public string? Remark { get; set; }
+ /// 是否系统内置(1=不可删,仅允许改 Value)
+ public byte IsSystem { get; set; }
+ public string? LastUpdateUser { get; set; }
+ public DateTime? LastUpdateTime { get; set; }
+ public DateTime? CreateTime { get; set; }
+ }
+
+ ///
+ /// 业务侧批量取值返回项
+ ///
+ public class SystemParamValueDto
+ {
+ public string Key { get; set; }
+ public string? Value { get; set; }
+ }
+}
diff --git a/Model/Entity/Asset/EquipmentEntity.cs b/Model/Entity/Asset/EquipmentEntity.cs
index c54eff4..c6bcddc 100644
--- a/Model/Entity/Asset/EquipmentEntity.cs
+++ b/Model/Entity/Asset/EquipmentEntity.cs
@@ -1,4 +1,4 @@
-using SqlSugar;
+using SqlSugar;
namespace Model.Entity.Asset
{
@@ -73,6 +73,19 @@ namespace Model.Entity.Asset
[SugarColumn(Length = 50, IsNullable = true)]
public string? QrCode { get; set; }
+ ///
+ /// 二维码扫码直达 URL(生成二维码时写入,扫码后跳转设备台账详情)
+ /// 形如 http://host/asset/ledger/equipment?id={Id};为空表示尚未生成二维码
+ ///
+ [SugarColumn(Length = 500, IsNullable = true)]
+ public string? QrCodeUrl { get; set; }
+
+ ///
+ /// RFID 编号(绑定时写入,用于 RFID 标签识别设备)
+ ///
+ [SugarColumn(Length = 64, IsNullable = true)]
+ public string? RfidCode { get; set; }
+
///
/// 设备描述
///
diff --git a/Model/Entity/System/DictItemEntity.cs b/Model/Entity/System/DictItemEntity.cs
new file mode 100644
index 0000000..843e951
--- /dev/null
+++ b/Model/Entity/System/DictItemEntity.cs
@@ -0,0 +1,32 @@
+using SqlSugar;
+
+namespace Model.Entity.System
+{
+ ///
+ /// 数据字典项表(某分类下的具体枚举值)
+ ///
+ [SugarTable("sys_dict_item")]
+ public class DictItemEntity : BaseEntity
+ {
+ [SugarColumn(ColumnName = "TypeId", ColumnDescription = "所属字典分类Id")]
+ public long TypeId { get; set; }
+
+ [SugarColumn(ColumnName = "Code", ColumnDescription = "字典项编码", Length = 64, IsNullable = false)]
+ public string Code { get; set; }
+
+ [SugarColumn(ColumnName = "Name", ColumnDescription = "字典项文本", Length = 100, IsNullable = false)]
+ public string Name { get; set; }
+
+ [SugarColumn(ColumnName = "Sort", ColumnDescription = "排序号", DefaultValue = "0")]
+ public int Sort { get; set; }
+
+ [SugarColumn(ColumnName = "Status", ColumnDescription = "状态(1=启用,0=停用)", ColumnDataType = "smallint", DefaultValue = "1")]
+ public byte Status { get; set; }
+
+ [SugarColumn(ColumnName = "IsDefault", ColumnDescription = "是否默认选中(同分类下唯一)", ColumnDataType = "smallint", DefaultValue = "0")]
+ public byte IsDefault { get; set; }
+
+ [SugarColumn(ColumnName = "Remark", ColumnDescription = "备注", Length = 500, IsNullable = true)]
+ public string? Remark { get; set; }
+ }
+}
diff --git a/Model/Entity/System/DictTypeEntity.cs b/Model/Entity/System/DictTypeEntity.cs
new file mode 100644
index 0000000..fe0897b
--- /dev/null
+++ b/Model/Entity/System/DictTypeEntity.cs
@@ -0,0 +1,26 @@
+using SqlSugar;
+
+namespace Model.Entity.System
+{
+ ///
+ /// 数据字典分类表(如:设备类型、故障类型、告警级别、工单优先级)
+ ///
+ [SugarTable("sys_dict_type")]
+ public class DictTypeEntity : BaseEntity
+ {
+ [SugarColumn(ColumnName = "Code", ColumnDescription = "字典编码(唯一)", Length = 64, IsNullable = false)]
+ public string Code { get; set; }
+
+ [SugarColumn(ColumnName = "Name", ColumnDescription = "字典名称", Length = 100, IsNullable = false)]
+ public string Name { get; set; }
+
+ [SugarColumn(ColumnName = "Status", ColumnDescription = "状态(1=启用,0=停用)", ColumnDataType = "smallint", DefaultValue = "1")]
+ public byte Status { get; set; }
+
+ [SugarColumn(ColumnName = "Sort", ColumnDescription = "排序号", DefaultValue = "0")]
+ public int Sort { get; set; }
+
+ [SugarColumn(ColumnName = "Remark", ColumnDescription = "备注", Length = 500, IsNullable = true)]
+ public string? Remark { get; set; }
+ }
+}
diff --git a/Model/Entity/System/FileRecordEntity.cs b/Model/Entity/System/FileRecordEntity.cs
new file mode 100644
index 0000000..9a1d321
--- /dev/null
+++ b/Model/Entity/System/FileRecordEntity.cs
@@ -0,0 +1,50 @@
+using SqlSugar;
+
+namespace Model.Entity.System
+{
+ ///
+ /// 文件上传记录表(每次上传都登记:来源 Provider、业务类型、访问 URL)
+ ///
+ [SugarTable("sys_file_record")]
+ public class FileRecordEntity : BaseEntity
+ {
+ /// 存储后的文件名(含相对路径,如 2026/09/18/snowflake.jpg)
+ [SugarColumn(ColumnName = "FileName", ColumnDescription = "存储文件名(含相对路径)", Length = 255, IsNullable = false)]
+ public string FileName { get; set; }
+
+ /// 原始文件名(用户上传时的文件名)
+ [SugarColumn(ColumnName = "OriginalName", ColumnDescription = "原始文件名", Length = 255, IsNullable = true)]
+ public string? OriginalName { get; set; }
+
+ /// 访问 URL(Endpoint + 相对路径)
+ [SugarColumn(ColumnName = "Url", ColumnDescription = "访问URL", Length = 500, IsNullable = true)]
+ public string? Url { get; set; }
+
+ /// 文件大小(字节)
+ [SugarColumn(ColumnName = "Size", ColumnDescription = "文件大小(字节)")]
+ public long Size { get; set; }
+
+ /// 扩展名(含点,如 .jpg)
+ [SugarColumn(ColumnName = "Ext", ColumnDescription = "扩展名", Length = 20, IsNullable = true)]
+ public string? Ext { get; set; }
+
+ /// 使用的存储 Provider(冗余,便于排查)
+ [SugarColumn(ColumnName = "Provider", ColumnDescription = "存储Provider", Length = 20, IsNullable = true)]
+ public string? Provider { get; set; }
+
+ /// 使用的存储配置Id(关联 sys_file_storage.Id)
+ public long StorageId { get; set; }
+
+ /// 上传人
+ [SugarColumn(ColumnName = "Uploader", ColumnDescription = "上传人", Length = 50, IsNullable = true)]
+ public string? Uploader { get; set; }
+
+ /// 业务类型(equipment_attachment/qrcode/generic 等,便于按业务查询)
+ [SugarColumn(ColumnName = "BizType", ColumnDescription = "业务类型", Length = 50, IsNullable = true)]
+ public string? BizType { get; set; }
+
+ /// 业务Id(关联业务实体的 Id,可空)
+ [SugarColumn(ColumnName = "BizId", ColumnDescription = "业务Id", Length = 64, IsNullable = true)]
+ public string? BizId { get; set; }
+ }
+}
diff --git a/Model/Entity/System/FileStorageConfigEntity.cs b/Model/Entity/System/FileStorageConfigEntity.cs
new file mode 100644
index 0000000..57e36a3
--- /dev/null
+++ b/Model/Entity/System/FileStorageConfigEntity.cs
@@ -0,0 +1,64 @@
+using SqlSugar;
+
+namespace Model.Entity.System
+{
+ ///
+ /// 文件存储配置表(多 Provider 配置:Local/MinIO/OSS,仅 Local 落地,其它留扩展点)
+ /// 一个 IsDefault=1 的配置为默认上传通道;AccessKey/SecretKey 对 Local 为空
+ ///
+ [SugarTable("sys_file_storage")]
+ public class FileStorageConfigEntity : BaseEntity
+ {
+ /// 存储 Provider(local/minio/oss)
+ [SugarColumn(ColumnName = "Provider", ColumnDescription = "存储Provider(local/minio/oss)", Length = 20, IsNullable = false)]
+ public string Provider { get; set; }
+
+ /// 配置名称(如:本地存储 / MinIO测试)
+ [SugarColumn(ColumnName = "Name", ColumnDescription = "配置名称", Length = 100, IsNullable = false)]
+ public string Name { get; set; }
+
+ /// 访问端点(Local 为 URL 前缀如 http://host/uploads 或 /uploads;MinIO 为 http://minio:9000)
+ [SugarColumn(ColumnName = "Endpoint", ColumnDescription = "访问端点", Length = 255, IsNullable = true)]
+ public string? Endpoint { get; set; }
+
+ /// 访问 Key(Local 为空;MinIO/OSS 为 AccessKey。注:当前明文存储,接入 MinIO/OSS 时应改为 AES 加密)
+ [SugarColumn(ColumnName = "AccessKey", ColumnDescription = "AccessKey(Local为空)", Length = 128, IsNullable = true)]
+ public string? AccessKey { get; set; }
+
+ /// 密钥(Local 为空;MinIO/OSS 为 SecretKey。注:当前明文存储,接入 MinIO/OSS 时应改为 AES 加密)
+ [SugarColumn(ColumnName = "SecretKey", ColumnDescription = "SecretKey(Local为空)", Length = 255, IsNullable = true)]
+ public string? SecretKey { get; set; }
+
+ /// 桶名(Local 为 BasePath 下子目录;MinIO/OSS 为 bucket 名)
+ [SugarColumn(ColumnName = "Bucket", ColumnDescription = "桶名", Length = 100, IsNullable = true)]
+ public string? Bucket { get; set; }
+
+ /// 区域(OSS 用,Local/MinIO 为空)
+ [SugarColumn(ColumnName = "Region", ColumnDescription = "区域(OSS用)", Length = 50, IsNullable = true)]
+ public string? Region { get; set; }
+
+ /// 本地存储根路径(Local 专用,如 wwwroot/uploads;MinIO/OSS 为空)
+ [SugarColumn(ColumnName = "BasePath", ColumnDescription = "本地存储根路径(Local专用)", Length = 255, IsNullable = true)]
+ public string? BasePath { get; set; }
+
+ /// 单个文件大小上限(MB)
+ [SugarColumn(ColumnName = "MaxSizeMB", ColumnDescription = "单个文件大小上限(MB)", DefaultValue = "10")]
+ public int MaxSizeMB { get; set; } = 10;
+
+ /// 允许的扩展名(JSON 数组,如 [".jpg",".png"];为空表示不限制)
+ [SugarColumn(ColumnName = "AllowedExts", ColumnDescription = "允许扩展名(JSON数组)", ColumnDataType = "text", IsNullable = true)]
+ public string? AllowedExts { get; set; }
+
+ /// 是否默认(1=默认上传通道,全局唯一)
+ [SugarColumn(ColumnName = "IsDefault", ColumnDescription = "是否默认(1=默认,0=否)", ColumnDataType = "smallint", DefaultValue = "0")]
+ public byte IsDefault { get; set; }
+
+ /// 状态(1=启用,0=停用)
+ [SugarColumn(ColumnName = "Status", ColumnDescription = "状态(1=启用,0=停用)", ColumnDataType = "smallint", DefaultValue = "1")]
+ public byte Status { get; set; } = 1;
+
+ /// 备注
+ [SugarColumn(ColumnName = "Remark", ColumnDescription = "备注", Length = 500, IsNullable = true)]
+ public string? Remark { get; set; }
+ }
+}
diff --git a/Model/Entity/System/SystemParamEntity.cs b/Model/Entity/System/SystemParamEntity.cs
new file mode 100644
index 0000000..314b667
--- /dev/null
+++ b/Model/Entity/System/SystemParamEntity.cs
@@ -0,0 +1,60 @@
+using SqlSugar;
+
+namespace Model.Entity.System
+{
+ ///
+ /// 系统参数配置表(键值对形式存储业务可调参数,按 Group 分组展示)
+ /// 内置参数(IsSystem=1)禁止删除,仅允许修改 Value
+ ///
+ [SugarTable("sys_param")]
+ public class SystemParamEntity : BaseEntity
+ {
+ /// 参数键(唯一,业务侧按 Key 取值,如 data_collect_default_frequency)
+ [SugarColumn(ColumnName = "ParamKey", ColumnDescription = "参数键(唯一)", Length = 64, IsNullable = false)]
+ public string ParamKey { get; set; }
+
+ /// 参数名称(中文展示名)
+ [SugarColumn(ColumnName = "ParamName", ColumnDescription = "参数名称", Length = 100, IsNullable = false)]
+ public string ParamName { get; set; }
+
+ /// 参数值(统一字符串存储,业务侧按 ParamType 自行转换)
+ [SugarColumn(ColumnName = "ParamValue", ColumnDescription = "参数值", ColumnDataType = "text", IsNullable = true)]
+ public string? ParamValue { get; set; }
+
+ /// 值类型(0=int, 1=text, 2=enum, 3=json)决定前端渲染控件
+ [SugarColumn(ColumnName = "ParamType", ColumnDescription = "值类型(0=int,1=text,2=enum,3=json)", ColumnDataType = "smallint", DefaultValue = "1")]
+ public byte ParamType { get; set; }
+
+ /// 枚举选项(JSON 数组,如 [{"value":"feishu","label":"飞书"}];仅 ParamType=enum 使用)
+ [SugarColumn(ColumnName = "ParamOptions", ColumnDescription = "枚举选项(JSON数组)", ColumnDataType = "text", IsNullable = true)]
+ public string? ParamOptions { get; set; }
+
+ /// 单位(如 秒/MB,前端展示用)
+ [SugarColumn(ColumnName = "Unit", ColumnDescription = "单位", Length = 20, IsNullable = true)]
+ public string? Unit { get; set; }
+
+ /// 分组(如 数据采集/告警/工单/文件,前端按分组卡片展示)
+ [SugarColumn(ColumnName = "Group", ColumnDescription = "分组", Length = 50, IsNullable = true)]
+ public string? Group { get; set; }
+
+ /// 排序号
+ [SugarColumn(ColumnName = "Sort", ColumnDescription = "排序号", DefaultValue = "0")]
+ public int Sort { get; set; }
+
+ /// 备注(注释提示,如工单编号规则占位符说明)
+ [SugarColumn(ColumnName = "Remark", ColumnDescription = "备注", Length = 500, IsNullable = true)]
+ public string? Remark { get; set; }
+
+ /// 是否系统内置(1=内置不可删,0=用户自定义)
+ [SugarColumn(ColumnName = "IsSystem", ColumnDescription = "是否系统内置(1=不可删,0=可删)", ColumnDataType = "smallint", DefaultValue = "0")]
+ public byte IsSystem { get; set; }
+
+ /// 最后修改人
+ [SugarColumn(ColumnName = "LastUpdateUser", ColumnDescription = "最后修改人", Length = 50, IsNullable = true)]
+ public string? LastUpdateUser { get; set; }
+
+ /// 最后修改时间
+ [SugarColumn(ColumnName = "LastUpdateTime", ColumnDescription = "最后修改时间", IsNullable = true)]
+ public DateTime? LastUpdateTime { get; set; }
+ }
+}
diff --git a/Model/Mapper/EntityMapper.cs b/Model/Mapper/EntityMapper.cs
index ea6585d..6adab9c 100644
--- a/Model/Mapper/EntityMapper.cs
+++ b/Model/Mapper/EntityMapper.cs
@@ -39,6 +39,8 @@ namespace Model.Mapper
Supplier = entity.Supplier,
ImageUrl = entity.ImageUrl,
QrCode = entity.QrCode,
+ QrCodeUrl = entity.QrCodeUrl,
+ RfidCode = entity.RfidCode,
Description = entity.Description,
Department = entity.Department,
Location = entity.Location,
@@ -98,6 +100,8 @@ namespace Model.Mapper
Supplier = dto.Supplier,
ImageUrl = dto.ImageUrl,
QrCode = dto.QrCode,
+ QrCodeUrl = dto.QrCodeUrl,
+ RfidCode = dto.RfidCode,
Description = dto.Description,
Department = dto.Department,
Location = dto.Location,
@@ -1242,5 +1246,205 @@ namespace Model.Mapper
public static List ToDtoList(this List entities)
=> entities?.Select(e => e.ToDto()).ToList() ?? new List();
#endregion
+
+ #region 数据字典
+ ///
+ /// DictTypeEntity → DictTypeDto
+ ///
+ public static DictTypeDto ToDto(this DictTypeEntity entity)
+ {
+ if (entity == null) return null;
+ return new DictTypeDto
+ {
+ Id = entity.Id.ToString(),
+ Code = entity.Code,
+ Name = entity.Name,
+ Status = entity.Status,
+ Sort = entity.Sort,
+ Remark = entity.Remark,
+ CreateTime = entity.CreateTime
+ };
+ }
+ public static List ToDtoList(this List entities)
+ => entities?.Select(e => e.ToDto()).ToList() ?? new List();
+ ///
+ /// DictTypeDto → DictTypeEntity(入参映射,IsDel/CreateTime 不映射)
+ ///
+ public static DictTypeEntity ToEntity(this DictTypeDto dto)
+ {
+ if (dto == null) return null;
+ return new DictTypeEntity
+ {
+ Id = ParseId(dto.Id),
+ Code = dto.Code,
+ Name = dto.Name,
+ Status = dto.Status,
+ Sort = dto.Sort,
+ Remark = dto.Remark
+ };
+ }
+
+ ///
+ /// DictItemEntity → DictItemDto
+ ///
+ public static DictItemDto ToDto(this DictItemEntity entity)
+ {
+ if (entity == null) return null;
+ return new DictItemDto
+ {
+ Id = entity.Id.ToString(),
+ TypeId = entity.TypeId.ToString(),
+ Code = entity.Code,
+ Name = entity.Name,
+ Sort = entity.Sort,
+ Status = entity.Status,
+ IsDefault = entity.IsDefault,
+ Remark = entity.Remark,
+ CreateTime = entity.CreateTime
+ };
+ }
+ public static List ToDtoList(this List entities)
+ => entities?.Select(e => e.ToDto()).ToList() ?? new List();
+ ///
+ /// DictItemDto → DictItemEntity(入参映射,CreateTime/IsDel 不映射;TypeId 由 Service 强制覆盖)
+ ///
+ public static DictItemEntity ToEntity(this DictItemDto dto)
+ {
+ if (dto == null) return null;
+ return new DictItemEntity
+ {
+ Id = ParseId(dto.Id),
+ TypeId = ParseId(dto.TypeId),
+ Code = dto.Code,
+ Name = dto.Name,
+ Sort = dto.Sort,
+ Status = dto.Status,
+ IsDefault = dto.IsDefault,
+ Remark = dto.Remark
+ };
+ }
+ #endregion
+
+ #region 系统参数
+ ///
+ /// SystemParamEntity → SystemParamDto
+ ///
+ public static SystemParamDto ToDto(this SystemParamEntity entity)
+ {
+ if (entity == null) return null;
+ return new SystemParamDto
+ {
+ Id = entity.Id.ToString(),
+ ParamKey = entity.ParamKey,
+ ParamName = entity.ParamName,
+ ParamValue = entity.ParamValue,
+ ParamType = entity.ParamType,
+ ParamOptions = entity.ParamOptions,
+ Unit = entity.Unit,
+ Group = entity.Group,
+ Sort = entity.Sort,
+ Remark = entity.Remark,
+ IsSystem = entity.IsSystem,
+ LastUpdateUser = entity.LastUpdateUser,
+ LastUpdateTime = entity.LastUpdateTime,
+ CreateTime = entity.CreateTime
+ };
+ }
+ public static List ToDtoList(this List entities)
+ => entities?.Select(e => e.ToDto()).ToList() ?? new List();
+ ///
+ /// SystemParamDto → SystemParamEntity(入参映射,CreateTime/IsDel/LastUpdateUser/LastUpdateTime 不映射;由 Service 控管)
+ ///
+ public static SystemParamEntity ToEntity(this SystemParamDto dto)
+ {
+ if (dto == null) return null;
+ return new SystemParamEntity
+ {
+ Id = ParseId(dto.Id),
+ ParamKey = dto.ParamKey,
+ ParamName = dto.ParamName,
+ ParamValue = dto.ParamValue,
+ ParamType = dto.ParamType,
+ ParamOptions = dto.ParamOptions,
+ Unit = dto.Unit,
+ Group = dto.Group,
+ Sort = dto.Sort,
+ Remark = dto.Remark,
+ IsSystem = dto.IsSystem
+ };
+ }
+ #endregion
+
+ #region 文件存储配置
+ public static FileStorageConfigDto ToDto(this FileStorageConfigEntity entity)
+ {
+ if (entity == null) return null;
+ return new FileStorageConfigDto
+ {
+ Id = entity.Id.ToString(),
+ Provider = entity.Provider,
+ Name = entity.Name,
+ Endpoint = entity.Endpoint,
+ AccessKey = entity.AccessKey,
+ SecretKey = entity.SecretKey,
+ Bucket = entity.Bucket,
+ Region = entity.Region,
+ BasePath = entity.BasePath,
+ MaxSizeMB = entity.MaxSizeMB,
+ AllowedExts = entity.AllowedExts,
+ IsDefault = entity.IsDefault,
+ Status = entity.Status,
+ Remark = entity.Remark,
+ CreateTime = entity.CreateTime
+ };
+ }
+ public static List ToDtoList(this List entities)
+ => entities?.Select(e => e.ToDto()).ToList() ?? new List();
+ public static FileStorageConfigEntity ToEntity(this FileStorageConfigDto dto)
+ {
+ if (dto == null) return null;
+ return new FileStorageConfigEntity
+ {
+ Id = ParseId(dto.Id),
+ Provider = dto.Provider,
+ Name = dto.Name,
+ Endpoint = dto.Endpoint,
+ AccessKey = dto.AccessKey,
+ SecretKey = dto.SecretKey,
+ Bucket = dto.Bucket,
+ Region = dto.Region,
+ BasePath = dto.BasePath,
+ MaxSizeMB = dto.MaxSizeMB,
+ AllowedExts = dto.AllowedExts,
+ IsDefault = dto.IsDefault,
+ Status = dto.Status,
+ Remark = dto.Remark
+ };
+ }
+ #endregion
+
+ #region 文件上传记录
+ public static FileRecordDto ToDto(this FileRecordEntity entity)
+ {
+ if (entity == null) return null;
+ return new FileRecordDto
+ {
+ Id = entity.Id.ToString(),
+ FileName = entity.FileName,
+ OriginalName = entity.OriginalName,
+ Url = entity.Url,
+ Size = entity.Size,
+ Ext = entity.Ext,
+ Provider = entity.Provider,
+ StorageId = entity.StorageId.ToString(),
+ Uploader = entity.Uploader,
+ BizType = entity.BizType,
+ BizId = entity.BizId,
+ CreateTime = entity.CreateTime
+ };
+ }
+ public static List ToDtoList(this List entities)
+ => entities?.Select(e => e.ToDto()).ToList() ?? new List();
+ #endregion
}
}
diff --git a/Service/Implement/Asset/QrCodeService.cs b/Service/Implement/Asset/QrCodeService.cs
index 1aa77af..5abc655 100644
--- a/Service/Implement/Asset/QrCodeService.cs
+++ b/Service/Implement/Asset/QrCodeService.cs
@@ -1,12 +1,227 @@
+using Model;
+using Model.Dto.Asset;
+using Model.Entity.Asset;
+using ORM;
+using QRCoder;
using Service.Interface;
+using SqlSugar;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
namespace Service.Implement
{
///
- /// 二维码 服务实现
+ /// 设备二维码 服务实现
+ /// 扫码直达 URL 约定:{baseUrl}/asset/ledger/equipment?id={equipmentId}
+ /// baseUrl 由 Controller 从 HTTP 请求(scheme://host)传入;如需固定域名可改为读 sys_param 的 site_base_url
///
public class QrCodeService : IQrCodeService
{
- // TODO: 实现 二维码 相关方法
+ public async Task> GenerateAsync(long equipmentId, string baseUrl)
+ {
+ if (equipmentId <= 0) return Result.Error("设备Id无效");
+ try
+ {
+ var entity = await LoadEquipmentAsync(equipmentId);
+ if (entity == null) return Result.Error("设备不存在或已被删除");
+
+ var url = BuildQrUrl(baseUrl, equipmentId);
+ if (string.IsNullOrEmpty(entity.QrCodeUrl))
+ {
+ await SqlSugarContext.DbContext.Updateable()
+ .SetColumns(x => x.QrCodeUrl == url)
+ .Where(x => x.Id == equipmentId).ExecuteCommandAsync();
+ entity.QrCodeUrl = url;
+ }
+ return Result.Success(ToDto(entity));
+ }
+ catch (Exception ex)
+ {
+ return Result.Error("生成二维码失败", ex);
+ }
+ }
+
+ public async Task> GenerateImageAsync(long equipmentId, string baseUrl)
+ {
+ if (equipmentId <= 0) return Result.Error("设备Id无效");
+ try
+ {
+ var entity = await LoadEquipmentAsync(equipmentId);
+ if (entity == null) return Result.Error("设备不存在或已被删除");
+
+ var url = string.IsNullOrEmpty(entity.QrCodeUrl) ? BuildQrUrl(baseUrl, equipmentId) : entity.QrCodeUrl;
+ if (string.IsNullOrEmpty(entity.QrCodeUrl))
+ {
+ await SqlSugarContext.DbContext.Updateable()
+ .SetColumns(x => x.QrCodeUrl == url)
+ .Where(x => x.Id == equipmentId).ExecuteCommandAsync();
+ entity.QrCodeUrl = url;
+ }
+
+ var png = EncodePng(url);
+ return Result.Success(png);
+ }
+ catch (Exception ex)
+ {
+ return Result.Error("生成二维码图片失败", ex);
+ }
+ }
+
+ public async Task>> BatchGenerateAsync(long[] equipmentIds, string baseUrl)
+ {
+ if (equipmentIds == null || equipmentIds.Length == 0)
+ return Result>.Error("设备Id列表不能为空");
+ var results = new List();
+ foreach (var id in equipmentIds.Distinct())
+ {
+ var r = await GenerateAsync(id, baseUrl);
+ results.Add(new QrCodeBatchResultDto
+ {
+ EquipmentId = id.ToString(),
+ EquipmentCode = r.Data?.EquipmentCode,
+ EquipmentName = r.Data?.EquipmentName,
+ QrCodeUrl = r.Data?.QrCodeUrl,
+ Success = r.IsSuccess,
+ Message = r.IsSuccess ? "成功" : r.Msg
+ });
+ }
+ return Result>.Success(results);
+ }
+
+ public async Task>> GetListPagedAsync(int pageIndex, int pageSize, RefAsync total, string? keyword = null, bool qrOnly = false)
+ {
+ try
+ {
+ var list = await SqlSugarContext.DbContext.Queryable()
+ .Where(x => x.IsDel == 0)
+ .WhereIF(!string.IsNullOrWhiteSpace(keyword), x => x.Code!.Contains(keyword!) || x.Name!.Contains(keyword!))
+ .WhereIF(qrOnly, x => x.QrCodeUrl != null && x.QrCodeUrl != "")
+ .OrderBy(x => x.Code)
+ .ToPageListAsync(pageIndex, pageSize, total);
+ return Result>.Success(list.Select(ToDto).ToList());
+ }
+ catch (Exception ex)
+ {
+ return Result>.Error("查询二维码列表失败", ex);
+ }
+ }
+
+ public async Task> GetPrintHtmlAsync(long equipmentId, string baseUrl)
+ {
+ if (equipmentId <= 0) return Result.Error("设备Id无效");
+ try
+ {
+ var entity = await LoadEquipmentAsync(equipmentId);
+ if (entity == null) return Result.Error("设备不存在或已被删除");
+
+ var url = string.IsNullOrEmpty(entity.QrCodeUrl) ? BuildQrUrl(baseUrl, equipmentId) : entity.QrCodeUrl;
+ var png = EncodePng(url);
+ var base64 = Convert.ToBase64String(png);
+
+ var html = BuildPrintHtml(entity, url, base64);
+ return Result.Success(html);
+ }
+ catch (Exception ex)
+ {
+ return Result.Error("生成打印页失败", ex);
+ }
+ }
+
+ public async Task BindRfidAsync(long equipmentId, string rfidCode)
+ {
+ if (equipmentId <= 0) return Result.Error("设备Id无效");
+ if (string.IsNullOrWhiteSpace(rfidCode)) return Result.Error("RFID 编号不能为空");
+ try
+ {
+ var exists = await SqlSugarContext.DbContext.Queryable()
+ .Where(x => x.Id == equipmentId && x.IsDel == 0).AnyAsync();
+ if (!exists) return Result.Error("设备不存在或已被删除");
+
+ await SqlSugarContext.DbContext.Updateable()
+ .SetColumns(x => x.RfidCode == rfidCode)
+ .Where(x => x.Id == equipmentId).ExecuteCommandAsync();
+ return Result.Success();
+ }
+ catch (Exception ex)
+ {
+ return Result.Error("绑定 RFID 失败", ex);
+ }
+ }
+
+ // ==================== 私有工具 ====================
+
+ private static async Task LoadEquipmentAsync(long id)
+ => await SqlSugarContext.DbContext.Queryable()
+ .Where(x => x.Id == id && x.IsDel == 0).FirstAsync();
+
+ private static QrCodeDto ToDto(EquipmentEntity e) => new()
+ {
+ EquipmentId = e.Id.ToString(),
+ EquipmentCode = e.Code,
+ EquipmentName = e.Name,
+ Location = e.Location,
+ QrCode = e.QrCode,
+ QrCodeUrl = e.QrCodeUrl,
+ RfidCode = e.RfidCode
+ };
+
+ private static string BuildQrUrl(string baseUrl, long equipmentId)
+ {
+ var b = (baseUrl ?? string.Empty).TrimEnd('/');
+ return $"{b}/asset/ledger/equipment?id={equipmentId}";
+ }
+
+ private static byte[] EncodePng(string content)
+ {
+ using var gen = new QRCodeGenerator();
+ var data = gen.CreateQrCode(content, QRCodeGenerator.ECCLevel.Q);
+ var png = new PngByteQRCode(data);
+ return png.GetGraphic(20); // 20 px per module,便于扫描识别
+ }
+
+ private static string BuildPrintHtml(EquipmentEntity e, string url, string base64)
+ {
+ // 简单 A4 打印模板:左侧二维码,右侧设备信息;浏览器 window.print() 即可
+ var safe = (string? s) => System.Net.WebUtility.HtmlEncode(s ?? string.Empty);
+ return $@"
+
+
+
+设备二维码 - {safe(e.Name)}
+
+
+
+
+
+
+
{safe(e.Name)}
+
+ - 设备编号
- {safe(e.Code)}
+ - 二维码编号
- {safe(e.QrCode)}
+ - 存放位置
- {safe(e.Location)}
+ - 责任人
- {safe(e.ResponsiblePerson)}
+
+
{safe(url)}
+
+
+
+
+
+";
+ }
}
}
diff --git a/Service/Implement/LocalFileStorageProvider.cs b/Service/Implement/LocalFileStorageProvider.cs
new file mode 100644
index 0000000..bd49663
--- /dev/null
+++ b/Service/Implement/LocalFileStorageProvider.cs
@@ -0,0 +1,120 @@
+using Model;
+using Model.Dto.System;
+using Model.Entity.System;
+using Service.Interface;
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Threading.Tasks;
+
+namespace Service.Implement
+{
+ ///
+ /// 本地文件系统存储 Provider
+ /// 落地策略:存到 BasePath/{Bucket?}/{yyyy}/{MM}/{dd}/{guid}{ext},URL = {Endpoint}/{Bucket?}/{yyyy}/{MM}/{dd}/{guid}{ext}
+ /// wwwroot/uploads 由 Program.UseStaticFiles 提供访问;MinIO/OSS 实现见 IFileStorageProvider 扩展点
+ ///
+ public class LocalFileStorageProvider : IFileStorageProvider
+ {
+ public string ProviderName => "local";
+
+ public async Task> UploadAsync(Stream stream, string originalName, long size, FileStorageConfigEntity config)
+ {
+ if (config == null) return Result.Error("存储配置不能为空");
+ if (stream == null || !stream.CanRead) return Result.Error("文件流不可读");
+ try
+ {
+ var basePath = ResolveBasePath(config.BasePath);
+ if (string.IsNullOrWhiteSpace(basePath))
+ return Result.Error("Local 存储必须配置 BasePath");
+
+ var ext = Path.GetExtension(originalName)?.ToLowerInvariant() ?? "";
+ var now = DateTime.Now;
+ var datePath = $"{now:yyyy}/{now:MM}/{now:dd}";
+ var fileName = $"{Guid.NewGuid():N}{ext}";
+ var storedFileName = string.IsNullOrWhiteSpace(config.Bucket)
+ ? $"{datePath}/{fileName}"
+ : $"{config.Bucket}/{datePath}/{fileName}";
+
+ var dir = Path.Combine(basePath,
+ string.IsNullOrWhiteSpace(config.Bucket) ? datePath : Path.Combine(config.Bucket, datePath));
+ Directory.CreateDirectory(dir);
+ var fullPath = Path.Combine(dir, fileName);
+
+ using (var fs = new FileStream(fullPath, FileMode.CreateNew, FileAccess.Write, FileShare.None))
+ {
+ await stream.CopyToAsync(fs);
+ }
+
+ var url = BuildUrl(config.Endpoint, config.Bucket, storedFileName);
+ return Result.Success(new FileUploadResultDto
+ {
+ FileName = storedFileName,
+ Url = url,
+ Ext = ext,
+ Size = size,
+ Provider = ProviderName
+ });
+ }
+ catch (Exception ex)
+ {
+ return Result.Error("本地文件上传失败", ex);
+ }
+ }
+
+ public Task DeleteAsync(string storedFileName, FileStorageConfigEntity config)
+ {
+ if (config == null || string.IsNullOrWhiteSpace(storedFileName))
+ return Task.FromResult(Result.Error("存储配置或文件名不能为空"));
+ try
+ {
+ var basePath = ResolveBasePath(config.BasePath);
+ var fullPath = Path.Combine(basePath, storedFileName.Replace('/', Path.DirectorySeparatorChar));
+ if (File.Exists(fullPath)) File.Delete(fullPath);
+ return Task.FromResult(Result.Success());
+ }
+ catch (Exception ex)
+ {
+ return Task.FromResult(Result.Error("删除本地文件失败", ex));
+ }
+ }
+
+ public Task TestConnectionAsync(FileStorageConfigEntity config)
+ {
+ if (config == null) return Task.FromResult(Result.Error("存储配置不能为空"));
+ try
+ {
+ var basePath = ResolveBasePath(config.BasePath);
+ if (string.IsNullOrWhiteSpace(basePath))
+ return Task.FromResult(Result.Error("Local 存储必须配置 BasePath"));
+ Directory.CreateDirectory(basePath);
+ var testFile = Path.Combine(basePath, $".storage_test_{Guid.NewGuid():N}");
+ File.WriteAllText(testFile, "ok");
+ File.Delete(testFile);
+ return Task.FromResult(Result.Success());
+ }
+ catch (Exception ex)
+ {
+ return Task.FromResult(Result.Error("Local 连接测试失败:" + ex.Message));
+ }
+ }
+
+ // ==================== 私有工具 ====================
+
+ private static string ResolveBasePath(string? basePath)
+ {
+ if (string.IsNullOrWhiteSpace(basePath)) return string.Empty;
+ // 相对路径以运行目录为基准(如 wwwroot/uploads)
+ return Path.IsPathRooted(basePath) ? basePath : Path.Combine(AppContext.BaseDirectory, basePath);
+ }
+
+ private static string BuildUrl(string? endpoint, string? bucket, string storedFileName)
+ {
+ var segs = new List();
+ if (!string.IsNullOrWhiteSpace(endpoint)) segs.Add(endpoint.TrimEnd('/'));
+ if (!string.IsNullOrWhiteSpace(bucket)) segs.Add(bucket.Trim('/'));
+ segs.Add(storedFileName);
+ return string.Join('/', segs);
+ }
+ }
+}
diff --git a/Service/Implement/System/DataSeeder.cs b/Service/Implement/System/DataSeeder.cs
index 05b193e..a7f43ac 100644
--- a/Service/Implement/System/DataSeeder.cs
+++ b/Service/Implement/System/DataSeeder.cs
@@ -13,7 +13,7 @@ namespace Service.Implement
public const byte DataScopeAll = 1;
public const byte DataScopeLab = 2;
- /// 6 个系统权限编码
+ /// 7 个系统权限编码
public static readonly (string Code, string Name, string Group)[] Permissions =
{
("device:view", "查看设备", "device"),
@@ -21,7 +21,8 @@ namespace Service.Implement
("device:control", "控制设备", "device"),
("alert:confirm", "确认告警", "alert"),
("inspection:manage", "巡检管理", "inspection"),
- ("user:manage", "用户管理", "user")
+ ("user:manage", "用户管理", "user"),
+ ("system:manage", "系统配置管理", "system")
};
///
@@ -30,8 +31,8 @@ namespace Service.Implement
///
public static readonly (string Code, string Name, byte DataScope, string[] Permissions)[] Roles =
{
- ("superadmin", "超级管理员", DataScopeAll, new[] { "device:view", "device:edit", "device:control", "alert:confirm", "inspection:manage", "user:manage" }),
- ("labadmin", "实验室管理员", DataScopeLab, new[] { "device:view", "device:edit", "device:control", "alert:confirm", "inspection:manage" }),
+ ("superadmin", "超级管理员", DataScopeAll, new[] { "device:view", "device:edit", "device:control", "alert:confirm", "inspection:manage", "user:manage", "system:manage" }),
+ ("labadmin", "实验室管理员", DataScopeLab, new[] { "device:view", "device:edit", "device:control", "alert:confirm", "inspection:manage", "system:manage" }),
("operator", "设备操作员", DataScopeLab, new[] { "device:view", "inspection:manage" }),
("maintengineer", "维修工程师", DataScopeLab, new[] { "device:view", "device:edit", "device:control", "alert:confirm" })
};
@@ -113,6 +114,12 @@ namespace Service.Implement
db.Insertable(new UserRoleEntity { UserId = adminId, RoleId = superAdmin.Id, CreateTime = now }).ExecuteCommand();
}
}
+
+ // 4. 系统参数种子(一期 4 个内置参数,幂等:按 ParamKey 跳过已存在)
+ SeedSystemParams(db, now);
+
+ // 5. 默认本地文件存储配置(幂等:仅当无任何配置时插入一条默认 Local 通道)
+ SeedDefaultFileStorage(db, now);
}
catch (Exception)
{
@@ -120,5 +127,70 @@ namespace Service.Implement
throw;
}
}
+
+ private static void SeedDefaultFileStorage(SqlSugar.ISqlSugarClient db, DateTime now)
+ {
+ var hasAny = db.Queryable().Where(x => x.IsDel == 0).Any();
+ if (hasAny) return;
+ var entity = new FileStorageConfigEntity
+ {
+ Provider = "local",
+ Name = "本地存储",
+ Endpoint = "/uploads",
+ AccessKey = null,
+ SecretKey = null,
+ Bucket = null,
+ Region = null,
+ BasePath = "wwwroot/uploads",
+ MaxSizeMB = 10,
+ AllowedExts = null, // 不限制扩展名;如需限制填 [".jpg",".png",".pdf",".xlsx"]
+ IsDefault = 1,
+ Status = 1,
+ Remark = "系统默认本地存储通道(文件落 wwwroot/uploads,通过 /uploads 静态访问)",
+ CreateTime = now
+ };
+ db.Insertable(entity).ExecuteReturnSnowflakeId();
+ }
+
+ /// 一期 4 个内置系统参数(IsSystem=1,禁止删除,仅允许改 Value/Remark)
+ /// Group 与 ParamType 约定:0=int,1=text,2=enum,3=json
+ private static readonly (string Key, string Name, string? Value, byte Type, string? Options, string? Unit, string Group, int Sort, string? Remark)[] SystemParams =
+ {
+ ("data_collect_default_frequency", "数据采集默认频率", "5", 0, null, "秒", "数据采集", 1, "设备数据采集的默认间隔(秒),新建设备时作为默认值"),
+ ("alert_notify_method", "告警通知方式", "feishu", 2,
+ "[{\"value\":\"feishu\",\"label\":\"飞书\"},{\"value\":\"dingtalk\",\"label\":\"钉钉\"},{\"value\":\"wecom\",\"label\":\"企业微信\"},{\"value\":\"email\",\"label\":\"邮件\"}]",
+ null, "告警", 2, "默认告警通知渠道(多选需在告警规则内单独配置)"),
+ ("work_order_code_rule", "工单编号规则", "WO{yyyyMMddHHmmss}", 1, null, null, "工单", 3,
+ "工单编号生成模板,支持占位符:{yyyy}年 {MM}月 {dd}日 {HH}时 {mm}分 {ss}秒,序列号用 {seq4} 表示 4 位顺序号"),
+ ("file_upload_limit", "文件上传限制", "10", 2,
+ "[{\"value\":\"5\",\"label\":\"5MB\"},{\"value\":\"10\",\"label\":\"10MB\"},{\"value\":\"20\",\"label\":\"20MB\"},{\"value\":\"50\",\"label\":\"50MB\"}]",
+ "MB", "文件", 4, "单个文件上传大小上限(MB)")
+ };
+
+ private static void SeedSystemParams(SqlSugar.ISqlSugarClient db, DateTime now)
+ {
+ foreach (var (key, name, value, type, options, unit, group, sort, remark) in SystemParams)
+ {
+ var exists = db.Queryable().Where(x => x.ParamKey == key).Any();
+ if (exists) continue;
+ var entity = new SystemParamEntity
+ {
+ ParamKey = key,
+ ParamName = name,
+ ParamValue = value,
+ ParamType = type,
+ ParamOptions = options,
+ Unit = unit,
+ Group = group,
+ Sort = sort,
+ Remark = remark,
+ IsSystem = 1,
+ LastUpdateUser = "system",
+ LastUpdateTime = now,
+ CreateTime = now
+ };
+ db.Insertable(entity).ExecuteReturnSnowflakeId();
+ }
+ }
}
}
diff --git a/Service/Implement/System/DictService.cs b/Service/Implement/System/DictService.cs
index 9b50928..7c8bf40 100644
--- a/Service/Implement/System/DictService.cs
+++ b/Service/Implement/System/DictService.cs
@@ -1,12 +1,360 @@
+using Microsoft.Extensions.Caching.Memory;
+using Model;
+using Model.Dto.System;
+using Model.Entity.System;
+using Model.Mapper;
+using ORM;
using Service.Interface;
+using SqlSugar;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
namespace Service.Implement
{
///
/// 数据字典管理 服务实现
+ /// 缓存策略:按 typeCode 缓存启用项,新增/修改/删除字典分类或字典项时失效对应 typeCode;启动首次访问时按需加载
///
public class DictService : IDictService
{
- // TODO: 实现 数据字典管理 相关方法
+ private readonly IMemoryCache _cache;
+ private static readonly object _cacheLock = new();
+
+ public DictService(IMemoryCache cache)
+ {
+ _cache = cache;
+ }
+
+ // ==================== 字典分类 ====================
+
+ public async Task>> GetTypesPagedAsync(int pageIndex, int pageSize, RefAsync total, string? keyword = null)
+ {
+ try
+ {
+ var list = await SqlSugarContext.DbContext.Queryable()
+ .Where(x => x.IsDel == 0)
+ .WhereIF(!string.IsNullOrWhiteSpace(keyword), x => x.Code.Contains(keyword!) || x.Name.Contains(keyword!))
+ .OrderBy(x => x.Sort).OrderBy(x => x.CreateTime, OrderByType.Desc)
+ .ToPageListAsync(pageIndex, pageSize, total);
+
+ var dtos = list.ToDtoList();
+ await FillItemCountAsync(dtos);
+ return Result>.Success(dtos);
+ }
+ catch (Exception ex)
+ {
+ return Result>.Error("查询字典分类失败", ex);
+ }
+ }
+
+ public async Task>> GetTypesAllAsync()
+ {
+ try
+ {
+ var list = await SqlSugarContext.DbContext.Queryable()
+ .Where(x => x.IsDel == 0 && x.Status == 1)
+ .OrderBy(x => x.Sort).OrderBy(x => x.Code)
+ .ToListAsync();
+ return Result>.Success(list.ToDtoList());
+ }
+ catch (Exception ex)
+ {
+ return Result>.Error("查询字典分类失败", ex);
+ }
+ }
+
+ public async Task> GetTypeByIdAsync(long id)
+ {
+ try
+ {
+ var entity = await SqlSugarContext.DbContext.Queryable()
+ .Where(x => x.Id == id && x.IsDel == 0).FirstAsync();
+ if (entity == null) return Result.Error("字典分类不存在或已被删除");
+ var dto = entity.ToDto();
+ await FillItemCountAsync(new List { dto });
+ return Result.Success(dto);
+ }
+ catch (Exception ex)
+ {
+ return Result.Error("查询字典分类详情失败", ex);
+ }
+ }
+
+ public async Task> AddTypeAsync(DictTypeDto dto)
+ {
+ if (dto == null || string.IsNullOrWhiteSpace(dto.Code) || string.IsNullOrWhiteSpace(dto.Name))
+ return Result.Error("字典编码和名称不能为空");
+
+ try
+ {
+ var exists = await SqlSugarContext.DbContext.Queryable()
+ .Where(x => x.Code == dto.Code && x.IsDel == 0).AnyAsync();
+ if (exists) return Result.Error($"字典编码「{dto.Code}」已存在");
+
+ var entity = dto.ToEntity();
+ entity.Id = 0;
+ entity.CreateTime = DateTime.Now;
+ var id = await SqlSugarContext.DbContext.Insertable(entity).ExecuteReturnSnowflakeIdAsync();
+ entity.Id = id;
+ return Result.Success(entity.ToDto());
+ }
+ catch (Exception ex)
+ {
+ return Result.Error("新增字典分类失败", ex);
+ }
+ }
+
+ public async Task> UpdateTypeAsync(DictTypeDto dto)
+ {
+ if (dto == null || !long.TryParse(dto.Id, out var id) || id <= 0)
+ return Result.Error("字典分类Id无效");
+
+ try
+ {
+ var entity = await SqlSugarContext.DbContext.Queryable()
+ .Where(x => x.Id == id && x.IsDel == 0).FirstAsync();
+ if (entity == null) return Result.Error("字典分类不存在或已被删除");
+
+ var codeExists = await SqlSugarContext.DbContext.Queryable()
+ .Where(x => x.Code == dto.Code && x.Id != id && x.IsDel == 0).AnyAsync();
+ if (codeExists) return Result.Error($"字典编码「{dto.Code}」已存在");
+
+ var newEntity = dto.ToEntity();
+ newEntity.Id = id;
+ await SqlSugarContext.DbContext.Updateable(newEntity)
+ .IgnoreColumns(x => new { x.CreateTime, x.IsDel })
+ .ExecuteCommandAsync();
+ // 编码可能变更:旧/新 Code 都失效
+ InvalidateCache(entity.Code);
+ InvalidateCache(dto.Code);
+ return Result.Success(newEntity.ToDto());
+ }
+ catch (Exception ex)
+ {
+ return Result.Error("修改字典分类失败", ex);
+ }
+ }
+
+ public async Task DeleteTypeAsync(long id)
+ {
+ if (id <= 0) return Result.Error("字典分类Id无效");
+ try
+ {
+ var entity = await SqlSugarContext.DbContext.Queryable()
+ .Where(x => x.Id == id && x.IsDel == 0).FirstAsync();
+ if (entity == null) return Result.Error("字典分类不存在或已被删除");
+
+ var hasItems = await SqlSugarContext.DbContext.Queryable()
+ .Where(x => x.TypeId == id && x.IsDel == 0).AnyAsync();
+ if (hasItems) return Result.Error("该字典分类下还有字典项,请先删除字典项再删除分类");
+
+ await SqlSugarContext.DbContext.Updateable()
+ .SetColumns(x => x.IsDel == 1)
+ .Where(x => x.Id == id).ExecuteCommandAsync();
+ InvalidateCache(entity.Code);
+ return Result.Success();
+ }
+ catch (Exception ex)
+ {
+ return Result.Error("删除字典分类失败", ex);
+ }
+ }
+
+ // ==================== 字典项 ====================
+
+ public async Task>> GetItemsByTypeAsync(long typeId)
+ {
+ try
+ {
+ var list = await SqlSugarContext.DbContext.Queryable()
+ .Where(x => x.TypeId == typeId && x.IsDel == 0)
+ .OrderBy(x => x.Sort).OrderBy(x => x.CreateTime, OrderByType.Desc)
+ .ToListAsync();
+ return Result>.Success(list.ToDtoList());
+ }
+ catch (Exception ex)
+ {
+ return Result>.Error("查询字典项失败", ex);
+ }
+ }
+
+ public async Task> AddItemAsync(DictItemDto dto)
+ {
+ if (dto == null || !long.TryParse(dto.TypeId, out var typeId) || typeId <= 0)
+ return Result.Error("所属字典分类Id无效");
+ if (string.IsNullOrWhiteSpace(dto.Code) || string.IsNullOrWhiteSpace(dto.Name))
+ return Result.Error("字典项编码和名称不能为空");
+
+ try
+ {
+ var typeExists = await SqlSugarContext.DbContext.Queryable()
+ .Where(x => x.Id == typeId && x.IsDel == 0).AnyAsync();
+ if (!typeExists) return Result.Error("所属字典分类不存在");
+
+ var codeExists = await SqlSugarContext.DbContext.Queryable()
+ .Where(x => x.TypeId == typeId && x.Code == dto.Code && x.IsDel == 0).AnyAsync();
+ if (codeExists) return Result.Error($"字典项编码「{dto.Code}」在该分类下已存在");
+
+ var entity = dto.ToEntity();
+ entity.Id = 0;
+ entity.TypeId = typeId;
+ entity.CreateTime = DateTime.Now;
+ if (entity.IsDefault == 1)
+ {
+ await SqlSugarContext.DbContext.Updateable()
+ .SetColumns(x => x.IsDefault == 0)
+ .Where(x => x.TypeId == typeId && x.IsDel == 0).ExecuteCommandAsync();
+ }
+ var id = await SqlSugarContext.DbContext.Insertable(entity).ExecuteReturnSnowflakeIdAsync();
+ entity.Id = id;
+
+ InvalidateCache(await GetTypeCodeAsync(typeId));
+ return Result.Success(entity.ToDto());
+ }
+ catch (Exception ex)
+ {
+ return Result.Error("新增字典项失败", ex);
+ }
+ }
+
+ public async Task> UpdateItemAsync(DictItemDto dto)
+ {
+ if (dto == null || !long.TryParse(dto.Id, out var id) || id <= 0)
+ return Result.Error("字典项Id无效");
+ if (!long.TryParse(dto.TypeId, out var typeId) || typeId <= 0)
+ return Result.Error("所属字典分类Id无效");
+
+ try
+ {
+ var entity = await SqlSugarContext.DbContext.Queryable()
+ .Where(x => x.Id == id && x.IsDel == 0).FirstAsync();
+ if (entity == null) return Result.Error("字典项不存在或已被删除");
+
+ var codeExists = await SqlSugarContext.DbContext.Queryable()
+ .Where(x => x.TypeId == typeId && x.Code == dto.Code && x.Id != id && x.IsDel == 0).AnyAsync();
+ if (codeExists) return Result.Error($"字典项编码「{dto.Code}」在该分类下已存在");
+
+ // 默认项互斥:改为默认时清零同分类其它默认项
+ if (dto.IsDefault == 1 && entity.IsDefault == 0)
+ {
+ await SqlSugarContext.DbContext.Updateable()
+ .SetColumns(x => x.IsDefault == 0)
+ .Where(x => x.TypeId == typeId && x.IsDel == 0 && x.Id != id).ExecuteCommandAsync();
+ }
+
+ var newEntity = dto.ToEntity();
+ newEntity.Id = id;
+ newEntity.TypeId = typeId;
+ await SqlSugarContext.DbContext.Updateable(newEntity)
+ .IgnoreColumns(x => new { x.CreateTime, x.IsDel })
+ .ExecuteCommandAsync();
+
+ InvalidateCache(await GetTypeCodeAsync(typeId));
+ return Result.Success(newEntity.ToDto());
+ }
+ catch (Exception ex)
+ {
+ return Result.Error("修改字典项失败", ex);
+ }
+ }
+
+ public async Task DeleteItemAsync(long id)
+ {
+ if (id <= 0) return Result.Error("字典项Id无效");
+ try
+ {
+ var entity = await SqlSugarContext.DbContext.Queryable()
+ .Where(x => x.Id == id && x.IsDel == 0).FirstAsync();
+ if (entity == null) return Result.Error("字典项不存在或已被删除");
+
+ await SqlSugarContext.DbContext.Updateable()
+ .SetColumns(x => x.IsDel == 1)
+ .Where(x => x.Id == id).ExecuteCommandAsync();
+
+ InvalidateCache(await GetTypeCodeAsync(entity.TypeId));
+ return Result.Success();
+ }
+ catch (Exception ex)
+ {
+ return Result.Error("删除字典项失败", ex);
+ }
+ }
+
+ // ==================== 业务侧:按 typeCode 查询启用项(带缓存) ====================
+
+ public async Task>> GetOptionsByCodeAsync(string typeCode)
+ {
+ if (string.IsNullOrWhiteSpace(typeCode))
+ return Result>.Error("字典编码不能为空");
+
+ try
+ {
+ var key = $"dict:options:{typeCode}";
+ if (!_cache.TryGetValue(key, out List? cached) || cached == null)
+ {
+ lock (_cacheLock)
+ {
+ cached ??= LoadOptionsByCodeAsync(typeCode).GetAwaiter().GetResult();
+ _cache.Set(key, cached, TimeSpan.FromHours(1));
+ }
+ }
+ return Result>.Success(cached);
+ }
+ catch (Exception ex)
+ {
+ return Result>.Error("查询字典选项失败", ex);
+ }
+ }
+
+ // ==================== 私有工具 ====================
+
+ private async Task GetTypeCodeAsync(long typeId)
+ {
+ var code = await SqlSugarContext.DbContext.Queryable()
+ .Where(x => x.Id == typeId).Select(x => x.Code).FirstAsync();
+ return code ?? string.Empty;
+ }
+
+ private async Task> LoadOptionsByCodeAsync(string typeCode)
+ {
+ var db = SqlSugarContext.DbContext;
+ var typeId = await db.Queryable()
+ .Where(x => x.Code == typeCode && x.IsDel == 0 && x.Status == 1)
+ .Select(x => x.Id).FirstAsync();
+ if (typeId == 0) return new List();
+
+ var items = await db.Queryable()
+ .Where(x => x.TypeId == typeId && x.IsDel == 0 && x.Status == 1)
+ .OrderBy(x => x.Sort).OrderBy(x => x.Code)
+ .ToListAsync();
+
+ return items.Select(e => new DictOptionDto
+ {
+ Code = e.Code,
+ Name = e.Name,
+ IsDefault = e.IsDefault == 1
+ }).ToList();
+ }
+
+ private void InvalidateCache(string? typeCode)
+ {
+ if (string.IsNullOrWhiteSpace(typeCode)) return;
+ _cache.Remove($"dict:options:{typeCode}");
+ }
+
+ private async Task FillItemCountAsync(List dtos)
+ {
+ if (dtos == null || dtos.Count == 0) return;
+ var ids = dtos.Select(d => long.Parse(d.Id)).ToList();
+ var counts = await SqlSugarContext.DbContext.Queryable()
+ .Where(x => ids.Contains(x.TypeId) && x.IsDel == 0)
+ .GroupBy(x => x.TypeId)
+ .Select(x => new { TypeId = x.TypeId, Count = SqlFunc.AggregateCount(x.Id) })
+ .ToListAsync();
+ var map = counts.ToDictionary(c => c.TypeId, c => c.Count);
+ foreach (var d in dtos) d.ItemCount = map.TryGetValue(long.Parse(d.Id), out var c) ? c : 0;
+ }
}
}
diff --git a/Service/Implement/System/FileStorageService.cs b/Service/Implement/System/FileStorageService.cs
index e4297e7..be51dc5 100644
--- a/Service/Implement/System/FileStorageService.cs
+++ b/Service/Implement/System/FileStorageService.cs
@@ -1,12 +1,322 @@
+using Model;
+using Model.Dto.System;
+using Model.Entity.System;
+using Model.Mapper;
+using ORM;
using Service.Interface;
+using SqlSugar;
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Threading.Tasks;
namespace Service.Implement
{
///
/// 文件存储管理 服务实现
+ /// 通过 IEnumerable<IFileStorageProvider> 按配置 Provider 字段挑选实现;当前仅 Local 落地
///
public class FileStorageService : IFileStorageService
{
- // TODO: 实现 文件存储管理 相关方法
+ private readonly IEnumerable _providers;
+ private readonly ICurrentUser _currentUser;
+
+ // 掩码标记:前端回显 AccessKey/SecretKey 时用 **** 脱敏,回写时若仍为掩码则保留原值
+ private const string MaskMarker = "****";
+
+ public FileStorageService(IEnumerable providers, ICurrentUser currentUser)
+ {
+ _providers = providers;
+ _currentUser = currentUser;
+ }
+
+ // ==================== 存储配置 ====================
+
+ public async Task>> GetListPagedAsync(int pageIndex, int pageSize, RefAsync total, string? keyword = null)
+ {
+ try
+ {
+ var list = await SqlSugarContext.DbContext.Queryable()
+ .Where(x => x.IsDel == 0)
+ .WhereIF(!string.IsNullOrWhiteSpace(keyword), x => x.Name.Contains(keyword!) || x.Provider.Contains(keyword!))
+ .OrderBy(x => x.IsDefault, OrderByType.Desc).OrderBy(x => x.CreateTime, OrderByType.Desc)
+ .ToPageListAsync(pageIndex, pageSize, total);
+ var dtos = list.ToDtoList();
+ // AccessKey/SecretKey 脱敏回显
+ foreach (var d in dtos) { d.AccessKey = Mask(d.AccessKey); d.SecretKey = Mask(d.SecretKey); }
+ return Result>.Success(dtos);
+ }
+ catch (Exception ex)
+ {
+ return Result>.Error("查询存储配置失败", ex);
+ }
+ }
+
+ public async Task> GetByIdAsync(long id)
+ {
+ try
+ {
+ var entity = await SqlSugarContext.DbContext.Queryable()
+ .Where(x => x.Id == id && x.IsDel == 0).FirstAsync();
+ if (entity == null) return Result.Error("存储配置不存在或已被删除");
+ var dto = entity.ToDto();
+ dto.AccessKey = Mask(dto.AccessKey);
+ dto.SecretKey = Mask(dto.SecretKey);
+ return Result.Success(dto);
+ }
+ catch (Exception ex)
+ {
+ return Result.Error("查询存储配置失败", ex);
+ }
+ }
+
+ public async Task> AddAsync(FileStorageConfigDto dto)
+ {
+ if (dto == null || string.IsNullOrWhiteSpace(dto.Provider) || string.IsNullOrWhiteSpace(dto.Name))
+ return Result.Error("Provider 和名称不能为空");
+ if (!IsProviderSupported(dto.Provider))
+ return Result.Error($"不支持的 Provider:{dto.Provider}(当前仅支持 local)");
+
+ try
+ {
+ var entity = dto.ToEntity();
+ entity.Id = 0;
+ entity.CreateTime = DateTime.Now;
+ if (entity.IsDefault == 1) await ClearOtherDefaultAsync(0);
+ var id = await SqlSugarContext.DbContext.Insertable(entity).ExecuteReturnSnowflakeIdAsync();
+ entity.Id = id;
+ var result = entity.ToDto();
+ result.AccessKey = Mask(result.AccessKey);
+ result.SecretKey = Mask(result.SecretKey);
+ return Result.Success(result);
+ }
+ catch (Exception ex)
+ {
+ return Result.Error("新增存储配置失败", ex);
+ }
+ }
+
+ public async Task> UpdateAsync(FileStorageConfigDto dto)
+ {
+ if (dto == null || !long.TryParse(dto.Id, out var id) || id <= 0)
+ return Result.Error("配置Id无效");
+ if (!string.IsNullOrWhiteSpace(dto.Provider) && !IsProviderSupported(dto.Provider))
+ return Result.Error($"不支持的 Provider:{dto.Provider}");
+
+ try
+ {
+ var entity = await SqlSugarContext.DbContext.Queryable()
+ .Where(x => x.Id == id && x.IsDel == 0).FirstAsync();
+ if (entity == null) return Result.Error("存储配置不存在或已被删除");
+
+ // AccessKey/SecretKey 为掩码时保留原值(前端未改密钥)
+ var newEntity = dto.ToEntity();
+ newEntity.Id = id;
+ newEntity.CreateTime = entity.CreateTime;
+ if (string.IsNullOrEmpty(newEntity.AccessKey) || newEntity.AccessKey.Contains(MaskMarker))
+ newEntity.AccessKey = entity.AccessKey;
+ if (string.IsNullOrEmpty(newEntity.SecretKey) || newEntity.SecretKey.Contains(MaskMarker))
+ newEntity.SecretKey = entity.SecretKey;
+
+ if (newEntity.IsDefault == 1) await ClearOtherDefaultAsync(id);
+ await SqlSugarContext.DbContext.Updateable(newEntity)
+ .IgnoreColumns(x => new { x.CreateTime, x.IsDel })
+ .ExecuteCommandAsync();
+ var result = newEntity.ToDto();
+ result.AccessKey = Mask(result.AccessKey);
+ result.SecretKey = Mask(result.SecretKey);
+ return Result.Success(result);
+ }
+ catch (Exception ex)
+ {
+ return Result.Error("修改存储配置失败", ex);
+ }
+ }
+
+ public async Task DeleteAsync(long id)
+ {
+ if (id <= 0) return Result.Error("配置Id无效");
+ try
+ {
+ var entity = await SqlSugarContext.DbContext.Queryable()
+ .Where(x => x.Id == id && x.IsDel == 0).FirstAsync();
+ if (entity == null) return Result.Error("存储配置不存在或已被删除");
+ if (entity.IsDefault == 1) return Result.Error($"配置「{entity.Name}」是默认通道,禁止删除(请先切换默认)");
+
+ await SqlSugarContext.DbContext.Updateable()
+ .SetColumns(x => x.IsDel == 1)
+ .Where(x => x.Id == id).ExecuteCommandAsync();
+ return Result.Success();
+ }
+ catch (Exception ex)
+ {
+ return Result.Error("删除存储配置失败", ex);
+ }
+ }
+
+ public async Task SetDefaultAsync(long id)
+ {
+ if (id <= 0) return Result.Error("配置Id无效");
+ try
+ {
+ var entity = await SqlSugarContext.DbContext.Queryable()
+ .Where(x => x.Id == id && x.IsDel == 0).FirstAsync();
+ if (entity == null) return Result.Error("存储配置不存在或已被删除");
+ if (entity.Status == 0) return Result.Error("已停用的配置不能设为默认");
+
+ await ClearOtherDefaultAsync(id);
+ await SqlSugarContext.DbContext.Updateable()
+ .SetColumns(x => x.IsDefault == 1)
+ .Where(x => x.Id == id).ExecuteCommandAsync();
+ return Result.Success();
+ }
+ catch (Exception ex)
+ {
+ return Result.Error("设置默认存储失败", ex);
+ }
+ }
+
+ public async Task TestAsync(long id)
+ {
+ if (id <= 0) return Result.Error("配置Id无效");
+ try
+ {
+ var entity = await SqlSugarContext.DbContext.Queryable()
+ .Where(x => x.Id == id && x.IsDel == 0).FirstAsync();
+ if (entity == null) return Result.Error("存储配置不存在或已被删除");
+
+ var provider = ResolveProvider(entity.Provider);
+ if (provider == null) return Result.Error($"未注册 Provider:{entity.Provider}");
+ return await provider.TestConnectionAsync(entity);
+ }
+ catch (Exception ex)
+ {
+ return Result.Error("测试连接失败", ex);
+ }
+ }
+
+ // ==================== 文件上传/记录 ====================
+
+ public async Task> UploadAsync(Stream stream, string originalName, long size, string? bizType = null, string? bizId = null, string? uploader = null)
+ {
+ if (stream == null || size <= 0) return Result.Error("文件流或大小无效");
+ if (string.IsNullOrWhiteSpace(originalName)) return Result.Error("文件名不能为空");
+
+ try
+ {
+ // 取默认启用的存储配置
+ var config = await SqlSugarContext.DbContext.Queryable()
+ .Where(x => x.IsDel == 0 && x.IsDefault == 1 && x.Status == 1).FirstAsync();
+ if (config == null) return Result.Error("未配置默认存储通道,请先在「文件存储管理」新增并设为默认");
+
+ // 大小校验
+ if (config.MaxSizeMB > 0 && size > config.MaxSizeMB * 1024L * 1024L)
+ return Result.Error($"文件大小超过上限 {config.MaxSizeMB}MB");
+
+ // 扩展名校验
+ var ext = Path.GetExtension(originalName)?.ToLowerInvariant() ?? "";
+ var allowed = ParseExts(config.AllowedExts);
+ if (allowed.Count > 0 && !allowed.Contains(ext))
+ return Result.Error($"不支持的文件类型 {ext}(允许:{string.Join(",", allowed)})");
+
+ var provider = ResolveProvider(config.Provider);
+ if (provider == null) return Result.Error($"未注册 Provider:{config.Provider}");
+
+ var upRes = await provider.UploadAsync(stream, originalName, size, config);
+ if (!upRes.IsSuccess || upRes.Data == null) return Result.Error(upRes.Msg);
+
+ var record = new FileRecordEntity
+ {
+ FileName = upRes.Data.FileName,
+ OriginalName = originalName,
+ Url = upRes.Data.Url,
+ Size = size,
+ Ext = ext,
+ Provider = config.Provider,
+ StorageId = config.Id,
+ Uploader = string.IsNullOrWhiteSpace(uploader) ? _currentUser.UserName : uploader,
+ BizType = string.IsNullOrWhiteSpace(bizType) ? "generic" : bizType,
+ BizId = bizId,
+ CreateTime = DateTime.Now
+ };
+ var rid = await SqlSugarContext.DbContext.Insertable(record).ExecuteReturnSnowflakeIdAsync();
+ record.Id = rid;
+ return Result.Success(record.ToDto());
+ }
+ catch (Exception ex)
+ {
+ return Result.Error("文件上传失败", ex);
+ }
+ }
+
+ public async Task>> GetFileListPagedAsync(int pageIndex, int pageSize, RefAsync total, string? keyword = null, string? bizType = null)
+ {
+ try
+ {
+ var list = await SqlSugarContext.DbContext.Queryable()
+ .Where(x => x.IsDel == 0)
+ .WhereIF(!string.IsNullOrWhiteSpace(keyword), x => x.FileName.Contains(keyword!) || x.OriginalName!.Contains(keyword!))
+ .WhereIF(!string.IsNullOrWhiteSpace(bizType), x => x.BizType == bizType)
+ .OrderBy(x => x.CreateTime, OrderByType.Desc)
+ .ToPageListAsync(pageIndex, pageSize, total);
+ return Result>.Success(list.ToDtoList());
+ }
+ catch (Exception ex)
+ {
+ return Result>.Error("查询文件记录失败", ex);
+ }
+ }
+
+ public async Task> GetFileByIdAsync(long id)
+ {
+ try
+ {
+ var entity = await SqlSugarContext.DbContext.Queryable()
+ .Where(x => x.Id == id && x.IsDel == 0).FirstAsync();
+ if (entity == null) return Result.Error("文件记录不存在或已被删除");
+ return Result.Success(entity.ToDto());
+ }
+ catch (Exception ex)
+ {
+ return Result.Error("查询文件记录失败", ex);
+ }
+ }
+
+ // ==================== 私有工具 ====================
+
+ private IFileStorageProvider? ResolveProvider(string? providerName)
+ {
+ if (string.IsNullOrWhiteSpace(providerName)) return null;
+ return _providers.FirstOrDefault(p => p.ProviderName == providerName);
+ }
+
+ private static bool IsProviderSupported(string provider)
+ => provider == "local" || provider == "minio" || provider == "oss";
+
+ private static string? Mask(string? value)
+ {
+ if (string.IsNullOrWhiteSpace(value)) return value;
+ if (value.Length <= 4) return MaskMarker;
+ return value[..2] + MaskMarker + value[^2..];
+ }
+
+ private static List ParseExts(string? json)
+ {
+ if (string.IsNullOrWhiteSpace(json)) return new List();
+ try
+ {
+ var arr = System.Text.Json.JsonSerializer.Deserialize>(json);
+ return arr?.Select(e => e.ToLowerInvariant()).ToList() ?? new List();
+ }
+ catch { return new List(); }
+ }
+
+ private async Task ClearOtherDefaultAsync(long keepId)
+ {
+ await SqlSugarContext.DbContext.Updateable()
+ .SetColumns(x => x.IsDefault == 0)
+ .Where(x => x.IsDel == 0 && x.Id != keepId).ExecuteCommandAsync();
+ }
}
}
diff --git a/Service/Implement/System/SystemParamService.cs b/Service/Implement/System/SystemParamService.cs
index d0f3770..5c04078 100644
--- a/Service/Implement/System/SystemParamService.cs
+++ b/Service/Implement/System/SystemParamService.cs
@@ -1,12 +1,257 @@
+using Microsoft.Extensions.Caching.Memory;
+using Model;
+using Model.Dto.System;
+using Model.Entity.System;
+using Model.Mapper;
+using ORM;
using Service.Interface;
+using SqlSugar;
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading.Tasks;
namespace Service.Implement
{
///
/// 系统参数配置 服务实现
+ /// 缓存策略:按 ParamKey 缓存值;增删改时失效对应 Key(Key 变更时新旧都失效)
///
public class SystemParamService : ISystemParamService
{
- // TODO: 实现 系统参数配置 相关方法
+ private readonly IMemoryCache _cache;
+ private readonly ICurrentUser _currentUser;
+ private static readonly object _cacheLock = new();
+
+ public SystemParamService(IMemoryCache cache, ICurrentUser currentUser)
+ {
+ _cache = cache;
+ _currentUser = currentUser;
+ }
+
+ // ==================== 管理侧 ====================
+
+ public async Task>> GetListPagedAsync(int pageIndex, int pageSize, RefAsync total, string? keyword = null, string? group = null)
+ {
+ try
+ {
+ var list = await SqlSugarContext.DbContext.Queryable()
+ .Where(x => x.IsDel == 0)
+ .WhereIF(!string.IsNullOrWhiteSpace(keyword), x => x.ParamKey.Contains(keyword!) || x.ParamName.Contains(keyword!))
+ .WhereIF(!string.IsNullOrWhiteSpace(group), x => x.Group == group)
+ .OrderBy(x => x.Sort).OrderBy(x => x.CreateTime, OrderByType.Desc)
+ .ToPageListAsync(pageIndex, pageSize, total);
+ return Result>.Success(list.ToDtoList());
+ }
+ catch (Exception ex)
+ {
+ return Result>.Error("查询系统参数失败", ex);
+ }
+ }
+
+ public async Task> GetByIdAsync(long id)
+ {
+ try
+ {
+ var entity = await SqlSugarContext.DbContext.Queryable()
+ .Where(x => x.Id == id && x.IsDel == 0).FirstAsync();
+ if (entity == null) return Result