生态产品多个设计器现已发布候选版本

查看生态总览
跳转到内容

接口对接指南 ​

本页是前后端分离对接的唯一参考:接口基址、统一响应信封、请求头、怎么拿到 token、分页与查询协议、动态 API 路由推导、错误处理、开放接口签名调用。所有字段、路由与取值都对照仓库源码核实,可直接照抄。

想先跑起来看接口,见 快速开始;想知道认证体系的设计(会话、2FA、密码哈希),见 身份与认证。

基址与在线文档 ​

环境监听端口API 基址在线文档(Scalar)
Development9708http://127.0.0.1:9708/apihttp://127.0.0.1:9708/scalar
Production9709(launch profile;Docker 镜像内为 9708)由部署域名决定生产建议关闭
  • 所有动态 API 都挂在 /api 前缀下。前端通过两个环境变量拼装:VITE_API_BASE_URL(协议+主机,开发态通常为空走 Vite 代理)与 VITE_API_PREFIX(默认 /api)。
  • 实际监听以 Hosting:Urls 为准;该配置会覆盖 launch profile 的 applicationUrl。

统一响应信封 ​

所有接口(无论成功失败)都返回同一个 JSON 信封 ApiResponse / ApiResponse<T>:

字段类型说明
codeint业务码,恒为数字(ApiResponse.Code 属性上标了 NumericEnumConverter,属性级优先级最高,不受全局枚举转字符串影响)
messagestring面向用户的提示,默认取业务码的描述文案(如「请求成功」)
dataany成功时是业务数据;失败时承载错误明细字符串
traceIdstring?请求追踪 ID,跨日志/链路定位一次请求
timestampstring服务端时间(UTC)
isSuccessboolcode 落在 [200, 300) 视为成功

成功:

json
{
  "code": 200,
  "message": "请求成功",
  "data": { "userName": "superadmin" },
  "traceId": "8f2b1c0d4e5a6b7c8d9e0f1a2b3c4d5e",
  "timestamp": "2026-08-04T02:11:33.4821567+00:00",
  "isSuccess": true
}

失败(具体错因在 data,message 只是业务码的通用描述):

json
{
  "code": 400,
  "message": "请求错误",
  "data": "用户名或密码错误",
  "timestamp": "2026-08-04T02:11:33.4821567+00:00",
  "isSuccess": false
}

客户端解包顺序

判定成功优先读 isSuccess(非空布尔、信封必写),不要靠「有没有 data 字段」——服务端 WhenWritingNull 会把 data: null 整个省略。错误提示优先读 data,其次 message。前端 RequestClient.request 就是这么实现的。

业务码全表 ​

code 分两段:协议段(100–599)与 HTTP 状态码同值同名;业务段(10000+)表达更细的语义。

段码名称含义
1xx100Continue继续请求
101SwitchingProtocols切换协议
2xx200Success请求成功
201Created资源创建成功
202Accepted请求已接受(异步任务提交,如导出)
204NoContent无内容
3xx300 / 301 / 302 / 304—多种响应可选 / 永久重定向 / 临时重定向 / 资源未修改
4xx400BadRequest参数错误、格式错误或缺少必要参数
401Unauthorized未通过身份认证,需重新登录或提供有效凭据
403Forbidden已认证但无权访问该资源
404NotFound资源不存在或已删除
405MethodNotAllowed请求方法不允许
408RequestTimeout请求超时
409Conflict请求冲突(重复创建、乐观锁版本冲突、防重放失败)
410Gone资源已永久删除
415UnsupportedMediaType媒体类型不支持
422UnprocessableEntity参数格式正确但业务语义校验未通过
423Locked会话已锁定 —— 身份仍有效,应引导解锁而不是跳登录页
429TooManyRequests请求过于频繁(限流/防刷)
5xx500InternalServerError服务器内部错误
501 / 502 / 503 / 504—功能未实现 / 网关错误 / 服务不可用 / 网关超时
认证授权10001LoginExpired登录已过期(区别于 401:曾经登录、现已过期)
10002TokenInvalid令牌无效(格式错误、签名不合法或已吊销)
10003TokenExpired令牌已过期,可用刷新令牌换新
10004PermissionDenied缺少所需权限码(面向按钮/字段级细粒度)
数据校验11000ValidationFailed数据校验失败,明细逐项置于 data
业务处理12000BusinessFailed业务规则不满足、状态机不允许当前操作
数据访问13000DatabaseError数据库访问异常
外部依赖14000ThirdPartyServiceError第三方服务调用异常

