用户中心模块讲三件事:登录与授权(四族登录入口 + token 语义)、租户与组织(多租户声明与切换)、用户主档维护(含头像/文件上传与零售注销)。本模块资源表受 MyBatis 租户拦截器按当前认证租户上下文约束,请求中的租户筛选字段不能代替空间切换(认证与空间)。
本页是 data-service
user-center模块接口的业务说明,非业务说明不在本页复述:
- OpenAPI JSON(可直接导入 Postman 等工具):
https://zxs.netbodycamera.com/main-service/v3/api-docs/user-center- 在线 Swagger 视图:data-service · 用户中心
- 端点全量清单(含权限点与中文说明):清单层 · user-center
参数、请求/响应字段、错误码等契约事实一律见上述 Swagger 与清单层;本页只讲业务语义与动线。
机构(组织树)主档:save/updateById 是「无异常即 true」的复合写,不提供 affected-row 数;importList 从 Web XLSX 行异步导入——受理成功只表示请求已包装成当前租户的 JMS 消息,逐行插入/更新、父机构解析与树重建发生在无关联回复的监听器里,没有进度、失败回传或完成事件,调用方只能稍后重新查询。listTreeCache 读取供选择器和业务视图使用的机构树(缓存视图,不承诺与最新写入实时一致)。
场景页:异步导入机构列表、读取当前租户的机构树缓存。契约见清单层 · org。
组织与人员域各族 list/page/树查询共用的查询控制约定(字段级形态见 Swagger;此处只留行为语义,同款适用于 basedata 的 employee/employee-user 与 authority 的 role/role-user):
sqlQueryCriteriaLike=false 或 sqlQueryCriteria.<field>="eq" 才是精确匹配;范围结束值左闭右开(<);sqlQueryCriteria.deleteTime="skip" 可包含已删除行。sqlQuerySorter.field 仅 Mapper 显式白名单字段生效,未知字段不形成排序;order 非 ascend 走降序分支。sqlQueryLimit 省略默认 100、钳制 1..100000(QueryLimits.normalizeSqlQueryLimit);Web 下拉选择用 1000、XLSX 导出用 100000。page 的 current/pageSize 为 Java primitive(省略为 0),本域 Controller 未调用 normalizePageSize,页长服务端边界未闭合;当前 Web 发送 current≥1、pageSize=10。orgNo 按精确值拒绝重复;更新时空 parentOrgId 会把 treeOrgId/treeOrgName 重设为自身 ID/名称。treeOrgId/treeOrgName 的 DTO 上限为 Java int 最大值,不能视为可无限输入。{title, value, children} 递归(Lists.TitleValueTreeNode),树根由请求的 parentOrgId/parentTenantId 决定。租户主档与租户树:listByUsernameCache 读取当前用户可切换的租户(缓存列表),listTreeByUsername 读取可见租户树。listByUsernameCache 的 username 为必填 query 但作用域受限:非空时仅 administrator 生效,空串或非 administrator 一律解析为当前登录名(不能查他人);getById 对非 administrator 请求把 id 覆盖为当前认证租户(等效只能读自己)。save/updateById 是绑定管理用户的复合写(同样「无异常即 true」)。租户的业务语义主要在登录声明(见 user 的多租户声明)与切换动线里,本资源其余为管理端 CRUD。
场景页:读取可切换租户、创建租户并绑定管理用户。契约见清单层 · tenant。
tenantId:Web 对 team/personal-space 分别生成 t_/p_ 前缀;非 administrator 提交的 tenantId 会被 Controller 覆盖为当前租户。tenantType 只决定保存/更新时的用户绑定分支(team → 唯一 team-management 用户、personal-space → 唯一空间用户),不改写数据库中的租户类型;其他非空值会写租户但不执行绑定。tenantNoPrefix/treeTenantId/treeTenantName/revision(当前 Web 编辑表单也不发送 parentTenantId,更新会把空值写回并重设树根字段)。第三方身份与平台账号的绑定面,配合 user 的登录族构成第四族登录入口:
bind:把一个第三方账号标识(类型 + 账号)登记到当前登录用户名下——先登录、后绑定;重复绑定同一组合不会幂等成功,而是报 Third party account exists。同一(类型, 账号)组合的并发绑定走 striped 锁(10 秒拿不到锁报并发错误);app 面没有解绑端点,解绑仅管理端 CRUD 可达。绑定成功后即可走 login 免密换取登录态。
login:匿名可调的登录入口,用已绑定的第三方标识免密换取加密 JWT(data.authorization)、落定租户(data.tenantId)与用户资料。前置业务条件:标识已绑定、绑定用户仍存在、该用户名下至少有一个租户(否则 Unbound tenant)。thirdPartyAccountType 是调用方自行维护的不透明类型标识(≤128 字符),服务端不限定具体平台。频控按(类型, 账号)组合计而非用户名计:3 秒内重复拒绝;24 小时滚动窗口满 100 次进入 5 分钟冷却,冷却拒绝的 error 为已插值文本,extraData.ss 为剩余秒数、extraData.message 为未插值模板(App 可用模板自行本地化渲染倒计时)。登录成功为该用户写个人 MQTT 账号缓存(user-{username},ACL 写 Redis),与三族 logind 下发静态 App MQTT 账号的机制不同,响应不返回 MQTT 账号字段。
code 占位惯例(bind/login 共用):code 在 Bean Validation 层必填,但服务端不校验其值——没有与第三方平台对接的授权码校验逻辑,账号查找只按(类型, 账号)精确匹配。旧 app 文档「预留,待对接各帐号平台后生效」标注已过时:现状是「校验必填、值不参与任何逻辑」的稳定占位,App 传任意非空字符串即可(建议仍传真实授权码,保持将来服务端启用校验时的兼容)。端点特有请求示例(bind 与 login 同构,code 为占位值):
其余 count/getById/list/page/removeById*/save/updateById 是带 @PreAuthorize 的管理端 CRUD,非 App 调用面。
已知边界:第三方绑定表随注销链路(UserDeactivateListener)一并纳入删除范围——注销会连带清除第三方绑定;code 不校验意味着绑定/登录链路的鉴权强度分别等于登录态与「知晓(类型, 账号)组合」本身(App 侧须自行保证来源可信)。
场景页:绑定第三方账号、第三方账号登录。契约见清单层 · third-party-account。
用户主档与登录族,本模块的业务重心。
updateById 是混合范围操作:用户主表按全局 userId 更新;角色、用户组和用户-租户关系只在当前租户内同步——跨租户授权须切换空间后另行操作。
用户名 / 邮箱 / 手机号 / 第三方账号四族登录入口共用同一套下游语义(登录 claims 决策已在后端收敛为四族共享实现):
dev profile 才生效)。手机号族限频报 Too frequent operation。data.authorization 为加密 JWT 但按不透明 token对待,后续请求以 Authorization: Bearer 原样转发,不做 JWT 解析;认证与空间规则见认证与空间。data.tenantId 为本次登录选择的租户;switchTenant 切换时缓存更新并重新签发 token(旧 token 被替换而非吊销,调用方必须替换本地凭据);非 administrator 切换到非成员租户抛业务拒绝(Wrong tenant id)。logind(及邮箱/手机号族的同名端点)每次调用都是全新签发而非续期,token 有效期自签发时刻起算(prod 3 天、非 prod 99 天);认证上下文无用户名时返回 data 全空的成功应答(防御分支,应引导重新登录)。响应 extraData 附 App 侧 MQTT 账号(mqttAppAccountUsername/mqttAppAccountPassword——取环境配置 mqtt-app-accounts 首项、空表兜底 public-app-3)与当前租户员工视图(employee,无档案时字段全空)。logout 使服务端会话/令牌失效并写操作日志;无 @PreAuthorize,匿名调用(认证上下文无用户名)直接返回成功——对未登录态幂等,重复调用无害。authorization 才完成登录;不得把 HTTP 成功等同设备或 MQTT 就绪。loginUser 嵌套字段、额外数据字段及运行时部署白名单未逐字段联调。getBaseInfo/changeBaseInfo(注意机构 Web 前端存在 changeBaseInfo%20 尾随空格的 wire 变体,App 按无尾随空格的服务端路径调用)、changePassword(已登录验证旧密码后设新密码)。changeBaseInfo/changePassword 共用同一按用户名限频——3 秒内重复提交拒绝、24 小时内超过 10 次进入 5 分钟冷却,冷却拒绝时 extraData.ss 返回剩余秒数(重试不无条件幂等)。UserUpdateRequestDTO 后按当前 userId 更新,DTO 未含的键不受影响;但 email/mobilePhone 更新的是 user 表冗余列,权威数据在独立子表(读取侧从子表回填,两处可能不一致)。getBaseInfo 的手机号/邮箱展示值同样由子表回填。forgetPassword(授权码校验方案)现役存疑:机构手册显式决定不发布正本、旧 App 文档亦无收录,对接前先补证。getRandomNumericByKey 为手机或邮箱验证码签名取得一次六位随机数——它不是验证码,不能直接用于注册或找回密码(轮换供给见运行时关联)。uploadAvatar(jpg/png ≤300KB,返回 /files/user/{username}/avatar.*,展示走 /files 静态路径);uploadByFileNo 以「文件号」为键上传到当前登录用户名下目录,返回 /user/{username}/{fileNo}.{ext} 相对路径——同一 fileNo 且同名扩展名重复上传直接覆盖旧文件,无版本保留,语义是「每号一文件」的固定槽位写入。已知部署缺口:/files 静态服务无鉴权、多节点一致性无证据(具名缺口见零售手册)。deactivateWithVerification 为验证码停用(Web 链路);零售端免验证码的注销见 v1-retail。getById 会用关联表查询到的最后一个手机号/邮箱覆盖主用户字段(无关联时置 null),展示值可能来自关联记录而非用户主档;updateById 仅非空字符串与非空 birthDate/revision 生效(空字符串不能清除旧值),对非当前登录用户 roleIds 仅在非空时同步(空数组不能清空角色),当前登录用户自身的角色/用户组变更被跳过。count/list/page/save/updateById/removeById* 为用户主档 CRUD;countByTenantId/listByTenantId/pageByTenantId 是 Plus 管理端租户口径变体(@PreAuthorize,非 App 面);changePasswordById 为管理端按 ID 改密。查询控制字段约定见 org · query-controls。契约见清单层 · user。
邮箱族登录入口与验证码通道(与手机号族同构,见 user-phone-number):
login:邮箱 + 密码登录,返回 data.authorization/data.tenantId;频控同登录族(3 秒内重复拒绝、24 小时超 100 次冷却)。sendVerificationCode:发送注册/改密/找回/停用所需的邮箱验证码。wire 惯例:先经 user/getRandomNumericByKey 取随机数,再按平台约定由随机数与邮箱计算 emailCrypto 一并提交(该值的生成算法属于内部实现,不在文档披露范围,具体生成方式请与开发人员沟通确认);type 为 register/change-password/retrieve-password/deactivate 四枚举(决定模板与存在性分支);频控 24 小时 10 条、60 秒内 1 条(dev profile 跳过)。完成判据是异步语义:成功只表示已发布邮件发送消息并写入验证码缓存,不等于收件箱已送达;响应 data 是轮换后的下一次签名随机数,不是验证码本身。register:邮箱 + 验证码注册,成功返回新 username——不返回登录态,账号开通流程需另行登录;验证码 5 分钟有效(非 dev)。logind:邮箱族的登录态恢复(App 冷启动/回前台用当前凭据重新换取登录态视图 + 新 token)。retrievePassword:邮箱找回密码。场景页:邮箱登录、发送邮箱验证码、邮箱注册、恢复邮箱登录态。契约见清单层 · user-email。
手机号族登录入口与短信验证码通道,与邮箱族同构:login(号码 10–16 位 + 密码)、sendVerificationCode(phoneNumberCrypto 按平台约定由随机数与手机号计算,该值的生成算法属于内部实现,不在文档披露范围,具体生成方式请与开发人员沟通确认;type 四枚举,register 时号码已存在、其他类型号码不存在分别拒绝;24 小时 10 条、60 秒内 1 条)、register(返回 username 不返回登录态)、logind(手机号族登录态恢复)、retrievePassword。短信的实际投递出口与消费者边界见 message(业务) 与运行时关联。
场景页:手机号登录、发送短信验证码、手机号注册、恢复手机号登录态。契约见清单层 · user-phone-number。
用户标签主档 CRUD(场景页未引用,无独立业务叙事);契约见清单层 · user-tag。
用户与租户的成员关系关联表(「用户可在哪些租户」的数据基础,支撑 tenant 的可切换租户口径),纯 CRUD;契约见清单层 · user-tenant。
/v1/retail 门面的用户中心唯一端点:零售 App 账号注销。业务语义:
USER_DEACTIVATE JMS 消息(受理 data=true 只表示消息已发布),由监听器异步删除该用户的个人空间数据、绑定关系与账户本身;何时对查询不可见取决于消费时序,不应假设同步完成。受理后应清除本地凭据并退出登录(删除范围明细见下文 deactivate-scope)。filesStoragePath 落盘文件残留(文件目录不在删除范围);旧 token 失效时序未由源码闭合。第三方绑定随注销链路一并删除(见 third-party-account)。场景页:注销账号(零售端)。契约见清单层 · v1-retail。
同一注销结果有两条入口,安全强度不同(验证码变体正本见机构手册停用账户):
| 维度 | 零售裸注销(本端点) | deactivateWithVerification(验证码变体) |
|---|---|---|
| 代际/调用面 | /v1/retail 新代,零售 App 专属 |
第一代 user Plus,Web 调用链(机构手册) |
| 验证码 | 无 | 必须:先经 email/phone sendVerificationCode(type=deactivate)取码,5 分钟过期、不符即拒绝;账号须绑定邮箱或手机其一 |
| 频控 | 无任何频控 | 24 小时内最多 3 次尝试 |
| 并发防护 | 无 | 同用户 striped 锁,10 秒拿不到锁失败 |
admin 保护 |
controller 不拦截;消费端监听器对 admin 直接跳过 |
controller 显式拒绝 |
| 触发动作 | 发布 USER_DEACTIVATE JMS 消息 |
同一 JMS |
| 数据删除 | 同一 UserDeactivateListener 异步消费,删除范围完全相同 |
同左 |
即:两个变体的差别只在触发前的验证强度,受理之后的删除链路完全同源——验证码与频控只存在于第一代入口,零售裸注销拿到有效 token 即可发起。
监听器消费 USER_DEACTIVATE 后:admin 用户名直接跳过;其余用户以 TenantIdIgnore 跨租户查出该用户名下全部租户、过滤出 tenantType=personal-space 的个人空间租户,对以下数据做软删(实体均带 @TableLogic):
| 删除对象 | 锚定条件 |
|---|---|
| 当前用户分享给他人的分享记录 | device_device_share.tenantId ∈ 个人空间租户 |
| 他人分享给当前用户的分享记录 | device_device_share.targetTenantId ∈ 个人空间租户 |
| 当前用户分享出去的设备投影 | device.sourceTenantId ∈ 个人空间租户 |
| 当前用户名下全部设备投影(自绑 + 收到分享) | device.tenantId ∈ 个人空间租户 |
| 第三方账号绑定 | third_party_account.username = 用户名 |
| 手机号绑定 | user_phone_number.username = 用户名 |
| 邮箱绑定 | user_email.username = 用户名 |
| 用户账户本身 | user.username_prefix + username = 用户名 |
| 个人空间租户 | tenant ∈ 个人空间租户 |
| 用户-租户绑定 | user_tenant ∈ 个人空间租户 |
最后清理该用户名的登录租户缓存(loginUserTenantIdCache.remove(username))。设备库存租户 pcm 的库存投影、其他用户的设备与分享记录不受影响;删除范围以个人空间租户为界。
本模块相关的定时任务 / 消费者 / 缓存(全量表见 data-service 运行时与缓存、device-service 消费者,此处只点名不复述):
ObfuscationCodeScheduling(PT10M):滚动生成并保留最近 3 个 6 位混淆码(缓存 stringListCache)——验证码签名随机数(getRandomNumericByKey 取号、sendVerificationCode 轮换返回)的供给侧。loginUserTenantIdCache(99 天,登录用户与租户映射——switchTenant 成功后更新的「登录租户缓存」)与 userCache(7 天,用户信息)。MqttAccountRemoveConsumer(main-service):账号删除时清除 Redis 中的 MQTT 用户/ACL 缓存——用户删除/注销链路会波及 MQTT 账号缓存。user deactivate 属内部 JMS destination,按约定不入全量表(其业务语义见上文 v1-retail)。