6. 服务开发与动态 API
后端手册服务动态API大约 5 分钟约 1594 字
服务类只要实现 IDynamicApiController,Furion 就会在启动时把它的方法直接变成 Controller Action,不需要写 Controller。
1. 原理与路由生成
[ApiDescriptionSettings(Order = 490, Description = "系统用户")]
public class SysUserService : IDynamicApiController, ITransient
│ │ │
│ Swagger 分组/排序/说明 │ 标记为动态 API │ 生命周期(瞬时)
│ │ └─ 也可 IScoped / ISingleton
└─ 路由前缀 = 类名去掉 Service 后缀后小写
SysUserService → "sysuser"
最终路由:/api/sysuser/{action}
默认 HTTP 动词:POST(不贴 [HttpGet]/[HttpPost] 时)| 服务类名 | 路由前缀 | 方法 | 最终地址 |
|---|---|---|---|
SysUserService | sysuser | Page | POST /api/sysuser/page |
SysConfigService | sysconfig | GetDetail | POST /api/sysconfig/getDetail(默认 POST) |
SysPosService | syspos | Import | POST /api/syspos/import |
TestService(组 main) | test | HelloWorld | GET /api/test/helloWorld |
方法级改名([ApiDescriptionSettings(Name = "...")])只改 Swagger 显示与 Action 名,方法名同时决定路由;想彻底改路由请用 [HttpPost("name")] / [HttpGet("name")]。
2. 特性清单
类级
| 特性 | 作用 |
|---|---|
IDynamicApiController | 标记为动态 API(必需) |
ITransient / IScoped / ISingleton | 生命周期(业务服务统一 ITransient) |
[ApiDescriptionSettings(Order = 490, Description = "系统用户")] | Swagger 排序与说明 |
[ApiDescriptionSettings("main", Order = 100)] | 指定 Swagger 分组 |
方法级
| 特性 | 作用 |
|---|---|
[DisplayName("增加用户")] | 操作日志与按钮权限的中文名(必写) |
[ApiDescriptionSettings(Name = "Add"), HttpPost] | 统一增删改的 Action 名,固定 POST |
[HttpGet] / [HttpPost] / [HttpGet("helloWorld")] | 指定动词与路由 |
[HttpPost] | 无 [ApiDescriptionSettings] 时默认也是 POST |
[UnitOfWork] | 方法级事务(多步写操作必加) |
[NonAction] | 不暴露为接口,仅供内部/其他服务调用 |
[AllowAnonymous] | 跳过鉴权 |
[Idempotent(intervalTime: 5)] | 防重复提交(分布式锁 + 缓存,5 秒内同参数拒绝) |
[SuppressMonitor] | 不记录请求/响应日志(登录等敏感接口) |
[NonUnify] | 不套统一返回值壳(导出文件、第三方回调) |
[McpTool] | 暴露给 MCP 客户端调用 |
[FromQuery] / [FromBody] / [FromForm] | 参数绑定源(简单类型 GET 要显式 [FromQuery]) |
3. 方法命名与 HTTP 动词
Furion 动态 API 默认动词是 POST(不贴
[HttpGet]/[HttpPost]时)。想要 GET 必须显式写[HttpGet]或[HttpGet("xxx")]。
Page → POST /api/xxx/page 分页列表(入参 PageXxxInput,返回 SqlSugarPagedList<XxxOutput>)
GetList → POST /api/xxx/getList 不分页列表(加 [HttpGet] 则变 GET)
GetDetail → POST /api/xxx/getDetail 详情(入参 [FromQuery] XxxInput,加 [HttpGet] 则变 GET)
AddXxx → POST /api/xxx/add 新增([ApiDescriptionSettings(Name="Add")])
UpdateXxx → POST /api/xxx/update 更新
DeleteXxx → POST /api/xxx/delete 删除
SetStatus → POST /api/xxx/setStatus 改状态(入参 BaseStatusInput)
Import → POST /api/xxx/import 导入([Required] IFormFile file)
Export → POST /api/xxx/export 导出(返回 IActionResult)约定:写操作一律 POST +
[UnitOfWork];读操作参数要贴[FromQuery],想用 GET 显式加[HttpGet]。
4. BaseService 复用
Admin.NET.Core/Service/BaseService.cs 提供四个开箱即用的接口,继承后不用写一行 CRUD:
public class BaseService<TEntity> : IDynamicApiController where TEntity : class, new()
{
private readonly SqlSugarRepository<TEntity> _rep;
public BaseService(SqlSugarRepository<TEntity> rep) => _rep = rep;
[DisplayName("获取详情")] public virtual async Task<TEntity> GetDetail(long id);
[DisplayName("获取集合")] public virtual async Task<List<TEntity>> GetList();
[ApiDescriptionSettings(Name = "Add"), HttpPost] [DisplayName("增加")] public virtual async Task<bool> Add(TEntity entity);
[ApiDescriptionSettings(Name = "Update"), HttpPost][DisplayName("更新")] public virtual async Task<int> Update(TEntity entity);
[ApiDescriptionSettings(Name = "Delete"), HttpPost][DisplayName("删除")] public virtual async Task<bool> Delete(long id);
}用法(全部 virtual,可 override 覆盖):
[ApiDescriptionSettings(Order = 500, Description = "简单字典维护")]
public class SysSimpleService : BaseService<SysSimple>, ITransient
{
public SysSimpleService(SqlSugarRepository<SysSimple> rep) : base(rep) { }
// 只需要额外加一个分页
[DisplayName("获取分页列表")]
public async Task<SqlSugarPagedList<SysSimple>> Page(PageSimpleInput input)
=> await _rep.AsQueryable().OrderBuilder(input).ToPagedListAsync(input.Page, input.PageSize);
}5. 完整 CRUD 服务示例
namespace Admin.NET.Application.Service;
/// <summary>
/// 请假单服务(骨架与 SysUserService / SysConfigService 完全一致)
/// </summary>
[ApiDescriptionSettings(Order = 500, Description = "请假单")]
public class LeaveService : IDynamicApiController, ITransient
{
private readonly UserManager _userManager;
private readonly SqlSugarRepository<Leave> _rep;
public LeaveService(UserManager userManager, SqlSugarRepository<Leave> rep)
{
_userManager = userManager;
_rep = rep;
}
/// <summary>
/// 获取请假单分页列表 🔖
/// </summary>
[DisplayName("获取请假单分页列表")]
public async Task<SqlSugarPagedList<Leave>> Page(PageLeaveInput input)
{
return await _rep.AsQueryable()
.WhereIF(!string.IsNullOrWhiteSpace(input.LeaveType), u => u.LeaveType == input.LeaveType)
.WhereIF(input.Status.HasValue, u => u.Status == input.Status.Value)
.WhereIF(!string.IsNullOrWhiteSpace(input.Keyword), u => u.Reason.Contains(input.Keyword))
.OrderBuilder(input)
.ToPagedListAsync(input.Page, input.PageSize);
}
/// <summary>
/// 增加请假单 🔖
/// </summary>
[UnitOfWork]
[ApiDescriptionSettings(Name = "Add"), HttpPost]
[DisplayName("增加请假单")]
public async Task<long> AddLeave(AddLeaveInput input)
{
var entity = input.Adapt<Leave>();
var newEntity = await _rep.AsInsertable(entity).ExecuteReturnEntityAsync();
return newEntity.Id;
}
/// <summary>
/// 更新请假单 🔖
/// </summary>
[UnitOfWork]
[ApiDescriptionSettings(Name = "Update"), HttpPost]
[DisplayName("更新请假单")]
public async Task UpdateLeave(UpdateLeaveInput input)
{
await _rep.AsUpdateable(input.Adapt<Leave>()).IgnoreColumns(true).ExecuteCommandAsync();
}
/// <summary>
/// 删除请假单 🔖
/// </summary>
[UnitOfWork]
[ApiDescriptionSettings(Name = "Delete"), HttpPost]
[DisplayName("删除请假单")]
public async Task DeleteLeave(DeleteLeaveInput input)
{
var entity = await _rep.GetByIdAsync(input.Id) ?? throw Oops.Oh(ErrorCodeEnum.D0009);
await _rep.DeleteAsync(entity); // 真删除
// await _rep.FakeDeleteAsync(entity); // 软删除(推荐)
}
/// <summary>
/// 设置状态 🔖
/// </summary>
[UnitOfWork]
[DisplayName("设置请假单状态")]
public async Task<int> SetStatus(BaseStatusInput input)
{
return await _rep.AsUpdateable()
.SetColumns(u => u.Status == input.Status)
.Where(u => u.Id == input.Id)
.ExecuteCommandAsync();
}
/// <summary>
/// 内部调用:不暴露为接口
/// </summary>
[NonAction]
public async Task<int> CountByStatus(int status)
{
return await _rep.AsQueryable().Where(u => u.Status == status).CountAsync();
}
}对应 DTO(真实约定见 Admin.NET.Core/Service/User/Dto/UserInput.cs):
public class PageLeaveInput : BasePageInput { public string LeaveType { get; set; } public int? Status { get; set; } public string Keyword { get; set; } }
public class AddLeaveInput : Leave { }
public class UpdateLeaveInput : AddLeaveInput { }
public class DeleteLeaveInput : BaseIdInput { }6. 服务里可用的上下文
| 对象 | 用途 |
|---|---|
UserManager _userManager | UserId / Account / RealName / TenantId / SuperAdmin / SysAdmin |
SqlSugarRepository<T> _rep | 当前实体的仓储(自动选库、自动带全局过滤器) |
SysCacheService | 分布式缓存读写 |
IEventPublisher | 发布事件(await _eventPublisher.PublishAsync(type, data)) |
SysConfigService | 读系统参数 await _sysConfigService.GetConfigValueByCode<string>(ConfigConst.SysPassword) |
App.GetRequiredService<T>() | 静态方法里临时取服务 |
Oops.Oh(ErrorCodeEnum.D0009) | 抛业务异常(自动转统一返回) |
7. 步骤清单
- 建
Service/Xxx/XxxService.cs,类声明IDynamicApiController, ITransient - 构造函数注入
SqlSugarRepository<Xxx>与需要的协作者 - 类上加
[ApiDescriptionSettings(Order = 5xx, Description = "...")] - 每个方法加
[DisplayName("中文名")] - 写操作:加
[UnitOfWork]+[ApiDescriptionSettings(Name="Add|Update|Delete"), HttpPost] - 读操作:
[FromQuery]标注简单类型参数 - 内部方法加
[NonAction] - 启动后打开
/kapi验证路由与入参
⚠️ 注意事项 / 常见坑
- 不实现
IDynamicApiController就不会生成接口,也不会被 Swagger 收录。 - 不贴生命周期接口(
ITransient等)无法注入,Furion 依赖IDynamicApiController+ 生命周期一起扫描。 - 类名必须以
Service结尾,否则路由前缀不是你预期的小写名。 - 同名 Action 冲突:一个服务里出现两个
Add会启动失败,用[ApiDescriptionSettings(Name="...")]区分。 [ApiDescriptionSettings(Name="Add")]只改 Action 名;路由仍是方法名小写,所以方法名请直接叫AddXxx。- 简单类型参数(long、string)在 GET 请求中必须
[FromQuery],复杂对象默认[FromBody]。 [UnitOfWork]只对同一个ISqlSugarClient生效;跨库(主库 + 日志库)写操作不在同一事务内。- 服务里用了
App.GetRequiredService<T>()拿自身会导致循环依赖,请走构造函数注入。
小结
IDynamicApiController, ITransient + [ApiDescriptionSettings] + [DisplayName] 三件套,加 [UnitOfWork] 保护写操作,增删改查就齐了;只想复用 CRUD 就继承 BaseService<TEntity>。路由永远記作:/api/{类名去 Service 小写}/{方法名}。