用户中心(业务)

用户中心模块讲三件事:登录与授权(四族登录入口 + token 语义)、租户与组织(多租户声明与切换)、用户主档维护(含头像/文件上传与零售注销)。本模块资源表受 MyBatis 租户拦截器按当前认证租户上下文约束,请求中的租户筛选字段不能代替空间切换(认证与空间)。

本页是 data-service user-center 模块接口的业务说明,非业务说明不在本页复述:

参数、请求/响应字段、错误码等契约事实一律见上述 Swagger 与清单层;本页只讲业务语义与动线。

org 组织机构

机构(组织树)主档:save/updateById 是「无异常即 true」的复合写,不提供 affected-row 数;importList 从 Web XLSX 行异步导入——受理成功只表示请求已包装成当前租户的 JMS 消息,逐行插入/更新、父机构解析与树重建发生在无关联回复的监听器里,没有进度、失败回传或完成事件,调用方只能稍后重新查询。listTreeCache 读取供选择器和业务视图使用的机构树(缓存视图,不承诺与最新写入实时一致)。

场景页:异步导入机构列表读取当前租户的机构树缓存。契约见清单层 · org

query-controls 通用查询控制字段

组织与人员域各族 list/page/树查询共用的查询控制约定(字段级形态见 Swagger;此处只留行为语义,同款适用于 basedata 的 employee/employee-user 与 authority 的 role/role-user):

  • 字符串筛选默认包含匹配;sqlQueryCriteriaLike=falsesqlQueryCriteria.<field>="eq" 才是精确匹配;范围结束值左闭右开(<);sqlQueryCriteria.deleteTime="skip" 可包含已删除行。
  • sqlQuerySorter.field 仅 Mapper 显式白名单字段生效,未知字段不形成排序;orderascend 走降序分支。
  • sqlQueryLimit 省略默认 100、钳制 1..100000(QueryLimits.normalizeSqlQueryLimit);Web 下拉选择用 1000、XLSX 导出用 100000。
  • pagecurrent/pageSize 为 Java primitive(省略为 0),本域 Controller 未调用 normalizePageSize,页长服务端边界未闭合;当前 Web 发送 current≥1pageSize=10

write-rules 写入规则

  • orgNo 按精确值拒绝重复;更新时空 parentOrgId 会把 treeOrgId/treeOrgName 重设为自身 ID/名称。
  • treeOrgId/treeOrgName 的 DTO 上限为 Java int 最大值,不能视为可无限输入。
  • 机构/租户树节点形态为 {title, value, children} 递归(Lists.TitleValueTreeNode),树根由请求的 parentOrgId/parentTenantId 决定。

tenant 租户

租户主档与租户树:listByUsernameCache 读取当前用户可切换的租户(缓存列表),listTreeByUsername 读取可见租户树。listByUsernameCacheusername 为必填 query 但作用域受限:非空时仅 administrator 生效,空串或非 administrator 一律解析为当前登录名(不能查他人);getById 对非 administrator 请求把 id 覆盖为当前认证租户(等效只能读自己)。save/updateById 是绑定管理用户的复合写(同样「无异常即 true」)。租户的业务语义主要在登录声明(见 user 的多租户声明)与切换动线里,本资源其余为管理端 CRUD。

场景页:读取可切换租户创建租户并绑定管理用户。契约见清单层 · tenant

write-rules 写入规则

  • tenantId:Web 对 team/personal-space 分别生成 t_/p_ 前缀;非 administrator 提交的 tenantId 会被 Controller 覆盖为当前租户。
  • tenantType 只决定保存/更新时的用户绑定分支(team → 唯一 team-management 用户、personal-space → 唯一空间用户),不改写数据库中的租户类型;其他非空值会写租户但不执行绑定。
  • 更新实现不持久化 tenantNoPrefix/treeTenantId/treeTenantName/revision(当前 Web 编辑表单也不发送 parentTenantId,更新会把空值写回并重设树根字段)。

third-party-account 第三方账号