JSON 序列化约定 ​

对接时最容易踩坑的几条(均由框架 XiHanWebCoreMvcOptions.ConfigureJsonOptionsDefault 统一设定):

约定表现注意
camelCase 命名UserName → userNameOAuthProviders → oAuthProviders(首字母小写只作用于第一个字符,别写成 oauthProviders)
null 省略DefaultIgnoreCondition = WhenWritingNull字段可能整个不出现,客户端类型要按可选处理
long 序列化为字符串12345 → "12345"避免 JavaScript Number 精度溢出;雪花 ID、耗时、字节数都是字符串,需要运算时显式转数字。反序列化时数字与字符串都接受
枚举序列化为成员名Status.Enabled → "Enabled"唯一例外是 ApiResponse.code,恒为 int。请求侧枚举既可发数字也可发成员名
DateTime / DateTimeOffset 按 X-Timezone 换算输出存储恒 UTC,输出按请求头时区不发头就按服务端默认;DateTimeOffset 走 ISO 8601 带偏移
忽略循环引用ReferenceHandler.IgnoreCycles—

请求头约定 ​

请求头必填值作用
Authorization受保护接口必填Bearer <accessToken>身份认证
Content-Type有 body 时application/json上传文件时用 multipart/form-data(此时不要手工设 Content-Type)
X-Language否zh-CN / en-US后端据此本地化 message 与业务文案、枚举标签
X-Timezone否IANA 时区,如 Asia/Shanghai后端把 UTC 时间换算为该时区后输出
X-Trace-Id否任意串入站链路 ID;未启用 OpenTelemetry 时作为 TraceId 回退来源。响应头必回该字段

前端另会带一个 X-Request-Id(前端自生成,用于本地请求日志面板),后端不消费它。

怎么获取 token(完整实操) ​

