27. 统一返回结果与错误处理
一句话定位:Admin.NET 所有接口(除显式 [NonUnify] 外)的出参都被 AdminNETResultProvider 重包成 {Code, Type, Message, Result, Extras, Time},业务代码只需 return 数据 或 throw Oops.Oh(错误码),永远不用手写包装对象。
一、执行链路
HTTP 请求
│
├─► 路由 / 授权(JwtHandler)──失败──► 401/403 ──► AdminNETResultProvider.OnResponseStatusCodes
│
├─► 模型绑定校验 ──失败──► OnValidateFailed(400)
│
├─► Action 执行
│ ├─ 正常返回 ─────────► OnSucceeded(200) Type="success"
│ └─ throw Oops.Oh ────► OnException(400) Type="error" Message=错误码文案
│
└─► 友好异常中间件 + 序列化器(JSON.Serialize / UnifyContext.GetSerializerSettings)
│
▼
{ "code":200, "type":"success", "message":"", "result":{...}, "extras":null, "time":"..." }统一的三件核心 truths:
| 关注点 | 实现体 | 路径 |
|---|---|---|
| 结果模型 | AdminNETResult<T> | Admin.NET.Core/Utils/System/AdminNETResultProvider.cs |
| 包装规则 | AdminNETResultProvider : IUnifyResultProvider | 同上 |
| 注册点 | .AddInjectWithUnifyResult<AdminNETResultProvider>() | Admin.NET.Web.Core/Startup.cs:129 |
二、出参结构 AdminNETResult<T>
源码(节选,注释已精简):
// Admin.NET.Core/Utils/System/AdminNETResultProvider.cs
public class AdminNETResult<T>
{
public int Code { get; set; } // 状态码:200/400/401/403/500
public string Type { get; set; } // success / error
public string Message { get; set; } // 提示语,失败时为中文文案
public T Result { get; set; } // 业务数据(Action 的返回值)
public object Extras { get; set; } // 附加数据(UnifyContext.Take())
public DateTime Time { get; set; } // 服务端生成时间
}一个真实响应体:
{
"code": 200,
"type": "success",
"message": "",
"result": {
"items": [ { "id": 1, "account": "superAdmin.NET" } ],
"page": 1,
"pageSize": 20,
"total": 1
},
"extras": null,
"time": "2026-10-18T10:12:33.456+08:00"
}
Time由Result()中DateTime.Now写入,走的是服务器本地时区,多地域部署时前端不要拿它做业务判定。
三、IUnifyResultProvider 五个钩子
Furion 在 MVC 过滤器的不同阶段回调不同方法,AdminNETResultProvider 的实现如下(源码精简):
[UnifyModel(typeof(AdminNETResult<>))]
public class AdminNETResultProvider : IUnifyResultProvider
{
// 401 / 403 之外的授权异常
public IActionResult OnAuthorizeException(DefaultHttpContext context, ExceptionMetadata metadata)
=> new JsonResult(Result(metadata.StatusCode, data: metadata.Data, msg: metadata.Errors),
UnifyContext.GetSerializerSettings(context));
// 业务异常(含 Oops.Oh)
public IActionResult OnException(ExceptionContext context, ExceptionMetadata metadata)
=> new JsonResult(Result(metadata.StatusCode, data: metadata.Data, msg: metadata.Errors),
UnifyContext.GetSerializerSettings(context));
// 成功
public IActionResult OnSucceeded(ActionExecutedContext context, object data)
=> new JsonResult(Result(StatusCodes.Status200OK, true, data),
UnifyContext.GetSerializerSettings(context));
// 参数校验失败(DataAnnotations / [Required] 等)
public IActionResult OnValidateFailed(ActionExecutingContext context, ValidationMetadata metadata)
=> new JsonResult(Result(metadata.StatusCode ?? StatusCodes.Status400BadRequest,
data: metadata.Data, msg: metadata.ValidationResult),
UnifyContext.GetSerializerSettings(context));
// 特定状态码:401 / 403 / 302
public async Task OnResponseStatusCodes(HttpContext context, int statusCode, UnifyResultSettingsOptions opt) { ... }
}Result() 私有辅助方法决定 Type 与 Message:
public static AdminNETResult<object> Result(int statusCode, bool succeeded = default, object data = default, object msg = default)
{
return new AdminNETResult<object>
{
Code = statusCode,
Message = msg is null or string ? (msg + "") : JSON.Serialize(msg), // 非字符串再序列化一次
Result = data,
Type = succeeded ? "success" : "error",
Extras = UnifyContext.Take(),
Time = DateTime.Now
};
}注意 Message 的分支:字符串原样输出(Oops.Oh 走这里),非字符串(比如 ValidationResult 字典)会被 JSON.Serialize 成字符串,所以前端拿到的 message 永远是字符串类型。
401 / 403 / 302 专项处理
switch (statusCode)
{
case StatusCodes.Status401Unauthorized:
var msg = "401 登录已过期,请重新登录";
if (context.Items.TryGetValue(SignatureAuthenticationDefaults.AuthenticateFailMsgKey, out var authFailMsg))
msg = authFailMsg + ""; // 开放接口签名认证失败时,回显具体原因
await context.Response.WriteAsJsonAsync(Result(statusCode, msg: msg), ...);
break;
case StatusCodes.Status403Forbidden:
await context.Response.WriteAsJsonAsync(Result(statusCode, msg: "403 禁止访问,没有权限"), ...);
break;
case StatusCodes.Status302Found:
if (context.Response.Headers.TryGetValue("Location", out var redirectUrl))
context.Response.Redirect(redirectUrl);
else
await context.Response.WriteAsJsonAsync(Result(statusCode, msg: "302 跳转失败,没有提供 Location 头信息"), ...);
break;
}| 场景 | HTTP 状态码 | Code | Type | Message |
|---|---|---|---|---|
| 正常返回 | 200 | 200 | success | "" |
throw Oops.Oh(...) | 400 | 400 | error | 错误码对应中文 |
| 参数校验失败 | 400 | 400 | error | 校验结果 JSON 串 |
| Token 过期/黑名单/版本不符 | 401 | 401 | error | 401 登录已过期,请重新登录 |
| 无接口权限 | 403 | 403 | error | 403 禁止访问,没有权限 |
四、Oops.Oh 与 ErrorCodeEnum
错误码枚举定义在 Admin.NET.Core/Enum/ErrorCodeEnum.cs,必须贴 [ErrorCodeType],每一项贴 [ErrorCodeItemMetadata("文案")]:
[ErrorCodeType]
[Description("系统错误码")]
public enum ErrorCodeEnum
{
[ErrorCodeItemMetadata("用户名或密码错误")] D0001,
[ErrorCodeItemMetadata("验证码错误")] D0008,
[ErrorCodeItemMetadata("记录不存在")] D1002,
[ErrorCodeItemMetadata("没有权限")] D1016,
[ErrorCodeItemMetadata("演示环境禁止修改数据")] D1200,
// ... 完整清单见源码
}分段约定(源码实际布局):
| 段位 | 领域 |
|---|---|
D0xxx / D1xxx | 通用、账号、租户、场景类型 |
D2xxx | 组织机构 |
D3xxx | 字典 |
D4xxx | 菜单/权限 |
D5xxx/D6xxx | 应用/职位 |
D8xxx | 文件 |
D9xxx | 系统参数 |
D11xx、D17xx | 任务调度、脚本作业 |
A1xxx/Z1xxx/O1xxx/C1xxx | 应用、租户、开放接口、报表数据源 |
xg1xxx、db1xxx、R2xxx、S0xxx | 项目/建表/行政区/流水号 |
业务代码怎么抛
public async Task Delete(DeleteBaseIdInput input)
{
var entity = await _rep.GetByIdAsync(input.Id) ?? throw Oops.Oh(ErrorCodeEnum.D1002);
if (entity.CreateUserId == _userManager.UserId)
throw Oops.Oh(ErrorCodeEnum.D1001); // 禁止删除自己
await _rep.DeleteAsync(entity);
}带占位符的错误码(ErrorCodeItemMetadata 中含 {0}):
throw Oops.Oh(ErrorCodeEnum.D1002); // 无参
throw Oops.Oh(ErrorCodeEnum.D8001, fileExt); // "不允许的文件类型:docx"
throw Oops.Bah("count分页数量最大不能超过1000"); // 一次性文案,无错误码
Oops.Oh= 友好异常(会被包装成 400 + Type=error);Oops.Bah同理但用于临时自定义文案,源码中APIJSONService就用了Oops.Bah。
五、新增自定义错误码
步骤:
- 打开
Admin.NET.Core/Enum/ErrorCodeEnum.cs - 按业务域挑选尚未占用的编号(建议业务二开统一用
D2xxx之后的自定义号段或xg1xxx风格) - 追加枚举项,必须带
[ErrorCodeItemMetadata("中文文案")] - 业务里
throw Oops.Oh(ErrorCodeEnum.Dxxxx) - 无需重启以外的额外注册——
[ErrorCodeType]会在启动时自动收集到字典 & Swagger
[ErrorCodeItemMetadata("设备已离线,无法下发指令")] D2100,
[ErrorCodeItemMetadata("工单状态不允许当前操作:{0}")] D2101,六、非规范化接口 [NonUnify]
需要"原样输出"的接口(文件流、第三方回调、Swagger 登录检查)贴 [NonUnify]:
// Admin.NET.Core/Service/Auth/SysAuthService.cs
[Route("/api/swagger/checkUrl"), NonUnify]
[ApiDescriptionSettings(Description = "Swagger登录检查", DisableInherite = true)]
[AllowAnonymous]
public int SwaggerCheckUrl()
{
return _httpContextAccessor.HttpContext!.User.Identity!.IsAuthenticated ? 200 : 401;
}粒度选择:
| 特性 | 作用范围 | 典型场景 |
|---|---|---|
[NonUnify] | 单个 Action / Controller(贴类上整个控制器生效) | 回调、导出二进制、健康检查 |
[NonValidation] | 关闭模型验证(仍走统一包装) | SysOnlineUserService.ForceOffline |
[SuppressMonitor] | 跳过 LoggingMonitor 审计日志 | GetCaptcha、SingleLogin |
七、替换整套 Provider(可选)
若要改成业界常见的 {code, msg, data}:
[UnifyModel(typeof(MyResult<>))]
public class MyResultProvider : IUnifyResultProvider
{
public IActionResult OnSucceeded(ActionExecutedContext ctx, object data)
=> new JsonResult(new { code = 200, msg = "ok", data });
public IActionResult OnException(ExceptionContext ctx, ExceptionMetadata md)
=> new JsonResult(new { code = md.StatusCode ?? 400, msg = md.Errors + "", data = md.Data });
public IActionResult OnValidateFailed(ActionExecutingContext ctx, ValidationMetadata md) => ...
public IActionResult OnAuthorizeException(DefaultHttpContext ctx, ExceptionMetadata md) => ...
public Task OnResponseStatusCodes(HttpContext ctx, int statusCode, UnifyResultSettingsOptions opt) => Task.CompletedTask;
}然后在宿主注册处替换(Admin.NET.Web.Core/Startup.cs:129):
.AddInjectWithUnifyResult<MyResultProvider>()提醒:改了出参结构必须同步改前端拦截器,否则表格/消息组件全线失效。除非甲方强制要求,不建议改动,直接沿用
AdminNETResult<T>。
八、前端如何解析
响应拦截器关键分支(以 Axios 为例):
import axios from 'axios';
const http = axios.create({ baseURL: import.meta.env.VITE_API_URL });
http.interceptors.response.use(
(res) => {
const body = res.data as {
code: number; type: string; message: string; result: any; extras: any; time: string;
};
// 分页接口:后端 SqlSugarPagedList 序列化为 { items, page, pageSize, total }
if (body.code === 200 && body.type === 'success') return body.result;
ElMessage.error(body.message || '请求失败');
return Promise.reject(body);
},
(err) => {
const status = err.response?.status;
if (status === 401) {
// code=401, message="401 登录已过期,请重新登录"
sessionStorage.clear();
router.push('/login');
} else if (status === 403) {
ElMessage.error('403 禁止访问,没有权限');
}
return Promise.reject(err);
}
);要点:
- 判成功别只看 HTTP 200,要
code === 200 && type === 'success'(有些网关会把 4xx 也返回 200 体) - 分页数据在
result.items,总数为result.total type只有success/error两种取值,不要指望warning- 与 Token 相关的自动续期见
Admin.NET.Web.Core/Handlers/JwtHandler.cs(过期会打access-token响应头)
九、步骤清单:给新服务加一个标准接口
- 新建
XxxInput/XxxOutputDTO,字段上加[Required]、[MaxLength]等 DataAnnotations - 服务类实现
IDynamicApiController(动态 API 自动暴露) - 业务分支用
throw Oops.Oh(ErrorCodeEnum.xxxx)返回错误,正常路径直接return数据 - 若需要动态附加信息(比如耗时、traceId),写
UnifyContext.Set(key, value),它会自动进Extras - 前端按第三节表格处理
code/type/message - 到
/kapi(Knife4j)或/sapi(Scalar)查看实际出参
// 写入附加数据示例
UnifyContext.Set("traceId", App.HttpContext.TraceIdentifier);
return await _rep.AsQueryable().ToPagedListAsync(input.Page, input.PageSize);⚠️ 注意事项 / 常见坑
Message是字符串分支处理:传对象会被二次 JSON 序列化,前端需要JSON.parse(res.message)才能拿到结构化校验错误。App.json的FriendlyExceptionSettings控制异常行为:ThrowBah = true表示Oops.Oh走业务异常(返回 400 而不是 500),LogError = false表示不把友好异常写进错误日志。生产环境建议保持默认。extras默认可能是 null:只有调用过UnifyContext.Set(...)才有值,前端务必做空判断。- 长时间导出/文件下载别用
[NonUnify]替代鉴权:[NonUnify]只是不改包装,授权仍然生效(除非再贴[AllowAnonymous])。 UnifyContext.Take()是一次性读取:读完即清空,重复调用Result()会导致第二次 Extras 为空。- 时间格式:全局序列化在
AddJsonOptions中配置了AddDateTimeConverters("yyyy-MM-dd HH:mm:ss")与AddLongConverters(overMaxLengthOf17: true)(大整数自动转字符串防精度丢失),自定义 Converter 时注意别覆盖这两项。 - 循环引用:已启用
ReferenceHandler.IgnoreCycles,实体上带[Navigate]的双向导航不会爆栈,但别指望能靠异常发现设计问题。 - Swagger 里的
[NonUnify]接口:文档显示的模型是原始类型,别按统一包装去写前端。
小结
- 出参永远是
AdminNETResult<T>,Code是 HTTP 状态码语义,Type只有 success/error - 业务层只管
return或throw Oops.Oh(ErrorCodeEnum.xxx),包装由AdminNETResultProvider完成 - 错误码集中在
ErrorCodeEnum,按领域分段编号,新增项只需贴[ErrorCodeItemMetadata] - 例外出口是
[NonUnify](不包装)、[NonValidation](不校验)、[SuppressMonitor](不审计) - 前端按
code/type/message/result四段解析,分页取result.items与result.total