This commit is contained in:
wanghu
2026-09-02 09:38:45 +08:00
commit 8774b6cc44
128 changed files with 9382 additions and 0 deletions
@@ -0,0 +1,24 @@
{
"desc": "业务失败信封样例(与 Java 端对拍):HTTP 200 + code≠0,data 原样携带;客户端必须抛 DException(code,msg,data)",
"cases": [
{
"name": "code=4 未登录/登录过期",
"httpStatus": 200,
"envelope": { "code": 4, "msg": "登录已经过期,请重新登录", "data": null }
},
{
"name": "code=5 业务校验失败(组下有子组/用户,拒绝删除)",
"httpStatus": 200,
"envelope": {
"code": 5,
"msg": "组下存在子组或用户,禁止删除",
"data": { "groupId": 9, "childGroups": 2, "users": 35 }
}
},
{
"name": "code=5 身份切换:非白名单 appKey",
"httpStatus": 200,
"envelope": { "code": 5, "msg": "当前应用无权切换身份", "data": null }
}
]
}
@@ -0,0 +1,41 @@
{
"desc": "成功信封样例(与 Java 端对拍):BoaUser 裸数据,含 currentRole 多角色嵌套;亦兼容 {code:0,data:{}} 包裹形态",
"bareData": {
"id": 1001,
"account": "zhangsan",
"name": "张三",
"idcard": "510101199001011234",
"phone": "13800138000",
"email": "zhangsan@example.com",
"gender": 1,
"profilePicture": "https://cdn.example.com/avatar/1001.png",
"type": 1,
"status": 1,
"dept": { "id": 11, "name": "教务处", "parentId": 2, "type": 3, "sort": 1 },
"school": { "id": 2, "name": "云海第一中学", "parentId": 1, "type": 2, "sort": 1 },
"bureau": { "id": 1, "name": "云海市教育局", "parentId": null, "type": 1, "sort": 0 },
"groups": [
{ "id": 1, "name": "云海市教育局", "type": 1 },
{ "id": 2, "name": "云海第一中学", "type": 2 },
{ "id": 11, "name": "教务处", "type": 3 }
],
"roleIds": [3, 5],
"currentRole": {
"roleId": 3,
"roleName": "教师",
"schId": 2,
"schName": "云海第一中学",
"childId": 21,
"childName": "高一(3)班"
},
"schools": [
{ "id": 2, "name": "云海第一中学", "type": 2 },
{ "id": 4, "name": "云海第二中学", "type": 2 }
]
},
"wrappedData": {
"code": 0,
"msg": "",
"data": { "id": 1001, "account": "zhangsan", "name": "张三" }
}
}
@@ -0,0 +1,124 @@
{
"algorithm": "HmacSHA256",
"dataLevel": {
"canonical": "data + '|' + ts",
"output": "lowercase hex"
},
"requestLevel": {
"canonical": "METHOD + '\\n' + canonicalTarget + '\\n' + ts",
"output": "base64",
"note": "canonicalTarget 为剥离部署 basePath(如 /userbase,与 UserBase context-path 一致)后的服务端 canonical 口径(/oapi/**);客户端实现须在签名前完成剥离(对齐 Java AppSignInterceptor.stripBasePath / AppAuthFilter 剥 contextPath)"
},
"minSecretLength": 16,
"cases": [
{
"name": "数据级-常规",
"ts": "1700000000000",
"secret": "0123456789abcdef",
"data": "hello",
"expectedDataSignHex": "9d79a45345dca5d12c0348d8c5cfd9be1a1ef9733b3f0c617f1eed5319b4a398"
},
{
"name": "数据级-空串",
"ts": "1700000000000",
"secret": "0123456789abcdef",
"data": "",
"expectedDataSignHex": "022915adc73b73229e09bc6f1c5a5f51e6c323f48ba581c0e4af49ba5684ff20"
},
{
"name": "数据级-含分隔符",
"ts": "1700000000000",
"secret": "0123456789abcdef",
"data": "a|b",
"expectedDataSignHex": "f988e0bcbc2c3ece3a5391c22255a3f1ed1d213b1afb1e0d8969a3b2384aaa8c"
},
{
"name": "数据级-中文UTF8",
"ts": "1700000000000",
"secret": "app-secret-100476-yunhai",
"data": "云海学通",
"expectedDataSignHex": "e113d7086b2d4c0146477033cd386114d102b3acd0c12ce5c89d6833c7186d25"
},
{
"name": "数据级-JSON体",
"ts": "1699999999999",
"secret": "app-secret-100476-yunhai",
"data": "{\"id\":1,\"name\":\"zhang\"}",
"expectedDataSignHex": "b405a624e084c6ebedb85f56931d3809917b87eba750057b72665ebf3d382c5e"
},
{
"name": "数据级-超长数据",
"ts": "1700000000000",
"secret": "app-secret-100476-yunhai",
"data": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
"expectedDataSignHex": "be7812a22c1c7b9b6c0cd37382f4a074ca8c81d2e6d718053c3792df8c9ea84c"
},
{
"name": "数据级-超长密钥",
"ts": "1700000000010",
"secret": "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
"data": "user/login",
"expectedDataSignHex": "1b48440b7a7cb89502d9b2b2977bd7c2720eeef11ee1e8178167f170fb8a3d01"
},
{
"name": "请求级-GET无参",
"ts": "1700000000000",
"secret": "app-secret-100476-yunhai",
"method": "GET",
"canonicalTarget": "/oapi/user/byId",
"fullUrl": "http://base.bestsch.com/userbase/oapi/user/byId",
"basePath": "/userbase",
"expectedRequestSignBase64": "R4YDIRnThxqk40dWXsWPtPYpE6AnpyzGDzTlsRgB9L0="
},
{
"name": "请求级-GET带query",
"ts": "1700000000000",
"secret": "app-secret-100476-yunhai",
"method": "GET",
"canonicalTarget": "/oapi/user/byId?id=123",
"fullUrl": "http://base.bestsch.com/userbase/oapi/user/byId?id=123",
"basePath": "/userbase",
"expectedRequestSignBase64": "0xkI3XWSbomr+iddwXr4FS0lvMnzamJVpaAUg/35CoM="
},
{
"name": "请求级-GET多参数",
"ts": "1700000000000",
"secret": "0123456789abcdef",
"method": "GET",
"canonicalTarget": "/oapi/group/byId?groupId=9&withSub=true",
"fullUrl": "http://base.bestsch.com/userbase/oapi/group/byId?withSub=true&groupId=9",
"basePath": "/userbase",
"expectedRequestSignBase64": "O4f8XxmwxFapdzeuJ382PHCzau5kx6iEfmfJyXxlL3I="
},
{
"name": "请求级-POST",
"ts": "1700000000000",
"secret": "app-secret-100476-yunhai",
"method": "POST",
"canonicalTarget": "/oapi/identity/switch",
"fullUrl": "http://base.bestsch.com/userbase/oapi/identity/switch",
"basePath": "/userbase",
"expectedRequestSignBase64": "AW1LU585v3JLPvHj5jm6XTPtshFg6E+FtEkIG1p3bm4="
},
{
"name": "请求级-中文query",
"ts": "1700000000001",
"secret": "app-secret-100476-yunhai",
"method": "GET",
"canonicalTarget": "/oapi/user/byLoginName?loginName=张三",
"fullUrl": "http://base.bestsch.com/userbase/oapi/user/byLoginName?loginName=%E5%BC%A0%E4%B8%89",
"basePath": "/userbase",
"expectedRequestSignBase64": "cTBpNkIqJLAOgwh/MasWUjIZKk8v6ZKDXjxSISCidjQ="
},
{
"name": "请求级-小写method(与GET不同)",
"method": "get",
"canonicalTarget": "/oapi/user/byId",
"fullUrl": "http://base.bestsch.com/userbase/oapi/user/byId",
"basePath": "/userbase",
"ts": "1700000000000",
"secret": "0123456789abcdef",
"expectedRequestSignBase64": "g6mmXqcSNiTMHQKQUACLG4YwIP4CDq7eNbjNXtH/HWM="
}
]
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,580 @@
openapi: 3.0.3
info:
title: UserBase /oapi 开放接口(契约工件)
description: |
UserBase 对子应用暴露的开放接口。权威源为 UserBase controller.oapi(springdoc 导出 + 人工补错误信封 oneOf)。
成功响应为裸数据对象/数组;业务/校验失败为 HTTP 200 + {code:4/5, msg, data}。
除 /oapi/internal/** 外所有请求须带 X-App-Key / X-Timestamp / X-Signature(见 sign-protocol.md)。
version: 1.0.0
servers:
- url: '{baseHost}/userbase'
variables:
baseHost:
default: http://userbase.example.com
security:
- appSign: []
paths:
/oapi/user/byId:
get:
tags: [user]
summary: 按用户主键查询(裸数据 BoaUser)
parameters:
- { name: id, in: query, required: true, schema: { type: integer } }
responses:
'200': { $ref: '#/components/responses/BoaUserOrError' }
/oapi/user/byLoginName:
get:
tags: [user]
summary: 按登录账号查询用户明细
parameters:
- { name: loginName, in: query, required: true, schema: { type: string } }
responses:
'200': { $ref: '#/components/responses/BoaUserOrError' }
/oapi/user/byPhone:
get:
tags: [user]
summary: 按手机号查询用户明细
parameters:
- { name: phone, in: query, required: true, schema: { type: string } }
responses:
'200': { $ref: '#/components/responses/BoaUserOrError' }
/oapi/user/modules:
get:
tags: [user]
summary: 按用户查询模块列表
parameters:
- { name: userId, in: query, required: true, schema: { type: integer } }
- { name: schId, in: query, required: false, schema: { type: integer } }
responses:
'200': { $ref: '#/components/responses/BoaModuleListOrError' }
/oapi/user/add:
post:
tags: [user]
summary: 新增用户
requestBody: { content: { application/json: { schema: { $ref: '#/components/schemas/SysUserRequest' } } } }
responses:
'200': { $ref: '#/components/responses/BoaUserOrError' }
/oapi/user/edit/{id}:
post:
tags: [user]
summary: 编辑用户(路径 id 权威)
parameters:
- { name: id, in: path, required: true, schema: { type: integer } }
requestBody: { content: { application/json: { schema: { $ref: '#/components/schemas/SysUserRequest' } } } }
responses:
'200': { $ref: '#/components/responses/BoaUserOrError' }
/oapi/user/delete/{id}/{groupId}/{schoolId}:
post:
tags: [user]
summary: 删除用户(按组解绑)
parameters:
- { name: id, in: path, required: true, schema: { type: integer } }
- { name: groupId, in: path, required: true, schema: { type: integer } }
- { name: schoolId, in: path, required: true, schema: { type: integer } }
responses:
'200': { $ref: '#/components/responses/VoidOrError' }
/oapi/user/deleteUsers:
post:
tags: [user]
summary: 批量删除用户-组关系
requestBody:
content:
application/json:
schema:
type: array
items: { $ref: '#/components/schemas/SysUserGroupDeleteItem' }
responses:
'200': { $ref: '#/components/responses/VoidOrError' }
/oapi/user/batchAdd:
post:
tags: [user]
summary: 批量新增用户(整批回滚)
requestBody:
content:
application/json:
schema:
type: array
items: { $ref: '#/components/schemas/SysUserRequest' }
responses:
'200':
content:
application/json:
schema:
oneOf:
- type: array
items: { $ref: '#/components/schemas/BoaUser' }
- { $ref: '#/components/schemas/ErrorEnvelope' }
/oapi/user/moveToGroup:
post:
tags: [user]
summary: 移动用户到指定组(同校内,覆盖式替换主组)
requestBody: { content: { application/json: { schema: { $ref: '#/components/schemas/SysUserMoveRequest' } } } }
responses:
'200': { $ref: '#/components/responses/BoaUserOrError' }
/oapi/user/roles:
get:
tags: [user]
summary: 按用户查询角色身份列表
parameters:
- { name: userId, in: query, required: true, schema: { type: integer } }
responses:
'200':
content:
application/json:
schema:
oneOf:
- type: array
items: { $ref: '#/components/schemas/BoaRoleInfo' }
- { $ref: '#/components/schemas/ErrorEnvelope' }
/oapi/identity/current:
get:
tags: [identity]
summary: 查询用户当前激活身份(未切换时兜底 is_main 主身份)
parameters:
- { name: userId, in: query, required: true, schema: { type: integer } }
responses:
'200': { $ref: '#/components/responses/BoaRoleInfoOrError' }
/oapi/identity/available:
get:
tags: [identity]
summary: 查询用户可切换的身份列表(多角色迁移配套)
parameters:
- { name: userId, in: query, required: true, schema: { type: integer } }
responses:
'200':
content:
application/json:
schema:
oneOf:
- type: array
items: { $ref: '#/components/schemas/BoaRoleInfo' }
- { $ref: '#/components/schemas/ErrorEnvelope' }
/oapi/identity/switch:
post:
tags: [identity]
summary: 切换身份(调用方须在 yh.identity.switch-allowed-app-keys 白名单内)
requestBody:
content:
application/json:
schema:
type: object
properties:
userId: { type: integer }
roleId: { type: integer }
schoolId: { type: integer }
responses:
'200': { $ref: '#/components/responses/BoaRoleInfoOrError' }
/oapi/user/password:
post:
tags: [user]
summary: 修改用户密码
requestBody:
content:
application/json:
schema:
type: object
properties:
userId: { type: integer }
oldPassword: { type: string }
newPassword: { type: string }
responses:
'200': { $ref: '#/components/responses/VoidOrError' }
/oapi/user/groupState:
get:
tags: [user]
summary: 查询用户在指定组上的状态(0=正常 1=转出)
parameters:
- { name: userId, in: query, required: true, schema: { type: integer } }
- { name: groupId, in: query, required: true, schema: { type: integer } }
- { name: schoolId, in: query, required: true, schema: { type: integer } }
responses:
'200': { $ref: '#/components/responses/IntOrError' }
post:
tags: [user]
summary: 设置用户在指定组上的状态(转出/恢复)
requestBody: { content: { application/json: { schema: { $ref: '#/components/schemas/SysUserGroupStateRequest' } } } }
responses:
'200': { $ref: '#/components/responses/VoidOrError' }
/oapi/user/transferToSchool:
post:
tags: [user]
summary: 跨校转入
requestBody: { content: { application/json: { schema: { $ref: '#/components/schemas/SysUserTransferSchoolRequest' } } } }
responses:
'200': { $ref: '#/components/responses/BoaUserOrError' }
/oapi/user/changeStatus:
post:
tags: [user]
summary: 设置单个用户状态
requestBody:
content:
application/json:
schema:
type: object
properties:
userId: { type: integer }
disabled: { type: boolean }
responses:
'200': { $ref: '#/components/responses/VoidOrError' }
/oapi/user/batchChangeStatus:
post:
tags: [user]
summary: 批量设置用户状态(任一无效整体拒绝)
requestBody:
content:
application/json:
schema:
type: object
properties:
userIds:
type: array
items: { type: integer }
disabled: { type: boolean }
responses:
'200': { $ref: '#/components/responses/VoidOrError' }
/oapi/group/tree:
get:
tags: [group]
summary: 平台组树(根=教育云中心,children 递归)
responses:
'200':
content:
application/json:
schema:
oneOf:
- { $ref: '#/components/schemas/BoaGroup' }
- { $ref: '#/components/schemas/ErrorEnvelope' }
/oapi/group/byId:
get:
tags: [group]
summary: 按组ID查询单个组
parameters:
- { name: groupId, in: query, required: true, schema: { type: integer } }
- { name: withSub, in: query, required: false, schema: { type: boolean } }
responses:
'200': { $ref: '#/components/responses/BoaGroupOrError' }
/oapi/group/list:
get:
tags: [group]
summary: 按多个组ID批量查询
parameters:
- { name: ids, in: query, required: true, schema: { type: string } }
- { name: withSub, in: query, required: false, schema: { type: boolean } }
responses:
'200':
content:
application/json:
schema:
oneOf:
- type: array
items: { $ref: '#/components/schemas/BoaGroup' }
- { $ref: '#/components/schemas/ErrorEnvelope' }
/oapi/group/add:
post:
tags: [group]
summary: 新增组
requestBody: { content: { application/json: { schema: { $ref: '#/components/schemas/SysGroupRequest' } } } }
responses:
'200': { $ref: '#/components/responses/BoaGroupOrError' }
/oapi/group/edit/{id}:
post:
tags: [group]
summary: 编辑组(路径 id 权威,body 不含 id)
parameters:
- { name: id, in: path, required: true, schema: { type: integer } }
requestBody: { content: { application/json: { schema: { $ref: '#/components/schemas/SysGroupRequest' } } } }
responses:
'200': { $ref: '#/components/responses/BoaGroupOrError' }
/oapi/group/delete/{id}/{schoolId}:
post:
tags: [group]
summary: 删除组(组下存在子组或用户时服务端拒绝 code=5)
parameters:
- { name: id, in: path, required: true, schema: { type: integer } }
- { name: schoolId, in: path, required: true, schema: { type: integer } }
responses:
'200': { $ref: '#/components/responses/VoidOrError' }
/oapi/group/batchAdd:
post:
tags: [group]
summary: 批量新增子组
requestBody: { content: { application/json: { schema: { $ref: '#/components/schemas/SysGroupBatchRequest' } } } }
responses:
'200':
content:
application/json:
schema:
oneOf:
- type: array
items: { $ref: '#/components/schemas/BoaGroup' }
- { $ref: '#/components/schemas/ErrorEnvelope' }
/oapi/group/move/{schoolId}:
post:
tags: [group]
summary: 移动组(学校限定)
parameters:
- { name: schoolId, in: path, required: true, schema: { type: integer } }
requestBody: { content: { application/json: { schema: { $ref: '#/components/schemas/SysGroupMoveRequest' } } } }
responses:
'200': { $ref: '#/components/responses/VoidOrError' }
/oapi/app/{appId}:
get:
tags: [app]
summary: 按应用ID查询应用信息
parameters:
- { name: appId, in: path, required: true, schema: { type: integer } }
responses:
'200': { $ref: '#/components/responses/ObjectOrError' }
/oapi/app/list:
get:
tags: [app]
summary: 按用户+客户端类型查询有权访问的应用列表(按应用类型分组)
parameters:
- { name: userId, in: query, required: true, schema: { type: integer } }
- { name: clientType, in: query, required: false, schema: { type: integer, description: 2 的幂位掩码 } }
responses:
'200':
content:
application/json:
schema:
oneOf:
- type: array
items: { $ref: '#/components/schemas/BoaAppType' }
- { $ref: '#/components/schemas/ErrorEnvelope' }
components:
securitySchemes:
appSign:
type: apiKey
in: header
name: X-App-Key
description: 另需 X-Timestamp / X-Signature,见 sign-protocol.md
responses:
BoaUserOrError:
content:
application/json:
schema:
oneOf:
- { $ref: '#/components/schemas/BoaUser' }
- { $ref: '#/components/schemas/ErrorEnvelope' }
BoaRoleInfoOrError:
content:
application/json:
schema:
oneOf:
- { $ref: '#/components/schemas/BoaRoleInfo' }
- { $ref: '#/components/schemas/ErrorEnvelope' }
BoaGroupOrError:
content:
application/json:
schema:
oneOf:
- { $ref: '#/components/schemas/BoaGroup' }
- { $ref: '#/components/schemas/ErrorEnvelope' }
BoaModuleListOrError:
content:
application/json:
schema:
oneOf:
- type: array
items: { $ref: '#/components/schemas/BoaModule' }
- { $ref: '#/components/schemas/ErrorEnvelope' }
IntOrError:
content:
application/json:
schema:
oneOf:
- { type: integer }
- { $ref: '#/components/schemas/ErrorEnvelope' }
ObjectOrError:
content:
application/json:
schema:
oneOf:
- { type: object }
- { $ref: '#/components/schemas/ErrorEnvelope' }
VoidOrError:
content:
application/json:
schema:
oneOf:
- { type: 'null' }
- { $ref: '#/components/schemas/ErrorEnvelope' }
schemas:
BoaRoleInfo:
type: object
properties:
roleId: { type: integer, nullable: true }
roleName: { type: string, nullable: true }
schId: { type: integer, nullable: true }
schName: { type: string, nullable: true }
childId: { type: integer, nullable: true }
childName: { type: string, nullable: true }
BoaGroup:
type: object
properties:
id: { type: integer, nullable: true }
name: { type: string, nullable: true }
parentId: { type: integer, nullable: true }
type: { type: integer, nullable: true }
sort: { type: integer, nullable: true }
children:
type: array
nullable: true
items: { $ref: '#/components/schemas/BoaGroup' }
BoaModule:
type: object
properties:
id: { type: integer, nullable: true }
parentId: { type: integer, nullable: true }
customId: { type: string, nullable: true }
name: { type: string, nullable: true }
path: { type: string, nullable: true }
level: { type: integer, nullable: true }
sortNo: { type: integer, nullable: true }
icon: { type: string, nullable: true }
BoaApp:
type: object
properties:
id: { type: integer, nullable: true }
name: { type: string, nullable: true }
icon: { type: string, nullable: true }
version: { type: string, nullable: true }
type: { type: integer, nullable: true }
webAddr: { type: string, nullable: true }
adminAddress: { type: string, nullable: true }
apiRoot: { type: string, nullable: true }
appKey: { type: integer, nullable: true }
orderNumber: { type: integer, nullable: true }
createUser: { type: integer, nullable: true }
createTime: { type: string, format: date-time, nullable: true }
mobiIcon: { type: string, nullable: true }
clients: { type: integer, nullable: true }
integMode: { type: integer, nullable: true }
builtInTag: { type: integer, nullable: true }
webPagePkgAddr: { type: string, nullable: true }
webAddress: { type: string, nullable: true }
wxgzhAddress: { type: string, nullable: true }
dingAddress: { type: string, nullable: true }
isPublic: { type: boolean, nullable: true }
modules:
type: array
nullable: true
items: { $ref: '#/components/schemas/BoaModule' }
BoaAppType:
type: object
properties:
id: { type: integer, nullable: true }
name: { type: string, nullable: true }
apps:
type: array
nullable: true
items: { $ref: '#/components/schemas/BoaApp' }
BoaUser:
type: object
properties:
id: { type: integer, nullable: true }
account: { type: string, nullable: true }
name: { type: string, nullable: true }
idcard: { type: string, nullable: true }
phone: { type: string, nullable: true }
email: { type: string, nullable: true }
gender: { type: integer, nullable: true }
profilePicture: { type: string, nullable: true }
type: { type: integer, nullable: true }
status: { type: integer, nullable: true }
dept: { $ref: '#/components/schemas/BoaGroup' }
school: { $ref: '#/components/schemas/BoaGroup' }
bureau: { $ref: '#/components/schemas/BoaGroup' }
groups:
type: array
nullable: true
items: { $ref: '#/components/schemas/BoaGroup' }
roleIds:
type: array
nullable: true
items: { type: integer }
currentRole: { $ref: '#/components/schemas/BoaRoleInfo' }
schools:
type: array
nullable: true
items: { $ref: '#/components/schemas/BoaGroup' }
SysUserRequest:
type: object
properties:
id: { type: integer, nullable: true }
groupId: { type: integer, nullable: true }
account: { type: string, nullable: true }
password: { type: string, nullable: true }
confirmPassword: { type: string, nullable: true }
realName: { type: string, nullable: true }
phone: { type: string, nullable: true }
idcard: { type: string, nullable: true }
email: { type: string, nullable: true }
gender: { type: integer, nullable: true }
birthday: { type: string, nullable: true }
profilePicture: { type: string, nullable: true }
schoolId: { type: integer, nullable: true }
roleIds:
type: array
nullable: true
items: { type: integer }
disabled: { type: integer, nullable: true }
expired: { type: boolean, nullable: true }
SysUserGroupDeleteItem:
type: object
properties:
userId: { type: integer, nullable: true }
groupId: { type: integer, nullable: true }
schoolId: { type: integer, nullable: true }
SysUserMoveRequest:
type: object
properties:
userId: { type: integer, nullable: true }
groupId: { type: integer, nullable: true }
schoolId: { type: integer, nullable: true }
SysUserGroupStateRequest:
type: object
properties:
userId: { type: integer, nullable: true }
groupId: { type: integer, nullable: true }
schoolId: { type: integer, nullable: true }
state: { type: integer, nullable: true }
SysUserTransferSchoolRequest:
type: object
properties:
userId: { type: integer, nullable: true }
fromSchoolId: { type: integer, nullable: true }
schoolId: { type: integer, nullable: true }
groupId: { type: integer, nullable: true }
SysGroupRequest:
type: object
properties:
name: { type: string, nullable: true }
parentId: { type: integer, nullable: true }
type: { type: integer, nullable: true }
sort: { type: integer, nullable: true }
SysGroupBatchRequest:
type: object
properties:
pid: { type: integer, nullable: true }
type: { type: integer, nullable: true }
items:
type: array
nullable: true
items:
type: object
properties:
name: { type: string, nullable: true }
SysGroupMoveRequest:
type: object
properties:
moveId: { type: integer, nullable: true }
pid: { type: integer, nullable: true }
ErrorEnvelope:
type: object
description: 业务/校验失败信封(HTTP 200)
required: [code, msg]
properties:
code: { type: integer, description: 4=未登录/登录过期 5=业务校验失败 }
msg: { type: string }
data: {}
@@ -0,0 +1,71 @@
# yh-platform 签名协议规范(v1.0)
> 权威源:Java `yh-utils-core` `utils.crypto.SignUtil` + `yh-app-auth-spring-boot-starter` `SignCanonical`。
> 本文档为双端(Java / C#)共同遵守的契约工件;任何变更须先改本文,再同步两侧实现。
## 1. 算法
统一使用 **HmacSHA256**,密钥与签名串均为 UTF-8 字节。
## 2. 双签名规则(长期并存,见方案 9.5 裁决 #5 收敛计划)
### 2.1 数据级签名(业务内容签名)
```
source = data + "|" + timestamp
output = HmacSHA256(source, secret) 的小写十六进制
timestamp = 毫秒级时间戳字符串
```
- 验签忽略大小写比对(`equalsIgnoreCase`)。
- `verifyWithExpire`:先比对签名,再校验 `|now - ts| <= expireMs`(默认 5 分钟,双向偏差均拒绝)。
### 2.2 请求级签名(HTTP 请求签名,SDK 出站默认)
```
canonicalTarget = path + ("?" + sortedQuery # query 为空则不带
source = METHOD + "\n" + canonicalTarget + "\n" + timestamp
output = HmacSHA256(source, secret) 的 Base64
```
- `METHOD`:大写(GET/POST),**大小写敏感**精确比对(这是既有行为,是否收敛见方案 9.5 裁决 #3)。
- `path`:**只取路径**,不含 host/port/context-path(保证服务发现/反代前后端天然对齐)。
- **basePath 剥离(客户端与服务端共同口径)**:客户端签名前从 path 剥离部署 basePath(与 UserBase 部署 context-path 一致,如 `/userbase`,对齐 Java `AppSignInterceptor.stripBasePath`);服务端验签前剥 `request.getContextPath()`(对齐 Java `AppAuthFilter`)。剥离后 canonical 口径统一为 `/oapi/**`。剥离规则:path 不以 basePath 开头时原样保留;剥离后为空或丢失前导斜杠时补 `/`(`/userbase` → `/`)。
- `sortedQuery`:query 按 `k=v` 整串字典序排序后以 `&` 连接;k、v 先 URL 解码再参与排序;多值参数的值也排序。
- 验签为**精确比对**(Base64 区分大小写)。
- `verifyRequestWithExpire` 语义同数据级。
- 测试向量中的 `canonicalTarget` 即**剥离后**的服务端口径(含 `fullUrl`/`basePath` 字段可复现剥离前 URL)。
### 2.3 密钥约束
- secret 最小长度 **16** 位(`MIN_SECRET_LENGTH`),低于此值签名方法抛参数异常。
## 3. HTTP 头契约(逐字一致)
| 头名 | 值 |
|---|---|
| `X-App-Key` | 调用方在 UserBase 注册的 appKey |
| `X-Timestamp` | 毫秒级时间戳字符串 |
| `X-Signature` | 请求级签名(Base64) |
## 4. 防重放
- 时间窗口:`|now - X-Timestamp| > expireMs`(默认 **300000ms**)即拒绝。
- nonce(可选扩展):同一 `(appKey, timestamp, nonce)` 在窗口内只允许提交一次;
Redis 键 `sign:replay:{appKey}:{timestamp}:{nonce}`,SETNX + TTL=窗口。
## 5. 验签密钥解析链(服务端)
```
1. Redis key = sign:app:{appKey} → 值为 SignAppKeyPayload JSON {appKey, secret, enabled}
2. 进程内缓存(Caffeine / IMemoryCache,TTL 5min)
3. 兜底 GET {userbase}/oapi/internal/sign-key/{appKey} → 信封 data 为 SignAppKeyPayload
```
- 仅当 `enabled=true` 且 `secret` 非空才可用;未知 appKey 直接拒绝。
- 任一层异常静默降级到下一层(Redis 宕机不阻断业务)。
## 6. 测试向量
`fixtures/sign-vectors.json`:≥10 组向量(含空 body、超长参数、中文 UTF-8、query 排序、密钥长度边界、毫秒时间戳边界)。
**基线由 Java `SignUtil` 实际运行产出**;CI 中 Java 侧重算 fixture 防漂移,C# 侧逐字节对拍。