认证入口是 AuthAppService,类级 [DynamicApi(RouteTemplate = "api/Auth")],因此全部端点都在 /api/Auth/* 下。核心设计是先登录后选租户:登录时不选租户,认证通过后由服务端按成员关系决定落点。

端点总表 ​

端点方法匿名说明
/api/Auth/LoginConfigGET✅查可用登录方式与 OAuth 提供商
/api/Auth/LoginPOST✅账号密码登录(可能返回 2FA 挑战)
/api/Auth/EmailLoginCodePOST✅下发邮箱登录验证码
/api/Auth/EmailLoginPOST✅邮箱验证码登录
/api/Auth/PhoneLoginCodePOST✅下发短信登录验证码
/api/Auth/PhoneLoginPOST✅短信验证码登录
/api/Auth/RefreshTokenPOST✅用旧 Access + Refresh 换新令牌
/api/Auth/RegisterPOST✅注册
/api/Auth/PasswordResetRequestPOST✅申请找回密码(防枚举,恒返回「已受理」)
/api/Auth/ConsumePasswordResetTokenPOST✅消费重置令牌设新密码
/api/Auth/UserInfoGET❌当前用户资料 + 角色 + 权限码
/api/Auth/PermissionsGET❌权限码 + 角色 + 菜单路由(前端动态路由数据源)
/api/Auth/SwitchTenantPOST❌切换租户 / 进入平台运维态,换发令牌
/api/Auth/LogoutPOST❌登出,撤销会话
/api/Auth/LockSessionPOST❌锁屏(此后接口返回 423)
/api/Auth/UnlockSessionPOST❌解锁
/api/Auth/OAuthBindTicketPOST❌生成三方账号绑定一次性票据

路由名是方法名剥离动词前缀并去掉 Async 后缀的结果:GetLoginConfigAsync → LoginConfig、CreateOAuthBindTicketAsync → OAuthBindTicket。规则见下文。

第 1 步:登录换令牌 ​

bash
curl -X POST http://127.0.0.1:9708/api/Auth/Login \
  -H "Content-Type: application/json" \
  -H "X-Language: zh-CN" \
  -d '{
    "username": "superadmin",
    "password": "SuperAdmin@123"
  }'

请求体 LoginRequestDto:

字段类型必填说明
usernamestring✅邮箱(全平台唯一登录标识);平台账号也可用用户名
passwordstring✅明文密码(走 HTTPS)
captchaId / captchaCodestring?配置要求时图形验证码(LoginConfig.captchaEnabled 为真时首段必填;一次性消费,读取即销毁)
twoFactorCodestring?—开启 2FA 时的验证码
twoFactorMethodstring?—totp / email / phone
twoFactorTicketstring?2FA 后续阶段首段挑战响应签发的两步验证票据;后续阶段带票即免图形验证码
deviceIdstring?—设备指纹,用于多端会话管理

成功响应(data 是 LoginResponseDto):

json
{
  "code": 200,
  "message": "请求成功",
  "data": {
    "requiresTwoFactor": false,
    "token": {
      "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9....",
      "refreshToken": "b7c1f0e9a2d34c5f8e6b0a1d2c3e4f5a",
      "tokenType": "Bearer",
      "expiresIn": 3600,
      "issuedAt": "2026-08-04 10:11:33",
      "expiresAt": "2026-08-04 11:11:33"
    }
  },
  "isSuccess": true
}

需要两步验证时不签发令牌,而是返回挑战(此时 email/phone 方式已自动下发验证码,TOTP 由认证器本地生成):

json
{
  "code": 200,
  "data": {
    "requiresTwoFactor": true,
    "availableTwoFactorMethods": ["totp", "email"],
    "twoFactorMethod": "totp",
    "codeSent": false,
    "twoFactorTicket": "S2m4…(不透明随机串)",
    "token": null
  },
  "isSuccess": true
}

带上验证码与票据重新请求同一端点即可完成登录。两步验证是无状态三段式(凭据 → 选方式 / 下发码 → 提交码),每段都重新提交凭据;图形验证码只在首段消费,后续阶段改带 twoFactorTicket(10 分钟有效,可多次出示,登录完成后作废)。票据不存在 / 过期 / 不属于本次认证用户时返回「两步验证已过期,请重新登录。」,客户端应回到凭据阶段并刷新图形验证码:

bash
curl -X POST http://127.0.0.1:9708/api/Auth/Login \
  -H "Content-Type: application/json" \
  -d '{ "username": "a@b.com", "password": "***", "twoFactorMethod": "totp", "twoFactorCode": "123456", "twoFactorTicket": "S2m4…" }'

免密码的两条路

邮箱验证码登录是两步:先 POST /api/Auth/EmailLoginCode(body {"email":"a@b.com"},返回 expiresInSeconds,开发环境才回显 debugCode),再 POST /api/Auth/EmailLogin(body {"email":"a@b.com","code":"123456"})直接拿到 LoginTokenDto。短信同理走 PhoneLoginCode / PhoneLogin(body 字段是 phone),前提是参数 saas.auth.login 的 methods 里有 phone、手机号已在账号上验证、短信网关已配置并启用且模板映射里有 auth-sms-login-code,否则发码接口直接报错。验证码 6 位、10 分钟、一次性消费(读取即销毁,无论成败)。

第 2 步:带令牌调接口 ​

把 accessToken 放进 Authorization 头:

bash
curl http://127.0.0.1:9708/api/Auth/UserInfo \
  -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...." \
  -H "X-Timezone: Asia/Shanghai"

/api/Auth/UserInfo 返回 UserInfoDto:basicId、userName、nickName、avatar、email、phone、tenantId、isPlatform(当前是否平台运维态)、canAccessPlatform、roles[]、permissions[],外加站点品牌字段 appTitle / appSubtitle / appLogo。

/api/Auth/Permissions 返回 PermissionInfoDto:roles[]、permissions[]、menus[](菜单路由树,前端据此 addRoute 生成动态路由)。

权限码不在令牌里

Access Token 的 Claim 只有 sub/jti/UserId/UserName/SessionId/TenantId/DeviceFingerprint 与多个 Role;具体权限码不冻结进令牌(超管例外,放一个通配 * 作快路径)。鉴权一律走服务端授权快照实时判定——所以授予/回收权限无需重新登录即时生效,也避免权限清单随令牌泄露。

第 3 步:令牌过期怎么办 ​

Access Token 默认 60 分钟、Refresh Token 默认 7 天(以 XiHan:Authentication:Jwt 配置为准)。过期后用旧 AccessToken + RefreshToken 换新:

bash
curl -X POST http://127.0.0.1:9708/api/Auth/RefreshToken \
  -H "Content-Type: application/json" \
  -d '{ "accessToken": "<旧的 accessToken>", "refreshToken": "<refreshToken>" }'

返回新的 LoginTokenDto。注意两点:

  • 该端点是匿名的(否则过期令牌进不来),两个令牌都必须带上、缺一即失败。
  • 前端 RequestClient 已内建自动刷新:任一请求收到 401 就尝试刷一次并重放原请求;刷新期间的并发请求排队等待同一次刷新结果;刷新失败即清空本地令牌、跳登录页。刷新请求自身失败不会再次触发刷新(_isRefresh 标记,防死循环)。

第 4 步:切换租户 ​

登录后可随时切换到某个租户或回到平台运维态,换发一套新令牌(复用当前会话轮换令牌,不产生新的登录事件、不新增设备):

bash
curl -X POST http://127.0.0.1:9708/api/Auth/SwitchTenant \
  -H "Authorization: Bearer <accessToken>" \
  -H "Content-Type: application/json" \
  -d '{ "tenantId": "1001" }'      # tenantId 传 null 表示切到平台运维态

可切换的租户列表来自 GET /api/TenantQuery/MyAvailableTenants。

登出与锁屏 ​

  • POST /api/Auth/Logout:撤销会话、吊销关联令牌。
  • POST /api/Auth/LockSession(body {"password":"***"})后,后续接口返回 423 Locked。这不是 401:用户身份仍然有效,客户端应弹解锁遮罩而不是跳登录页;POST /api/Auth/UnlockSession 解锁。

认证相关的失败排查 ​

现象多半是
401 且 data 提示令牌无效/过期没带 Authorization、Bearer 前缀漏了、或令牌已过期未刷新
登录返回 400 且提示账号或密码错误username 要传邮箱(平台账号才可用用户名)
403认证通过但缺权限码,或租户版本(Edition)白名单未放行该权限
423会话被锁屏,先解锁
刷新令牌一直失败并被登出accessToken 与 refreshToken 必须成对提交,且 refresh 未过期(默认 7 天)

分页与查询协议 ​

分页统一走 POST ​

框架的动态 API 会把 GetXxxAsync 推导成 GET,但分页方法一律显式标 [HttpPost],把整个查询对象作为 JSON body 发送——GET 查询串装不下嵌套的过滤/排序数组。

bash
curl -X POST http://127.0.0.1:9708/api/UserQuery/UserPage \
  -H "Authorization: Bearer <accessToken>" \
  -H "Content-Type: application/json" \
  -d '{
    "conditions": {
      "keyword": { "value": "张", "fields": ["userName", "nickName"] },
      "filters": [
        { "field": "status", "operator": 1000, "value": "Enabled" },
        { "field": "createdTime", "operator": 4000, "values": ["2026-01-01T00:00:00", "2026-08-04T23:59:59.999"] }
      ],
      "sorts": [
        { "field": "createdTime", "direction": 1001, "priority": 0 }
      ]
    },
    "page": { "pageIndex": 1, "pageSize": 20 }
  }'

请求体结构(后端 PageRequestDtoBase,业务分页 DTO 继承它并追加自己的顶层字段):

字段类型说明
conditions.keyword{ value, fields[], matchMode }关键字跨字段搜索,matchMode 默认 Contains
conditions.filtersQueryFilter[]通用过滤:{ field, operator, value };In / Between 用 values 数组
conditions.sortsQuerySort[]多字段排序:{ field, direction, priority },priority 越小越优先(前端以数组下标填充)
page.pageIndexint页码,从 1 开始;小于 1 自动纠正为 1
page.pageSizeint每页条数,默认 20,上限 500(超出自动截断,小于 1 回退默认值)

QueryOperator 取值:

值名称值名称
1000Equal2000Contains
1001NotEqual2001StartsWith
1002GreaterThan2002EndsWith
1003GreaterThanOrEqual3000In
1004LessThan3001NotIn
1005LessThanOrEqual4000Between
5000 / 5001IsNull / IsNotNull

SortDirection:1000 = 升序,1001 = 降序。

枚举请求侧既接受数字也接受成员名("Between");前端契约层统一发数字。

分页响应 ​

json
{
  "code": 200,
  "data": {
    "items": [ /* ... */ ],
    "page": {
      "pageIndex": 1, "pageSize": 20, "totalCount": 137, "totalPages": 7,
      "currentPageCount": 20, "startRecord": 1, "endRecord": 20,
      "hasPrevious": false, "hasNext": true,
      "isFirstPage": true, "isLastPage": false
    },
    "extendDatas": null
  },
  "isSuccess": true
}