第三方身份与平台账号的绑定面,配合 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 为占位值):

    1{"thirdPartyAccountType": "wechat-app", "thirdPartyAccount": "oX1-abc123", "code": "WECHAT_AUTH_CODE_PLACEHOLDER"}
  • 其余 count/getById/list/page/removeById*/save/updateById 是带 @PreAuthorize 的管理端 CRUD,非 App 调用面。

  • 已知边界:第三方绑定表随注销链路(UserDeactivateListener)一并纳入删除范围——注销会连带清除第三方绑定;code 不校验意味着绑定/登录链路的鉴权强度分别等于登录态与「知晓(类型, 账号)组合」本身(App 侧须自行保证来源可信)。

场景页:绑定第三方账号第三方账号登录。契约见清单层 · third-party-account

user 用户

用户主档与登录族,本模块的业务重心。

update-scope 更新范围

updateById 是混合范围操作:用户主表按全局 userId 更新;角色、用户组和用户-租户关系只在当前租户内同步——跨租户授权须切换空间后另行操作。

login-and-auth 登录与授权

用户名 / 邮箱 / 手机号 / 第三方账号四族登录入口共用同一套下游语义(登录 claims 决策已在后端收敛为四族共享实现):

  • 密码 wire 惯例:Web/App 发送前按平台约定先作摘要变换;服务端同时接受明文与变换形态比对(历史兼容,集成方任选其一,禁止记录原始密码;该值的生成算法属于内部实现,不在文档披露范围,具体生成方式请与开发人员沟通确认)。
  • 登录限频:同一用户名/邮箱/手机号 3 秒内再次登录失败;24 小时高频登录会被拒绝(邮箱族为 24 小时窗口累计超 100 次进入冷却,非 dev profile 才生效)。手机号族限频报 Too frequent operation
  • token 语义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,无档案时字段全空)。
  • 与 MQTT 的关系:登录响应额外数据含 MQTT 凭据,但 Broker 连接另行完成——登录成功不等于设备/MQTT 就绪;切换租户后 MQTT 连接需以新凭据重建的时序未由接口闭合。
  • 注销语义logout 使服务端会话/令牌失效并写操作日志;无 @PreAuthorize,匿名调用(认证上下文无用户名)直接返回成功——对未登录态幂等,重复调用无害。
  • 完成判据:响应业务成功且保存新 authorization 才完成登录;不得把 HTTP 成功等同设备或 MQTT 就绪。
  • 已知缺口:登录响应的 loginUser 嵌套字段、额外数据字段及运行时部署白名单未逐字段联调。

涉及场景页:用户名登录切换租户恢复登录态

profile-and-files 主档与文件

  • 个人资料:getBaseInfo/changeBaseInfo(注意机构 Web 前端存在 changeBaseInfo%20 尾随空格的 wire 变体,App 按无尾随空格的服务端路径调用)、changePassword(已登录验证旧密码后设新密码)。
  • 修改类限频changeBaseInfo/changePassword 共用同一按用户名限频——3 秒内重复提交拒绝、24 小时内超过 10 次进入 5 分钟冷却,冷却拒绝时 extraData.ss 返回剩余秒数(重试不无条件幂等)。
  • changeBaseInfo 更新语义:服务端把请求体拷贝到 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-email 用户邮箱

邮箱族登录入口与验证码通道(与手机号族同构,见 user-phone-number):

  • login:邮箱 + 密码登录,返回 data.authorization/data.tenantId;频控同登录族(3 秒内重复拒绝、24 小时超 100 次冷却)。
  • sendVerificationCode:发送注册/改密/找回/停用所需的邮箱验证码。wire 惯例:先经 user/getRandomNumericByKey 取随机数,再按平台约定由随机数与邮箱计算 emailCrypto 一并提交(该值的生成算法属于内部实现,不在文档披露范围,具体生成方式请与开发人员沟通确认);typeregister/change-password/retrieve-password/deactivate 四枚举(决定模板与存在性分支);频控 24 小时 10 条、60 秒内 1 条(dev profile 跳过)。完成判据是异步语义:成功只表示已发布邮件发送消息并写入验证码缓存,不等于收件箱已送达;响应 data 是轮换后的下一次签名随机数,不是验证码本身。
  • register:邮箱 + 验证码注册,成功返回新 username——不返回登录态,账号开通流程需另行登录;验证码 5 分钟有效(非 dev)。
  • logind:邮箱族的登录态恢复(App 冷启动/回前台用当前凭据重新换取登录态视图 + 新 token)。
  • retrievePassword:邮箱找回密码。
  • 其余为邮箱主档 CRUD。

