bag
This commit is contained in:
@@ -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# 侧逐字节对拍。
|
||||
Reference in New Issue
Block a user