排序/过滤受字段级安全门控

读侧会经 IFieldSecurityService.GuardQueryAsync 门控:只有当前用户能看明文的字段才允许参与排序、过滤与关键字搜索,其余被静默剔除;剔完没有有效排序时回退到该接口的默认排序。所以「排序没生效」优先查字段安全,见 数据权限。

动态 API 路由推导规则 ​

BasicApp 没有 Controller——应用服务打上 [DynamicApi] 就是 REST 接口(机制见动态 API)。对接时只需记住这套推导:

路由 = /api + 控制器名 + 动作名,控制器名取服务类名去掉 AppService / QueryService 等后缀,或由类级 RouteTemplate 直接钉死(如 AuthAppService 钉在 api/Auth)。

动作名 = 方法名去掉 Async 后缀再剥离动词前缀;HTTP 谓词由同一个动词前缀推导:

方法名前缀HTTP 谓词方法名前缀HTTP 谓词
Get / Retrieve / Fetch / Find / Query / List / SearchGETUpdate / Edit / ModifyPUT
Create / Add / InsertPOSTDelete / Remove / DestroyDELETE
Patch / PartialUpdatePATCH无匹配前缀POST(默认)

细节:

  • 前缀匹配要求词边界(前缀后必须是大写字母或下划线),所以 AddressBook 不会被 Add 命中、EditorTemplate 不会被 Edit 命中。
  • 方法上显式标注的 [HttpGet] / [HttpPost] 等优先于前缀推导。
  • 前缀被剥离:GetUserPageAsync → 动作名 UserPage;SwitchTenantAsync(无动词前缀)→ 动作名 SwitchTenant,谓词落默认 POST。