场景页:邮箱登录发送邮箱验证码邮箱注册恢复邮箱登录态。契约见清单层 · user-email

user-phone-number 用户手机号

手机号族登录入口与短信验证码通道,与邮箱族同构:login(号码 10–16 位 + 密码)、sendVerificationCodephoneNumberCrypto 按平台约定由随机数与手机号计算,该值的生成算法属于内部实现,不在文档披露范围,具体生成方式请与开发人员沟通确认;type 四枚举,register 时号码已存在、其他类型号码不存在分别拒绝;24 小时 10 条、60 秒内 1 条)、register(返回 username 不返回登录态)、logind(手机号族登录态恢复)、retrievePassword。短信的实际投递出口与消费者边界见 message(业务)运行时关联

场景页:手机号登录发送短信验证码手机号注册恢复手机号登录态。契约见清单层 · user-phone-number

user-tag 用户标签

用户标签主档 CRUD(场景页未引用,无独立业务叙事);契约见清单层 · user-tag

user-tenant 用户与租户关联

用户与租户的成员关系关联表(「用户可在哪些租户」的数据基础,支撑 tenant 的可切换租户口径),纯 CRUD;契约见清单层 · user-tenant

v1-retail 零售接口(v1)

/v1/retail 门面的用户中心唯一端点:零售 App 账号注销。业务语义:

  • 免验证码、免频控——服务端除登录态外无任何校验(不验证码、不限频、admin 保护在消费端监听器),App 侧应自行做强确认交互(二次确认弹层等)再发起。
  • 异步删除:调用即发布 USER_DEACTIVATE JMS 消息(受理 data=true 只表示消息已发布),由监听器异步删除该用户的个人空间数据、绑定关系与账户本身;何时对查询不可见取决于消费时序,不应假设同步完成。受理后应清除本地凭据并退出登录(删除范围明细见下文 deactivate-scope)。
  • 已知边界:注销后 filesStoragePath 落盘文件残留(文件目录不在删除范围);旧 token 失效时序未由源码闭合。第三方绑定随注销链路一并删除(见 third-party-account)。

场景页:注销账号(零售端)。契约见清单层 · v1-retail

variant-comparison 双变体对照(验证码停用 vs 零售裸注销)

同一注销结果有两条入口,安全强度不同(验证码变体正本见机构手册停用账户):

维度 零售裸注销(本端点) deactivateWithVerification(验证码变体)
代际/调用面 /v1/retail 新代,零售 App 专属 第一代 user Plus,Web 调用链(机构手册)
验证码 必须:先经 email/phone sendVerificationCodetype=deactivate)取码,5 分钟过期、不符即拒绝;账号须绑定邮箱或手机其一
频控 无任何频控 24 小时内最多 3 次尝试
并发防护 同用户 striped 锁,10 秒拿不到锁失败
admin 保护 controller 不拦截;消费端监听器对 admin 直接跳过 controller 显式拒绝
触发动作 发布 USER_DEACTIVATE JMS 消息 同一 JMS
数据删除 同一 UserDeactivateListener 异步消费,删除范围完全相同 同左

即:两个变体的差别只在触发前的验证强度,受理之后的删除链路完全同源——验证码与频控只存在于第一代入口,零售裸注销拿到有效 token 即可发起。

deactivate-scope 异步删除范围(admin 除外)

监听器消费 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)。