举例:

C# 方法实际端点
UserQueryService.GetUserDetailAsync(long id)GET /api/UserQuery/UserDetail?id=...
UserQueryService.GetUserPageAsync(dto) + [HttpPost]POST /api/UserQuery/UserPage
UserAppService.CreateUserAsync(dto)POST /api/User/User
UserAppService.UpdateUserStatusAsync(dto)PUT /api/User/UserStatus
AuthAppService.SwitchTenantAsync(dto)POST /api/Auth/SwitchTenant

错误处理约定 ​

  • HTTP 状态码与 code 通常一致,但请以信封里的 code 为准做业务判定。
  • 401 → 尝试刷新令牌并重放;再失败即登出。
  • 423 → 弹解锁,不要登出。
  • 429 → 退避重试。
  • 校验失败(400 / 422 / 11000)时明细在 data,直接展示即可。
  • 排障时把响应头 X-Trace-Id(或信封里的 traceId)交给后端,可在日志与审计里定位到这一次请求的完整轨迹。

开放接口:签名调用(无 JWT) ​

面向服务端到服务端的第三方调用,走 HMAC 签名而非 JWT。用户在个人中心自助申请凭证:AppKey(ak_ 前缀)+ AppSecret(sk_ 前缀,明文仅在创建/滚动时返回一次,服务端只存哈希),每人最多 5 个。

由框架中间件 XiHanOpenApiSecurityMiddleware 统一验签,配置节 XiHan:Web:Api:OpenApiSecurity:

配置默认值说明
IsEnabledfalse总开关
ProtectedPathPrefixes["/api"]受保护的路径前缀。框架默认值是 /api,必须覆盖,否则开启后整站接口都要验签;BasicApp 已在 appsettings.json 里改成 ["/api/openapi"]
IgnoredPathPrefixes—豁免前缀,BasicApp 配 ["/openapi", "/swagger", "/health", "/connect"]
AllowUnsignedRequestsfalse灰度开关:允许未带安全头的请求直接放行(BasicApp 默认打开,便于灰度上线)
RequireContentSignaturetrue是否强制校验内容签名
EnableReplayProtectiontrue防重放(Nonce 去重)
TimestampToleranceSeconds300时间戳允许误差
NonceExpireSeconds300Nonce 存活期
DefaultSignatureAlgorithmHMACSHA256也支持 HMACSHA512 / RSASHA256 / SM2;HMACSHA1 需显式放开 AllowLegacySignatureAlgorithms
DefaultContentSignatureAlgorithmSHA256也支持 SHA512;MD5 需显式放开
MaxRequestBodySize2 MiB允许读取的最大请求体

请求头 ​

请求头说明
X-Access-Key凭证 AppKey
X-TimestampUnix 秒
X-Nonce一次性随机串(防重放)
X-Signature请求签名(Hex 或 Base64 均接受,HMAC 场景大小写不敏感)
X-Content-Sign请求体内容签名
X-Sign-Algorithm / X-Content-Sign-Algorithm覆盖默认算法(可选)
X-Encrypt-Algorithm请求体加密算法;不加密时必须显式发 NONE
X-Encrypt-Iv请求体加密 IV(Base64,加密时用)
X-Encrypt-Response要求响应加密

签名怎么算 ​

  1. 内容签名:contentSign = lowerhex(SHA256(请求体原文)),无 body 时对空串求。

  2. 规范化查询串:查询参数按 key 序(Ordinal)升序、同 key 值再升序,Uri.EscapeDataString 转义后以 key=value 用 & 连接;无查询参数则为空串。

  3. 待签名串:以 \n 连接六段,顺序固定——

    text
    {HTTP方法大写}\n{请求路径}\n{规范化查询串}\n{contentSign}\n{X-Timestamp}\n{X-Nonce}
  4. 签名:X-Signature = HMACSHA256(AppSecret, 待签名串),输出 Hex 或 Base64 均可。

自测端点 ​

Saas 模块内置两个只做回显的自测端点([AllowAnonymous],身份完全由签名确立),验签通过后回显调用方 AppKey,用来确认链路打通:

端点方法说明
/api/openapi/PingGET返回 ok / accessKey / ownerUserId / serverTimeUtc
/api/openapi/EchoPOST原样回显请求体

两个高频坑

  1. ProtectedPathPrefixes 别用框架默认的 /api——那会让登录、前端调用等全部接口都要求签名。只钉开放接口专属前缀。
  2. 不加密请求体时必须显式发 X-Encrypt-Algorithm: NONE,否则会走加密解析分支而失败。

实时通信(SignalR) ​

两条 Hub:通知 /hubs/notification、聊天 /hubs/chat。鉴权用同一个 JWT,经 accessTokenFactory 传递(浏览器 WebSocket 不能自定义头,SignalR 会把令牌放到查询串 access_token)。传输回退顺序 WebSockets → SSE → LongPolling。

载荷需手动投影

Hub 侧没有 long → string 与枚举的自动转换器(那是 MVC JSON 管道的配置,不作用于 SignalR)。服务端推送前须应用侧手动投影,Hub 方法参数用 string 接收 ID。

下一步 ​

Released under The MIT License