Compare commits

..
56 Commits
Author SHA1 Message Date
chenxing e0632d3c7b Merge remote-tracking branch 'origin/main'
build / build-push (push) Successful in 1m2s
# Conflicts:
#	src/main/java/com/yunhai/userbase/entity/ApplicationInfo.java
2026-10-30 10:22:26 +08:00
chenxing e713d92d49 feat(mobile-auth): MOB-AUTH-03/04 移动认证入库(含 StringRedisTemplate @Qualifier 修复)
- mobile/ 包:移动身份服务适配(verifyPassword/session 快照/授权种子)
- MobileStrongConfiguration:@Qualifier("stringRedisTemplate") 修复双 bean 歧义导致的 /oapi/mobile/* 静默 500
- PasswordVerificationController:POST /oapi/user/verifyPassword(classic app-auth + portal 白名单)
- ApplicationKeyReservation(+Repository)、AppController/Identity/ChannelBind oapi 适配、app_code 守卫配套
- MOB_AUTH_03_schema_DRAFT.sql(8 表 DRAFT)、application_bootstrap.sql 100000 幂等
- 测试:PasswordVerificationControllerTest、ApplicationKeyGateTest、AppControllerActivatedTest、entity/mobile 测试
- 依赖:com.yunhai.platform:yh-mobile-auth:1.1.0-mobauth03-rc.1(Nexus 已有同字节制品)
2026-10-30 10:21:29 +08:00
chenxing 6f60d65195 feat(app): 应用稳定来源主体 UUID + /oapi/app/activated 开通判定(FL 授权门禁依赖)
build / build-push (push) Successful in 2m10s
2026-10-28 11:45:41 +08:00
chenxing 809d88c838 feat(dept): SysGroupVo 补 memberCount/memberNames,getTree 回填部门成员数与姓名预览
build / build-push (push) Successful in 2m23s
- SysGroupVo 新增 memberCount(直属成员数)与 memberNames(前 50 个姓名预览)
- SysGroupService 新增 fillMemberStats:整棵树只发两次查询(关联表 in + 用户表 in),
  getTree()/getTree(scope) 均回填,口径同 oapi 部门目录(sys_user_group_rel 按 groupId 计数,不排除转出)
- 预览上限 MEMBER_PREVIEW_LIMIT=50,避免大部门把名单塞进 getTree;完整名单走 /api/sysuser/List?groupId=
2026-10-23 10:30:52 +08:00
chenxing 72fe0f218a feat(app): application_info app_code 写入口守卫(已登记拒改名/拒清空,初次登记唯一性保持)
build / build-push (push) Successful in 1m26s
2026-10-22 17:14:27 +08:00
chenx b1b769a1b8 feat(dept): CUSTOM 自定义部门与统一部门目录契约(双来源部门二期)
build / build-push (push) Successful in 59s
- 三表 ub_custom_dept_category/ub_custom_department/ub_custom_department_member(DDL 入 service/sql,160 待人工执行)
- 实体/仓库/服务:学校隔离(AccessScopeService 新增 requireSchoolInScope/resolveWritableSchoolId)、
  del_marker=id 软删让位 uk、成员=本校教职工黑名单口径(排除学生组 type=8 与禁用账号)
- 管理端 /api/deptcategory、/api/customdept 共 17 端点(含 CandidateUsers 候选口)
- 开放端 /oapi/departments 四口:目录/分类/resolve-batch/成员(scope 缺省=select 严档),
  应用×学校授权走 sys_group_app_rel(仅验签不放行跨校)
- SysGroupService.changeIncludeChildren 补学校组类型校验
- 新增单测 83 例全绿(部门26/分类12/目录25/schoolguard12/includeChildren8)
2026-10-22 09:35:37 +08:00
chenxing bf3f3344c1 feat(dept): sys_group 增列 include_children,支持「包含子组」落库
build / build-push (push) Successful in 1m12s
- 新增 sys_group.include_children(1=部门列表含下级部门 / 0=仅本级,默认 1),仅学校节点使用
- 实体 / Vo / Repository / Service / Controller 全链路打通:单列 UPDATE + 发布 GroupChangedEvent 驱逐 groupSnapshot 缓存
- 新端点 POST /api/sysgroup/SetIncludeChildren/{id},权限只用 requireGroupInScope
  (不能用 requireGroupModifiable:后者禁止 SCHOOL 级操作学校组本身,而本属性正是学校管理员要改的本校属性)
- deploy/sql/20261021_add_sys_group_include_children.sql 提供幂等 DDL,需在 160 手工执行
2026-10-21 15:12:31 +08:00
ops 996eed3caf fix(ci): 构建容器名唯一化 + npm 走国内镜像源(避免并发互踩 exit 137) 2026-10-21 06:25:25 +00:00
ops 8e7b69c795 fix(ci): 构建容器名唯一化 + npm 走国内镜像源(避免并发互踩 exit 137) 2026-10-21 06:24:29 +00:00
ops 7247c1193a ci: apk 源统一走 aliyun(规避 dl-cdn 时段性被拦卡 60s) 2026-10-21 01:37:01 +00:00
chenxing ddaa3a6f42 feat(oapi): 新增移动端渠道绑定开放接口
build / build-push (push) Successful in 59s
- GET /oapi/channel/user 按 (channel, openid) 查已绑定用户,未绑定返回 null
- POST /oapi/channel/bind 凭证绑定:账号密码本服务校验 + 三层查重(openid/账号/unionid)
- POST /oapi/channel/bindByPhone 手机号证明绑定(归属由渠道验签器证明)
- POST /oapi/channel/unbind 按登录账号 + 渠道删绑定行
- 抢绑防护:openid/unionId 仅由服务端从一次性 loginTicket 获取,前端不接触
- 新增 sys_user_channel_rel 实体/仓储/服务,写操作经 @OapiLog 记审计
- 补 ChannelBindServiceTest
2026-10-21 08:52:52 +08:00
chenxing cb40524b8e feat(oapi): user/modules 新增可选 appCode,支持查询指定应用的模块树
- 传 appCode => 按 application_info.app_code 解析目标应用(供托管型应用代渲染导航)
- 未传 => 保持原行为,按验签 appKey 解析调用方应用
- 应用解析失败一律返回空列表(安全优先,禁止意外泄露其他应用模块)
- 参数用 appCode 而非整数 appId:跨环境稳定,与 form-center 的 /app/{appCode}/ 同键
2026-10-21 08:52:42 +08:00
chenxing 195936236d feat(module): 子模块支持按钮权限型,custom_id 放开为可空
- AppModule.customId 去掉 @NotBlank / nullable=false;AppModuleRequest 同步放开必填校验
- AppModuleService 空白归一为 null,仅在有值时做应用内唯一校验;path 同样归一 + trim
- 必须存 NULL 而非 '':'' 在 uk_app_module_custom(app_id, custom_id) 下每应用只能有一条
- 新增幂等 DDL sql/app_module_custom_id_nullable.sql(ddl-auto=update 不会改列属性)
- 补 AppModuleServiceButtonModuleTest
2026-10-21 08:52:34 +08:00
chenxing ad434dbc36 refactor(path): API 前缀收敛为 /api,退役 /{platform} 通配
- Config.API_PATH 由 "/{platform}" 改为 "/api",另留 MOBI_API_PATH 常量做对齐
- ManageAccessInterceptor 白名单改显式 /api 前缀,路径从通配变闭集
- DWebMvcConfig 拦截器注册由 /{platform}/** 改 /api/**,堵掉「任意前缀绕过白名单」的洞
2026-10-21 08:52:26 +08:00
chenxing 035df02f63 部门线P0最小批:sys_group加status启停(单列UPDATE+快照驱逐+oapi透出+160幂等SQL)
build / build-push (push) Successful in 57s
2026-10-20 22:37:22 +08:00
chenxing 2b754c08ca feat(app): 应用矢量图标 icon_key/icon_color 落库 + icon-map 出参 + svg 上传消毒
build / build-push (push) Successful in 1m25s
2026-10-20 16:24:51 +08:00
chenxing 336b50675c ci: apk 源不可达时按可达性自动切换镜像(修复流水线卡死在第一步)
build / build-push (push) Successful in 1m16s
现象:10-20 12:18 / 12:19 两轮构建都失败在第一步 apk add:
  fetch https://dl-cdn.alpinelinux.org/alpine/v3.20/main: Permission denied(每个源卡满 60s)
  ERROR: unable to select packages: curl (no such package)
后续 clone / 构建 / 打包 / 建档全部被 skip,代码根本没被编译到。
同一时刻本机访问该源是 200,说明是 runner 容器侧网络受限。

改法:装包前用 busybox wget 依次探测 dl-cdn / 阿里云 / 腾讯云 / 华为云,
取第一个可达源替换 /etc/apk/repositories 后再 apk add;四源全不可达则显式报错退出,
不再以 120 秒超时的形式掩盖真实原因。
2026-10-20 14:18:49 +08:00
chenxing fe6c07cc4b fix(授权): 父级勾选态改「任一子节点选中即选中」+ 无模块应用整应用授权(-appId 哨兵)
build / build-push (push) Failing after 2m2s
1. 父级选中态(组授权 / 角色授权口径统一)
   两页 isAppChecked 由「全部子模块都勾选才 true」改为「任一子孙被勾选即 true」,
   配合前端样式把 zTree checkbox_true_part 图标指向 checkbox_true_full,
   折叠态也能一眼看出「部分授权」,不再依赖视觉很弱的半选。

2. 无子模块应用(如「一周安排」)此前勾选不落库、刷新即失效
   - 新增 constant/AppLevelModuleId:用 -appId 作「整应用授权」的 module_id 哨兵。
     两套授权表都是 (owner_id, module_id) 唯一键,用 0 会让同组/同角色的第二个
     无模块应用撞唯一键;真实模块 id 恒正,负数段天然空闲且权限判定永不命中。
   - GroupAuthzService#resolveModuleIds:应用无任何模块时返回 -appId,使「整应用」
     授权能落库;findGroupAppsWithModules 按 -appId 回填应用级 checked / inheritFlag。
   - AppRoleService:findRoleModuleTree 同口径回填 checked;saveRoleModules 对负数项
     跳过模块校验,改为校验应用存在性与角色授权范围(角色授权是全量覆盖,哨兵必须原样落库)。
   - GroupAuthzService#buildModuleTree:脏数据兜底——parent_id 指向已删模块导致根节点
     全丢时,把全部模块提升为根,避免「有模块的应用」被误判成无模块应用。

涉及前端配套改动见 UserBaseFront 同名提交(GroupAuth.vue / AppModuleCheckTree.vue)。
2026-10-20 12:18:22 +08:00
chenxing c790187f35 学校应用开通硬闸门:单点前置闸门+应用列表/authorized-codes 收窄(D1 保超管 bypass、D2 收敛)
build / build-push (push) Successful in 54s
- GroupAuthzService: findUserModulesForApp 超管短路后插生效学校单点开通前置闸门;新增 findUserAuthorizedAppIds(userId,schoolId) 收窄重载
- AppCategoryService.listBoaAppType 改用收窄口径;authorized-codes 端点迁至 (userId,null) 走当前身份校兜底
- 测试: 新增 GroupAuthzServiceSchoolGateTest,适配 ModuleTree/Alias/BaseFilter/AuthorizedCodes mock 签名
2026-10-12 14:23:05 +08:00
chenxing d7d239e77c 外观SDK化C:UserBase 接入外观代理 starter(端点 GET /api/appearance 三态兜底)
build / build-push (push) Successful in 1m41s
- pom 加 yh-openapi-spring-boot-starter(聚合包例外须显式引出站引擎)+
  yh-appearance-spring-boot-starter(平台新件,proxy 默认直连 UserHome /oapi/appearance;
  不配 yh.open.services.userhome 时端点 503=预期兜底,前端通道 init 静默保键)
- ManageAccessInterceptor 白名单放行 /api/appearance(仅登录可读自身偏好;
  择迹暂存只含本行,工作区既有他人拦截器重构 WIP 未混入)
- 前端零改动(通道 init 裸 fetch;alova 不生成定义)
- 方案:《外观代理SDK化与侧栏默认翻转-实施方案-20261011.md》任务 C
2026-10-11 13:59:53 +08:00
chenxing 34750c03ac feat(oapi): 学校维度应用清单端点 GET /oapi/app/list-by-school(流程中心应用设置源池)
build / build-push (push) Successful in 54s
- 开通口径=学校组单点已开通(sys_group_app_rel where group_id=schId,不沿祖先/不并子树角色,拍板 D7);
  基石应用过滤/端掩码过滤/convertToBoaApp 不透 secret/applyAlias 学校别名合并,骨架复用 listBoaAppType
- 新缓存 schoolAppList('s:'+schId+':c:'+clientType,60s,学校 id 不可枚举→授权/组/应用/别名事件整清)
- AppAuthzChangedEvent/GroupChangedEvent/ApplicationInfoCacheChangedEvent 三事件挂接整清
- 仅含本功能 5 文件;工作区其余未提交改动(icon-map/channel-bind 实验线)未纳入
2026-10-10 21:50:50 +08:00
chenxing 96855a8565 fix(identity): 可选身份列表排序改为「主校置顶→其他校 schId 升序→校内 roleId 升序」
build / build-push (push) Successful in 56s
修复同校多身份被主身份提升拆到头尾(如云海小学教师置顶、管理员落尾)。新增 IdentityService.sortForDisplay 统一 /oapi/user/roles 与 /oapi/identity/available 顺序;主身份行不再单独置顶,主校内部一律按 roleId(老师1→校领导2→管理员7),其余学校按 schId 升序。
2026-10-09 17:36:26 +08:00
chenxing 705cbde7cf fix(app): 应用列表两处——「全部」视图按 id 正序 + 基石应用不进用户端列表
build / build-push (push) Successful in 1m8s
1) 排序(管理端 GET /AppList):未选应用类型(前端「全部」)统一按 id 正序,最新创建的在最后。
   - SUPER:去掉 @SortDefault(sort="sortNo"),改 categoryId==null ? Sort.by(ASC,"id") : Sort.unsorted();
     类别视图排序改由 Specification 内嵌 orderBy(NULL 沉底 + sortNo + id)——NULL 沉底须 case
     表达式,Sort 表达不了,故该分支必须传 unsorted,否则 Pageable 排序会整体覆盖 spec 的 orderBy。
   - SCHOOL:listSchoolApps 按视图分叉,全部视图走新增 appIdOrder(),选中类型仍走生效排序
     (override sortNo 优先、空回落主表、并列 id 升序),学校拖拽排序管理不回归。
   - 口径:sort_no 是「类型内」序号,跨类型打平无全局意义(服务内注释 + 2026-07-22 记忆)。
2) 基石过滤(oapi GET /oapi/app/list):AppCategoryService.listBoaAppType 补 AppNature.isBaseApp
   过滤,UserBase 自身(appKey<=100000)与 app_nature=BASE(UserHome / 移动门户)不进用户端应用列表。
   根因:findUserAuthorizedAppIds 超管分支返回全量,而该读链全程无基石判定——即
   《消息卡片直批与跳转基址统一方案-20260928》§4.2「一开四关」漏掉的第 6 个触点(现收口为「关」)。
   未动 findUserAuthorizedAppIds(被 /oapi/app/authorized-codes 跳转判权共用,改了会连带把
   「跳到基石应用」判无权限);未动 manage 端口径(仅 isUserbaseSelf,保留基石供重置密钥)。

测试:AppCategoryServiceBaseAppFilterTest(3 用例)+ SchoolAppOverrideServiceListSchoolAppsTest(5 用例)
2026-10-09 12:03:49 +08:00
chenxing 90c08a5f14 feat(oapi): 本人联系方式修改端点 POST /oapi/user/self/contact——只写 sys_user phone/email 两字段(空白=不修改),手机号格式/唯一查重+邮箱格式校验,UserChangedEvent 驱逐 boaUser 缓存(含旧 phone: key 提示);附带修复 BoaUserLoader 过时置空:email 列早已存在(实体/管理端/导入均读写),回读 BoaUser 须携带 email(任务B·UserBase 侧)
build / build-push (push) Successful in 2m8s
2026-10-02 08:53:30 +08:00
chenxing f0eaebd186 feat(sysuser): ByIds 批量姓名回显端点——管理面聚合页(消息追溯台收件明细)用,仅超管、窄投影 id/account/realName
build / build-push (push) Failing after 30s
2026-09-29 12:33:43 +08:00
chenxing 8f8a6b8cca feat(app): app_code/app_nature 编辑必填(C5,2026-09-28 拍板)——校验组 Create 扩 {Create,Update},存量 null 历史行不追溯;学校覆盖分支由前端回传原值满足校验(editAppForSchool 只读 name/desc/pcIcon 不受影响)
build / build-push (push) Failing after 49s
2026-09-28 17:07:44 +08:00
chenxing 7e222f19b8 feat(app): 新增 /oapi/app/authorized-codes 判权端点(消息跳转收口 C1)——findUserAuthorizedAppIds 判权 + listAppCodesByIds 映射 app_code(空值跳过/首现去重);数据面与控制器用例补齐 2026-09-28 16:52:37 +08:00
chenxing fe19954d8b fix(test): userAppList 驱逐计数 16→18——上游 SNAPSHOT ClientEnum 增 MINIAPP(9 维度×2 格式),非本线改动 2026-09-28 12:44:28 +08:00
chenxing 257ba2dd64 feat(app): 应用身份统一 app_code/app_nature——五处基石判定收口 + by-code/by-key 开放端点(消息卡片方案 B1) 2026-09-28 12:44:26 +08:00
chenxing 3846ec21a9 chore(config): identity switch-allowed-app-keys 加 100009(流程中心 switch 授权) 2026-09-28 11:54:30 +08:00
chenxing 0b4a596bce feat(app): 注册表多端地址补 miniprogram_url 三层映射(实体/Request/Vo + Boa 出参),160 表列待 DDL 2026-09-28 11:54:29 +08:00
chenxing 6cc1bc23f7 refactor(push): 测试推送改为手动触发(移除请求预览)
build / build-push (push) Successful in 1m0s
- PushDispatcher 删除 previewManual(预览体构建),保留 dispatchManual 手动投递
- PushSubscriptionController /push/test 不再返回 preview,改为「手动触发一次推送」语义(前端推送测试页已下线)
- PushTestRequest 注释同步;同文件的「推送角色」过滤改走身份行(findUserIdsWithAnyRole),与 roleList 同源
2026-09-18 11:13:05 +08:00
chenxing 712e94803f fix(identity): 身份读侧口径统一 + 写口收口(退组/转学/本校移动/追加语义/按校收窄)
读侧(同一根因:sys_user.roles 停写后读侧漏改):
- 组用户管理列表/导出「用户身份」列只取当前组范围解析出的学校集合内的身份行(原为全平台身份并集)
- 推送 roleList 改读 sys_user_role_rel(原读停写列 → 新建/改过身份的用户恒为空,用于 lwj 空推)
- 推送订阅「推送角色」过滤与 /oapi/group/users 角色过滤共用 findUserIdsWithAnyRole(原读停写列会静默漏人)

写口收口:
- 退组补 alignIdentity(退掉某校最后一个组时清出该校身份行与滞留激活身份,消除已离校校领导仍带该校管理范围)
- 激活身份滞留检查由「只比 schoolId」升级为 (roleId, schoolId) 对(同校校领导→老师后不再返回旧角色)
- 跨校转学把源校正常态身份行复制到目标校(原源校清出、目标校不建 → 用户零身份落地)
- moveUserWithinSchool 只重建本校子树内组关系:他校关系与他校 isMain 一律不动,目标组主标记沿用原本校旧关系(不制造双主组)
- 开放接口编辑身份改追加语义(只加不删,学校缺省按 groupId 解析;BschClass 无需改动),管理端保持全量覆盖并加护栏(有 schoolId 无 roleIds 报错)
- 角色授权模块按上下文学校收窄(schId/groupId 非空时只认该校子树内归属组绑定,NULL 全局兜底行仍生效)
- /oapi/group/users 轻量 BoaUser 补 status;SysUserVo.roles 删除(读死列最后一个展示位)

契约与文档:oapi 接口文档 6 处修订 + SDK(yh-openapi-client)三处注释同步;知识库 §3 新增身份写口纪律、§8 记 D3 决议与三项待决策
测试:139 → 158(+19 例)
2026-09-18 11:12:56 +08:00
chenxing ef5849377a feat(log): 三类日志大字段加写入时 size 列,按 50KB 门槛切换在线预览/下载
build / build-push (push) Successful in 54s
- push_record/oplog/oapilog 各加 payload_size/response_size(写时算 UTF-8 字节数),列表只读该整数列、不关联大字段
- 去掉 oplog/oapilog 响应写时截断(全量 GZIP 入库),退役 maxResponseSize 注解属性与常量
- 删除 oapilog 列表逐行 findById+解压回填的 N+1,SysOapiLogListVo 去掉 responseData
- 附三个 resources/sql 迁移(DBA 手动执行,含历史行回填)
2026-09-18 09:24:21 +08:00
chenxing 8799d72d47 feat(access): 数据范围收敛到当前激活身份所在学校 + 「本组」备注规则修正
build / build-push (push) Successful in 1m17s
问题1 学校管理员多校并存:SCHOOL 级判定原先收拢该管理员名下所有学校
(多校 = 多棵子树并集),导致一个在云海小学+云海中学都有管理员身份的账号
左侧组织树出现两个学校根。

- AccessScopeService.computeScope:SCHOOL 分支优先取 sys_user_current_role
  的 schoolId(须命中「该用户确有 {校领导,管理员} 正常态身份行且组为学校组」白名单,
  一并挡住脏记录指向别校、切到非管理身份两类越权)→ 无记录/不合法回落本职主校
  → 都解析不出才保留历史宽口径(范围为空会误降 NONE 被整体 403,宁宽勿窄)
- 落在 scopeGroupIds 上游:树/列表/搜索/导入导出/写口 guard 一起收敛,
  避免「树里看不到但能搜到、能跨校写」的半修复
- 新增 AccessScopeServiceTest 9 例(该类此前无测试):当前身份收敛/回落主校/
  脏记录与区域组回落/主组链无学校的宽口径/超管不受影响/无管理身份行不进 SCHOOL
- 类注释与《管理端权限分级改造方案》§1/§5/§6/§8 同步修订留痕(原写明与当前身份无关)

问题3 权限一览「(本组)」备注:撤销上一版「上下文组覆盖主组」的做法,
改为 own = 用户归属组数>1 且节点为主组——单组用户不再带备注,多组用户
只有本职组带标记(兼职组不带),切换查看时才具备区分意义。
2026-09-16 19:13:46 +08:00
chenxing 30b4b0504a oapi验签链治理③:删除 /oapi/internal/sign-key 兜底端点,oapi 可整段对外
build / build-push (push) Successful in 53s
- 删 SignKeyInternalController(/oapi/internal/sign-key/{appId},返回 appSecret)、
  SignKeyInternalIpFilter、SignKeyInternalProperties 三文件
- application.yml 删 yh.sign-key-internal 块(其中 192.168.10.0/200 本就是非法前缀,永不匹配)
- OpenApiAuthEnforceConfiguration:删 IP 白名单过滤器与 SignKeyInternalProperties 注册;
  **保留 /api/open 与 /oapi/* 的强制签名**(② oapi 整段对外代理依赖它)
- OapiLogAspect:删对 SignKeyInternalController 的排除死代码
- SignKeyCacheService 契约固化:验签密钥以共享 Redis sign:app:* 为单一真源、UserBase 唯一写入方、
  读写走密钥链专用库(缺省 db0)、读侧无兜底(Redis 不可用即 fail-closed)
- 实测线上该端点在容器内匿名访问为 403(IP 白名单生效)、公网无 Caddy 路由;
  删除后既无密钥外泄面,也使 /userbase/oapi/* 整段代理安全

验证:mvn compile 通过;本模块单测 124 项中 5 项 MySQL 连接失败为环境性(已用 git stash 在
原始树复跑证明同样失败)。
2026-09-16 18:08:25 +08:00
chenxing 9ec65698b6 feat(approle): 角色属性绑定加组维度,一行=用户×组×角色
build / build-push (push) Successful in 3m37s
- sys_user_app_role_rel 新增 group_id:组=绑定发生时所选组(路径不落库,按组ID实时拼)
- 角色用户列表按绑定行返回,新增部门列(groupPath 按查看者数据范围裁剪,学校管理员只看本校)
- 保存改为按组覆盖/增量添加:编辑弹窗按所在组覆盖,角色授权页增量添加不误删其他组绑定
- 调整组不带走角色:旧组绑定保留不生效,调回自动恢复
- collectEnabledRoleModuleIds 只算用户当前所在组上的启用角色(NULL 视为全局兜底)
- UserGroupTree/UserRoles 增加可选 groupId:所属部门按行收窄、角色按组回显
- 附迁移脚本 sys_user_app_role_rel_add_group_dimension.sql(回填主组 + 换唯一约束)
2026-09-16 16:33:29 +08:00
chenxing ce131bf399 feat: oapi group/users 组成员端点(一键拉取教师组数据源)+ SignKeyCacheService 专用库支持 + 组授权存量
build / build-push (push) Successful in 1m17s
- /oapi/group/users?groupId=&withSub=&roleIds=:组内用户列表(flow-center 办理人映射批量导入数据源)
- SignKeyCacheService:sign-redis.database 配置时走专用库模板(sign:app:* 跨应用共享)
- 含组授权/身份切换相关存量(GroupAuthz/GroupController 等 2026-09 前期改动)
2026-09-16 01:41:57 +08:00
chenxing 283ac4ca0a feat(module): 模块表新增 icon 字段,GetUserMod 下发模块自身图标
build / build-push (push) Successful in 56s
旧实现 toBoaModule 用 app.getPcIcon() 覆写每个模块的 icon,导致各子应用左侧菜单图标
恒等于应用图标;且 app_module 根本没有图标列,"模块自己的图标"这一概念此前不存在。

- app_module 新增 icon 列(VARCHAR(128),存 YschIcons 图标名);实体/Request/Vo 同步,
  新增走 BeanUtils 自动拷贝,edit 为逐字段赋值故显式补 setIcon(字段缺省即置空,清空可生效)
- GetUserMod:toBoaModule 不再覆写 icon,改由同名属性自动拷贝。**非必填**:模块未配图标时
  BoaModule.icon 即为 null,不再回退应用图标,由消费方自行兜底
- 附幂等加列脚本 sql/app_module_add_icon.sql(5.7 无 ADD COLUMN IF NOT EXISTS,用
  information_schema + PREPARE 兜子;本地 dev 靠 ddl-auto=update,脚本供 dev/prod 手工执行)
- 单测:修正原「icon 按应用级下发」断言为断言 null,并新增用例锁定「配了下发自身、没配为
  null、应用 pcIcon 不得覆盖」,共 10 例全绿
2026-09-15 18:11:02 +08:00
chenxing b0a02e56b0 feat(oapi): GetUserMod 下发嵌套模块树,层级经 BoaModule.children 承载
build / build-push (push) Successful in 2m42s
模块表 app_module 是 level<=5 的树,管理端下发时带 children,但开放接口这条链
转成平台 BoaModule 时层级被拍平(且旧 SDK 契约无 children 字段可承载),导致每个
子应用各自复制一份 buildModuleTree 重建父子关系。

现按同目录 BoaGroup.children 的既有口径补齐:findUserModulesForApp 的三个返回点
(超管短路 / 应用无模块 / 正常路径)统一收口到 assembleModuleTree 建树。

语义:顶层只放真根(parentId 为空),兄弟沿用开放接口原有 customId 升序口径(不与
管理端 sortNo+id 统一),叶子 children 为空列表以便前端无判空递归;父模块不在本次已
授权集合内的孤儿子节点连同整枝子孙一并丢弃,并逐条打 warn([oapi:modules] drop
orphan module)避免静默少菜单——授权配置须保证「授权子模块必先授权父模块」。

新增 GroupAuthzServiceModuleTreeTest 9 例:三级嵌套与排序、sortNo 不参与、叶子空
列表、parentId 成环不死循环、全 level-1 时与改造前扁平输出等价、无模块返空、孤儿整
枝丢弃、父子同授权则嵌套、JSON 往返证明层级确实经线格式下发且可反序列化。
2026-09-15 16:20:05 +08:00
chenxing 0b67382076 fix(oapi): 请求体反序列化失败给友好提示,避免误报 code=6 内部服务错误
build / build-push (push) Successful in 2m31s
GlobalExceptionHandler 新增 HttpMessageNotReadableException 处理:Jackson 在进 Controller 前抛的
解析失败(如 birthday 非 yyyy-MM-dd)此前无专属 handler,穿透到 api-std 兜底渲染成 code=6「内部服务错误」。
改为从异常 path 取失败字段名,birthday 点名「字段类型需为yyyy-MM-dd」、其余走通用文案,统一复用
paramError 返回 code=4。空串/null 由 Jackson 归一为 null 不抛,满足「空不管」。附单测 2 例。
2026-09-11 14:48:38 +08:00
chenxing 877e800f82 perf(identity): 主身份解析先查身份行短路 + 接入无身份计数观测
build / build-push (push) Successful in 57s
- resolveMainIdentity / resolveMainIdentityByUser 求值顺序反转:先取身份行,
  为空立即 null,不再先走 resolveSchoolId(沿 sys_group 祖先链无缓存、6~8 次 SQL);
  批量版只对确有身份行的用户解析主校,跳过空页。有身份用户结果与查询次数不变。
- IdentityService 新增进程级 nullIdentityLookups,getCurrent 落 null 分支自增,
  经 getNullIdentityLookupCount() 暴露;CacheRegistryController 在 /cache/registry
  输出 identityCurrentNullLookups,作为「是否给 null 加哨兵缓存」的唯一数据源。
- IdentityServiceTest 补 4 用例锁住短路契约与计数,旧 noGroup 用例补桩身份行。
2026-09-11 10:29:33 +08:00
chenxing 2106ba3d00 fix(user): 组用户导出「用户身份」图例 2 由「教职工」改为「校领导」
build / build-push (push) Successful in 52s
对齐权威枚举 SysRoleEnum(SCHOOL_LEADER=2,"校领导"),导出列头图例
(1教师 2教职工 3学生 4家长) 中的旧称「教职工」更正为「校领导」。
2026-09-11 10:20:52 +08:00
chenxing d6e995a954 fix(sysuser): 编辑用户接口误抛「组ID不能为空」
build / build-push (push) Successful in 1m40s
SysUserController.edit 无条件调用 requireGroupInScope(request.getGroupId()),
而该方法对 null 直接抛错;但 service.edit 不读 groupId、DTO 仅在 Create 校验、
前端编辑按契约不提交 groupId,导致每次编辑必崩。改为仅当 groupId 非空时才校验
其落在数据范围内,越权防线仍由 requireUserInScope(id) 承担。mvn test 102 全绿。
2026-09-11 09:58:58 +08:00
chenxing 5cec25736a revert 7acfabfa70
build / build-push (push) Canceled after 10s
revert 删除目录「.gitea」
2026-09-10 15:00:54 +08:00
chenxing 7acfabfa70 删除目录「.gitea」 2026-09-10 14:59:53 +08:00
chenxing fb3c5671c6 fix(ci): .dockerignore 产物白名单对齐 app.jar
build / build-push (push) Successful in 57s
4131dca 统一 Dockerfile/build.yml 固定产物名为 app.jar 时漏改本文件,
残留初始提交的 !target/userbase.jar 白名单——target/* 规则把 CI 拷回的
target/app.jar 排除出构建上下文,COPY 报 not found(run 特征:maven 步绿、
docker 步炸、context 仅传 ~3.5kB)。
2026-09-10 11:39:46 +08:00
chenxing 3421851b6f feat: oapi /oapi/app/list 接入学校别名 + userbase 缓存事件失效基建
build / build-push (push) Failing after 20s
别名接入(本次):
- SchoolAppOverrideService 新增上下文无关合并口 applyAlias:按单一学校覆盖
  BoaAppType/BoaApp 名称/图标/排序,覆盖字段为空回落主表真名(无别名=真实名),
  分类间/分类内按生效排序重排,口径与管理端 mergeAppVo/mergeCategoryList 一致
- listBoaAppType 新增可选 schoolId:生效学校三级兜底=显式传校(assertSchoolId
  锚点+用户归属校验,违规 code=5)→ 当前激活身份学校 → 主归属学校,均无则不合并
- userAppList 缓存 key 扩 :s: 维度:auto 字面量随用户精确枚举驱逐(8→16 条),
  显式学校后缀不可枚举靠 60s TTL 自愈;别名写口 AppAuthzChangedEvent 整清链路复用
- 新增 SchoolAppOverrideServiceApplyAliasTest(10)/AppCategoryServiceSchoolAliasTest(8),
  更新 CacheInvalidationListenerTest 16-key 断言;全量 102 用例全绿

缓存事件失效基建(前次会话实现,随本 commit 入库):
- AppAuthzChangedEvent/UserGroupRelLoader/CacheInvalidationListener 四事件
  AFTER_COMMIT 失效链路 + CacheNames 注册表(TTL/抖动/存放路由/依赖表/失效责任)
- 应用授权域/组域/用户域各写口发布失效事件(AppModule/AppRole/GroupAuthz/
  SysGroup/SysUser/Identity 等服务),配套 AppAuthzEventPublishTest 等用例
2026-09-10 11:13:30 +08:00
chenxing 46a9587160 ci: build.yml maven 容器名唯一化(- 按 SHA 前缀),消除同 runner 跨仓库 mavenbuild 冲突
build / build-push (push) Failing after 1m18s
2026-09-09 20:47:11 +08:00
chenxing 4131dca66e chore: 关联注入的标准 CI(build.yml)——移除遗留 ci.yml,Dockerfile/run-app.sh 产物对齐 app.jar
build / build-push (push) Failing after 47s
2026-09-09 18:16:35 +08:00
chenxing a2a6145ed2 chore: 合并注入的标准 CI(909117e build.yml) 2026-09-09 18:13:51 +08:00
chenxing f5680be7a8 chore: env-config 迁移重建镜像重试(并发原因 run879 失败) 2026-09-09 18:07:58 +08:00
admin 909117ea42 ci: init build workflow [skip ci] 2026-09-09 18:01:36 +08:00
chenxing 0d0a680b82 refactor: 删除 PropertyEnvConfigSource,env-config 数据源由平台默认挂载文件源兜底(yh-env-config starter B2 下沉)
ci-userbase-service / build-and-push (push) Failing after 49s
2026-09-09 11:27:43 +08:00
chenxing 5347559112 ci: markDeployed 修复后端到端复验
ci-userbase-service / build-and-push (push) Successful in 53s
2026-09-08 16:48:41 +08:00
chenxing 783ccd99fe ci: 换仓对接验证(userbase/UserbaseService)
ci-userbase-service / build-and-push (push) Successful in 1m0s
2026-09-08 16:22:58 +08:00
197 changed files with 14268 additions and 942 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
target/*
!target/userbase.jar
!target/app.jar
src
.idea
*.iml
+114
View File
@@ -0,0 +1,114 @@
name: build
# Spring Boot 应用 CI(仿 UserBase ci.yml build 阶段 + projman webhook 建档,不含部署)
# 触发:push 到 main / 手动 Run workflow(可勾选缓存重建)
# 链路:clone 源码 -> maven 容器构建 jar(复用宿主 m2 缓存)-> docker build -f docker/Dockerfile -> push Nexus -> 通知 projman 建档(部署由运维在 DeployTab 手动触发)
# 依赖仓库 Secrets:NEXUS_USER / NEXUS_PASSWORD(docker-hosted 推送)/ DEPLOY_TOKEN(webhook 鉴权)
# 依赖 docker/Dockerfile(由 projman 注入,开发者也可自备——已写则尊重不覆盖)
# maven 依赖走 http://nexus:8081/repository/maven-public/(infra-infra-net 内容器名直连,匿名可拉公司 yh-* starter)
on:
push:
branches: [ main ]
workflow_dispatch:
inputs:
cache_bust:
description: '构建前清空 maven m2 缓存'
required: false
default: 'false'
jobs:
build-push:
runs-on: ubuntu-latest
container:
image: docker:24
options: --network infra-infra-net
steps:
- name: 安装 git / curl(alpine)
run: |
set -e
# runner 容器偶发拉不到公网 apk 源(dl-cdn.alpinelinux.org 报 Permission denied 并卡满 60s×2),
# 这里先按可达性挑一个能通的镜像源再装包,避免整条流水线卡死在第一步、后续步骤全被 skip
chosen=""
for host in mirrors.aliyun.com mirrors.huaweicloud.com mirrors.cloud.tencent.com dl-cdn.alpinelinux.org; do
if wget -q -T 6 -O /dev/null "https://${host}/alpine/v3.20/main/x86_64/APKINDEX.tar.gz"; then
chosen="${host}"
break
fi
echo "apk 源不可达: ${host}"
done
if [ -z "${chosen}" ]; then echo "所有 apk 源均不可达,请检查 runner 容器网络/代理"; exit 1; fi
echo "使用 apk 源: ${chosen}"
sed -i "s#dl-cdn.alpinelinux.org#${chosen}#g" /etc/apk/repositories
apk add --no-cache git curl
# 内网 Gitea 无法访问 GitHub 拉 actions/checkout,手动 clone 本仓库
- name: 拉取源码(内网无 GitHub,手动 clone 本仓库)
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
git clone --depth 1 --branch main "http://x-access-token:${GITHUB_TOKEN}@gitea.yunhaixingtong.com/${{ github.repository }}.git" /tmp/src
cp -a /tmp/src/. .
rm -rf /tmp/src
# 取提交备注首行,脱去换行/双引号/反斜杠(避免破坏 webhook JSON),截断到 200 字符
COMMIT_MSG=$(git log -1 --pretty=%s | tr '\n' ' ' | sed 's/["\\]/ /g' | cut -c1-200)
echo "COMMIT_MSG=${COMMIT_MSG}" >> "$GITHUB_ENV"
echo "MB=$(echo ${GITHUB_SHA} | cut -c1-6)" >> "$GITHUB_ENV"
# 可选:清空 m2 缓存(仅手动勾选 cache_bust 时)
- name: 可选清空 m2 缓存
if: ${{ inputs.cache_bust == 'true' }}
run: |
docker run --rm -v /opt/docker/projman-m2:/root/.m2 alpine sh -c 'rm -rf /root/.m2/*'
echo "m2 cache busted"
# maven 构建:起独立 maven 容器(入 infra-infra-net 用容器名 nexus 直连 maven-public)
# 通过 docker.sock 调宿主 dockerd,故 -v 路径按宿主解析;用 docker cp 进出源码/产物
# m2 缓存挂宿主固定目录 /opt/docker/projman-m2,跨构建复用依赖
# settings.xml 用 profile 方式追加 nexus maven-public 仓库(匿名可拉,不带凭据)
# maven 实际产物名 = artifactId-version.jar(随 pom 变,-DfinalName 命令行 property 不可靠覆盖不了),
# 故 mvn package 后在容器内 cp 成固定名 app.jar,使 docker cp 与 docker/Dockerfile 的 COPY target/app.jar 绑定
- name: 构建 jar(maven 容器,复用宿主 m2 缓存)
run: |
set -e
# 构建容器名必须全局唯一:runner 挂的是宿主 docker.sock,容器名在整个 daemon 上共享
# 命名空间。固定名会在多仓/多条 workflow 并发时互相 docker rm -f 掉对方正在构建的
# 容器,表现为无编译报错的 exit 137(SIGKILL,2026-10-21 实测复现)。
# GITHUB_RUN_ID 实例级唯一,再拼时间戳兜底。
BUILD_CONTAINER="mavenbuild-${GITHUB_RUN_ID:-0}-$(date +%s)"
echo "BUILD_CONTAINER=${BUILD_CONTAINER}"
# 构建容器名必须全局唯一:runner 挂的是宿主 docker.sock,容器名在整个 daemon 上共享
# 命名空间。固定名会在多仓/多条 workflow 并发时互相 docker rm -f 掉对方正在构建的
# 容器,表现为无编译报错的 exit 137(SIGKILL,2026-10-21 实测复现)。
# GITHUB_RUN_ID 实例级唯一,再拼时间戳兜底。
BUILD_CONTAINER="mavenbuild-${GITHUB_RUN_ID:-0}-$(date +%s)"
echo "BUILD_CONTAINER=${BUILD_CONTAINER}"
docker rm -f mavenbuild-${MB} 2>/dev/null || true
docker run -d --name mavenbuild-${MB} \
--network infra-infra-net \
-v /opt/docker/projman-m2:/root/.m2 \
-w /build maven:3.6.3-jdk-8 sleep 3600
docker cp "${PWD}/." mavenbuild-${MB}:/build/
docker exec mavenbuild-${MB} sh -c "printf '%s' '<settings><profiles><profile><id>nexus-repo</id><repositories><repository><id>nexus-internal</id><url>http://nexus:8081/repository/maven-public/</url><releases><enabled>true</enabled></releases><snapshots><enabled>true</enabled><updatePolicy>always</updatePolicy></snapshots></repository></repositories></profile></profiles><activeProfiles><activeProfile>nexus-repo</activeProfile></activeProfiles></settings>' > /root/.m2/settings.xml"
docker exec mavenbuild-${MB} sh -c 'cd /build && mvn -B -DskipTests clean package'
docker exec mavenbuild-${MB} sh -c 'cd /build/target && cp *.jar app.jar'
mkdir -p "${PWD}/target"
docker cp mavenbuild-${MB}:/build/target/app.jar "${PWD}/target/app.jar"
docker rm -f mavenbuild-${MB}
- name: 构建并推送镜像
run: |
set -e
TAG=$(echo "$GITHUB_SHA" | cut -c1-8)
echo "${{ secrets.NEXUS_PASSWORD }}" | docker login nexus:8083 -u "${{ secrets.NEXUS_USER }}" --password-stdin
docker build -t "nexus:8083/docker-hosted/userbase-service:${TAG}" -f docker/Dockerfile .
docker push "nexus:8083/docker-hosted/userbase-service:${TAG}"
echo "TAG=${TAG}" >> "$GITHUB_ENV"
# 通知 projman:webhook 收到后只建档 Release(status=pending),部署由运维在 DeployTab 手动触发(App.deployMode=manual)
- name: 通知 projman 建档(部署由运维手动触发)
run: |
curl -sS -X POST "http://192.168.10.160:8090/projman/api/deploy/webhook" \
-H "Content-Type: application/json" \
-H "X-Deploy-Token: ${{ secrets.DEPLOY_TOKEN }}" \
-d "{\"app\":\"userbase\",\"tag\":\"${TAG}\",\"sha\":\"${GITHUB_SHA}\",\"message\":\"${COMMIT_MSG}\"}"
-87
View File
@@ -1,87 +0,0 @@
name: ci-userbase-service
# 后端标准化 CI(projman 发布链路版):
# maven 构建 jar -> docker build -> 推 Nexus docker-hosted -> webhook 通知 projman 建档
# 部署由 projman 执行(app userbase id=18,目标 10001):manual 模式待手动部署 / auto 模式自动部署
# 触发:push 到 main / 手动 Run workflow
# 依赖仓库 Actions Secrets:NEXUS_USER / NEXUS_PASSWORD(ci-docker 账号)/ DEPLOY_TOKEN(projman webhook 鉴权)
on:
push:
branches: [main]
workflow_dispatch:
jobs:
build-and-push:
runs-on: ubuntu-latest
# docker:24 自带 docker CLI,复用宿主 docker daemon。
# 注意:act_runner v0.6.1 默认已把宿主 docker.sock 挂入每个 job 容器,
# 切勿在此再声明 volumes: docker.sock(会与默认挂载重复,报
# "Duplicate mount point" 导致容器创建失败、全部 step cancelled)。
container:
image: docker:24
steps:
# docker:24 基于 alpine,默认无 git/curl;先装再手动 clone 本仓库
- name: Install git/curl (alpine)
run: apk add --no-cache git curl
# 内网 Gitea 无法访问 github.com 拉取 actions/checkout,改为直接 clone 本仓库
- name: Checkout (manual, internal Gitea)
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
git clone --depth 1 --branch main "http://x-access-token:${GITHUB_TOKEN}@gitea.yunhaixingtong.com/${GITHUB_REPOSITORY}.git" /tmp/src
cp -a /tmp/src/. .
rm -rf /tmp/src
# 取提交备注首行,脱去换行/双引号/反斜杠(避免破坏 webhook JSON),截断到 200 字符
COMMIT_MSG=$(git log -1 --pretty=%s | tr '\n' ' ' | sed 's/["\\]/ /g' | cut -c1-200)
echo "COMMIT_MSG=${COMMIT_MSG}" >> "$GITHUB_ENV"
- name: Compute image tag
id: meta
run: echo "tag=$(echo ${GITHUB_SHA} | cut -c1-7)" >> $GITHUB_OUTPUT
- name: Build jar with Maven
run: |
# 注意:job 容器通过 docker.sock 调的是【宿主 docker daemon】,
# 因此 docker run -v <path> 里的路径会被宿主按【宿主路径】解析;
# 而 act 工作区是宿主上的 docker volume,容器内 $PWD 对宿主不可见,
# 直接 -v $PWD:/build 会绑成空目录导致找不到 pom.xml。
# 故改为:起 maven 容器 -> docker cp 源码入容器 -> exec 构建 -> cp 产物回工作区。
# mavenbuild 加入 infra-infra-net,用容器名 nexus 直连 Nexus(8081 maven-public)。
# settings.xml profile 把 pom 的 HTTP 仓库重定向到 http://nexus:8081/repository/maven-public/。
docker rm -f mavenbuild 2>/dev/null || true
docker run -d --name mavenbuild \
--network infra-infra-net \
-v /opt/docker/ubs-m2:/root/.m2 \
-w /build maven:3.6.3-jdk-8 sleep 3600
docker cp "${PWD}/." mavenbuild:/build/
docker exec mavenbuild sh -c "printf '%s' '<settings><profiles><profile><id>nexus-repo</id><repositories><repository><id>nexus-internal</id><url>http://nexus:8081/repository/maven-public/</url><releases><enabled>true</enabled></releases><snapshots><enabled>true</enabled><updatePolicy>always</updatePolicy></snapshots></repository></repositories></profile></profiles><activeProfiles><activeProfile>nexus-repo</activeProfile></activeProfiles></settings>' > /root/.m2/settings.xml"
# 仅清旧 yh-auth 缓存(缺 CasSsoAutoConfiguration 的 08-05 本地安装版),强制从 Nexus 拉 -7
docker exec mavenbuild rm -rf /root/.m2/repository/com/yunhai/platform/yh-auth-spring-boot-starter
docker exec mavenbuild sh -c 'cd /build && mvn -U -B -DskipTests clean package'
mkdir -p "${PWD}/target"
docker cp mavenbuild:/build/target/userbase.jar "${PWD}/target/userbase.jar"
docker rm -f mavenbuild
- name: Login to Nexus docker registry
run: echo "${{ secrets.NEXUS_PASSWORD }}" | docker login nexus:8083 -u ${{ secrets.NEXUS_USER }} --password-stdin
- name: Build image
run: docker build -t nexus:8083/docker-hosted/userbase-service:${{ steps.meta.outputs.tag }} -f docker/Dockerfile .
- name: Push image
run: docker push nexus:8083/docker-hosted/userbase-service:${{ steps.meta.outputs.tag }}
# 通知 projman 建档(Release);部署由 projman 按 app.deploy_mode 决定(manual=待部署 / auto=自动部署 10001)
# 注意:projman 业务错误也返回 HTTP 200(体含 {"code":N}),必须检查响应体,否则失败会静默
- name: Notify projman (webhook)
run: |
RESP=$(curl -sS -X POST "http://192.168.10.160:8090/projman/api/deploy/webhook" \
-H "Content-Type: application/json" \
-H "X-Deploy-Token: ${{ secrets.DEPLOY_TOKEN }}" \
-d "{\"app\":\"userbase\",\"tag\":\"${{ steps.meta.outputs.tag }}\",\"sha\":\"${GITHUB_SHA}\",\"message\":\"${COMMIT_MSG}\"}")
echo "webhook resp: ${RESP:-<empty=ok>}"
if echo "${RESP}" | grep -q '"code"'; then echo "!! webhook rejected by projman"; exit 8; fi
- name: Report build
run: echo "pushed nexus:8083/docker-hosted/userbase-service:${{ steps.meta.outputs.tag }}"
+4 -4
View File
@@ -1,4 +1,4 @@
# UserBase 后端镜像(基于已构建好的 target/userbase.jar)
# UserBase 后端镜像(基于已构建好的 target/app.jar,CI 已把 maven 产物 cp 成固定名)
# 构建上下文 = service 目录: docker build -f docker/Dockerfile -t <img> .
FROM eclipse-temurin:8-jre
@@ -9,9 +9,9 @@ RUN apt-get update \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY target/userbase.jar /app/userbase.jar
# 镜像内 application-prod.yml 仅作「nacos 不可达时的兜底默认」;
# 正常启动由 run-app.sh 从 nacos 拉取线上配置(dataId=userbase-prod.yml)后覆盖。
COPY target/app.jar /app/app.jar
# 镜像内 application-prod.yml 即生产配置基准(nacos 已停用),
# 启动由 run-app.sh 以 application-prod.yml + 环境变量注入完成。
COPY docker/application-prod.yml /app/config/application-prod.yml
COPY docker/run-app.sh /app/run-app.sh
RUN chmod +x /app/run-app.sh && mkdir -p /data/upload
+1 -1
View File
@@ -5,5 +5,5 @@ set -e
echo "[run-app] starting UserBase (profile=prod) ..."
exec java ${JAVA_OPTS:--Xms512m -Xmx1024m} -Dfile.encoding=UTF-8 -jar /app/userbase.jar \
exec java ${JAVA_OPTS:--Xms512m -Xmx1024m} -Dfile.encoding=UTF-8 -jar /app/app.jar \
--spring.profiles.active=prod
+19
View File
@@ -35,6 +35,8 @@
</dependencyManagement>
<dependencies>
<dependency><groupId>com.h2database</groupId><artifactId>h2</artifactId><scope>test</scope></dependency>
<dependency><groupId>com.yunhai.platform</groupId><artifactId>yh-mobile-auth</artifactId><version>1.1.0-mobauth03-rc.1</version></dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
@@ -195,6 +197,18 @@
<groupId>com.yunhai.platform</groupId>
<artifactId>yh-env-config-spring-boot-starter</artifactId>
</dependency>
<!-- 服务间出站调用引擎(YschOpenCall 自动装配):外观代理端点的上游调用链。
UserBase 是聚合 starter 例外,须显式引入(Notice/svc-hall/flow-center 走聚合包已含)。 -->
<dependency>
<groupId>com.yunhai.platform</groupId>
<artifactId>yh-openapi-spring-boot-starter</artifactId>
</dependency>
<!-- 外观偏好同源读端点 GET /api/appearance(三态 proxy/local/off,默认 proxy 代理 UserHome)。
平台件,不进聚合包;前端零消费(通道 init 裸 fetch),alova 勿生成定义。 -->
<dependency>
<groupId>com.yunhai.platform</groupId>
<artifactId>yh-appearance-spring-boot-starter</artifactId>
</dependency>
<!-- CAS 客户端已由 yh-auth-spring-boot-starter 传递引入(3.5.1):CAS 过滤器注册与
SPA 登录跳转统一在框架内,本工程不再直接依赖,避免重复定义过滤器 bean。 -->
@@ -256,6 +270,11 @@
<directory>C:/ub-build2/userbase</directory>
</build>
</profile>
<profile>
<id>mobauth03</id>
<build><directory>${mobauth03.output}/${project.artifactId}</directory></build>
</profile>
</profiles>
</project>
@@ -0,0 +1,38 @@
-- =====================================================================
-- 部门管理 ·「包含子组」落库(方案:部门管理-包含子组落库方案.md)
-- sys_group 增列 include_children:学校节点上的一个开关,
-- 1 = 部门列表连同下级部门一起列出(默认)/ 0 = 只列一级部门
-- 交付文件,不在本机执行;由人工在 160 执行
-- 执行前请备份;JPA 实体已同名映射,执行后重启后端即可生效
-- =====================================================================
-- ---------------------------------------------------------------------------
-- 1. 增列 include_children
-- 粒度:1 学校 : 1 值,与 status 同为节点自身属性,故直接挂在 sys_group,不另建表
-- 默认 1:与升级前的页面表现一致,存量学校无需回填
-- ⚠ application.yml 是 ddl-auto=update,后端先发也会自动加列,但**不带** NOT NULL/DEFAULT/COMMENT,
-- 存量会留 NULL。因此本脚本建议先执行,保证列定义可控;即使漏执行,前端已按 null = 含子组 兜底
-- ---------------------------------------------------------------------------
SET @col := (SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'sys_group'
AND COLUMN_NAME = 'include_children');
SET @sql := IF(@col = 0,
'ALTER TABLE sys_group ADD COLUMN include_children TINYINT(1) NOT NULL DEFAULT 1 COMMENT ''包含子组:1=部门列表含下级部门,0=仅本级'' AFTER status',
'SELECT ''include_children already exists'' AS msg');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- ---------------------------------------------------------------------------
-- 2. 核验(可选)
-- ---------------------------------------------------------------------------
-- SHOW COLUMNS FROM sys_group LIKE 'include_children';
-- SELECT COUNT(*) AS total, SUM(include_children = 1) AS include_on FROM sys_group;
-- ---------------------------------------------------------------------------
-- 回滚(仅当确认不再需要该能力时执行;只加列不改列,代码回滚无需删列)
-- ---------------------------------------------------------------------------
-- ALTER TABLE sys_group DROP COLUMN include_children;
@@ -0,0 +1,102 @@
-- =====================================================================
-- UserBase 双来源部门 · CUSTOM 侧建表(任务书 USERBASE_DEPARTMENT_QODER_TASK.md T1 / 规格书 UB-1)
-- 三张新表:ub_custom_dept_category(自定义分类)/ ub_custom_department(自定义部门)
-- / ub_custom_department_member(部门成员关系)
-- 交付文件,不在本机执行;由人工在 160 执行
-- 执行前请备份;JPA 实体与列同名映射,执行后重启后端即可生效
-- ⚠ application.yml 是 ddl-auto=update,后端先发也会自动建表,但**不带** NOT NULL/DEFAULT/COMMENT/唯一键,
-- 且 del_marker 之类参与唯一约束的列必须由本脚本定义,故务必先执行本脚本
-- 口径:school_id = sys_group 中 type=2(学校组)节点的 id,与 AccessScopeService.scopeGroupIds() 同域
-- =====================================================================
-- ---------------------------------------------------------------------------
-- 1. 自定义分类(学校的容器层,本身无启停、无成员)
-- 名称唯一:del_marker 技巧——MySQL 的 uk 对 NULL 不去重,deleted_at 进 uk 挡不住「软删后再激活同名」,
-- 故软删时把 del_marker 置为该行 id,未删行恒为 0,同校同名只能有一条**有效**行;
-- name 列永不改写(软删保留原名,保证历史解析拿到原名、旧引用不指向新对象)
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS ub_custom_dept_category (
id INT NOT NULL AUTO_INCREMENT COMMENT '主键(不可变稳定 ID)',
school_id INT NOT NULL COMMENT '学校组 id(sys_group.type=2 的节点 id)',
name VARCHAR(64) NOT NULL COMMENT '分类名称,学校自定;永不改写',
sort_no INT NOT NULL DEFAULT 0 COMMENT '展示顺序,越小越靠前',
is_deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '软删标记:0=有效 / 1=已软删(不是业务状态)',
del_marker BIGINT NOT NULL DEFAULT 0 COMMENT '软删时置为该行 id;参与唯一键,保证同名只挡有效行',
deleted_at DATETIME NULL COMMENT '软删时间',
deleted_by INT NULL COMMENT '软删操作人 sys_user.id',
create_user INT NOT NULL COMMENT '创建人 sys_user.id(BaseEntity)',
create_time DATETIME NOT NULL COMMENT '创建时间(BaseEntity)',
update_user INT NULL COMMENT '修改人 sys_user.id(BaseEntity)',
update_time DATETIME NULL COMMENT '修改时间(BaseEntity)',
PRIMARY KEY (id),
UNIQUE KEY uk_school_name_marker (school_id, name, del_marker),
KEY idx_school_sort (school_id, is_deleted, sort_no)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '自定义部门分类(学校级容器,非可选部门)';
-- ---------------------------------------------------------------------------
-- 2. 自定义部门(可配成员、可启停的实际可选对象)
-- 唯一键=同校同分类下未软删的部门名不重复;跨分类、SYSTEM 与 CUSTOM 之间允许重名(A06)
-- enabled 语义直读 1=启用/0=停用,**与 sys_group.status(0=启用/1=停用)方向相反,严禁混用**
-- 软删不级联删成员:成员关系保留在库,由查询侧按部门 is_deleted 过滤(A12)
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS ub_custom_department (
id INT NOT NULL AUTO_INCREMENT COMMENT '主键(CUSTOM 侧独立 ID,与 SYSTEM 组 id 不同域,引用须带 departmentType)',
school_id INT NOT NULL COMMENT '学校组 id(冗余自分类,写口服务端复核)',
category_id INT NOT NULL COMMENT '所属分类 ub_custom_dept_category.id,必须同校',
name VARCHAR(64) NOT NULL COMMENT '部门名称,学校自定;永不改写',
sort_no INT NOT NULL DEFAULT 0 COMMENT '同分类内展示顺序,越小越靠前',
enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT '启停:1=启用 / 0=停用(新建默认启用)',
is_deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '软删标记:0=有效 / 1=已软删(删除不是第三种业务状态)',
del_marker BIGINT NOT NULL DEFAULT 0 COMMENT '软删时置为该行 id;参与唯一键',
deleted_at DATETIME NULL COMMENT '软删时间',
deleted_by INT NULL COMMENT '软删操作人 sys_user.id',
create_user INT NOT NULL COMMENT '创建人 sys_user.id(BaseEntity)',
create_time DATETIME NOT NULL COMMENT '创建时间(BaseEntity)',
update_user INT NULL COMMENT '修改人 sys_user.id(BaseEntity)',
update_time DATETIME NULL COMMENT '修改时间(BaseEntity)',
PRIMARY KEY (id),
UNIQUE KEY uk_school_category_name_marker (school_id, category_id, name, del_marker),
KEY idx_category_list (category_id, is_deleted, sort_no),
KEY idx_school_list (school_id, is_deleted, enabled)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '学校自定义部门(与 SYSTEM 组织树完全分离)';
-- ---------------------------------------------------------------------------
-- 3. 自定义部门成员关系(引用 sys_user.id,不复制账号)
-- 移除成员=物理删该关系行(本仓无软删惯例,增删审计靠 @OperationLog),不得残留有效成员
-- uk 挡不住并发重复插入之外的场景(如软删部门后重新激活)由服务端逐个复核兜底
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS ub_custom_department_member (
id INT NOT NULL AUTO_INCREMENT COMMENT '主键',
school_id INT NOT NULL COMMENT '学校组 id(与部门同校,写口服务端复核)',
department_id INT NOT NULL COMMENT 'ub_custom_department.id',
user_id INT NOT NULL COMMENT 'sys_user.id(本校教职工,排除学生组成员)',
create_user INT NOT NULL COMMENT '添加人 sys_user.id(BaseEntity)',
create_time DATETIME NOT NULL COMMENT '添加时间(BaseEntity)',
update_user INT NULL COMMENT '修改人 sys_user.id(BaseEntity)',
update_time DATETIME NULL COMMENT '修改时间(BaseEntity)',
PRIMARY KEY (id),
UNIQUE KEY uk_department_user (department_id, user_id),
KEY idx_department (department_id),
KEY idx_school (school_id),
KEY idx_user (user_id)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '自定义部门成员关系(一个用户可属同校多个部门)';
-- ---------------------------------------------------------------------------
-- 4. 核验(可选,执行后逐条确认)
-- ---------------------------------------------------------------------------
-- SHOW CREATE TABLE ub_custom_dept_category;
-- SHOW CREATE TABLE ub_custom_department;
-- SHOW CREATE TABLE ub_custom_department_member;
-- SELECT TABLE_NAME, TABLE_COMMENT FROM information_schema.TABLES
-- WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME LIKE 'ub_custom%';
-- 唯一键生效自检(手工插入应报 1062):
-- INSERT INTO ub_custom_dept_category (school_id, name, create_user, create_time) VALUES (1, '自检分类', 0, NOW());
-- INSERT INTO ub_custom_dept_category (school_id, name, create_user, create_time) VALUES (1, '自检分类', 0, NOW()); -- 期望失败
-- DELETE FROM ub_custom_dept_category WHERE school_id = 1 AND name = '自检分类';
-- ---------------------------------------------------------------------------
-- 回滚(本期未上线,回滚即删表;CUSTOM 数据不与 sys_group 有任何写耦合)
-- ---------------------------------------------------------------------------
-- DROP TABLE IF EXISTS ub_custom_department_member;
-- DROP TABLE IF EXISTS ub_custom_department;
-- DROP TABLE IF EXISTS ub_custom_dept_category;
+29
View File
@@ -0,0 +1,29 @@
-- sys_group 加启停状态列(2026-10-20 部门线 P0 最小批)
-- 目标环境:160(MySQL 8,容器网 mysql8:3306,库见 UserBase 生产连接串)
-- 幂等:INFORMATION_SCHEMA 判列存在即跳过,可重复执行;160 一律以本脚本为准(不依赖 ddl-auto)
-- 回滚:ALTER TABLE sys_group DROP COLUMN status;(存量数据全 0=启用,回滚无行为影响)
DELIMITER $$
DROP PROCEDURE IF EXISTS add_sys_group_status $$
CREATE PROCEDURE add_sys_group_status()
BEGIN
IF NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'sys_group'
AND COLUMN_NAME = 'status'
) THEN
ALTER TABLE sys_group
ADD COLUMN status TINYINT NOT NULL DEFAULT 0
COMMENT '0 启用 / 1 停用(停用后新建选不到,已有引用与在途流程不受影响)';
END IF;
END $$
CALL add_sys_group_status() $$
DROP PROCEDURE IF EXISTS add_sys_group_status $$
DELIMITER ;
-- 执行后核对(计数入台账,业务数据不入):
-- SELECT status, COUNT(*) FROM sys_group GROUP BY status; -- 预期全部 status=0
@@ -30,6 +30,7 @@ import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
@@ -40,7 +41,6 @@ import java.util.Map;
* 拦截 {@code controller.oapi} 包下所有方法,规则:<br>
* - POST 写操作:全量自动记录(无需注解),级别/类型由 OperationLogResolver 关键词判定;<br>
* - GET 查询:默认不记录(高频调用,防刷爆表),满足任一条件则记录:方法标注 {@code @OapiLog}、或配置 oapi.log-get=true;<br>
* - 排除 {@code SignKeyInternalController}(/oapi/internal,响应含 appSecret 明文,绝不落库);<br>
* - 请求参数与响应均经 {@link SensitiveDataMasker} 脱敏后入库;<br>
* - finally 中异步落库(有界池 oapiLogExecutor),失败不影响主业务。<br>
*
@@ -52,9 +52,6 @@ import java.util.Map;
@Component
public class OapiLogAspect {
/** 内部密钥端点控制器,排除(响应含 appSecret 明文)。 */
private static final String INTERNAL_CONTROLLER = "com.yunhai.userbase.controller.oapi.SignKeyInternalController";
/** GET 默认不记录,开启本配置后全量记录。 */
@Value("${oapi.log-get:false}")
private boolean logGet;
@@ -67,10 +64,6 @@ public class OapiLogAspect {
@Around("execution(* com.yunhai.userbase.controller.oapi..*.*(..))")
public Object around(ProceedingJoinPoint point) throws Throwable {
if (INTERNAL_CONTROLLER.equals(point.getSignature().getDeclaringTypeName())) {
return point.proceed();
}
HttpServletRequest request = getRequest();
String httpMethod = request != null ? request.getMethod() : "";
boolean isGet = "GET".equalsIgnoreCase(httpMethod);
@@ -85,18 +78,17 @@ public class OapiLogAspect {
SysOapiLog entity = buildEntity(point, request, httpMethod, oapiLog);
boolean saveResponse = oapiLog != null ? oapiLog.saveResponse() : true;
int maxResponseSize = oapiLog != null ? oapiLog.maxResponseSize() : 10240;
Object result = null;
try {
result = point.proceed();
entity.setStatusCode(200);
fillResponse(entity, result, saveResponse, maxResponseSize);
fillResponse(entity, result, saveResponse);
} catch (Throwable e) {
entity.setStatusCode(500);
entity.setErrorMsg(truncate(e.getMessage(), 2000));
// 业务异常(DException/BizException)同样构造实际返回的响应体落库,便于详情下载
fillErrorResponse(entity, e, saveResponse, maxResponseSize);
fillErrorResponse(entity, e, saveResponse);
throw e;
} finally {
entity.setCostTime(System.currentTimeMillis() - start);
@@ -224,20 +216,20 @@ public class OapiLogAspect {
}
/**
* 写入响应结果(脱敏 → 截断 → GZIP)。
* 写入响应结果(脱敏 → GZIP,不截断)。
*/
private void fillResponse(SysOapiLog entity, Object result, boolean saveResponse, int maxResponseSize) {
private void fillResponse(SysOapiLog entity, Object result, boolean saveResponse) {
if (!saveResponse || result == null) {
return;
}
writeResponseData(entity, result, maxResponseSize);
writeResponseData(entity, result);
}
/**
* 异常路径:与 {@code GlobalExceptionHandler} 保持一致的响应体(DException/BizException → ApiResponse),
* 使日志下载内容等于调用方实际收到的 body;其余异常不构造(响应不可预期)。
*/
private void fillErrorResponse(SysOapiLog entity, Throwable e, boolean saveResponse, int maxResponseSize) {
private void fillErrorResponse(SysOapiLog entity, Throwable e, boolean saveResponse) {
if (!saveResponse) {
return;
}
@@ -245,7 +237,7 @@ public class OapiLogAspect {
if (response == null) {
return;
}
writeResponseData(entity, response, maxResponseSize);
writeResponseData(entity, response);
}
private ApiResponse<?> toApiResponse(Throwable e) {
@@ -260,7 +252,7 @@ public class OapiLogAspect {
return null;
}
private void writeResponseData(SysOapiLog entity, Object payload, int maxResponseSize) {
private void writeResponseData(SysOapiLog entity, Object payload) {
if (payload == null) {
return;
}
@@ -269,10 +261,8 @@ public class OapiLogAspect {
if (resp == null) {
return;
}
if (resp.length() > maxResponseSize) {
resp = resp.substring(0, maxResponseSize) + "...[截断]";
}
entity.setResponseData(GzipUtil.compress(resp));
entity.setResponseSize(resp.getBytes(StandardCharsets.UTF_8).length);
} catch (Exception e) {
log.warn("开放接口日志响应序列化失败, uri={}", entity.getRequestUri(), e);
}
@@ -23,6 +23,7 @@ import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
@@ -45,9 +46,6 @@ public class OperationLogAspect {
/** 不记录日志查询/下载接口自身,避免递归与刷屏。 */
private static final String LOG_CONTROLLER = "com.yunhai.userbase.controller.api.SysOperationLogController";
/** GET 自动记录时使用的默认响应保存上限(10KB)。 */
private static final int DEFAULT_MAX_RESPONSE_SIZE = 10240;
@Autowired
private ISysOperationLogService sysOperationLogService;
@@ -76,13 +74,12 @@ public class OperationLogAspect {
// GET 未标注注解时使用默认参数;标注了则优先用注解配置
boolean saveResponse = operationLog != null ? operationLog.saveResponse() : true;
int maxResponseSize = operationLog != null ? operationLog.maxResponseSize() : DEFAULT_MAX_RESPONSE_SIZE;
Object result = null;
try {
result = point.proceed();
entity.setStatusCode(200);
fillResponse(entity, result, saveResponse, maxResponseSize);
fillResponse(entity, result, saveResponse);
} catch (Throwable e) {
entity.setStatusCode(500);
entity.setErrorMsg(truncate(e.getMessage(), 2000));
@@ -174,18 +171,16 @@ public class OperationLogAspect {
}
/**
* 写入响应结果(GZIP 压缩 + 截断)。
* 写入响应结果(全量 GZIP 压缩入库,不再截断)。
*/
private void fillResponse(SysOperationLog entity, Object result, boolean saveResponse, int maxResponseSize) {
private void fillResponse(SysOperationLog entity, Object result, boolean saveResponse) {
if (!saveResponse || result == null) {
return;
}
try {
String resp = JSON.toJSONString(result);
if (resp.length() > maxResponseSize) {
resp = resp.substring(0, maxResponseSize) + "...[截断]";
}
entity.setResponseData(GzipUtil.compress(resp));
entity.setResponseSize(resp.getBytes(StandardCharsets.UTF_8).length);
} catch (Exception e) {
log.warn("操作日志响应结果序列化失败, uri={}", entity.getRequestUri(), e);
}
@@ -1,7 +1,9 @@
package com.yunhai.userbase.common;
import com.fasterxml.jackson.databind.JsonMappingException;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.validation.BindException;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
@@ -13,6 +15,7 @@ import org.springframework.web.method.annotation.MethodArgumentTypeMismatchExcep
import javax.validation.ConstraintViolation;
import javax.validation.ConstraintViolationException;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@@ -66,6 +69,27 @@ public class GlobalExceptionHandler {
return paramError("参数类型不匹配: " + ex.getName());
}
/**
* 请求体反序列化失败(Jackson 在进 Controller 前抛出,@Valid 兜不到)。典型如 birthday 等非空字段格式非法:
* 从异常最内层 {@link JsonMappingException} 的 path 取失败字段名,birthday 给点名提示,其余走通用文案。
* 空串/null 由 Jackson JSR-310 归一为 null、不抛,故不会误入此分支(满足「空不管」)。
*/
@ExceptionHandler(HttpMessageNotReadableException.class)
public Map<String, Object> handleNotReadable(HttpMessageNotReadableException ex) {
String field = null;
Throwable cause = ex.getMostSpecificCause();
if (cause instanceof JsonMappingException) {
List<JsonMappingException.Reference> path = ((JsonMappingException) cause).getPath();
if (path != null && !path.isEmpty()) {
field = path.get(path.size() - 1).getFieldName();
}
}
if ("birthday".equals(field)) {
return paramError("birthday 字段类型需为yyyy-MM-dd");
}
return paramError("请求参数格式错误");
}
private String fieldErrorsMessage(java.util.List<FieldError> fieldErrors) {
if (fieldErrors == null || fieldErrors.isEmpty()) {
return "参数校验失败";
@@ -32,9 +32,4 @@ public @interface OapiLog {
* 是否保存响应结果(GZIP 压缩后入库)。
*/
boolean saveResponse() default true;
/**
* 响应结果序列化后的最大字符数,超过则截断。默认 10KB。
*/
int maxResponseSize() default 10240;
}
@@ -27,9 +27,4 @@ public @interface OperationLog {
* 是否保存响应结果(GZIP 压缩后入库)。
*/
boolean saveResponse() default true;
/**
* 响应结果序列化后的最大字符数,超过则截断。默认 10KB。
*/
int maxResponseSize() default 10240;
}
@@ -19,7 +19,7 @@ import java.util.List;
* 白名单:GetUserInfo / GetUserScope / GetSysDict / RedirectTo(仅登录即可访问);
* env-config(免登录,SPA 启动阶段在 CAS 登录前就要拉运行时配置,且内容为非敏感前端配置,
* 端点由平台 yh-env-config-spring-boot-starter 提供,旁路过滤器见 EnvConfigSsoBypassFilter)。<br>
* 注册于 /{platform}/**(api/manage/mobiapi 三前缀均命中),排除 /oapi/**(服务间签名调用,行为不变)。<br>
* 注册于 /api/**(显式前缀,路径闭集),排除 /oapi/**(服务间签名调用,行为不变)。<br>
* SUPER/BUREAU/SCHOOL 之间的细粒度差异由各 Controller 内 guard 实现。
*
* @author chenx
@@ -29,16 +29,18 @@ import java.util.List;
public class ManageAccessInterceptor implements HandlerInterceptor {
/**
* 白名单(Ant 风格,* 匹配 platform 前缀):
* 白名单(显式 /api 前缀,路径闭集):
* 登录后骨架所需的 4 个只读接口——用户信息、访问范围、系统字典、跳转代理;
* appearance 外观偏好读(仅登录即可读自身偏好,平台 yh-appearance-spring-boot-starter 端点);
* env-config 免登录(端点与默认路径见平台 yh-env-config-spring-boot-starter)。
*/
private static final List<String> WHITELIST = Arrays.asList(
"/*/base/GetUserInfo",
"/*/base/GetUserScope",
"/*/base/GetSysDict",
"/*/base/RedirectTo/*",
"/*/env-config");
"/api/base/GetUserInfo",
"/api/base/GetUserScope",
"/api/base/GetSysDict",
"/api/base/RedirectTo/*",
"/api/appearance",
"/api/env-config");
private static final AntPathMatcher MATCHER = new AntPathMatcher();
@@ -87,6 +87,64 @@ public final class CacheNames {
*/
public static final String APP_KEY_TO_ID = "appKeyToId";
/**
* 用户当前激活身份(GET /oapi/identity/current 读路径,压测占比 40%)。<br>
* key 规则:{@code 'id:' + userId};TTL 5min(用户身份型:脏 currentRole = 权限事故,与 boaUser 同档同事件);<br>
* 存放:Redis;依赖表:sys_user_current_role、sys_user_role_rel、sys_user_group_rel、sys_group;<br>
* 失效责任:{@link com.yunhai.userbase.event.UserChangedEvent}
* (身份切换/换组/回填等全部用户域写口 AFTER_COMMIT 精确驱逐)+
* {@link com.yunhai.userbase.event.GroupChangedEvent}(结果内嵌校名,组改名后不能吃 5min 旧值)。<br>
* 设计说明:<b>不用 sync=true</b>——方法对「连主身份都没有」的用户合法返回 null(unless 拦截不入缓存),
* SD Redis 2.7 的 sync 路径对 null 会无条件 put 并在 {@code disableCachingNullValues} 下抛 IAE
* (同 {@link com.yunhai.userbase.service.impl.BoaUserLoader} 既有纪律)。
*/
public static final String IDENTITY_CURRENT = "identityCurrent";
/**
* 用户可见应用列表(GET /oapi/app/list 读路径,压测占比 20%,每请求 ~8 条 SQL)。<br>
* key 规则:{@code 'u:' + userId + ':c:' + (clientType 为 null ? 'all' : ClientEnum.name())
* + ':s:' + (schoolId 为 null ? 'auto' : schoolId)}——client 维度共 1+7=8 个;
* 学校维度:未显式传校归一为字面量 'auto'(生效学校随 userId 由身份/主归属推导,可精确枚举驱逐),
* 显式传校后缀为学校 id(不可枚举,用户自身变更场景靠 TTL 自愈,别名/授权/组/应用变更均整清);<br>
* TTL 60s(结果敏感型短档:脏了=用户看到不该看的菜单;叠加全哨口事件后 TTL 仅兜直改 DB);<br>
* 存放:Redis;依赖表:sys_group_app_rel、sys_group_module_perm、sys_user_app_role_rel、sys_app_role、
* sys_app_role_module_rel、app_module、app_category、application_info、sys_group、
* sys_school_app_override、sys_school_category_override(2026-09-10 起后两者经
* SchoolAppOverrideService.applyAlias 正式在读链上:返回前合并学校私有别名/图标/排序);<br>
* 失效责任:{@link com.yunhai.userbase.event.AppAuthzChangedEvent}(应用授权域 24 写口整清,
* 含学校别名覆盖全部写口)+
* {@link com.yunhai.userbase.event.UserChangedEvent}(按用户 16 条可枚举 key 精确驱逐)+
* {@link com.yunhai.userbase.event.GroupChangedEvent} / {@link com.yunhai.userbase.event.ApplicationInfoCacheChangedEvent}
* (组移动改变继承开通面 / 应用启停排序删除整清)。
*/
public static final String USER_APP_LIST = "userAppList";
/**
* 学校维度应用清单(GET /oapi/app/list-by-school 读路径,2026-10-10;流程中心 flw_app 源池)。<br>
* key 规则:{@code 's:' + schoolId + ':c:' + (clientType 为 null ? 'all' : ClientEnum.name())}——
* 学校 id 不可枚举,用户级变更不涉及(学校层无用户),授权/组/应用/别名事件一律整清,
* 其余直改 DB 场景靠 TTL 自愈(同 USER_APP_LIST 显式学校后缀策略);<br>
* TTL 60s(同 USER_APP_LIST 档位:脏了=流程中心显示过期应用池,短档足够);<br>
* 存放:Redis;依赖表:sys_group_app_rel、app_category、application_info、
* sys_school_app_override、sys_school_category_override;<br>
* 失效责任:{@link com.yunhai.userbase.event.AppAuthzChangedEvent}(授权域写口整清,含学校别名)+
* {@link com.yunhai.userbase.event.GroupChangedEvent} / {@link com.yunhai.userbase.event.ApplicationInfoCacheChangedEvent}
* (组开通面变化 / 应用启停排序删除整清)。
*/
public static final String SCHOOL_APP_LIST = "schoolAppList";
/**
* 用户组关系状态 Map(GET /oapi/user/groupState 数据源,groupId→status,status null 归一 0=正常)。<br>
* key 规则:{@code 'u:' + userId}(按用户整张 Map,不做 (userId,groupId) 多维 key——changeGroups
* 整批重写组关系无法从事件反推旧 key,多维只能整清,写流量下会打穿缓存);<br>
* TTL 5min(用户身份型;全部写口有 UserChangedEvent,TTL 仅兜直改 DB);<br>
* 存放:Redis;依赖表:sys_user_group_rel;<br>
* 失效责任:{@link com.yunhai.userbase.event.UserChangedEvent}(含 groupState 写口本次补发事件)+
* {@link com.yunhai.userbase.event.GroupChangedEvent}(组删除级联的卫生性整清)。<br>
* 无组关系用户为空 Map 恒非 null,sync=true 安全(穿透防护天然成立)。
*/
public static final String USER_GROUP_REL = "userGroupRel";
// ===================== 注册表元数据(驱动 CacheConfig 与对账接口) =====================
/** 存放策略:Redis(跨重启、单条小总量大)或 LOCAL(Caffeine 仅本地,单条大/读频极高/配置型小数据)。 */
@@ -174,6 +232,28 @@ public final class CacheNames {
"#appKey",
"ApplicationInfoCacheChangedEvent → CacheInvalidationListener(搭车 clear)",
"application_info"));
m.put(IDENTITY_CURRENT, new Meta(IDENTITY_CURRENT, Store.REDIS, Duration.ofMinutes(5), 0.1,
"'id:'+userId",
"UserChangedEvent(精确驱逐)+ GroupChangedEvent(整清)→ CacheInvalidationListener",
"sys_user_current_role, sys_user_role_rel, sys_user_group_rel, sys_group"));
m.put(USER_APP_LIST, new Meta(USER_APP_LIST, Store.REDIS, Duration.ofSeconds(60), 0.1,
"'u:'+userId+':c:'+(clientType?.name()?:'all')+':s:'+(schoolId?:'auto')(8 client × auto/显式校)",
"AppAuthzChangedEvent(整清,含学校别名写口)+ UserChangedEvent(按用户 16 条可枚举 key 驱逐,"
+ "显式学校后缀 TTL 自愈)+ GroupChangedEvent / ApplicationInfoCacheChangedEvent(整清)"
+ " → CacheInvalidationListener",
"sys_group_app_rel, sys_group_module_perm, sys_user_app_role_rel, sys_app_role,"
+ " sys_app_role_module_rel, app_module, app_category, application_info, sys_group,"
+ " sys_school_app_override, sys_school_category_override(2026-09-10 起在读链:applyAlias 合并别名)"));
m.put(USER_GROUP_REL, new Meta(USER_GROUP_REL, Store.REDIS, Duration.ofMinutes(5), 0.1,
"'u:'+userId(整张 groupId→status Map)",
"UserChangedEvent(精确驱逐)+ GroupChangedEvent(整清)→ CacheInvalidationListener",
"sys_user_group_rel"));
m.put(SCHOOL_APP_LIST, new Meta(SCHOOL_APP_LIST, Store.REDIS, Duration.ofSeconds(60), 0.1,
"'s:'+schoolId+':c:'+(clientType?.name()?:'all')(8 client × 学校 id,不可枚举→整清)",
"AppAuthzChangedEvent(整清,含学校别名写口)+ GroupChangedEvent / ApplicationInfoCacheChangedEvent"
+ "(整清)→ CacheInvalidationListener;直改 DB 靠 TTL 自愈",
"sys_group_app_rel, app_category, application_info, sys_group,"
+ " sys_school_app_override, sys_school_category_override"));
REGISTRY = Collections.unmodifiableMap(m);
}
@@ -13,9 +13,10 @@ public enum Config
Inst;
// 末尾不要带斜杠:各控制器用 API_PATH + "/xxx/" 拼路径,若这里带尾斜杠会变成
// "/{platform}//xxx/" 这种字面双斜杠;Boot 2.6+ 默认 PathPatternParser 不合并连续斜杠,
// "/api//xxx/" 这种字面双斜杠;Boot 2.6+ 默认 PathPatternParser 不合并连续斜杠,
// 会导致 /api/xxx 这类正常请求匹配不上而 404(旧 AntPathMatcher 会自动合并,故以前不报错)。
public static final String API_PATH = "/{platform}"; // manage / api / mobiapi
public static final String API_PATH = "/api"; // PC 管理端(CAS 会话),路径闭集:/manage 等旧前缀随之 404
public static final String MOBI_API_PATH = "/mobiapi"; // 移动端(平台 token;UserBase 自身不挂 mobiapi 控制器,仅供常量对齐)
public static final String OPEN_API_PATH = "/oapi/";
public String getString(String key)
@@ -38,11 +38,12 @@ public class DWebMvcConfig implements WebMvcConfigurer
.addPathPatterns("/**")
.excludePathPatterns("/login", "/logout", "/static/**", "/error", "/upload/**");
// 管理端统一防线:NONE 级 403(白名单见拦截器内)。
// /{platform}/** 命中 api/manage/mobiapi 三前缀;显式排除 /oapi/**(服务间签名调用不受影响)。
// 注册 /api/**(显式前缀,路径闭集:/manage、/{platform} 通配退役,旧前缀随之 404,
// 同时堵掉"任意前缀绕过白名单"的洞);显式排除 /oapi/**(服务间签名调用不受影响)。
// /actuator/**:健康探针与 prometheus 抓取无登录态(projman 部署健康检查依赖,误拦会触发回滚);
// /v3/api-docs、/swagger-ui:接口文档工具链。
registry.addInterceptor(manageAccessInterceptor)
.addPathPatterns("/{platform}/**")
.addPathPatterns("/api/**")
.excludePathPatterns("/oapi/**", "/login", "/logout", "/static/**", "/error", "/upload/**",
"/actuator/**", "/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html", "/doc.html",
"/webjars/**");
@@ -0,0 +1,29 @@
package com.yunhai.userbase.config;
import com.yunhai.platform.mobile.*;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.*;
import org.springframework.core.Ordered;
import org.springframework.data.redis.core.StringRedisTemplate;
import java.time.Clock;
@Configuration(proxyBeanMethods=false)
public class MobileStrongConfiguration {
@Bean public FilterRegistrationBean<StrongRequestFilter> userBaseMobileStrong(
@org.springframework.beans.factory.annotation.Qualifier("stringRedisTemplate") ObjectProvider<StringRedisTemplate> redis,
@Value("${yh.mobile.env:}") String env,
@Value("${yh.mobile.app-id:}") String appId,@Value("${yh.mobile.key-registry-file:}") String keyFile,
@Value("${yh.mobile.key-registry-sha256:}") String keyHash) {
StrongRequestFilter filter=new StrongRequestFilter(()-> {
StringRedisTemplate r=redis.getIfAvailable();if(r==null)return null;
return new RequestAssertion(new PinnedKeyRegistry(keyFile,keyHash),new RedisReplay(r),Clock.systemUTC(),"userbase");
},r->{String path=r.getRequestURI().substring(r.getContextPath().length());String action=SensitivePaths.action(r.getMethod(),path);
if(action==null)return null;
AuthFailure.require(!env.isEmpty()&&!appId.isEmpty(),503,"CALLER_VERIFIER_UNAVAILABLE");
return new StrongRequestFilter.Required(env,appId,action,path);});
FilterRegistrationBean<StrongRequestFilter> bean=new FilterRegistrationBean<>(filter);
bean.setOrder(Ordered.HIGHEST_PRECEDENCE+3);bean.addUrlPatterns("/*");return bean;
}
}
@@ -25,8 +25,8 @@ import java.io.IOException;
* 强制开放接口必须带应用签名 + UserBase 自身 CAS 认证兜底的 UserDetailsProvider。<br>
* <br>
* yh-app-auth 的 {@link AppAuthFilter} 设计为「无签名头则放行」(以兼容浏览器 CAS 流),
* 但 UserBase 作为用户中心的开放接口({/open/api/**})与内部密钥兜底端点({/oapi/internal/**})
* 绝不允许匿名访问。本过滤器在 {@code AppAuthFilter} 之后运行,校验其写入的请求属性:
* 但 UserBase 作为用户中心的开放接口({/open/api/**} 与 {/oapi/**})绝不允许匿名访问。
* 本过滤器在 {@code AppAuthFilter} 之后运行,校验其写入的请求属性:
* 已验签(属性存在)则放行,否则返回 401。<br>
* 顺序须晚于 {@code AppAuthFilter}(HIGHEST_PRECEDENCE+10),故置为 +11。<br>
* <br>
@@ -39,7 +39,7 @@ import java.io.IOException;
* 导致其 @ConditionalOnMissingBean 抢先注册 NoOp,PathBasedAuthInterceptor 注入后首次请求即抛错。
*/
@Configuration
@EnableConfigurationProperties({ SignKeyInternalProperties.class, AppAuthProperties.class })
@EnableConfigurationProperties({ AppAuthProperties.class })
public class OpenApiAuthEnforceConfiguration {
@Bean
@@ -51,21 +51,6 @@ public class OpenApiAuthEnforceConfiguration {
return bean;
}
/**
* 内部开放接口(/oapi/internal/*)来源 IP 白名单:
* 在验签过滤器之前先执行(HIGHEST_PRECEDENCE + 9),先卡掉公网来源再进行后续处理。
* 配置项 yh.sign-key-internal.ips / enabled 可由 Nacos 下发。
*/
@Bean
public FilterRegistrationBean<SignKeyInternalIpFilter> signKeyInternalIpFilter(
SignKeyInternalProperties properties) {
FilterRegistrationBean<SignKeyInternalIpFilter> bean = new FilterRegistrationBean<>();
bean.setFilter(new SignKeyInternalIpFilter(properties));
bean.addUrlPatterns("/oapi/internal/*");
bean.setOrder(Ordered.HIGHEST_PRECEDENCE + 9);
return bean;
}
/**
* UserBase 本地 CAS 登录用户提供者:按登录名/手机号查 UserBase 自身库返回 BoaUser。<br>
* 加 {@code @Primary} 稳定覆盖 yh-auth 默认 NoOp 实现;bean 名固定=userDetailsProvider,
@@ -83,7 +68,9 @@ public class OpenApiAuthEnforceConfiguration {
throws IOException, ServletException {
HttpServletRequest req = (HttpServletRequest) request;
HttpServletResponse res = (HttpServletResponse) response;
if (req.getAttribute(AppAuthFilter.APP_AUTH_ATTR) == null) {
if (req.getAttribute(AppAuthFilter.APP_AUTH_ATTR) == null
&& !(req.getAttribute(com.yunhai.platform.mobile.StrongRequestFilter.VERIFIED)
instanceof com.yunhai.platform.mobile.CallerAppContext)) {
res.sendError(HttpServletResponse.SC_UNAUTHORIZED, "missing app signature");
return;
}
@@ -1,124 +0,0 @@
package com.yunhai.userbase.config;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.util.CollectionUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.net.Inet6Address;
import java.net.InetAddress;
import java.net.UnknownHostException;
import java.util.List;
/**
* 内部开放接口(/oapi/internal/*)来源 IP 白名单过滤器。<p>
* 防止密钥兜底端点(/oapi/internal/sign-key/{appId})被公网直接枚举调用;
* 只允许内网 / 显式配置的白名单来源通过,未命中返回 403。<p>
* 支持 X-Forwarded-For / X-Real-IP 等常见代理头解析,取最左侧真实来源 IP 做校验。
*/
public class SignKeyInternalIpFilter extends OncePerRequestFilter {
private static final Logger log = LoggerFactory.getLogger(SignKeyInternalIpFilter.class);
private final SignKeyInternalProperties properties;
public SignKeyInternalIpFilter(SignKeyInternalProperties properties) {
this.properties = properties;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
if (!properties.isEnabled()) {
filterChain.doFilter(request, response);
return;
}
String sourceIp = resolveSourceIp(request);
List<String> effectiveIps = properties.getEffectiveIps();
if (CollectionUtils.isEmpty(effectiveIps) || matchAny(sourceIp, effectiveIps)) {
filterChain.doFilter(request, response);
return;
}
log.warn("[SignKeyInternal] 拒绝非白名单来源访问内部接口: uri={}, sourceIp={}, whitelist={}",
request.getRequestURI(), sourceIp, effectiveIps);
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"code\":403,\"msg\":\"内部接口不允许从该来源访问\",\"data\":null}");
response.getWriter().flush();
}
private static String resolveSourceIp(HttpServletRequest request) {
String[] headers = new String[] {
"X-Forwarded-For",
"X-Real-IP",
"Proxy-Client-IP",
"WL-Proxy-Client-IP",
"HTTP_CLIENT_IP",
"HTTP_X_FORWARDED_FOR"
};
for (String header : headers) {
String raw = request.getHeader(header);
if (raw == null || raw.isEmpty() || "unknown".equalsIgnoreCase(raw)) {
continue;
}
// X-Forwarded-For 可能是 多个IP逗号分隔,取最左侧
String first = raw.split(",")[0].trim();
if (!first.isEmpty()) {
return first;
}
}
return request.getRemoteAddr();
}
private static boolean matchAny(String ip, List<String> cidrs) {
for (String cidr : cidrs) {
if (matchCidr(ip, cidr)) {
return true;
}
}
return false;
}
private static boolean matchCidr(String ip, String cidr) {
if (ip == null || cidr == null) {
return false;
}
try {
int prefixLen;
String network;
if (cidr.contains("/")) {
String[] parts = cidr.split("/");
network = parts[0].trim();
prefixLen = Integer.parseInt(parts[1].trim());
} else {
network = cidr.trim();
// 判断地址族决定默认前缀长度
InetAddress na = InetAddress.getByName(network);
prefixLen = (na instanceof Inet6Address) ? 128 : 32;
}
InetAddress ipAddr = InetAddress.getByName(ip);
InetAddress netAddr = InetAddress.getByName(network);
if (ipAddr.getClass() != netAddr.getClass()) {
return false;
}
byte[] ipBytes = ipAddr.getAddress();
byte[] netBytes = netAddr.getAddress();
int bits = prefixLen;
for (int i = 0; i < ipBytes.length && bits > 0; i++) {
int mask = bits >= 8 ? 0xFF : (0xFF << (8 - bits));
if ((ipBytes[i] & mask) != (netBytes[i] & mask)) {
return false;
}
bits -= 8;
}
return true;
} catch (UnknownHostException | NumberFormatException ignored) {
return false;
}
}
}
@@ -1,55 +0,0 @@
package com.yunhai.userbase.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
/**
* 内部开放接口(/oapi/internal/*)访问白名单 IP 配置。<br>
* 支持单 IP、CIDR(如 10.0.0.0/8、172.16.0.0/12、192.168.0.0/16、127.0.0.1/32)。<br>
* <p>
* 可在 Nacos 上配置,例如:<pre>
* yh:
* sign-key-internal:
* ips:
* - 10.0.0.0/8
* - 172.16.0.0/12
* - 192.168.0.0/16
* - 127.0.0.1/32
* - 172.20.10.5/32 # 某台固定机器
* </pre>
* 未配置时,默认允许 10/172.16-31/192.168 私有段 + 127.0.0.1(方便本地开发/内网部署)。
*/
@ConfigurationProperties(prefix = "yh.sign-key-internal")
public class SignKeyInternalProperties {
/** 显式允许的来源 IP(CIDR 或 单 IP)。为空时使用默认私有段规则。 */
private List<String> ips = new ArrayList<>();
/** 是否启用白名单校验;设置 false 可临时关闭(生产不建议)。 */
private boolean enabled = true;
public List<String> getIps() { return ips; }
public void setIps(List<String> ips) { this.ips = ips; }
public boolean isEnabled() { return enabled; }
public void setEnabled(boolean enabled) { this.enabled = enabled; }
/** 未配置 ips 时返回默认私有段 + localhost 规则。 */
public List<String> getEffectiveIps() {
if (ips != null && !ips.isEmpty()) {
return ips;
}
return Arrays.asList(
"10.0.0.0/8",
"172.16.0.0/12",
"192.168.0.0/16",
"127.0.0.1/32",
"0:0:0:0:0:0:0:1/128"
);
}
}
@@ -0,0 +1,45 @@
package com.yunhai.userbase.constant;
/**
* 「整应用授权」的 module_id 哨兵编码。<br>
* <p>
* 授权关系表 {@code sys_group_module_perm} / {@code sys_app_role_module_rel} 都只有
* (owner_id, module_id) 两列,<b>没有 app_id</b>。当某个应用下没有任何 {@code app_module} 记录时
* (如「一周安排」这类单页应用),模块级授权无处可挂,于是用 <b>-appId</b> 作为该应用
* 「整体已授权」的哨兵值:
* <ul>
* <li><b>不是 workaround</b>:module_id 命名空间里真实模块 id 恒为正整数,负数段天然是空闲的
* 应用级通道,与真实模块不会冲突;</li>
* <li>不同应用编码不同,满足 {@code uk_group_module(group_id, module_id)} /
* {@code uk_app_role_module(role_id, module_id)} 唯一键,同组/同角色可同时授权多个无模块应用;</li>
* <li>权限判定链路(hasModulePermission / collectEnabledRoleModuleIds 等)按真实 moduleId 匹配,
* 哨兵值永远匹配不上,不会被误判成某个模块的权限。</li>
* </ul>
* 写入侧由 Service 内部把「入参 moduleId=0(整应用语义)」翻译成本编码,前端无需感知。
*/
public final class AppLevelModuleId {
private AppLevelModuleId() {
}
/**
* 应用ID → 整应用授权的哨兵 module_id(-appId)。appId 为空时返回 null。
*/
public static Integer of(Integer appId) {
return appId == null ? null : -appId;
}
/**
* 哨兵 module_id → 应用ID;传入真实模块 id(正数)或 null 时返回 null。
*/
public static Integer resolveAppId(Integer moduleId) {
return (moduleId != null && moduleId < 0) ? -moduleId : null;
}
/**
* 是否为整应用授权的哨兵值。
*/
public static boolean isAppLevel(Integer moduleId) {
return moduleId != null && moduleId < 0;
}
}
@@ -0,0 +1,39 @@
package com.yunhai.userbase.constant;
/**
* 应用性质(application_info.app_nature)。<br>
* BASE=基石应用:平台基建身份(UserHome / 移动门户等),需要出站签名身份但不进门户开通/授权/模块管理;<br>
* BUSINESS=业务应用:门户可见,参与开通/授权。<br>
* 与 app_category 的「基础应用」分类无关——那边是"类型",这边是"性质"(两界面不同屏,存储层并存)。
*/
public enum AppNature { BASE, BUSINESS;
/**
* UserBase 自身的 appKey —— appKey 从 100000 起自增,"基石应用段"起点(application_bootstrap.sql)。<br>
* 它是验签的管理者,不拿自己的身份被验签,故不标 app_nature,由 {@link #isUserbaseSelf} 覆盖。
*/
public static final int USER_CENTER_APP_KEY = 100000;
/**
* 条件一:UserBase 自身(延续原「appKey &gt; 100000」排除逻辑的取反语义,行为不变)。
*/
public static boolean isUserbaseSelf(Integer appKey) {
return appKey != null && appKey <= USER_CENTER_APP_KEY;
}
/**
* 条件二:显式标记的基石应用(UserHome / 移动门户等)。
*/
public static boolean isMarkedBase(String appNature) {
return BASE.name().equals(appNature);
}
/**
* 是否基石应用(不参与应用开通 / 授权 / 模块管理等业务功能)。<br>
* 两条件取「或」。注意 JPQL @Query 内无法调用本方法——Repository 三处为常量拼接的 SQL 形态,
* 语义单点仍在本枚举,改动须同步(含三值逻辑 NULL 分支)。
*/
public static boolean isBaseApp(Integer appKey, String appNature) {
return isUserbaseSelf(appKey) || isMarkedBase(appNature);
}
}
@@ -108,16 +108,17 @@ public class AppRoleController {
}
/**
* 查询角色已绑定的用户列表。
* 查询角色已绑定的用户列表(一行 = 一条「用户×组」绑定,含部门路径)。<br>
* 路径查询时按 groupId 实时拼接;非超管按数据范围过滤行并裁剪路径(学校管理员只看到本校路径)。
*/
@GetMapping("/RoleUsers")
public List<AppRoleUserVo> roleUsers(@RequestParam Integer roleId) {
accessScopeService.requireAdmin(); // S+B+C:NONE 已被拦截器挡住,双保险
return appRoleService.findRoleUsers(roleId);
return appRoleService.findRoleUsers(roleId, accessScopeService.scopeGroupIds());
}
/**
* 角色绑定用户保存(角色维度,全量覆盖)。
* 角色绑定用户保存(角色维度,增量添加「用户×组」绑定)。
*/
@OperationLog("保存角色用户绑定")
@PostMapping("SaveRoleUsers")
@@ -127,7 +128,7 @@ public class AppRoleController {
}
/**
* 用户绑定角色保存(用户维度,全量覆盖)。
* 用户绑定角色保存(用户维度,按组覆盖:只覆盖该用户在指定组下的绑定)。
*/
@OperationLog("保存用户角色绑定")
@PostMapping("SaveUserRoles")
@@ -137,13 +138,14 @@ public class AppRoleController {
}
/**
* 移除单个用户与角色的绑定关系(精确删除单条记录,非全量重提)。<br>
* 移除单条「用户×组×角色」绑定(精确删除一行,非全量重提)。<br>
* 前端「用户管理 - 删除」调用此接口。
*/
@OperationLog("移除角色用户绑定")
@PostMapping("RemoveRoleUser")
public void removeRoleUser(@RequestParam Integer roleId, @RequestParam Integer userId) {
public void removeRoleUser(@RequestParam Integer roleId, @RequestParam Integer userId,
@RequestParam Integer groupId) {
accessScopeService.requireAdmin(); // S+B+C:NONE 已被拦截器挡住,双保险
appRoleService.removeRoleUser(roleId, userId);
appRoleService.removeRoleUser(roleId, userId, groupId);
}
}
@@ -30,7 +30,6 @@ import com.yunhai.userbase.service.resolve.AccessScopeService;
import com.yunhai.userbase.utils.PageUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Sort;
import org.springframework.data.web.SortDefault;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
@@ -195,8 +194,14 @@ public class AppSettingsController {
@GetMapping("/AppList")
public BasePageResult<ApplicationInfoVo> appList(ApplicationInfoQuery query, BasePageQuery pageQuery,
@SortDefault(sort = "sortNo", direction = Sort.Direction.ASC) Sort sort,
@RequestParam(value = "schoolId", required = false) Integer schoolId) {
// 排序必须带唯一兜底键:同键行 MySQL 不保证稳定序,加删一行即整组洗牌(应用列表乱序的根因)。
// 全部(未选类别)= id 升序——sort_no 是「类型内」序号,跨类型打平无全局意义;
// 选中类别 = 未排序(NULL)沉底 + sortNo 升序 + id 兜底——由 Specification 内嵌 orderBy 实现
// (NULL 沉底须 case 表达式,Sort 只走属性解析表达不了;此处传 unsorted,否则 Pageable 排序会覆盖 spec 的 orderBy)
Sort sort = query.getCategoryId() == null
? Sort.by(Sort.Direction.ASC, "id")
: Sort.unsorted();
if (isSchoolLevel()) {
// 学校:仅本校已开通应用,override 合并 名称/描述/图标/排序(多校默认并集,可指定单校)
AccessScope scope = accessScopeService.resolveCurrentScope();
@@ -5,6 +5,7 @@ import com.yunhai.userbase.config.CacheConfig;
import com.yunhai.userbase.config.CacheNames;
import com.yunhai.userbase.config.CacheNames.Meta;
import com.yunhai.userbase.config.Config;
import com.yunhai.userbase.service.impl.IdentityService;
import io.swagger.v3.oas.annotations.Operation;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
@@ -26,12 +27,15 @@ import java.util.TreeSet;
/**
* 缓存注册表对账接口(方案 C 的运行时可观测面)。<br><br>
*
* {@code GET /{platform}/cache/registry} 输出三块内容:<br>
* {@code GET /api/cache/registry} 输出三块内容:<br>
* <ul>
* <li><b>caches</b>:注册表({@link CacheNames#REGISTRY})逐项声明(TTL/抖动/存放/key 规则/失效责任/依赖表)
* × 运行时是否已出现 × Caffeine 命中率统计(已 recordStats);</li>
* <li><b>unregisteredRuntimeCaches</b>:运行时出现但注册表没有 = 有人硬编码缓存名(应清零);</li>
* <li><b>declaredButUnusedCaches</b>:注册表声明但运行时从未出现 = 声明了没人用(启动初期属正常)。</li>
* <li><b>identityCurrentNullLookups</b>:无身份身份查询累计次数(进程级)。identityCurrent 对 null 不缓存
* (unless 拦截),故「无身份请求数」可直接读出;与埋点里 identity/current 的调用数相除即得空值占比,
* 是判断是否值得为 null 加哨兵缓存的唯一依据。Redis 侧缓存无命中率输出,此计数是其替代观测面。</li>
* </ul>
* 降级态显式标注 degraded=true(Redis 探活失败切 Caffeine),不误报「缓存全丢」——
* 此时 Redis 侧缓存名不可见属预期,恢复后自动切回。<br>
@@ -44,6 +48,10 @@ public class CacheRegistryController {
@Autowired
private CacheManager cacheManager;
/** 无身份查询计数来源(identityCurrent 对 null 不缓存,计数即无身份请求数)。 */
@Autowired
private IdentityService identityService;
/** Redis 降级兜底实例(Redis 侧缓存名的运行时来源,且只在降级期承载条目)。 */
@Autowired
@Qualifier("caffeineCacheManager")
@@ -91,6 +99,7 @@ public class CacheRegistryController {
result.put("caches", caches);
result.put("unregisteredRuntimeCaches", unregistered);
result.put("declaredButUnusedCaches", unused);
result.put("identityCurrentNullLookups", identityService.getNullIdentityLookupCount());
return result;
}
@@ -0,0 +1,74 @@
package com.yunhai.userbase.controller.api;
import com.yunhai.userbase.common.annotation.OperationLog;
import com.yunhai.userbase.config.Config;
import com.yunhai.userbase.model.customdept.CustomDeptCategoryRequest;
import com.yunhai.userbase.model.customdept.CustomDeptCategorySortRequest;
import com.yunhai.userbase.model.customdept.CustomDeptCategoryVo;
import com.yunhai.userbase.service.ICustomDeptCategoryService;
import io.swagger.v3.oas.annotations.Operation;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* @author chenx
* @date 2026/10/21
* @description 自定义部门分类管理(CUSTOM 侧,部门管理页左侧平铺列表的数据源)。<br>
* 动词式端点、{@code /api} 前缀与 {@code SysGroupController} 同范式;
* 四级访问范围与跨校拦截在 Service 内统一执行(写口只认登录身份,不认请求体 schoolId)。<br>
* 分类是容器:无启停、无成员;删除只有软删,且非空分类明确拒绝(无级联)。
*/
@RestController
@RequestMapping(value = Config.API_PATH + "/deptcategory/")
public class CustomDeptCategoryController {
@Autowired
private ICustomDeptCategoryService customDeptCategoryService;
@Operation(operationId = "customDeptCategoryList")
@GetMapping("List")
public List<CustomDeptCategoryVo> list(@RequestParam(value = "schoolId", required = false) Integer schoolId) {
return customDeptCategoryService.list(schoolId);
}
@OperationLog("新增自定义部门分类")
@Operation(operationId = "customDeptCategoryAdd")
@PostMapping("Add")
public CustomDeptCategoryVo add(@RequestBody @Validated CustomDeptCategoryRequest request) {
return customDeptCategoryService.add(request);
}
@OperationLog("重命名自定义部门分类")
@Operation(operationId = "customDeptCategoryEdit")
@PostMapping("Edit/{id}")
public CustomDeptCategoryVo edit(@PathVariable Integer id,
@RequestBody @Validated CustomDeptCategoryRequest request) {
return customDeptCategoryService.rename(id, request);
}
@OperationLog("自定义部门分类排序")
@Operation(operationId = "customDeptCategorySort")
@PostMapping("Sort")
public void sort(@RequestBody @Validated CustomDeptCategorySortRequest request) {
customDeptCategoryService.sort(request);
}
/**
* 软删分类:分类下仍有有效部门时明确拒绝(A14),不做任何级联删除。
*/
@OperationLog("删除自定义部门分类")
@Operation(operationId = "customDeptCategoryDelete")
@PostMapping("Delete/{id}")
public void delete(@PathVariable Integer id) {
customDeptCategoryService.softDelete(id);
}
}
@@ -0,0 +1,149 @@
package com.yunhai.userbase.controller.api;
import com.yunhai.userbase.common.annotation.OperationLog;
import com.yunhai.userbase.config.Config;
import com.yunhai.userbase.model.customdept.CustomDepartmentMemberRequest;
import com.yunhai.userbase.model.customdept.CustomDepartmentMemberVo;
import com.yunhai.userbase.model.customdept.CustomDepartmentMoveRequest;
import com.yunhai.userbase.model.customdept.CustomDepartmentRequest;
import com.yunhai.userbase.model.customdept.CustomDepartmentSortRequest;
import com.yunhai.userbase.model.customdept.CustomDepartmentVo;
import com.yunhai.userbase.service.ICustomDepartmentService;
import io.swagger.v3.oas.annotations.Operation;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* @author chenx
* @date 2026/10/21
* @description 自定义部门管理(CUSTOM 侧:部门本体 + 启停 + 软删 + 成员)。<br>
* 与 SYSTEM 组织树完全隔离:本控制器不读写 {@code sys_group} 的任何写路径,
* 也不提供删除 SYSTEM 部门的入口(任务书 D1)。<br>
* 跨校/学生成员等校验全部在 Service 服务端执行,前端传值只作核对。
*/
@RestController
@RequestMapping(value = Config.API_PATH + "/customdept/")
public class CustomDeptController {
@Autowired
private ICustomDepartmentService customDepartmentService;
@Operation(operationId = "customDeptList")
@GetMapping("List")
public List<CustomDepartmentVo> list(@RequestParam(value = "schoolId", required = false) Integer schoolId,
@RequestParam("categoryId") Integer categoryId) {
return customDepartmentService.list(schoolId, categoryId);
}
@OperationLog("新增自定义部门")
@Operation(operationId = "customDeptAdd")
@PostMapping("Add")
public CustomDepartmentVo add(@RequestBody @Validated CustomDepartmentRequest request) {
return customDepartmentService.add(request);
}
@OperationLog("重命名自定义部门")
@Operation(operationId = "customDeptEdit")
@PostMapping("Edit/{id}")
public CustomDepartmentVo edit(@PathVariable Integer id,
@RequestBody @Validated CustomDepartmentRequest request) {
return customDepartmentService.rename(id, request);
}
/**
* 移动至本校其他分类:目标分类跨校直接拒绝。
*/
@OperationLog("移动自定义部门")
@Operation(operationId = "customDeptMove")
@PostMapping("Move/{id}")
public CustomDepartmentVo move(@PathVariable Integer id,
@RequestBody @Validated CustomDepartmentMoveRequest request) {
return customDepartmentService.move(id, request);
}
@OperationLog("自定义部门排序")
@Operation(operationId = "customDeptSort")
@PostMapping("Sort")
public void sort(@RequestBody @Validated CustomDepartmentSortRequest request) {
customDepartmentService.sort(request);
}
/**
* 停用自定义部门:单列 UPDATE enabled=0。<br>
* 停用只影响「选」(新业务选不到),不影响「看」与「办」(历史/在途仍可解析),与 SYSTEM 同口径。
*/
@OperationLog("停用自定义部门")
@Operation(operationId = "customDeptDisable")
@PostMapping("Disable/{id}")
public void disable(@PathVariable Integer id) {
customDepartmentService.changeEnabled(id, false);
}
/**
* 启用自定义部门:单列 UPDATE enabled=1,原 id 与成员保持不变(A11)。
*/
@OperationLog("启用自定义部门")
@Operation(operationId = "customDeptEnable")
@PostMapping("Enable/{id}")
public void enable(@PathVariable Integer id) {
customDepartmentService.changeEnabled(id, true);
}
/**
* 软删自定义部门:默认列表不再出现,历史引用仍可解析(A12);成员关系保留在库、由查询侧过滤。
*/
@OperationLog("删除自定义部门")
@Operation(operationId = "customDeptDelete")
@PostMapping("Delete/{id}")
public void delete(@PathVariable Integer id) {
customDepartmentService.softDelete(id);
}
@Operation(operationId = "customDeptMembers")
@GetMapping("Members/{id}")
public List<CustomDepartmentMemberVo> members(@PathVariable Integer id) {
return customDepartmentService.members(id);
}
/**
* 可添加的候选教职工:本校教职工(服务端排除学生组)且尚未在该部门。<br>
* 现有 {@code GroupAuthz.GroupUsers} 是组维度、会带出学生组用户,不能当候选源,故单独成口(D4)。
*/
@Operation(operationId = "customDeptCandidateUsers")
@GetMapping("CandidateUsers/{id}")
public List<CustomDepartmentMemberVo> candidateUsers(@PathVariable Integer id,
@RequestParam(value = "keyword", required = false) String keyword) {
return customDepartmentService.candidateUsers(id, keyword);
}
/**
* 批量添加成员:服务端逐个复核「本校 + 教职工(排除学生组)+ 有效账号」,重复添加按幂等跳过(A07/A08)。
*/
@OperationLog("添加自定义部门成员")
@Operation(operationId = "customDeptAddMembers")
@PostMapping("AddMembers/{id}")
public void addMembers(@PathVariable Integer id,
@RequestBody @Validated CustomDepartmentMemberRequest request) {
customDepartmentService.addMembers(id, request);
}
/**
* 移除成员:物理删关系行,不残留有效成员。
*/
@OperationLog("移除自定义部门成员")
@Operation(operationId = "customDeptRemoveMembers")
@PostMapping("RemoveMembers/{id}")
public void removeMembers(@PathVariable Integer id,
@RequestBody @Validated CustomDepartmentMemberRequest request) {
customDepartmentService.removeMembers(id, request);
}
}
@@ -148,29 +148,46 @@ public class GroupAuthzController {
/**
* 权限一览:查询用户所属的业务角色列表(中间下方「所属角色」区域展示用)。<br>
* 返回该用户绑定的全部角色(含禁用角色,disabled 字段标记),普通角色回填所属应用名称。
* 返回该用户绑定的全部角色(含禁用角色,disabled 字段标记),普通角色回填所属应用名称。<br>
* groupId 可选:传入时只返回该组下的绑定(编辑弹窗按所在组回显角色用);为空返回全部绑定聚合去重。
*/
@GetMapping("/UserRoles")
public List<UserRoleVo> userRoles(@RequestParam Integer userId) {
public List<UserRoleVo> userRoles(@RequestParam Integer userId,
@RequestParam(required = false) Integer groupId) {
if (!accessScopeService.resolveCurrentScope().isSuper()
&& !accessScopeService.userIntersectsScope(userId)) {
return Collections.emptyList();
}
return groupAuthzService.findUserRoles(userId);
return groupAuthzService.findUserRoles(userId, groupId);
}
/**
* 权限一览:查询用户所属部门树(左侧上方「所属部门」区域展示用)。<br>
* 树仅由「用户直接归属的组 + 其全部祖先组」构成(如:云平台-学校中学-语文组)。<br>
* 非超管:目标用户归属组与数据范围无交集时返回空集合。
* groupId 可选(上下文组):传入且用户确属该组时只画该组的祖先链——中间列表每行是「用户×组」对,
* 所属部门只展示行所在组的链路,而非用户全部归属组。<br>
* 非超管:目标用户归属组与数据范围无交集时返回空集合;且树按数据范围剪枝——
* 学校管理员视角只展示到学校组(区域组/平台根不在其范围内,不再冒头到根节点)。
*/
@GetMapping("/UserGroupTree")
public List<UserGroupNodeVo> userGroupTree(@RequestParam Integer userId) {
public List<UserGroupNodeVo> userGroupTree(@RequestParam Integer userId,
@RequestParam(required = false) Integer groupId) {
if (!accessScopeService.resolveCurrentScope().isSuper()
&& !accessScopeService.userIntersectsScope(userId)) {
return Collections.emptyList();
}
return groupAuthzService.findUserGroupTree(userId);
return groupAuthzService.findUserGroupTree(userId, accessScopeService.scopeGroupIds(), groupId);
}
/**
* 权限一览:查询<b>组本身</b>的有效已授权应用模块树(点击「所属部门」树节点联动右侧「已授权应用」面板)。<br>
* 判定口径与组授权/权限一览的组侧一致(开通池=本组+祖先并集、就近原则、子组继承闸门、学校截止),
* 不并入用户角色授权;目标组须在当前管理端数据范围内。
*/
@GetMapping("/GroupPermissionTree")
public List<UserPermissionCategoryVo> groupPermissionTree(@RequestParam Integer groupId) {
accessScopeService.requireGroupInScope(groupId);
return groupAuthzService.findGroupPermissionTree(groupId);
}
// ===================== 新增:树节点式增量授权(左侧树 + 右侧 appAuthzList 勾选交互) =====================
@@ -27,7 +27,7 @@ import java.util.LinkedHashMap;
import java.util.Map;
/**
* 推送订阅控制器:测试推送 + 推送日志记录 / 重试。
* 推送订阅控制器:手动触发推送(组/用户全量同步)+ 推送日志记录 / 重试。
*
* @author chenx
* @date 2026/8/14
@@ -45,17 +45,14 @@ public class PushSubscriptionController {
@Autowired
private AccessScopeService accessScopeService;
/** 测试推送:手动触发一次推送(投递 MQ),并返回请求预览(URL/头/体,密钥不外露)。 */
/** 手动触发一次推送(投递 MQ),供应用推送设置的组/用户全量同步等场景调用。 */
@PostMapping("Test")
public Map<String, Object> test(@RequestBody PushTestRequest request) {
accessScopeService.requireSuper(); // 日志/推送管理仅超管
pushDispatcher.dispatchManual(request.getAppId(), request.getInterfacePath(), request.getTargetIds());
Map<String, Object> preview = pushDispatcher.previewManual(
request.getAppId(), request.getInterfacePath(), request.getTargetIds());
Map<String, Object> result = new LinkedHashMap<>();
result.put("sent", true);
result.put("message", "已投递推送队列,可在「推送日志」查看实际响应与耗时");
result.put("preview", preview);
return result;
}
@@ -4,6 +4,7 @@ import com.yunhai.userbase.common.annotation.OperationLog;
import com.yunhai.userbase.common.validation.ValidationGroups;
import com.yunhai.userbase.config.Config;
import com.yunhai.userbase.model.sysgroup.SysGroupBatchRequest;
import com.yunhai.userbase.model.sysgroup.SysGroupIncludeChildrenRequest;
import com.yunhai.userbase.model.sysgroup.SysGroupMoveRequest;
import com.yunhai.userbase.model.sysgroup.SysGroupQuery;
import com.yunhai.userbase.model.sysgroup.SysGroupRequest;
@@ -65,6 +66,46 @@ public class SysGroupController {
sysGroupService.delete(id);
}
/**
* 停用组(2026-10-20 部门线 P0 最小批):单列 UPDATE sys_group.status=1,不碰其他任何表与列。<br>
* 不判组类型(status 当前无消费方);删除行为不动。范式对齐 SysUserController.Disable。
*/
@OperationLog("停用组")
@Operation(operationId = "sysGroupDisable")
@PostMapping("Disable/{id}")
public void disable(@PathVariable Integer id) {
accessScopeService.requireGroupInScope(id);
accessScopeService.requireGroupModifiable(id);
sysGroupService.changeStatus(id, true);
}
/**
* 启用组(2026-10-20 部门线 P0 最小批):单列 UPDATE sys_group.status=0。
*/
@OperationLog("启用组")
@Operation(operationId = "sysGroupEnable")
@PostMapping("Enable/{id}")
public void enable(@PathVariable Integer id) {
accessScopeService.requireGroupInScope(id);
accessScopeService.requireGroupModifiable(id);
sysGroupService.changeStatus(id, false);
}
/**
* 设置「包含子组」(2026-10-21 部门线):该学校在部门管理页是否连同下级部门一起列出。<br>
* 单列 UPDATE sys_group.include_children,不碰其他列与表。<br>
* 权限<b>只用</b> {@code requireGroupInScope}:不能加 {@code requireGroupModifiable}——
* 后者禁止 SCHOOL 级操作学校组本身,而本属性恰恰是学校管理员必须能改的本校属性。
*/
@OperationLog("设置包含子组")
@Operation(operationId = "sysGroupSetIncludeChildren")
@PostMapping("SetIncludeChildren/{id}")
public void setIncludeChildren(@PathVariable Integer id,
@RequestBody @Validated SysGroupIncludeChildrenRequest request) {
accessScopeService.requireGroupInScope(id);
sysGroupService.changeIncludeChildren(id, Boolean.TRUE.equals(request.getIncludeChildren()));
}
@Operation(operationId = "sysGroupList")
@GetMapping("/List")
public List<SysGroupVo> list(SysGroupQuery query) {
@@ -28,7 +28,10 @@ import org.springframework.web.multipart.MultipartFile;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* @author chenx
@@ -66,10 +69,15 @@ public class SysUserController {
@OperationLog("编辑用户")
@PostMapping("Edit/{id}")
public void edit(@PathVariable Integer id, @RequestBody @Validated(ValidationGroups.Update.class) SysUserRequest request) {
// 关键防线:目标用户本身必须与数据范围有交集。request.groupId 只是前端上下文,
// 伪造 groupId(范围内任一组)+ 任意 id 可跨校改他人资料/重置密码(账号接管)。
// 关键防线:目标用户本身必须与数据范围有交集。伪造任意 id 可跨校改他人资料/重置密码(账号接管),
// 由 requireUserInScope(id) 拦截——它与 request.groupId 无关,是编辑越权的唯一有效锚点。
accessScopeService.requireUserInScope(id);
accessScopeService.requireGroupInScope(request.getGroupId());
// groupId 对编辑无业务语义(SysUserService.edit 不读取它,仅按 schoolId+roleIds 落身份),
// 且 DTO 与前端契约均声明「groupId 仅 Create 校验、编辑不提交」。故为空时不再拦截(修复「组ID不能为空」);
// 仅当前端确实带了组上下文时,才二次校验该组落在数据范围内,保留防御纵深。
if (request.getGroupId() != null) {
accessScopeService.requireGroupInScope(request.getGroupId());
}
sysUserService.edit(id, request);
}
@@ -160,7 +168,8 @@ public class SysUserController {
/**
* 查询用户身份(按所在组解析 schoolId):返回 {schoolId, roleIds};同一学校多组返回同一 schoolId 与并集。<br>
* 「用户身份」回显唯一来源(roles 列已停写,仅历史兼容读取);编辑请求提交时原样携带 {schoolId, roleIds}。
* 「用户身份」回显唯一来源(数据源=身份行 sys_user_role_rel;roles 列已停写、不再读取);
* 编辑请求提交时原样携带 {schoolId, roleIds}。
*/
@GetMapping("UserIdentity")
public UserIdentityVo userIdentity(@RequestParam Integer userId, @RequestParam Integer groupId) {
@@ -197,4 +206,22 @@ public class SysUserController {
requireQueryGroupInScope(groupId);
sysUserService.exportUsers(groupId, withSub, name, response);
}
/**
* 按 id 批量取用户展示信息(id/账号/姓名),管理面聚合页(消息/待办追溯台收件明细)回显名称用。<br>
* 只读窄投影:不透出手机号/身份证等敏感列;用户目录属平台主数据,仅超管。
*/
@GetMapping("ByIds")
public List<Map<String, Object>> byIds(@RequestParam("ids") List<Integer> ids) {
accessScopeService.requireSuper();
return sysUserService.listByIds(ids).stream()
.map(u -> {
Map<String, Object> item = new LinkedHashMap<>();
item.put("id", u.getId());
item.put("account", u.getAccount());
item.put("realName", u.getRealName());
return item;
})
.collect(Collectors.toList());
}
}
@@ -5,9 +5,13 @@ import com.yunhai.platform.utils.enums.IEnum;
import com.yunhai.platform.utils.model.BoaAppType;
import com.yunhai.userbase.config.Config;
import com.yunhai.userbase.entity.ApplicationInfo;
import com.yunhai.userbase.model.application.AppActivatedVo;
import com.yunhai.userbase.model.application.AppIconMapVo;
import com.yunhai.userbase.model.application.ApplicationInfoVo;
import com.yunhai.userbase.service.IAppCategoryService;
import com.yunhai.userbase.service.IApplicationInfoService;
import com.yunhai.userbase.service.IGroupAuthzService;
import com.yunhai.platform.utils.exception.DException;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
@@ -33,6 +37,12 @@ public class AppController {
@Autowired
private IApplicationInfoService applicationInfoService;
@Autowired
private IGroupAuthzService groupAuthzService;
@Autowired
private com.yunhai.userbase.service.ISysGroupService sysGroupService;
/**
* 按主键 ID 查询应用信息(带缓存)。<br>
* 与客户端契约 {@code GET /oapi/app/{appId}} 对应;查不到返回 null(HTTP 200 + data:null 信封)。<br>
@@ -43,7 +53,53 @@ public class AppController {
*/
@GetMapping("{appId}")
public ApplicationInfoVo byId(@PathVariable Integer appId) {
ApplicationInfo info = applicationInfoService.getById(appId);
return toVo(applicationInfoService.getById(appId));
}
/**
* 按技术标识查询应用信息(跳转查询键主用端点)。<br>
* app_code = projman app.name(如 notice/flow-center),即消息 {@code jump_app} 的值域;<br>
* 查不到返回 null(与 byId 契约一致)。返回视图清空 appSecret。
*
* @param appCode 技术标识(必填)
* @return 应用信息视图;不存在时返回 null
*/
@GetMapping("by-code/{appCode}")
public ApplicationInfoVo byCode(@PathVariable String appCode) {
return toVo(applicationInfoService.getByAppCode(appCode));
}
/**
* 按整型 appKey 查询应用信息(兼容兜底端点,存量自发场景按数字回落)。<br>
* ⚠️ 查的是 app_key 列而非主键 id——实测 id ≠ app_key(15 行中仅 2 行相等,
* 如流程中心 appKey=100009 的主键是 100011),按 id 查会把「流程中心」查成「课堂作业」。<br>
* 查不到返回 null;返回视图清空 appSecret。
*
* @param appKey 应用标识(必填)
* @return 应用信息视图;不存在时返回 null
*/
@GetMapping("by-key/{appKey}")
public ApplicationInfoVo byKey(@PathVariable Integer appKey) {
return toVo(applicationInfoService.getByAppKey(appKey));
}
/**
* 查询用户有权应用的 app_code 集合(消息跳转判权,C1)。<br>
* 判权复用 {@code IGroupAuthzService.findUserAuthorizedAppIds(userId, null)}(2026-10-12 硬闸门 D2 收敛:
* 走生效学校锚点单点开通集合,不沿祖先、角色不补充未开通应用,超级管理员返回全部),
* 再映射为 app_code(空值行跳过)。只读,不透出 appSecret。<br>
* 消费方(UserHome 消息代理)以「集合是否包含消息 jump_app」决定是否显示跳转入口;
* 本端点不可达时消费方放行(不判权),绝不因判权通道故障批量隐藏入口。
*
* @param userId 用户ID(必填)
* @return 有权应用的 app_code 集合(可能为空,不为 null)
*/
@GetMapping("authorized-codes")
public List<String> authorizedCodes(@RequestParam("userId") Integer userId) {
return applicationInfoService.listAppCodesByIds(groupAuthzService.findUserAuthorizedAppIds(userId, null));
}
private ApplicationInfoVo toVo(ApplicationInfo info) {
if (info == null) {
return null;
}
@@ -55,20 +111,81 @@ public class AppController {
}
/**
* 查询用户在指定客户端类型下有权访问的应用列表(按应用类型分组)。<br>
* 权限过滤复用 {@code IGroupAuthzService.findUserAuthorizedAppIds}(组开通 ∪ 启用角色模块所属应用),
* 超级管理员返回全部;不透出 appSecret。<br>
* clientType 传枚举 key(2 的幂值,如 1=PC 2=移动App 4=系统模块…);非法/缺失按不限制端处理。
* 查询用户在指定客户端类型下有权访问的应用列表(按应用类型分组,已合并学校别名)。<br>
* 权限过滤(2026-10-12 学校单点开通硬闸门)复用 {@code IGroupAuthzService.findUserAuthorizedAppIds(userId, schoolId)}
* ——生效学校锚点单点开通的应用集合(不沿祖先、角色授权不再补充学校未开通的应用),超级管理员返回全部;
* 不透出 appSecret。<br>
* clientType 传枚举 key(2 的幂值,如 1=PC 2=移动App 4=系统模块…);非法/缺失按不限制端处理。<br>
* schoolId 可选:显式传入则按该校合并应用/类型别名(须为合法学校锚点且用户属该校,否则 code=5);
* 不传则按「当前激活身份学校 → 主归属学校」兜底推导生效学校,均无则不合并(返回主表真名)。
*
* @param userId 用户ID(必填)
* @param clientType 客户端类型 key(可选)
* @param schoolId 学校上下文(可选;null 走身份/主归属兜底)
* @return 应用类型分组列表;无权限时各类型 apps 为空数组
*/
@GetMapping("list")
public List<BoaAppType> list(@RequestParam("userId") Integer userId,
@RequestParam(value = "clientType", required = false) Integer clientType,
@RequestParam(value = "schoolId", required = false) Integer schoolId) {
ClientEnum clientEnum = (clientType == null) ? null
: IEnum.getByKey(ClientEnum.class, clientType);
return appCategoryService.listBoaAppType(userId, clientEnum, schoolId);
}
/**
* 按学校维度查询该校已开通的应用列表(按应用类型分组,已合并学校别名,2026-10-10)。<br>
* 开通口径 = 学校组<b>单点</b>已开通(sys_group_app_rel where group_id=schoolId;开通不继承,
* 不并入子组/角色模块应用)——学校把应用只开给子组时本端点看不到,属口径自然结果。<br>
* 基石应用(UserBase 自身 / BASE)不返回;不透出 appSecret。<br>
* schoolId 必填且须为合法学校组锚点,否则 code=5。<br>
* 消费方(flow-center 应用设置)可传 clientType=null 不过滤端,避免漏纯移动端应用。
*
* @param schoolId 学校组ID(必填)
* @param clientType 客户端类型 key(可选;null=不按端过滤)
* @return 应用类型分组列表;未开通应用的类型 apps 为空数组
*/
@GetMapping("list-by-school")
public List<BoaAppType> listBySchool(@RequestParam("schoolId") Integer schoolId,
@RequestParam(value = "clientType", required = false) Integer clientType) {
ClientEnum clientEnum = (clientType == null) ? null
: IEnum.getByKey(ClientEnum.class, clientType);
return appCategoryService.listBoaAppType(userId, clientEnum);
return appCategoryService.listBoaAppTypeBySchool(schoolId, clientEnum);
}
/**
* 全量应用矢量图标映射(AppIcon 资产库通道):[{id, iconKey, iconColor}],仅含已配置行。<br>
* 背景:BoaApp 属 BasePlatform SDK 本期不动,/oapi/app/list 出参无矢量字段;
* UserHome(PC GetUserApp)与 MobilePortal(移动端桌面)拉本端点后在各自出参上合并注入。<br>
* 只读,无敏感字段;消费方拉取失败时按空映射降级,端侧回退链不受影响。
*/
@GetMapping("icon-map")
public List<AppIconMapVo> iconMap() {
return applicationInfoService.listIconMap();
}
/**
* 应用×学校单点开通判定(2026-10-25 T01 最小只读口)。<br>
* 权威事实源 = {@code sys_group_app_rel}(组应用开通表,学校组单点、不沿祖先、不并角色模块),
* 与 {@code GET /oapi/app/list-by-school} 同一口径,只是收敛成 boolean 判定——
* form-center/flow-center 等下游把本端点作为「该应用是否在该校开通」的平台资格第二重证明
* (fail-closed:本端点不可达时下游必须拒绝,不得降级为"仅验签通过")。<br>
* 应用不存在(含已注销)返回 activated=false,不区分"没开通"与"没这应用",
* 避免把应用存在性做成探测面;schoolId 非法(非学校组锚点)按既有 {@code assertSchoolId}
* 口径抛业务错误(code=5)。只读、无敏感字段。
*
* @param appId 应用主键 ID(必填;稳定主体,非 appKey 凭据索引)
* @param schoolId 学校组 ID(必填)
*/
@GetMapping("activated")
public AppActivatedVo activated(@RequestParam("appId") Integer appId,
@RequestParam("schoolId") Integer schoolId) {
if (appId == null || schoolId == null) {
throw new DException("appId 与 schoolId 必填");
}
sysGroupService.assertSchoolId(schoolId);
boolean appExists = applicationInfoService.getById(appId) != null;
boolean activated = appExists && groupAuthzService.findAuthorizedAppIds(schoolId).contains(appId);
return new AppActivatedVo(appId, schoolId, activated);
}
}
@@ -0,0 +1,19 @@
package com.yunhai.userbase.controller.oapi;
import com.yunhai.platform.mobile.*;
import com.yunhai.userbase.mobile.MobileIdentityService;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
/** Legacy paths retained; naked phone/account/openid authority is retired. */
@RestController @RequestMapping("/oapi/channel")
public class ChannelBindController {
private final ObjectProvider<MobileIdentityService> provider;
public ChannelBindController(ObjectProvider<MobileIdentityService> provider){this.provider=provider;}
private MobileIdentityService service(){MobileIdentityService s=provider.getIfAvailable();AuthFailure.require(s!=null,503,"AUTHORITY_UNAVAILABLE");return s;}
public static class Bind {public String channelProof,userProof,operationId;}
public static class Unbind {public String userActionProof,reauthProof,operationId;}
@GetMapping("/user") public Map<String,Object> user(@RequestHeader("X-YH-Proof") String proof){return service().resolve(proof);}
@PostMapping("/bind") public Map<String,Object> bind(@RequestBody Bind b){return service().bind(b.channelProof,b.userProof,b.operationId);}
@PostMapping("/bindByPhone") public void phone(){Contexts.requireCaller("channel.bind.phone");throw new AuthFailure(403,"METHOD_DISABLED");}
@PostMapping("/unbind") public Map<String,Object> unbind(@RequestBody Unbind b){return service().unbind(b.userActionProof,b.reauthProof,b.operationId);}
}
@@ -0,0 +1,166 @@
package com.yunhai.userbase.controller.oapi;
import com.yunhai.platform.appauth.AppAuthFilter;
import com.yunhai.platform.utils.exception.DException;
import com.yunhai.platform.utils.model.BoaUser;
import com.yunhai.userbase.config.Config;
import com.yunhai.userbase.enums.DepartmentTypeEnum;
import com.yunhai.userbase.model.customdept.CustomDeptCategoryVo;
import com.yunhai.userbase.model.dept.DepartmentEntryVo;
import com.yunhai.userbase.model.dept.DepartmentRefRequest;
import com.yunhai.userbase.service.IDepartmentDirectoryService;
import com.yunhai.userbase.service.IApplicationInfoService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletRequest;
import java.util.List;
/**
* 通用部门目录开放接口({@code /oapi/departments/...},规格书 UB-2)。<br>
* <br>
* 鉴权链路照 {@code /oapi/group} 现行做法:验签由 {@code OpenApiAuthEnforceConfiguration} 的
* {@code RequireAppSignFilter} 在过滤器层强制(无签名直接 401),本层不做验签;
* 但<b>验签只证明「是哪个应用」</b>,能否读这所学校由 {@code sys_group_app_rel}(应用在该校已开通)决定,
* 越校读取抛业务异常(A15)。<br>
* 引用一律 {@code schoolId + departmentType + departmentId} 三件套,禁止以名称做键。
*/
@Slf4j
@RestController
@RequestMapping(Config.OPEN_API_PATH + "departments")
public class DepartmentController {
/** 成员解析上下文:供新的指派/绑定(严档,也是缺省)。 */
private static final String SCOPE_SELECT = "select";
/** 成员解析上下文:已有实例的历史解析(宽档,停用/软删照发)。 */
private static final String SCOPE_HISTORY = "history";
@Autowired
private IDepartmentDirectoryService departmentDirectoryService;
@Autowired
private IApplicationInfoService applicationInfoService;
/**
* 可选部门目录:默认只返回启用且未软删的条目。<br>
* {@code type=SYSTEM|CUSTOM}(缺省两类都返回);{@code categoryId} 只对 CUSTOM 生效;
* {@code withChildren=true} 只对 SYSTEM 生效,连同部门下级一起返回(扁平 + parentId)。
*/
@GetMapping
public List<DepartmentEntryVo> list(@RequestParam("schoolId") Integer schoolId,
@RequestParam(value = "type", required = false) String type,
@RequestParam(value = "categoryId", required = false) Integer categoryId,
@RequestParam(value = "withChildren", required = false) Boolean withChildren,
HttpServletRequest request) {
DepartmentTypeEnum parsed = parseType(type, true);
return departmentDirectoryService.list(resolveCallerAppId(request), schoolId, parsed,
categoryId, Boolean.TRUE.equals(withChildren));
}
/**
* CUSTOM 分类目录(学校通用,不按调用方应用隔离;分类只是容器,不能指派人员)。
*/
@GetMapping("categories")
public List<CustomDeptCategoryVo> categories(@RequestParam("schoolId") Integer schoolId,
HttpServletRequest request) {
return departmentDirectoryService.categories(resolveCallerAppId(request), schoolId);
}
/**
* 批量解析引用(<b>唯一</b>允许返回停用/软删部门的读口,A12)。<br>
* 一批里任一学校未对本应用开通 → 整批拒绝;解析不到的引用返回 name=null、selectable=false。
*/
@PostMapping("resolve-batch")
public List<DepartmentEntryVo> resolveBatch(@RequestBody @Validated List<DepartmentRefRequest> refs,
HttpServletRequest request) {
return departmentDirectoryService.resolveBatch(resolveCallerAppId(request), refs);
}
/**
* 部门成员解析。{@code scope=select}(缺省)=供新的指派:部门停用/软删直接拒绝;<br>
* {@code scope=history}=已有实例解析:停用/软删照常返回成员,不影响在途(A17)。<br>
* 出参字段与 {@code /oapi/group/users} 同形状(轻量 BoaUser,禁用账号靠 status=0 标识、不静默剔除)。
*/
@GetMapping("{type}/{id}/members")
public List<BoaUser> members(@PathVariable String type,
@PathVariable Integer id,
@RequestParam(value = "scope", required = false) String scope,
HttpServletRequest request) {
DepartmentTypeEnum parsed = parseType(type, false);
return departmentDirectoryService.members(resolveCallerAppId(request), parsed, id, parseScope(scope));
}
// ===================== 私有 =====================
/**
* 解析成员上下文:缺省与 {@code select} 都按「供新的指派」处理(更严的一档做默认),
* 只有明确 {@code history} 才放行停用/软删;其余取值直接拒绝——写错的 scope 不能静默变成宽口径。
*/
private boolean parseScope(String scope) {
String normalized = scope == null ? "" : scope.trim().toLowerCase();
if (normalized.isEmpty() || SCOPE_SELECT.equals(normalized)) {
return true;
}
if (SCOPE_HISTORY.equals(normalized)) {
return false;
}
throw new DException("scope 只允许 select / history");
}
/**
* 解析来源类型:路径段必解析(非法即拒),查询参数缺省表示「两类都要」。
*/
private DepartmentTypeEnum parseType(String raw, boolean allowMissing) {
if (raw == null || raw.trim().isEmpty()) {
if (allowMissing) {
return null;
}
throw new DException("departmentType 只允许 SYSTEM / CUSTOM");
}
DepartmentTypeEnum parsed = DepartmentTypeEnum.of(raw);
if (parsed == null) {
throw new DException("departmentType 只允许 SYSTEM / CUSTOM");
}
return parsed;
}
/**
* 从验签属性取 appKey,再映射为 application_info 主键(与 {@code oapi/user} 的 {@code modules} 同一链路)。<br>
* 解析不出应用 → 传 null 给 Service,由其按「无法识别调用方」拒绝:部门目录不能匿名兜底成「全量可读」。
*/
private Integer resolveCallerAppId(HttpServletRequest request) {
Object attr = request.getAttribute(AppAuthFilter.APP_AUTH_ATTR);
if (attr == null) {
log.warn("[oapi:departments] no AppAuth attr (should be blocked by RequireAppSignFilter), uri={}",
request.getRequestURI());
return null;
}
String appKeyStr = (attr instanceof String) ? (String) attr : String.valueOf(attr);
if (appKeyStr.trim().isEmpty()) {
log.warn("[oapi:departments] AppAuth attr is blank, uri={}", request.getRequestURI());
return null;
}
Integer appKey;
try {
appKey = Integer.parseInt(appKeyStr.trim());
} catch (NumberFormatException e) {
log.warn("[oapi:departments] invalid appKey format={}, uri={}", appKeyStr, request.getRequestURI());
return null;
}
Integer appId = applicationInfoService.findIdByAppKey(appKey);
if (appId == null) {
log.warn("[oapi:departments] appKey={} not found in application_info, uri={}", appKey,
request.getRequestURI());
}
return appId;
}
}
@@ -1,6 +1,7 @@
package com.yunhai.userbase.controller.oapi;
import com.yunhai.platform.utils.model.BoaGroup;
import com.yunhai.platform.utils.model.BoaUser;
import com.yunhai.userbase.common.validation.ValidationGroups;
import com.yunhai.userbase.config.Config;
import com.yunhai.userbase.model.sysgroup.SysGroupBatchRequest;
@@ -47,7 +48,7 @@ public class GroupController {
* 获取单个组;withSub=true 时递归挂上全部子孙(整棵子树)。
*
* @param groupId 组ID(必填)
* @param withSub 是否展开子节点,缺省 false
* @param withSub 是否递归加上子节点,缺省 false
* @return 组节点;不存在返回 null
*/
@GetMapping("byId")
@@ -69,6 +70,25 @@ public class GroupController {
return sysGroupService.getGroupList(ids, withSub);
}
/**
* 获取组内用户(不分页,一次返回全部):<br>
* withSub=true 时含该组全部子孙组(口径同 {@code /oapi/group/byId} 的 withSub);<br>
* roleIds 逗号分隔(复用 SysRoleEnum 键),按身份行 {@code sys_user_role_rel}(正常态)过滤,缺省不过滤。<br>
* 返回裸 {@code List<BoaUser>}(轻量字段 id/account/name/type),不套信封、无分页元数据。<br>
* 注意:轻量 BoaUser 不填 {@code status}/组内转出标记,需判禁用或转出请走 {@code /oapi/user/byId}。
*
* @param groupId 组ID(必填)
* @param withSub 是否递归子组,缺省 false
* @param roleIds 角色过滤(逗号分隔),缺省不过滤
* @return 组内用户列表(按用户去重;无匹配返回空列表)
*/
@GetMapping("users")
public List<BoaUser> groupUsers(@RequestParam("groupId") Integer groupId,
@RequestParam(value = "withSub", required = false) Boolean withSub,
@RequestParam(value = "roleIds", required = false) String roleIds) {
return sysGroupService.listGroupUsers(groupId, withSub, roleIds);
}
/**
* 新增组:返回创建后的组节点(含 id)。<br>
* 层级类型约束、根节点保护等复用后台逻辑;鉴权由 OpenApiAuthEnforceConfiguration 在过滤器层强制签名。
@@ -1,93 +1,16 @@
package com.yunhai.userbase.controller.oapi;
import com.yunhai.platform.utils.exception.DException;
import com.yunhai.platform.utils.model.BoaRoleInfo;
import com.yunhai.userbase.common.context.CallerAppContext;
import com.yunhai.userbase.config.Config;
import com.yunhai.userbase.model.identity.IdentitySwitchOapiRequest;
import com.yunhai.userbase.service.impl.IdentityService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
/**
* 用户身份开放接口:供其他业务服务经 yh-app-auth 签名调用({@code /oapi/identity/...})。<br>
* <br>
* 契约统一 {@link BoaRoleInfo}(roleId/roleName/schId/schName/childId/childName),
* 与 {@code /oapi/user/roles}、{@code BoaUser.currentRole} 完全同构,前端零转换。<br>
* 签名由 {@code OpenApiAuthEnforceConfiguration} 在过滤器层强制;<br>
* <b>切换(switch)</b>额外受 {@code yh.identity.switch-allowed-app-keys} 白名单约束(AppKey 级闸门),
* 且目标用户必须真实存在于其身份集({@code sys_user_role_rel} 三元组校验,防越权切换他人)。<br>
* POST 写操作由 {@code OapiLogAspect} 自动记录审计日志。
*
* @author chenx
* @date 2026/8/27
*/
@RestController
@RequestMapping(Config.OPEN_API_PATH + "identity/")
import com.yunhai.platform.mobile.*;
import com.yunhai.userbase.mobile.MobileIdentityService;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@RestController @RequestMapping("/oapi/identity")
public class IdentityController {
@Autowired
private IdentityService identityService;
/** 允许调用 switch 的应用 appKey 白名单(逗号分隔,缺省空 = 禁闭) */
@Value("${yh.identity.switch-allowed-app-keys:}")
private String switchAllowedAppKeys;
/**
* 查询用户当前激活身份:优先 sys_user_current_role,未切换时兜底主身份(动态解析);
* 无任何身份返回 null。
*/
@GetMapping("current")
public BoaRoleInfo current(@RequestParam("userId") Integer userId) {
return identityService.getCurrent(userId);
}
/**
* 用户全部可选身份(正常态),按 主身份优先 → sortNo → id 排序,供「切换身份」页渲染。
*/
@GetMapping("available")
public List<BoaRoleInfo> available(@RequestParam("userId") Integer userId) {
return identityService.listAvailable(userId);
}
/**
* 切换身份:AppKey 白名单校验后,校验三元组合法并 UPSERT sys_user_current_role。
*/
@PostMapping("switch")
public BoaRoleInfo switchIdentity(@RequestBody @Validated IdentitySwitchOapiRequest request) {
checkSwitchAllowed();
return identityService.switchIdentity(request.getUserId(), request.getRoleId(), request.getSchoolId());
}
/** AppKey 级闸门:仅允许白名单内的应用替用户切换身份。 */
private void checkSwitchAllowed() {
Integer callerAppKey = CallerAppContext.getAppKey();
if (callerAppKey == null || !parseAllowedAppKeys().contains(callerAppKey)) {
throw new DException("当前应用无权调用身份切换接口");
}
}
private Set<Integer> parseAllowedAppKeys() {
if (switchAllowedAppKeys == null || switchAllowedAppKeys.trim().isEmpty()) {
return new HashSet<>();
}
return Arrays.stream(switchAllowedAppKeys.split(","))
.map(String::trim)
.filter(s -> !s.isEmpty())
.map(Integer::valueOf)
.collect(Collectors.toSet());
}
private final ObjectProvider<MobileIdentityService> provider;
public IdentityController(ObjectProvider<MobileIdentityService> provider){this.provider=provider;}
private MobileIdentityService service(){MobileIdentityService s=provider.getIfAvailable();AuthFailure.require(s!=null,503,"AUTHORITY_UNAVAILABLE");return s;}
@GetMapping("/current") public Map<String,Object> current(@RequestHeader("X-YH-Proof") String proof){return service().readIdentity(proof,false);}
@GetMapping("/available") public Map<String,Object> available(@RequestHeader("X-YH-Proof") String proof){return service().readIdentity(proof,true);}
public static class Switch {public String userActionProof,operationId;public int roleId,schoolId;public long expectedIdentityVersion;}
@PostMapping("/switch") public Map<String,Object> change(@RequestBody Switch b){return service().switchIdentity(b.userActionProof,b.roleId,b.schoolId,b.expectedIdentityVersion,b.operationId);}
}
@@ -0,0 +1,39 @@
package com.yunhai.userbase.controller.oapi;
import com.yunhai.platform.appauth.AppAuthFilter;
import com.yunhai.platform.mobile.*;
import com.yunhai.userbase.mobile.MobileIdentityService;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import javax.servlet.http.*;
import java.util.*;
/** Existing app-sign authentication; explicit portal allowlist. Never log request/credentials. */
@RestController
@RequestMapping("/oapi/user")
public class PasswordVerificationController {
private final ObjectProvider<MobileIdentityService> services;
private final String env; private final Set<String> portals;
public PasswordVerificationController(ObjectProvider<MobileIdentityService> services,
@Value("${yh.mobile.env:}") String env,
@Value("${yh.mobile.password-allowed-app-keys:}") String allowed) {
this.services=services;this.env=env;this.portals=new HashSet<>(Arrays.asList(allowed.replace(" ","").split(",")));
}
public static class Credentials { public String account,password; }
@PostMapping("/verifyPassword")
public SessionAuthenticator.Snapshot verify(@RequestBody Credentials b,HttpServletRequest r,HttpServletResponse response) {
response.setHeader("Cache-Control","no-store");
Object app=r.getAttribute(AppAuthFilter.APP_AUTH_ATTR);
AuthFailure.require(app!=null,401,"APP_SIGNATURE_REQUIRED");
AuthFailure.require(portals.contains(String.valueOf(app)),403,"PORTAL_CALLER_REQUIRED");
MobileIdentityService service=services.getIfAvailable();AuthFailure.require(service!=null,503,"AUTHORITY_UNAVAILABLE");
return service.verifyPortalPassword(env,b.account,b.password);
}
@ExceptionHandler(AuthFailure.class)
public ResponseEntity<Map<String,Object>> failure(AuthFailure e) {
Map<String,Object> result=new LinkedHashMap<>();result.put("code",e.status==401?40101:e.status==403?40301:50301);result.put("msg",e.status==401?"账号或密码错误":e.code);
return ResponseEntity.status("APP_SIGNATURE_REQUIRED".equals(e.code)||"PORTAL_CALLER_REQUIRED".equals(e.code)?e.status:200).header("Cache-Control","no-store").body(result);
}
}
@@ -1,70 +0,0 @@
package com.yunhai.userbase.controller.oapi;
import com.yunhai.platform.appauth.AppAuthProperties;
import com.yunhai.userbase.config.Config;
import com.yunhai.userbase.entity.ApplicationInfo;
import com.yunhai.userbase.repository.ApplicationInfoRepository;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 验签兜底端点:供其他业务服务在「共享 Redis 宕机且本地缓存也未命中」时直连获取某应用的密钥。<br><br>
* 直接从 MySQL 读(绕过可能同宕的共享 Redis),是密钥的权威源;写入侧仍由
* {@code SignKeyCacheService} 同步到 Redis。返回形状与 BasePlatform 读侧解析契约一致:<br>
* {@code {"appKey":"100616","secret":"...","enabled":true}}。<br><br>
* 解析优先级:<br>
* 1) 本库 application_info(覆盖全部「已颁发」应用的实时密钥);<br>
* 2) 配置式受信应用 {@code yh.app-auth.trusted-apps}(平台自身默认密钥,如 应用中心 100616,
* 即便尚未落库也可解析,避免「默认 appKey 未建行」时此处返回 404 导致验签链路失败);<br>
* 两者皆无 → 404。<br><br>
* 注意:此端点返回的是应用密钥,必须仅内网可达(由部署网络隔离保障),切勿暴露到公网。
*/
@RestController
@RequestMapping(Config.OPEN_API_PATH + "internal")
public class SignKeyInternalController {
private final ApplicationInfoRepository applicationInfoRepository;
private final AppAuthProperties appAuthProperties;
public SignKeyInternalController(ApplicationInfoRepository applicationInfoRepository,
AppAuthProperties appAuthProperties) {
this.applicationInfoRepository = applicationInfoRepository;
this.appAuthProperties = appAuthProperties;
}
@GetMapping("sign-key/{appId}")
public ResponseEntity<Map<String, Object>> signKey(@PathVariable String appId) {
Integer key;
try {
key = Integer.parseInt(appId.trim());
} catch (NumberFormatException e) {
return ResponseEntity.notFound().build();
}
ApplicationInfo info = applicationInfoRepository.findByAppKey(key);
if (info != null && info.getAppSecret() != null && !info.getAppSecret().trim().isEmpty()) {
return ok(info.getAppKey() == null ? appId : String.valueOf(info.getAppKey()),
info.getAppSecret());
}
// 库内无此应用密钥:回退到配置式受信应用(平台自身默认密钥 100616 等),
// 避免「应用中心默认 appKey 未落库」时兜底查询返回 404。
String cfgSecret = appAuthProperties.getTrustedApps().get(appId);
if (cfgSecret != null && !cfgSecret.trim().isEmpty()) {
return ok(appId, cfgSecret);
}
return ResponseEntity.notFound().build();
}
private static ResponseEntity<Map<String, Object>> ok(String appKey, String secret) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("appKey", appKey);
body.put("secret", secret);
body.put("enabled", true);
return ResponseEntity.ok(body);
}
}
@@ -10,8 +10,10 @@ import com.yunhai.platform.utils.model.BoaUser;
import com.yunhai.userbase.common.annotation.OapiLog;
import com.yunhai.userbase.common.validation.ValidationGroups;
import com.yunhai.userbase.config.Config;
import com.yunhai.userbase.entity.ApplicationInfo;
import com.yunhai.userbase.model.sysuser.BatchChangeStatusRequest;
import com.yunhai.userbase.model.sysuser.SysUserChangeStatusRequest;
import com.yunhai.userbase.model.sysuser.SysUserContactRequest;
import com.yunhai.userbase.model.sysuser.SysUserGroupDeleteItem;
import com.yunhai.userbase.model.sysuser.SysUserGroupStateRequest;
import com.yunhai.userbase.model.sysuser.SysUserMoveRequest;
@@ -90,30 +92,49 @@ public class UserController {
}
/**
* 查询用户在<b>调用方应用</b>下、指定学校(上下文)里的已授权模块列表。<br>
* 查询用户在<b>指定应用</b>下、指定学校(上下文)里的已授权模块列表。<br>
* <br>
* 与管理端「权限一览」返回<b>全应用聚合</b>不同,本方法严格按调用方应用维度过滤:<br>
* ① 通过 {@link AppAuthFilter#APP_AUTH_ATTR} 拿到验签时的 appKey(RequireAppSignFilter
* 已保证非空);<br>
* ② 查 application_info 映射到具体 appId;<br>
* 与管理端「权限一览」返回<b>全应用聚合</b>不同,本方法严格按单一应用维度过滤。应用来源两态:<br>
* ① 传了可选参数 appCode → 按 application_info.app_code 解析<b>目标应用</b>(供托管型应用
* 如 form-center 代快应用渲染导航时查询目标应用的模块清单;查不到返回空列表并 warn,
* 口径同现有兜底);<br>
* ② 未传 appCode → 现行为不变:通过 {@link AppAuthFilter#APP_AUTH_ATTR} 拿到验签时的
* appKey(RequireAppSignFilter 已保证非空),查 application_info 映射到具体 appId;<br>
* ③ 调 Service 专用方法:所有 SQL/集合构建均限定在该 appId 范围内(数据库层面过滤)。<br>
* <br>
* 边界:调用方 appKey 查不到对应应用 → 直接返回空列表(安全优先,禁止意外返回其他应用的模块)。<br>
* 边界:应用解析失败(appCode/appKey 查不到对应应用)→ 直接返回空列表(安全优先,禁止意外
* 返回其他应用的模块)。<br>
* 记账:本参数暴露面=模块名/路径/图标级低敏数据;如需收紧可后续限定仅 BASE 应用调用
* (暂放开,表单中心建设方案 §6.2 已记)。参数用 appCode(字符串)而非整数 appKey:
* 跨环境稳定,与 form-center 分发路由 /app/{appCode}/ 同键。
*
* @param request 用于取 AppAuthFilter 设置的 appKey
* @param request 用于取 AppAuthFilter 设置的 appKey(未传 appCode 时使用)
* @param userId 用户ID(必填)
* @param schId 学校ID/组ID(可选;用于限定「学校上下文」:非空时仅取该学校子树内的真实归属组参与判定,
* 为空时取用户全部真实归属组,与权限一览一致)
* @param appCode 目标应用技术标识(可选;传=查该应用的模块树,不传=按签名调用方解析)
* @return 该应用下用户已授权模块(扁平化;无权限时返回空列表)
*/
@GetMapping("modules")
public List<BoaModule> modules(HttpServletRequest request,
@RequestParam("userId") Integer userId,
@RequestParam(value = "schId", required = false) Integer schId) {
@RequestParam(value = "schId", required = false) Integer schId,
@RequestParam(value = "appCode", required = false) String appCode) {
if (userId == null) {
return Collections.emptyList();
}
Integer appId = resolveCallerAppId(request);
Integer appId;
if (appCode != null && !appCode.trim().isEmpty()) {
ApplicationInfo app = applicationInfoService.getByAppCode(appCode.trim());
if (app == null) {
log.warn("[oapi:user:modules] appCode={} not found in application_info, uri={}",
appCode, request.getRequestURI());
return Collections.emptyList();
}
appId = app.getId();
} else {
appId = resolveCallerAppId(request);
}
if (appId == null) {
return Collections.emptyList();
}
@@ -165,12 +186,15 @@ public class UserController {
}
/**
* 编辑用户:密码为空时不修改密码;返回更新后的用户(含 id 与组织链)。
* 编辑用户:密码为空时不修改密码;返回更新后的用户(含 id 与组织链)。<br>
* 用户身份按<b>追加语义</b>写({@code roleIds} 已存在则不变、不存在则补,绝不删除):开放接口调用方
* 只能表达"该用户应有某身份"这层意图,覆盖语义会误删该校其它身份;学校范围取 {@code schoolId},
* 缺省按 {@code groupId} 解析。管理端编辑仍是该校全量覆盖(见 {@code /sysuser/Edit/{id}})。
*/
@PostMapping("edit/{id}")
public BoaUser edit(@PathVariable Integer id,
@RequestBody @Validated(ValidationGroups.Update.class) SysUserRequest request) {
sysUserService.edit(id, request);
sysUserService.editWithIdentityAppend(id, request);
return userCenterService.getUserInfoWithDeptById(id);
}
@@ -237,6 +261,19 @@ public class UserController {
sysUserService.changePassword(request.getUserId(), request.getOldPassword(), request.getNewPassword());
}
/**
* 本人联系方式修改(手机号/邮箱):body {userId, phone, email},phone/email 至少一项非空,
* 空白=不修改该字段;只更新 sys_user 的 phone/email 两字段(其余一律不动),
* 手机号/邮箱格式与长度校验、手机号唯一查重在 Service 层。返回更新后的用户
* (getUserInfoWithDeptById,事务提交后缓存已被 UserChangedEvent 驱逐,回读即新值)。<br>
* 鉴权由过滤器层强制签名;POST 自动记 OapiLog。
*/
@PostMapping("self/contact")
public BoaUser selfContact(@RequestBody @Validated SysUserContactRequest request) {
sysUserService.updateSelfContact(request.getUserId(), request.getPhone(), request.getEmail());
return userCenterService.getUserInfoWithDeptById(request.getUserId());
}
/**
* 修改用户头像:校验图片(非空/≤20MB/扩展名白名单)→ 压缩 → 存储并写入 profile_picture,<br>
* 返回新头像完整 Web URL。multipart 直传(form 字段 userId + 文件 part file)。<br>
@@ -55,9 +55,9 @@ public class AppModule extends BaseEntity {
/**
* 自定义ID(应用内唯一,业务编码,如 "user-mgmt")。<br>
* 按钮权限型子模块(无路由无编码)为 null;NULL 在唯一键 uk_app_module_custom 下可多行并存。
*/
@NotBlank
@Column(name = "custom_id", nullable = false, length = 64)
@Column(name = "custom_id", nullable = true, length = 64)
private String customId;
/**
@@ -66,6 +66,13 @@ public class AppModule extends BaseEntity {
@Column(name = "path", length = 255)
private String path;
/**
* 模块图标(YschIcons 图标名,如 "notice-list")。非必填;<br>
* 为空时开放接口 {@code BoaModule.icon} 即为 null(不回退应用图标,由消费方自行兜底)。<br>
*/
@Column(name = "icon", length = 128)
private String icon;
/**
* 层级:根=1,子=父+1,上限5(由 Service 校验)。<br>
*/
@@ -26,6 +26,16 @@ import java.util.List;
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
public class ApplicationInfo extends BaseEntity {
/** Immutable application incarnation; never derive it from reusable numeric IDs or credentials. */
@Column(name = "source_principal_id", unique = true, updatable = false, length = 36)
private String sourcePrincipalId;
@javax.persistence.PrePersist
void assignSourcePrincipal() {
// Always generate server-side, including when an entity supplied a value.
sourcePrincipalId = java.util.UUID.randomUUID().toString();
}
/**
* 应用名称(页面*必填)
*/
@@ -52,7 +62,7 @@ public class ApplicationInfo extends BaseEntity {
* appKey 系统生成唯一标识,整型,从100000起自增。<br>
*/
@NotNull
@Column(name = "app_key", unique = true, nullable = false)
@Column(name = "app_key", unique = true, nullable = false, updatable = false)
private Integer appKey;
/**
@@ -62,6 +72,19 @@ public class ApplicationInfo extends BaseEntity {
@Column(name = "app_secret", nullable = false, length = 128)
private String appSecret;
/**
* 技术标识 = projman app.name(如 notice/flow-center),跨系统稳定引用键。<br>
* 跳转查询键(sys_message.jump_app 值域);引用方存的是字符串,改名须盘点引用。
*/
@Column(name = "app_code", length = 32)
private String appCode;
/**
* 应用性质:BASE=基石 / BUSINESS=业务(判定口径单点见 {@link com.yunhai.userbase.constant.AppNature})。
*/
@Column(name = "app_nature", length = 16)
private String appNature;
//应用前端类型
@Convert(converter = ListIntegerJsonConverter.class)
@Column(name = "clients", columnDefinition = "json")
@@ -91,12 +114,31 @@ public class ApplicationInfo extends BaseEntity {
@Column(name = "app_icon")
private String appIcon;
/**
* 矢量图标键(AppIcon 资产库图标名,如 tongzhi)。空 = 未配置,端侧走 关键词映射 → URL 位图 → 首字 原回退链。<br>
* 不复用 pc_icon/app_icon:那两列存完整 URL,被 oapi BoaApp 出参与 appIconPath() 按 URL 语义消费(方案 §5.2)。
*/
@Column(name = "icon_key", length = 64)
private String iconKey;
/**
* 矢量图标着色(CSS 色值,如 #366EF4)。空 = 端侧默认(场景 token / 分组色)。人工配置值非派生值,落库安全(方案 §5.3)。
*/
@Column(name = "icon_color", length = 16)
private String iconColor;
/**
* 公众号应用地址
*/
@Column(name = "official_account_url")
private String officialAccountUrl;
/**
* 微信小程序应用地址
*/
@Column(name = "miniprogram_url")
private String miniprogramUrl;
/**
* 钉钉应用地址
*/
@@ -142,4 +184,4 @@ public class ApplicationInfo extends BaseEntity {
@Column(name = "push_secret")
private String pushSecret;
}
}
@@ -0,0 +1,18 @@
package com.yunhai.userbase.entity;
import javax.persistence.*;
/** Permanent credential-key tombstone. Application deletion must never delete this row. */
@Entity @Table(name="application_key_reservation")
public class ApplicationKeyReservation {
/**
* 历史身份审计未完成的机械门禁哨兵(sql/20261026_application_source_principal_draft.sql §Gate):
* 该行存在=禁止发放任何新 appKey。取 -1 而非 0:默认 sql_mode 下字面 0 会被
* AUTO_INCREMENT 改写为下一个生成值,-1 按字面落库且低于保留段,即使被返回也会被
* allocateAppKey 的 key&lt;100000 熔断拒绝。
*/
public static final Integer GATE_CLOSED_SENTINEL = -1;
@Id @GeneratedValue(strategy=GenerationType.IDENTITY)
@Column(name="app_key")
private Integer appKey;
public Integer getAppKey(){return appKey;}
}
@@ -33,6 +33,21 @@ public class SysGroup extends BaseEntity {
private Integer sort;
/**
* 启停状态(2026-10-20 部门线 P0 最小批):0=启用 / 1=停用,对齐 flw_dept 枚举。<br>
* 停用只影响「选」(新建选择器过滤),不影响「看」(展示/指南页)与「办」(在途流程解析);
* 当前无消费方,语义在未来部门场景(P1)生效。翻转只允许走 {@code updateStatus} 单列 UPDATE。
*/
@Column(name = "status")
private Integer status = 0;
/**
* 包含子组(2026-10-21 部门线):1=部门列表含下级部门 / 0=仅一级部门,仅学校节点使用。<br>
* 与 status 同为节点自身属性,翻转只允许走 {@code updateIncludeChildren} 单列 UPDATE。
*/
@Column(name = "include_children")
private Integer includeChildren = 1;
// 一对多反向关联中间表
@OneToMany(cascade = CascadeType.ALL, fetch = FetchType.LAZY)
@@ -114,6 +114,12 @@ public class SysOapiLog implements Serializable {
@Column(name = "response_data", columnDefinition = "MEDIUMBLOB")
private byte[] responseData;
/**
* 响应原文(GZIP 前)UTF-8 字节数,写入时算好,列表按此列切换在线预览/下载,避免加载 response_data 大字段。
*/
@Column(name = "response_size")
private Integer responseSize;
/**
* HTTP状态码
*/
@@ -106,6 +106,12 @@ public class SysOperationLog implements Serializable {
@Column(name = "response_data", columnDefinition = "MEDIUMBLOB")
private byte[] responseData;
/**
* 响应原文(GZIP 前)UTF-8 字节数,写入时算好,列表按此列切换在线预览/下载,避免加载 response_data 大字段。
*/
@Column(name = "response_size")
private Integer responseSize;
/**
* HTTP状态码
*/
@@ -15,7 +15,9 @@ import javax.validation.constraints.NotNull;
* @author chenx
* @date 2026/7/27
* @description 用户-业务角色关系表(逻辑外键,无物理 FK)。<br>
* 一个用户可绑定多个业务角色;一个角色也可绑定多个用户。<br>
* 一行 = 用户 u 在组 g 下拥有角色 r;一个用户可在多个组下分别绑定角色,同一组下同一角色唯一。<br>
* 组维度(2026-09-16):groupId = 绑定发生时所选的组(角色授权页添加用户弹窗左侧树选中组 / 用户编辑弹窗所在组),
* NULL = 历史数据视为全局生效(代码侧兜底,不参与按组过滤的排除)。
*/
@NoArgsConstructor
@Data
@@ -24,10 +26,11 @@ import javax.validation.constraints.NotNull;
name = "sys_user_app_role_rel",
indexes = {
@Index(name = "idx_user_app_role_user", columnList = "user_id"),
@Index(name = "idx_user_app_role_role", columnList = "role_id")
@Index(name = "idx_user_app_role_role", columnList = "role_id"),
@Index(name = "idx_user_app_role_group", columnList = "group_id")
},
uniqueConstraints = {
@UniqueConstraint(name = "uk_user_app_role", columnNames = {"user_id", "role_id"})
@UniqueConstraint(name = "uk_user_app_role_group", columnNames = {"user_id", "role_id", "group_id"})
}
)
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
@@ -46,4 +49,10 @@ public class SysUserAppRoleRel extends BaseEntity {
@NotNull
@Column(name = "role_id", nullable = false)
private Integer roleId;
/**
* 绑定发生时所选的组ID(逻辑外键 → SysGroup.id);NULL = 历史数据视为全局。<br>
*/
@Column(name = "group_id")
private Integer groupId;
}
@@ -0,0 +1,71 @@
package com.yunhai.userbase.entity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.Index;
import javax.persistence.Table;
import javax.persistence.UniqueConstraint;
import java.time.LocalDateTime;
/**
* 用户-移动渠道绑定关系(移动端接入 步 3,方案 §3.13)。
*
* <p>openid / unionid ↔ sys_user.id 的绑定属 UserBase 身份域;SDK 登录端点经
* {@code /oapi/channel/**}(应用签名)读写。channel 取值与前端 MobileChannel 对齐:
* wxgzh / miniapp / qywx / dingtalk。</p>
*
* <p>⚠ uk_user_channel 只约束半边:同一 unionid 仍可绑到两个账号(公众号一个、小程序一个),
* 应用层在 {@code ChannelBindService} 绑定时用 idx_unionid 查重补齐,勿仅依赖唯一约束。</p>
*
* @author chenx
* @date 2026/9/25
*/
@Data
@NoArgsConstructor
@Entity
@Table(
name = "sys_user_channel_rel",
uniqueConstraints = {
@UniqueConstraint(columnNames = {"channel", "openid"}),
@UniqueConstraint(columnNames = {"user_id", "channel"})
},
indexes = {@Index(columnList = "unionid")}
)
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
public class SysUserChannelRel {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
/** sys_user.id */
@Column(name = "user_id", nullable = false)
private Integer userId;
/** wxgzh / miniapp / qywx / dingtalk */
@Column(nullable = false, length = 20)
private String channel;
/** 渠道内用户标识 */
@Column(nullable = false, length = 64)
private String openid;
/** 同一开放平台下跨端一致;可空 */
@Column(name = "unionid", length = 64)
private String unionId;
/** 绑定时间(服务端 now() 写入) */
@Column(name = "bound_at", nullable = false)
private LocalDateTime boundAt;
/** 最近一次经该渠道登录时间(token 签发时更新,可空) */
@Column(name = "last_login_at")
private LocalDateTime lastLoginAt;
}
@@ -0,0 +1,71 @@
package com.yunhai.userbase.entity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Table;
import java.time.LocalDateTime;
/**
* @author chenx
* @date 2026/10/21
* @description 学校自定义部门(CUSTOM 侧可指派对象:有成员、有启停、删除为软删)。<br>
* 与 SYSTEM 组织树完全分离:不改组织树、不混入自定义节点;
* 主键独立于 {@code sys_group.id},跨应用引用一律带 {@code departmentType} 消歧,禁止以名称做键。<br>
* 唯一性由 {@code uk_school_category_name_marker(school_id, category_id, name, del_marker)} 保证:
* 同校同分类下未软删的部门名不重复,跨分类与 SYSTEM/CUSTOM 之间允许重名。
*/
@NoArgsConstructor
@Data
@Entity
@Table(name = "ub_custom_department")
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
public class UbCustomDepartment extends BaseEntity {
/** 学校组 id(冗余自分类,写口由服务端复核,不接受前端传值做授权)。 */
@Column(name = "school_id", nullable = false)
private Integer schoolId;
/** 所属分类(必须同校,移动只允许在本校其他分类之间)。 */
@Column(name = "category_id", nullable = false)
private Integer categoryId;
/** 部门名称,学校自定;软删也不改写。 */
@Column(name = "name", nullable = false, length = 64)
private String name;
@Column(name = "sort_no", nullable = false)
private Integer sortNo = 0;
/**
* 启用状态:1=启用 / 0=停用。<br>
* ⚠ 语义与 {@code sys_group.status}(0=启用/1=停用)方向相反,严禁混用;接口层统一输出 boolean enabled。
*/
@Column(name = "enabled", nullable = false)
private Integer enabled = 1;
/** 软删标记:0=有效 / 1=已软删;软删后成员关系保留在库,由查询侧过滤。 */
@Column(name = "is_deleted", nullable = false)
private Integer isDeleted = 0;
/** 软删时置为该行 id,参与唯一键。 */
@Column(name = "del_marker", nullable = false)
private Long delMarker = 0L;
@Column(name = "deleted_at")
private LocalDateTime deletedAt;
@Column(name = "deleted_by")
private Integer deletedBy;
public boolean isDeletedRow() {
return isDeleted != null && isDeleted == 1;
}
public boolean isEnabledRow() {
return enabled != null && enabled == 1;
}
}
@@ -0,0 +1,36 @@
package com.yunhai.userbase.entity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Table;
/**
* @author chenx
* @date 2026/10/21
* @description 自定义部门成员关系(引用 {@code sys_user.id},不复制账号)。<br>
* 一个用户可属同校多个 CUSTOM 部门;{@code uk_department_user(department_id, user_id)} 挡有效重复。<br>
* 移除成员=<b>物理删</b>该关系行(本仓无软删惯例,增删审计由 {@code @OperationLog} 承担),
* 因此不会残留「已移除但仍有效」的成员。<br>
* 成员范围=本校教职工且排除学生组(type=8)下的用户,由服务端逐个复核,不接受前端传入即认定。
*/
@NoArgsConstructor
@Data
@Entity
@Table(name = "ub_custom_department_member")
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
public class UbCustomDepartmentMember extends BaseEntity {
/** 学校组 id(与部门同校,写口服务端复核)。 */
@Column(name = "school_id", nullable = false)
private Integer schoolId;
@Column(name = "department_id", nullable = false)
private Integer departmentId;
@Column(name = "user_id", nullable = false)
private Integer userId;
}
@@ -0,0 +1,56 @@
package com.yunhai.userbase.entity;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Table;
import java.time.LocalDateTime;
/**
* @author chenx
* @date 2026/10/21
* @description 学校自定义部门分类(CUSTOM 侧容器层)。<br>
* 分类只是管理容器:<b>没有启停状态、不能配成员</b>,不是可指派的业务部门。<br>
* 唯一性由 {@code uk_school_name_marker(school_id, name, del_marker)} 保证:
* 未软删行 {@code del_marker=0},软删时置为该行 id,因此同校同名只挡有效行;
* {@code name} 永不改写,保证历史解析拿到原名、旧引用不指向新对象。
*/
@NoArgsConstructor
@Data
@Entity
@Table(name = "ub_custom_dept_category")
@EqualsAndHashCode(onlyExplicitlyIncluded = true, callSuper = false)
public class UbCustomDeptCategory extends BaseEntity {
/** 学校组 id(sys_group 中 type=2 节点),与 AccessScopeService.scopeGroupIds() 同域。 */
@Column(name = "school_id", nullable = false)
private Integer schoolId;
/** 分类名称,学校自定;软删也不改写。 */
@Column(name = "name", nullable = false, length = 64)
private String name;
@Column(name = "sort_no", nullable = false)
private Integer sortNo = 0;
/** 软删标记:0=有效 / 1=已软删。删除不是第三种业务状态,与 enabled 维度分离。 */
@Column(name = "is_deleted", nullable = false)
private Integer isDeleted = 0;
/** 软删时置为该行 id,参与唯一键(MySQL 的 uk 对 NULL 不去重,deleted_at 进 uk 挡不住重复激活)。 */
@Column(name = "del_marker", nullable = false)
private Long delMarker = 0L;
@Column(name = "deleted_at")
private LocalDateTime deletedAt;
@Column(name = "deleted_by")
private Integer deletedBy;
public boolean isDeletedRow() {
return isDeleted != null && isDeleted == 1;
}
}
@@ -0,0 +1,47 @@
package com.yunhai.userbase.enums;
import com.yunhai.platform.utils.enums.IEnum;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* @author chenx
* @date 2026/10/21
* @description 部门来源类型(跨业务统一引用的消歧位)。<br>
* 只有 {@code SYSTEM}(原组织架构组)与 {@code CUSTOM}(学校自定义部门)两种,
* <b>不得</b>扩成 FLOW/SVC/AUDIT 等业务类型;引用一律「schoolId + departmentType + departmentId」,禁止以名称做键。
*/
@Getter
@AllArgsConstructor
public enum DepartmentTypeEnum implements IEnum<String> {
SYSTEM("SYSTEM", "系统部门"),
CUSTOM("CUSTOM", "自定义部门");
private final String key;
private final String value;
@Override
public String getKey() {
return key;
}
@Override
public String getValue() {
return value;
}
/** 宽松解析(大小写/空白不敏感);非 SYSTEM/CUSTOM 返回 null,由调用方拒绝。 */
public static DepartmentTypeEnum of(String raw) {
if (raw == null) {
return null;
}
String normalized = raw.trim().toUpperCase();
for (DepartmentTypeEnum type : values()) {
if (type.key.equals(normalized)) {
return type;
}
}
return null;
}
}
@@ -0,0 +1,29 @@
package com.yunhai.userbase.event;
import org.springframework.context.ApplicationEvent;
/**
* 应用授权域数据变更事件:组应用开通(sys_group_app_rel)、组模块授权(sys_group_module_perm)、
* 业务角色及其模块/用户绑定(sys_app_role / sys_app_role_module_rel / sys_user_app_role_rel)、
* 应用分类(app_category)、模块(app_module)、学校别名覆盖(sys_school_app_override /
* sys_school_category_override)任意写操作事务提交后发布。<br>
* <b>消费方</b>:{@code CacheInvalidationListener} 整清 {@code userAppList}
* (/oapi/app/list 的组开通/角色授权输入面广,无法从事件反推受影响用户,按「宁可失效宽泛」原则整缓存 clear)。<br>
* <b>宽泛口径说明</b>:部分发布哨口(组模块授权、学校别名覆盖)经查不影响 /oapi/app/list 当前返回,
* 按失效总原则仍纳入——多清无害,防未来读链演化漏驱。<br>
* <b>粒度</b>:单一事件类型——所有发布方语义均为「应用授权面变了,重建列表」,不做精确到用户/组的最小化裁剪。
*/
public class AppAuthzChangedEvent extends ApplicationEvent {
/** 触发本次变更的写口(assignAppsToGroup/saveRoleModules/editCategory 等),仅用于日志定位。 */
private final String sourceOp;
public AppAuthzChangedEvent(Object source, String sourceOp) {
super(source);
this.sourceOp = sourceOp;
}
public String getSourceOp() {
return sourceOp;
}
}
@@ -50,7 +50,7 @@ public class OapiMetricsController {
vo.setDays(d);
vo.setTop(n);
Object[] total = repository.aggregateTotal(since, 0);
Object[] total = firstRow(repository.aggregateTotal(since, 0));
vo.setTotalCnt(asLong(total, 0));
vo.setTotalAvgCostMs(avg(asLong(total, 0), asLong(total, 1)));
vo.setTotalErrCnt(asLong(total, 2));
@@ -82,7 +82,7 @@ public class OapiMetricsController {
}
// 压测流量合计(单列口径,不与线上热度混算)
Object[] lt = repository.aggregateTotal(since, 1);
Object[] lt = firstRow(repository.aggregateTotal(since, 1));
vo.setLoadTestCnt(asLong(lt, 0));
return vo;
}
@@ -97,6 +97,17 @@ public class OapiMetricsController {
return row;
}
/**
* Spring Data 对「无 group by 的多列 select」返回 Object[]{行数组} 嵌套结构
* (每个元素是一行的 Object[],不是标量),直接下标取会 ClassCastException,统一先解一层。
*/
private Object[] firstRow(Object[] arr) {
if (arr != null && arr.length > 0 && arr[0] instanceof Object[]) {
return (Object[]) arr[0];
}
return arr;
}
private long asLong(Object[] arr, int i) {
if (arr == null || i >= arr.length || arr[i] == null) {
return 0L;
@@ -0,0 +1,46 @@
package com.yunhai.userbase.mobile;
import com.yunhai.platform.mobile.*;
import com.yunhai.userbase.entity.SysUser;
import com.yunhai.userbase.enums.StatusEnum;
import com.yunhai.userbase.repository.SysUserRepository;
import com.yunhai.userbase.service.impl.IdentityService;
import com.yunhai.platform.utils.model.BoaRoleInfo;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.*;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;
import org.springframework.security.crypto.password.PasswordEncoder;
import java.time.Clock;
@Configuration(proxyBeanMethods=false)
@ConditionalOnProperty(name="yh.mobile.identity-store-enabled",havingValue="true")
public class MobileIdentityConfiguration {
@Bean public MobileIdentityService mobileIdentityService(JdbcTemplate db,PlatformTransactionManager manager,
SysUserRepository users,PasswordEncoder encoder,IdentityService identities,com.yunhai.userbase.service.IGroupAuthzService authorization) {
MobileIdentityService.Accounts accounts=new MobileIdentityService.Accounts() {
public boolean applicationAllowed(int uid,Integer school,String audience){
if(school==null||school<0||audience==null||!audience.matches("[1-9][0-9]{0,8}"))return false;
if(school==0&&!users.findById(uid).map(u->u.getType()==com.yunhai.userbase.enums.UserTypeEnum.SUPER_ADMIN).orElse(false))return false;
return authorization.findUserAuthorizedAppIds(uid,school).contains(Integer.valueOf(audience));
}
public int casAccount(String principal) {
AuthFailure.require(users.countByAccount(principal)==1,403,"CAS_USER_UNMAPPED_OR_AMBIGUOUS");
SysUser u=users.findByAccount(principal);return u.getId();
}
public int verifyPassword(String account,String password) {
AuthFailure.require(account!=null&&password!=null,401,"PROOF_INVALID");SysUser user=users.findByAccount(account);
AuthFailure.require(user!=null&&encoder.matches(password,user.getPassword())&&user.getDisabled()!=StatusEnum.DISABLED&&!Boolean.TRUE.equals(user.getExpired()),401,"PROOF_INVALID");
return user.getId();
}
public SessionAuthenticator.Snapshot snapshot(int uid) {
SysUser u=users.findById(uid).orElseThrow(()->new AuthFailure(401,"USER_REVOKED"));BoaRoleInfo role=identities.getCurrentForAuthentication(uid);
boolean qualified=role!=null&&identities.listAvailable(uid).stream().anyMatch(r->java.util.Objects.equals(r.getSchId(),role.getSchId())&&java.util.Objects.equals(r.getRoleId(),role.getRoleId()));
return new SessionAuthenticator.Snapshot(uid,qualified?role.getSchId():null,qualified?role.getRoleId():null,
u.getDisabled()!=StatusEnum.DISABLED&&!Boolean.TRUE.equals(u.getExpired()),u.getType()==com.yunhai.userbase.enums.UserTypeEnum.SUPER_ADMIN,true,0,0,0,0,u.getAccount(),u.getRealName());
}
public java.util.List<BoaRoleInfo> available(int uid){return identities.listAvailable(uid);}
public void switchIdentity(int uid,int role,int school) { identities.switchIdentity(uid,role,school); }
};
return new MobileIdentityService(db,new TransactionTemplate(manager),accounts,Clock.systemUTC());
}
}
@@ -0,0 +1,236 @@
package com.yunhai.userbase.mobile;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yunhai.platform.mobile.*;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.transaction.support.TransactionTemplate;
import org.springframework.dao.DataIntegrityViolationException;
import java.time.Clock;
import java.util.*;
import java.util.function.Supplier;
/** UB-owned scope/proof/ledger state. Constructor collaborators permit isolated DB contract tests. */
public final class MobileIdentityService {
public interface Accounts {
int verifyPassword(String account,String password);
SessionAuthenticator.Snapshot snapshot(int userId);
void switchIdentity(int userId,int roleId,int schoolId);
default java.util.List<com.yunhai.platform.utils.model.BoaRoleInfo> available(int uid){throw new AuthFailure(503,"IDENTITY_UNAVAILABLE");}
default int casAccount(String principal){throw new AuthFailure(503,"CAS_MAPPING_NOT_CONFIGURED");}
default boolean applicationAllowed(int userId,Integer school,String audience){return false;}
}
private final JdbcTemplate db;private final TransactionTemplate tx;private final Accounts accounts;private final Clock clock;
private final ObjectMapper json=new ObjectMapper();
public MobileIdentityService(JdbcTemplate db,TransactionTemplate tx,Accounts accounts,Clock clock) {
this.db=db;this.tx=tx;this.accounts=accounts;this.clock=clock;
}
private CallerAppContext caller(String cap) { return Contexts.requireCaller(cap); }
private void scope(CallerAppContext c,String cap,String providerScope) {
Integer count=db.queryForObject("SELECT COUNT(*) FROM mob_auth_caller_scope WHERE env=? AND app_id=? AND capability=? AND provider_scope=?",
Integer.class,c.env,c.appId,cap,providerScope);
AuthFailure.require(count!=null&&count==1,403,"CALLER_CAPABILITY_DENIED");
}
public String channelProof(String flow,String providerScope,String subjectHash,long version,String action) {
CallerAppContext c=caller("proof.channel.register");scope(c,"proof.channel.register",providerScope);
AuthFailure.require(subjectHash!=null&&subjectHash.matches("[a-f0-9]{64}")&&Arrays.asList("RESOLVE","BIND").contains(action),400,"PROOF_INVALID");
long current=subjectVersion(c.env,providerScope,subjectHash);AuthFailure.require(current==version,409,"SUBJECT_STALE");
return issue(c,action,flow,null,providerScope,subjectHash,version);
}
public String passwordProof(String channelHandle,String account,String password) {
CallerAppContext c=caller("proof.user.issue");Map<String,Object> ch=proof(c,channelHandle,"BIND",false);
int uid=accounts.verifyPassword(account,password);AuthFailure.require(uid>0,401,"PROOF_INVALID");
return issue(c,"BIND_USER",str(ch,"flow_id"),uid,str(ch,"provider_scope"),str(ch,"provider_subject_hash"),number(ch,"subject_version"));
}
public String actionProof(int userId,String flow,String providerScope,String subject,long version,String action) {
CallerAppContext c=caller("proof.action.register");scope(c,"proof.action.register",providerScope);
AuthFailure.require(Arrays.asList("UNBIND","SWITCH","READ_CURRENT","READ_AVAILABLE").contains(action),400,"PROOF_INVALID");
AuthFailure.require(accounts.snapshot(userId).active,401,"USER_REVOKED");
if(!"SWITCH".equals(action)&&!action.startsWith("READ_")) AuthFailure.require(boundUser(c.env,providerScope,subject)==userId,403,"NOT_BINDING_OWNER");
return issue(c,action,flow,userId,providerScope,subject,version);
}
// This endpoint does not accept a claimed userId: it issues a proof ONLY for the credential-verified user.
public String reauth(String actionHandle,String account,String password) {
CallerAppContext c=caller("proof.user.issue");Map<String,Object> a=proof(c,actionHandle,"UNBIND",false);
int uid=accounts.verifyPassword(account,password);AuthFailure.require(uid==number(a,"user_id"),403,"PROOF_SUBJECT_MISMATCH");
return issue(c,"UNBIND_REAUTH",str(a,"flow_id"),uid,str(a,"provider_scope"),str(a,"provider_subject_hash"),number(a,"subject_version"));
}
public Map<String,Object> resolve(String handle) {
CallerAppContext c=caller("channel.resolve");return tx.execute(s->{Map<String,Object> p=proof(c,handle,"RESOLVE",true);
scope(c,"channel.resolve",str(p,"provider_scope"));return result(boundUser(c.env,str(p,"provider_scope"),str(p,"provider_subject_hash")));});
}
public Map<String,Object> bind(String channel,String user,String operationId) {
CallerAppContext c=caller("channel.bind.password");String digest=Crypto.hash("BIND\n"+Crypto.hash(channel)+"\n"+Crypto.hash(user));
return operation(c,operationId,digest,()-> {
Map<String,Object> ch=proof(c,channel,"BIND",false),p=proof(c,user,"BIND_USER",false);int uid=(int)number(p,"user_id");
requireSameFlow(ch,p);scope(c,"channel.bind.password",str(ch,"provider_scope"));
lockUser(uid);AuthFailure.require(accounts.snapshot(uid).active,401,"USER_REVOKED");
String providerScope=str(ch,"provider_scope"),subject=str(ch,"provider_subject_hash");long expected=number(ch,"subject_version");
AuthFailure.require(subjectVersion(c.env,providerScope,subject)==expected,409,"SUBJECT_STALE");
int owner=boundUser(c.env,providerScope,subject);AuthFailure.require(owner==0||owner==uid,409,"BINDING_CONFLICT");
if(expected==0) db.update("INSERT INTO mob_auth_subject(env,provider_scope,provider_subject_hash,user_id,version) VALUES(?,?,?,?,1)",c.env,providerScope,subject,uid);
else db.update("UPDATE mob_auth_subject SET user_id=?,version=version+1 WHERE env=? AND provider_scope=? AND provider_subject_hash=? AND version=?",uid,c.env,providerScope,subject,expected);
consume(c,channel,"BIND");consume(c,user,"BIND_USER");ensureEpoch(c.env,uid);return result(uid);
});
}
public Map<String,Object> unbind(String action,String reauth,String operationId) {
CallerAppContext c=caller("channel.unbind.self");return operation(c,operationId,Crypto.hash("UNBIND\n"+Crypto.hash(action)+"\n"+Crypto.hash(reauth)),()-> {
Map<String,Object> p=proof(c,action,"UNBIND",false),r=proof(c,reauth,"UNBIND_REAUTH",false);requireSameFlow(p,r);
int uid=(int)number(p,"user_id");AuthFailure.require(uid==number(r,"user_id"),403,"PROOF_SUBJECT_MISMATCH");lockUser(uid);
String providerScope=str(p,"provider_scope"),subject=str(p,"provider_subject_hash");scope(c,"channel.unbind.self",providerScope);
int changed=db.update("UPDATE mob_auth_subject SET user_id=NULL,version=version+1 WHERE env=? AND provider_scope=? AND provider_subject_hash=? AND user_id=? AND version=?",
c.env,providerScope,subject,uid,number(p,"subject_version"));AuthFailure.require(changed==1,409,"SUBJECT_STALE");
ensureEpoch(c.env,uid);db.update("UPDATE mob_auth_epoch SET user_epoch=user_epoch+1 WHERE env=? AND user_id=?",c.env,uid);
consume(c,action,"UNBIND");consume(c,reauth,"UNBIND_REAUTH");return result(uid);
});
}
public Map<String,Object> switchIdentity(String handle,int role,int school,long expected,String op) {
CallerAppContext c=caller("identity.switch.self");return operation(c,op,Crypto.hash(Crypto.hash(handle)+"\n"+role+"\n"+school+"\n"+expected),()-> {
Map<String,Object> p=proof(c,handle,"SWITCH",false);int uid=(int)number(p,"user_id");lockUser(uid);ensureEpoch(c.env,uid);
long current=db.queryForObject("SELECT identity_version FROM mob_auth_epoch WHERE env=? AND user_id=?",Long.class,c.env,uid);
AuthFailure.require(current==expected,409,"CONTEXT_STALE");AuthFailure.require(school>0||(school==0&&accounts.snapshot(uid).superAdmin),403,"SCHOOL_CONTEXT_REQUIRED");
accounts.switchIdentity(uid,role,school);
long updated=db.queryForObject("SELECT identity_version FROM mob_auth_epoch WHERE env=? AND user_id=?",Long.class,c.env,uid);
if(updated==current){db.update("UPDATE mob_auth_epoch SET identity_version=identity_version+1 WHERE env=? AND user_id=?",c.env,uid);updated=current+1;}
AuthFailure.require(updated>current,409,"CONTEXT_STALE");
consume(c,handle,"SWITCH");Map<String,Object> r=result(uid);r.put("identityVersion",updated);return r;
});
}
public Map<String,Object> readIdentity(String handle,boolean available) {
CallerAppContext c=caller("identity.read.self");return tx.execute(s->{Map<String,Object> p=proof(c,handle,available?"READ_AVAILABLE":"READ_CURRENT",true);
SessionAuthenticator.Snapshot a=accounts.snapshot((int)number(p,"user_id"));Map<String,Object> r=result(a.userId);if(available)r.put("roles",accounts.available(a.userId));r.put("schId",a.schoolId);r.put("roleId",a.roleId);return r;});
}
public Map<String,Object> recover(String op,String hash) {
CallerAppContext c=caller("mobile.operation.read");return existing(c,op,hash);
}
public SessionAuthenticator.Snapshot validate(SessionAuthenticator.Session s) {
CallerAppContext c=caller("mobile.identity.validate");AuthFailure.require(c.env.equals(s.env),401,"ENV_MISMATCH");
SessionAuthenticator.Snapshot user=accounts.snapshot(s.userId);
AuthFailure.require(s.audience!=null&&(s.audience.equals(c.appId)||accounts.applicationAllowed(s.userId,user.schoolId,s.audience)),403,"APPLICATION_DENIED");
Long generation=db.queryForObject("SELECT generation FROM mob_auth_generation WHERE env=? AND ready=TRUE",Long.class,c.env);
Map<String,Object> epoch=db.queryForMap("SELECT user_epoch,identity_version FROM mob_auth_epoch WHERE env=? AND user_id=?",c.env,s.userId);
long binding=0;boolean bound="h5".equals(s.channel);
if(!bound) {
String scope,subject;
try{com.fasterxml.jackson.databind.JsonNode n=json.readTree(s.subject);AuthFailure.require(n.isArray()&&n.size()==2,401,"CONTEXT_STALE");scope=n.get(0).asText();subject=n.get(1).asText();}catch(Exception e){throw new AuthFailure(401,"CONTEXT_STALE");}
List<Map<String,Object>> rows=db.queryForList("SELECT version FROM mob_auth_subject WHERE env=? AND provider_scope=? AND provider_subject_hash=? AND user_id=?",c.env,scope,subject,s.userId);
AuthFailure.require(rows.size()==1,401,"CONTEXT_STALE");binding=number(rows.get(0),"version");bound=true;}
return new SessionAuthenticator.Snapshot(user.userId,user.schoolId,user.roleId,user.active,user.superAdmin,bound,
number(epoch,"user_epoch"),number(epoch,"identity_version"),binding,generation,user.account,user.name);
}
/** Only called by the app-signed, portal-allowlisted password endpoint. No channel proof required. */
public SessionAuthenticator.Snapshot verifyPortalPassword(String env,String account,String password) {
AuthFailure.require(env!=null&&!env.trim().isEmpty(),503,"CONFIG_MISMATCH");
AuthFailure.require(account!=null&&!account.trim().isEmpty()&&account.length()<=256&&password!=null&&!password.isEmpty()&&password.length()<=1024,401,"CREDENTIALS_INVALID");
int uid=accounts.verifyPassword(account,password);
return tx.execute(status->{
lockUser(uid);ensureEpoch(env,uid);SessionAuthenticator.Snapshot u=accounts.snapshot(uid);
AuthFailure.require(u.active&&u.schoolId!=null&&(u.schoolId>0||(u.schoolId==0&&u.superAdmin))&&u.roleId!=null,403,"IDENTITY_DENIED");
Map<String,Object> epoch=db.queryForMap("SELECT user_epoch,identity_version FROM mob_auth_epoch WHERE env=? AND user_id=?",env,uid);
long generation=db.queryForObject("SELECT generation FROM mob_auth_generation WHERE env=? AND ready=TRUE",Long.class,env);
return new SessionAuthenticator.Snapshot(uid,u.schoolId,u.roleId,true,u.superAdmin,true,number(epoch,"user_epoch"),number(epoch,"identity_version"),0,generation,u.account,u.name);
});
}
public SessionAuthenticator.Snapshot resolveCas(String principal) {
CallerAppContext c=caller("mobile.cas.resolve");
AuthFailure.require(principal!=null&&!principal.trim().isEmpty()&&principal.length()<=256,400,"CAS_PRINCIPAL_INVALID");
return tx.execute(status->{
int uid=accounts.casAccount(principal);AuthFailure.require(uid>0,403,"CAS_USER_UNMAPPED");
lockUser(uid);ensureEpoch(c.env,uid);SessionAuthenticator.Snapshot u=accounts.snapshot(uid);
AuthFailure.require(u.active&&u.schoolId!=null&&(u.schoolId>0||(u.schoolId==0&&u.superAdmin))&&u.roleId!=null,403,"IDENTITY_DENIED");
Map<String,Object> epoch=db.queryForMap("SELECT user_epoch,identity_version FROM mob_auth_epoch WHERE env=? AND user_id=?",c.env,uid);
long generation=db.queryForObject("SELECT generation FROM mob_auth_generation WHERE env=? AND ready=TRUE",Long.class,c.env);
return new SessionAuthenticator.Snapshot(uid,u.schoolId,u.roleId,true,u.superAdmin,true,number(epoch,"user_epoch"),number(epoch,"identity_version"),0,generation,u.account,u.name);
});
}
/** The signed caller attests a LIVE portal session. The browser never supplies this envelope. */
private SessionAuthenticator.Snapshot requirePortalSession(SessionAuthenticator.Session s) {
CallerAppContext c=caller("mobile.identity.validate");
AuthFailure.require(s!=null&&s.parentHash==null&&c.appId.equals(s.audience),403,"PORTAL_SESSION_REQUIRED");
SessionAuthenticator.Snapshot a=validate(s);
AuthFailure.require(s.absoluteExpiresAt>clock.instant().getEpochSecond()&&a.active&&a.bindingActive&&a.roleId!=null
&&a.schoolId!=null&&(a.schoolId>0||(a.schoolId==0&&a.superAdmin))&&a.userEpoch==s.userEpoch
&&a.bindingVersion==s.bindingVersion&&a.generation==s.generation&&a.identityVersion>=s.identityVersion,401,"CONTEXT_STALE");
return a;
}
public Object readPortalIdentity(SessionAuthenticator.Session session,boolean available) {
caller("mobile.identity.read");SessionAuthenticator.Snapshot a=requirePortalSession(session);
if(available)return accounts.available(a.userId);
return accounts.available(a.userId).stream().filter(r->Objects.equals(r.getRoleId(),a.roleId)&&Objects.equals(r.getSchId(),a.schoolId))
.findFirst().orElseThrow(()->new AuthFailure(403,"IDENTITY_DENIED"));
}
public Map<String,Object> switchPortalIdentity(SessionAuthenticator.Session session,int role,int school,long expected,String op) {
CallerAppContext c=caller("mobile.identity.switch");
requirePortalSession(session);
String hash=Crypto.hash("PORTAL_SWITCH\n"+session.id+"\n"+session.userId+"\n"+role+"\n"+school+"\n"+expected);
return operation(c,op,hash,()->{
lockUser(session.userId);requirePortalSession(session);
long current=db.queryForObject("SELECT identity_version FROM mob_auth_epoch WHERE env=? AND user_id=?",Long.class,c.env,session.userId);
AuthFailure.require(current==expected,409,"CONTEXT_STALE");
AuthFailure.require(school>0||(school==0&&accounts.snapshot(session.userId).superAdmin),403,"SCHOOL_CONTEXT_REQUIRED");
AuthFailure.require(accounts.available(session.userId).stream().anyMatch(r->Objects.equals(r.getRoleId(),role)&&Objects.equals(r.getSchId(),school)),403,"IDENTITY_DENIED");
accounts.switchIdentity(session.userId,role,school);
long updated=db.queryForObject("SELECT identity_version FROM mob_auth_epoch WHERE env=? AND user_id=?",Long.class,c.env,session.userId);
if(updated==current){db.update("UPDATE mob_auth_epoch SET identity_version=identity_version+1 WHERE env=? AND user_id=?",c.env,session.userId);updated++;}
AuthFailure.require(updated>current,409,"CONTEXT_STALE");
Map<String,Object> result=result(session.userId);result.put("identityVersion",updated);result.put("roleId",role);result.put("schoolId",school);result.put("previousIdentityVersion",current);result.put("sessionRef",Crypto.hash(session.id));result.put("committedAt",clock.instant().getEpochSecond());return result;
});
}
public Map<String,Object> queryBindings(int actor,int offset,int size){
CallerAppContext c=caller("mobile.binding.read");SessionAuthenticator.Snapshot admin=accounts.snapshot(actor);
AuthFailure.require(admin.active&&admin.superAdmin,403,"ADMIN_REQUIRED");AuthFailure.require(offset>=0&&size>0&&size<=100,400,"PAGE_INVALID");
List<Map<String,Object>> rows=db.queryForList("SELECT user_id,provider_scope,version FROM mob_auth_subject WHERE env=? AND user_id IS NOT NULL ORDER BY user_id,provider_scope LIMIT ? OFFSET ?",c.env,size,offset);
List<Map<String,Object>> masked=new ArrayList<>();for(Map<String,Object> row:rows){Map<String,Object> item=new LinkedHashMap<>();item.put("userRef","u***"+(number(row,"user_id")%1000));item.put("providerScope",str(row,"provider_scope"));item.put("bindingVersion",number(row,"version"));masked.add(item);}
Map<String,Object> result=new LinkedHashMap<>();result.put("list",masked);result.put("count",db.queryForObject("SELECT COUNT(*) FROM mob_auth_subject WHERE env=? AND user_id IS NOT NULL",Long.class,c.env));result.put("authority","mob_auth_subject");result.put("legacyMigrationVerified",false);return result;
}
private Map<String,Object> operation(CallerAppContext c,String op,String hash,Supplier<Map<String,Object>> work) {
AuthFailure.require(op!=null&&op.matches("[A-Za-z0-9_-]{22,128}"),400,"IDEMPOTENCY_KEY_REQUIRED");
try { return tx.execute(status->{
// Shared database lock, not a JVM lock. Serializes this caller's idempotency namespace across nodes.
AuthFailure.require(!db.queryForList("SELECT app_id FROM mob_auth_caller WHERE env=? AND app_id=? FOR UPDATE",c.env,c.appId).isEmpty(),403,"CALLER_CAPABILITY_DENIED");
Map<String,Object> saved=existing(c,op,hash);if(saved!=null)return saved;
Map<String,Object> value=work.get();String encoded;
try{encoded=json.writeValueAsString(value);}catch(Exception e){throw new IllegalStateException(e);}
db.update("INSERT INTO mob_auth_operation(env,caller_id,operation_id,request_hash,result_json) VALUES(?,?,?,?,?)",c.env,c.appId,op,hash,encoded);return value;
}); } catch(DataIntegrityViolationException e) {throw new AuthFailure(409,"BINDING_CONFLICT");}
}
private Map<String,Object> existing(CallerAppContext c,String op,String hash) {
List<Map<String,Object>> rows=db.queryForList("SELECT request_hash,result_json FROM mob_auth_operation WHERE env=? AND caller_id=? AND operation_id=?",c.env,c.appId,op);
if(rows.isEmpty())return null;AuthFailure.require(Crypto.equal(hash,str(rows.get(0),"request_hash")),409,"IDEMPOTENCY_CONFLICT");
try{return json.readValue(str(rows.get(0),"result_json"),LinkedHashMap.class);}catch(Exception e){throw new AuthFailure(503,"LEDGER_UNAVAILABLE");}
}
private String issue(CallerAppContext c,String action,String flow,Integer user,String providerScope,String subject,long version) {
AuthFailure.require(flow!=null&&flow.length()<=128,400,"FLOW_INVALID");String raw=Crypto.random();
db.update("INSERT INTO mob_auth_proof(handle_hash,env,caller_id,action,flow_id,user_id,provider_scope,provider_subject_hash,subject_version,expires_at,user_epoch,consumed) VALUES(?,?,?,?,?,?,?,?,?,?,?,FALSE)",
Crypto.hash(raw),c.env,c.appId,action,flow,user,providerScope,subject,version,clock.instant().getEpochSecond()+120,user==null?0:epoch(c.env,user));return raw;
}
private Map<String,Object> proof(CallerAppContext c,String raw,String action,boolean consume) {
AuthFailure.require(raw!=null,401,"PROOF_INVALID");List<Map<String,Object>> rows=db.queryForList("SELECT * FROM mob_auth_proof WHERE handle_hash=?",Crypto.hash(raw));
AuthFailure.require(rows.size()==1,401,"PROOF_INVALID");Map<String,Object> p=rows.get(0);
AuthFailure.require(c.env.equals(str(p,"env"))&&c.appId.equals(str(p,"caller_id"))&&action.equals(str(p,"action"))
&& !Boolean.TRUE.equals(p.get("consumed"))&&!Boolean.TRUE.equals(p.get("CONSUMED"))&&number(p,"expires_at")>clock.instant().getEpochSecond(),401,"PROOF_INVALID");
if(get(p,"user_id")!=null){int uid=(int)number(p,"user_id");AuthFailure.require(accounts.snapshot(uid).active&&epoch(c.env,uid)==number(p,"user_epoch"),401,"PROOF_INVALID");}
if(consume) consume(c,raw,action);return p;
}
private void consume(CallerAppContext c,String raw,String action) {
int n=db.update("UPDATE mob_auth_proof SET consumed=TRUE WHERE handle_hash=? AND env=? AND caller_id=? AND action=? AND consumed=FALSE AND expires_at>?",
Crypto.hash(raw),c.env,c.appId,action,clock.instant().getEpochSecond());AuthFailure.require(n==1,401,"PROOF_INVALID");
}
private long subjectVersion(String env,String providerScope,String subject) {
List<Long> rows=db.queryForList("SELECT version FROM mob_auth_subject WHERE env=? AND provider_scope=? AND provider_subject_hash=?",Long.class,env,providerScope,subject);return rows.isEmpty()?0:rows.get(0);
}
private int boundUser(String env,String providerScope,String subject) {
List<Integer> rows=db.queryForList("SELECT user_id FROM mob_auth_subject WHERE env=? AND provider_scope=? AND provider_subject_hash=?",Integer.class,env,providerScope,subject);return rows.isEmpty()||rows.get(0)==null?0:rows.get(0);
}
private void lockUser(int uid) {AuthFailure.require(!db.queryForList("SELECT id FROM sys_user WHERE id=? FOR UPDATE",uid).isEmpty(),401,"USER_REVOKED");}
private void ensureEpoch(String env,int uid) { if(db.queryForObject("SELECT COUNT(*) FROM mob_auth_epoch WHERE env=? AND user_id=?",Integer.class,env,uid)==0)
db.update("INSERT INTO mob_auth_epoch(env,user_id,user_epoch,identity_version) VALUES(?,?,1,1)",env,uid); }
private long epoch(String env,int uid){List<Long> rows=db.queryForList("SELECT user_epoch FROM mob_auth_epoch WHERE env=? AND user_id=?",Long.class,env,uid);return rows.isEmpty()?1:rows.get(0);}
private static void requireSameFlow(Map<String,Object> a,Map<String,Object> b) {
AuthFailure.require(str(a,"flow_id").equals(str(b,"flow_id"))&&Objects.equals(a.get("provider_scope"),b.get("provider_scope"))
&&Objects.equals(a.get("provider_subject_hash"),b.get("provider_subject_hash")),401,"PROOF_INVALID");
}
private static Map<String,Object> result(int uid) {Map<String,Object> r=new LinkedHashMap<>();r.put("userId",uid);r.put("status","COMMITTED");return r;}
private static Object get(Map<String,Object> p,String k) {return p.containsKey(k)?p.get(k):p.get(k.toUpperCase(Locale.ROOT));}
private static String str(Map<String,Object> p,String k) {Object v=get(p,k);return v==null?null:String.valueOf(v);}
private static long number(Map<String,Object> p,String k) {return ((Number)get(p,k)).longValue();}
}
@@ -0,0 +1,27 @@
package com.yunhai.userbase.mobile;
import com.yunhai.platform.mobile.*;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.web.bind.annotation.*;
import java.util.*;
@RestController @RequestMapping("/oapi/mobile")
public class MobileProofController {
private final ObjectProvider<MobileIdentityService> provider;
public MobileProofController(ObjectProvider<MobileIdentityService> provider) { this.provider=provider; }
private MobileIdentityService service(){MobileIdentityService s=provider.getIfAvailable();AuthFailure.require(s!=null,503,"AUTHORITY_UNAVAILABLE");return s;}
public static class Channel {public String flowId,subjectHash,action;public ProviderScope scope;public long subjectVersion;}
public static class User {public String channelProof,actionProof,account,password;}
public static class Action {public int userId;public String flowId,subjectHash,action;public ProviderScope scope;public long subjectVersion;}
public static class Operation {public String operationId,requestHash;}
public static class PortalIdentity {public SessionAuthenticator.Session session;public boolean available;public int roleId,schoolId;public long expectedIdentityVersion;public String operationId;}
@PostMapping("/identity/read") public Object readPortal(@RequestBody PortalIdentity b){return service().readPortalIdentity(b.session,b.available);}
@PostMapping("/identity/switch") public Object switchPortal(@RequestBody PortalIdentity b){return service().switchPortalIdentity(b.session,b.roleId,b.schoolId,b.expectedIdentityVersion,b.operationId);}
public static class Cas {public String issuer,principal;}
public static class BindingQuery {public int actor,offset,size=20;}
@PostMapping("/bindings/query") public Object bindings(@RequestBody BindingQuery b){return service().queryBindings(b.actor,b.offset,b.size);}
@PostMapping("/cas/resolve") public void cas(@RequestBody Cas b){throw new AuthFailure(410,"CAS_RETIRED");}
@PostMapping("/identity/validate") public SessionAuthenticator.Snapshot validate(@RequestBody SessionAuthenticator.Session b){return service().validate(b);}
@PostMapping("/proofs/channel") public Map<String,String> channel(@RequestBody Channel b){AuthFailure.require(b.scope!=null,400,"PROVIDER_SCOPE_INVALID");return Collections.singletonMap("proofHandle",service().channelProof(b.flowId,b.scope.key(),b.subjectHash,b.subjectVersion,b.action));}
@PostMapping("/proofs/user") public Map<String,String> user(@RequestBody User b){return Collections.singletonMap("proofHandle",b.actionProof!=null?service().reauth(b.actionProof,b.account,b.password):service().passwordProof(b.channelProof,b.account,b.password));}
@PostMapping("/proofs/action") public Map<String,String> action(@RequestBody Action b){AuthFailure.require(b.scope!=null,400,"PROVIDER_SCOPE_INVALID");return Collections.singletonMap("proofHandle",service().actionProof(b.userId,b.flowId,b.scope.key(),b.subjectHash,b.subjectVersion,b.action));}
@PostMapping("/operations/result") public Map<String,Object> result(@RequestBody Operation b){return service().recover(b.operationId,b.requestHash);}
}
@@ -0,0 +1,18 @@
package com.yunhai.userbase.mobile;
import com.yunhai.platform.mobile.AuthFailure;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.*;
@RestControllerAdvice @Order(Ordered.HIGHEST_PRECEDENCE)
public class MobileSecurityAdvice {
@ExceptionHandler(org.springframework.dao.DataAccessException.class)
public ResponseEntity<Map<String,Object>> unavailable(org.springframework.dao.DataAccessException e) {
return failure(new AuthFailure(503,"AUTHORITY_UNAVAILABLE"));
}
@ExceptionHandler(AuthFailure.class) public ResponseEntity<Map<String,Object>> failure(AuthFailure e) {
Map<String,Object> dto=new LinkedHashMap<>();dto.put("code",e.code);dto.put("message",e.code);dto.put("data",null);
return ResponseEntity.status(e.status).header("Cache-Control","no-store").body(dto);
}
}
@@ -0,0 +1,19 @@
package com.yunhai.userbase.mobile;
import com.yunhai.platform.mobile.AuthFailure;
import com.yunhai.userbase.event.*;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.event.EventListener;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import org.springframework.transaction.support.TransactionSynchronizationManager;
/** Synchronous events join the writer's transaction; never rely on AFTER_COMMIT invalidation. */
@Component @ConditionalOnProperty(name="yh.mobile.identity-store-enabled",havingValue="true")
public class MobileVersionListener {
private final JdbcTemplate db;public MobileVersionListener(JdbcTemplate db){this.db=db;}
private void transactional(){if(!TransactionSynchronizationManager.isActualTransactionActive()){
db.update("UPDATE mob_auth_generation SET ready=FALSE");throw new AuthFailure(503,"VERSION_WRITE_OUTSIDE_TRANSACTION");}}
@EventListener public void user(UserChangedEvent event){transactional();for(Integer uid:event.getUserIds())
db.update(event.getSource() instanceof com.yunhai.userbase.service.impl.IdentityService ? "UPDATE mob_auth_epoch SET identity_version=identity_version+1 WHERE user_id=?" : "UPDATE mob_auth_epoch SET user_epoch=user_epoch+1 WHERE user_id=?",uid);}
@EventListener public void group(GroupChangedEvent event){transactional();db.update("UPDATE mob_auth_epoch SET user_epoch=user_epoch+1");}
}
@@ -0,0 +1,25 @@
package com.yunhai.userbase.model.application;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 应用×学校单点开通判定结果(2026-10-25 T01 最小只读口)。
* 只回判定所需三件,不透出 secret/别名等管理信息;消费方(form-center/flow-center)
* 把它作为「该应用是否在该校开通」的平台事实第二重证明,fail-closed 消费。
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class AppActivatedVo {
/** 应用主键 ID(稳定主体,与入参一致) */
private Integer appId;
/** 学校组 ID(与入参一致) */
private Integer schoolId;
/** true=该校单点开通集合包含该应用(sys_group_app_rel 口径,不沿祖先) */
private boolean activated;
}
@@ -0,0 +1,29 @@
package com.yunhai.userbase.model.application;
import lombok.Data;
/**
* 应用矢量图标映射条目({@code /oapi/app/icon-map} 出参)。<br>
* 消费方:UserHome(PC GetUserApp 合并注入)与 MobilePortal(移动端桌面 AppItemVo 合并)。<br>
* 只透出 id/iconKey/iconColor 三字段,不含名称/地址等其余应用信息。
*
* @author chenx
*/
@Data
public class AppIconMapVo {
/**
* 应用主键 ID(与 BoaApp.id 同源,消费方按此合并)。
*/
private Integer id;
/**
* 矢量图标键(AppIcon 资产库图标名);null 表示该行不携带,消费方跳过。
*/
private String iconKey;
/**
* 矢量图标着色(CSS 色值);null 表示端侧默认。
*/
private String iconColor;
}
@@ -5,6 +5,7 @@ import lombok.Data;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Size;
import java.util.List;
/**
@@ -34,6 +35,22 @@ public class ApplicationInfoRequest {
@NotNull(groups = {ValidationGroups.Create.class, ValidationGroups.Update.class}, message = "应用类别不能为空")
private Integer categoryId;
/**
* 技术标识(= projman app.name,如 notice),全库唯一,新增与编辑均必填。<br>
* 编辑必填(2026-09-28 拍板):app_code 是应用身份锚点(消息 jump_app/服务目录键),不许置空;
* 存量 null 历史行不追溯,但编辑提交时必须补填。值域为引用键,改名须盘点引用。
*/
@NotBlank(groups = {ValidationGroups.Create.class, ValidationGroups.Update.class}, message = "技术标识不能为空")
@Size(max = 32, message = "技术标识长度不能超过32")
private String appCode;
/**
* 应用性质:BASE=基石 / BUSINESS=业务,新增与编辑均必填(2026-09-28 拍板,存量 null 行不追溯)。
*/
@NotBlank(groups = {ValidationGroups.Create.class, ValidationGroups.Update.class}, message = "应用性质不能为空")
@Size(max = 16, message = "应用性质长度不能超过16")
private String appNature;
/**
* 应用描述。<br>
*/
@@ -64,11 +81,26 @@ public class ApplicationInfoRequest {
*/
private String appIcon;
/**
* 矢量图标键(AppIcon 资产库图标名)。空 = 不指定(编辑时空值不回写,沿用原值)。<br>
*/
private String iconKey;
/**
* 矢量图标着色(CSS 色值)。空 = 端侧默认。编辑时空值不回写。<br>
*/
private String iconColor;
/**
* 公众号应用地址。<br>
*/
private String officialAccountUrl;
/**
* 微信小程序应用地址。<br>
*/
private String miniprogramUrl;
/**
* 钉钉应用地址。<br>
*/
@@ -12,6 +12,9 @@ import java.util.List;
*/
@Data
public class ApplicationInfoVo {
/** Stable, non-secret application incarnation used for source isolation. */
private String sourcePrincipalId;
/**
* 主键ID。<br>
*/
@@ -47,6 +50,16 @@ public class ApplicationInfoVo {
*/
private String appSecret;
/**
* 技术标识(= projman app.name,如 notice)。<br>
*/
private String appCode;
/**
* 应用性质:BASE=基石 / BUSINESS=业务。<br>
*/
private String appNature;
/**
* 应用前端类型列表。<br>
*/
@@ -72,11 +85,26 @@ public class ApplicationInfoVo {
*/
private String appIcon;
/**
* 矢量图标键(AppIcon 资产库图标名);空 = 未配置。<br>
*/
private String iconKey;
/**
* 矢量图标着色(CSS 色值);空 = 端侧默认。<br>
*/
private String iconColor;
/**
* 公众号应用地址。<br>
*/
private String officialAccountUrl;
/**
* 微信小程序应用地址。<br>
*/
private String miniprogramUrl;
/**
* 钉钉应用地址。<br>
*/
@@ -26,11 +26,17 @@ public class AppModuleRequest {
@NotBlank(groups = {ValidationGroups.Create.class, ValidationGroups.Update.class}, message = "模块名称不能为空")
private String name;
@NotBlank(groups = {ValidationGroups.Create.class, ValidationGroups.Update.class}, message = "自定义ID不能为空")
/**
* 自定义ID(应用内唯一,业务编码)。子模块双定位(树节点/按钮权限):<br>
* 按钮权限型子模块可不填,服务层把空白归一为 null(仅在有值时做应用内唯一校验)。<br>
*/
private String customId;
/** 模块路径(前端路由/资源路径)。<br> */
/** 模块路径(前端路由/资源路径);按钮权限型子模块可不填。<br> */
private String path;
/** 模块图标(YschIcons 图标名;非必填,留空则开放接口返回 null)。<br> */
private String icon;
private Integer sortNo;
}
@@ -18,6 +18,7 @@ public class AppModuleVo {
private String name;
private String customId;
private String path;
private String icon;
private Integer level;
private Integer sortNo;
/** 子模块(递归);默认空列表,避免 JSON 序列化出现 null。 */
@@ -29,6 +29,13 @@ public class AppRoleAuthzAppVo {
/** 排序号。 */
private Integer sortNo;
/**
* 应用级勾选态:<b>仅当该应用下没有任何模块时</b>才有效。<br>
* 此时授权落在 sys_app_role_module_rel 的 -appId 哨兵记录上(见 {@code AppLevelModuleId});
* 有模块的应用恒为 false,勾选态由 modules 树逐节点表达。
*/
private Boolean checked = Boolean.FALSE;
/** 该应用下的模块权限树(含勾选回显)。 */
private List<AppRoleAuthzModuleVo> modules = new ArrayList<>();
}
@@ -2,12 +2,14 @@ package com.yunhai.userbase.model.approle;
import lombok.Data;
import javax.validation.Valid;
import javax.validation.constraints.NotNull;
import java.util.List;
/**
* 角色绑定用户保存入参(全量覆盖,角色维度)。<br>
* userIds 为该角色本次绑定的用户ID全量列表;传空列表即解绑该角色全部用户。<br>
* 角色绑定用户保存入参(增量添加,角色维度)。<br>
* bindings 为本次新增的「用户×组」绑定明细(组 = 弹窗左侧树当前选中组);已存在的 (userId, groupId) 跳过。<br>
* 组维度改造(2026-09-16)后不再全量覆盖:覆盖会把其他组下的既有绑定一并删掉。
*
* @author chenx
*/
@@ -21,7 +23,7 @@ public class AppRoleUserBindRequest {
private Integer roleId;
/**
* 绑定的用户ID全量列表。<br>
* 本次新增的「用户×组」绑定明细。<br>
*/
private List<Integer> userIds;
private List<@Valid AppRoleUserBinding> bindings;
}
@@ -0,0 +1,23 @@
package com.yunhai.userbase.model.approle;
import lombok.Data;
import javax.validation.constraints.NotNull;
/**
* 角色绑定用户明细项:一行 = 用户在指定组下的绑定。<br>
* groupId = 添加用户弹窗左侧树当前选中组(绑定发生时的组),部门列展示时按组ID实时拼路径。
*
* @author chenx
*/
@Data
public class AppRoleUserBinding {
/** 用户ID。 */
@NotNull(message = "用户ID不能为空")
private Integer userId;
/** 绑定发生时所选的组ID。 */
@NotNull(message = "组ID不能为空")
private Integer groupId;
}
@@ -3,7 +3,9 @@ package com.yunhai.userbase.model.approle;
import lombok.Data;
/**
* 角色已绑定用户列表项视图。<br>
* 角色已绑定用户列表项视图(一行 = 一条「用户×组」绑定)。<br>
* groupPath 为查询时按 groupId 实时拼接的组路径(不落库),scope 非空时按查看者数据范围裁剪——
* 学校管理员只看到本校路径(如 /云海小学/教师组),超管看全路径。
*
* @author chenx
*/
@@ -18,4 +20,10 @@ public class AppRoleUserVo {
/** 真实姓名。 */
private String realName;
/** 绑定发生时所选的组ID。 */
private Integer groupId;
/** 组路径(查询时按 groupId 现算,scope 裁剪后)。 */
private String groupPath;
}
@@ -6,8 +6,9 @@ import javax.validation.constraints.NotNull;
import java.util.List;
/**
* 用户绑定角色保存入参(全量覆盖,用户维度)。<br>
* roleIds 为该用户本次绑定的角色ID全量列表;传空列表即解绑该用户全部角色。<br>
* 用户绑定角色保存入参(按组覆盖,用户维度)。<br>
* roleIds 为该用户在 groupId 组下的角色ID全量列表;传空列表即解绑该用户在该组下的全部角色,
* 不影响其在其他组的绑定;groupId 为空时兜底主组(历史调用方兼容)。<br>
*
* @author chenx
*/
@@ -21,7 +22,12 @@ public class UserAppRoleBindRequest {
private Integer userId;
/**
* 绑定的角色ID全量列表。<br>
* 绑定组ID(可选):角色归属的组,空则兜底该用户主组。<br>
*/
private Integer groupId;
/**
* 该组下绑定的角色ID全量列表。<br>
*/
private List<Integer> roleIds;
}
@@ -0,0 +1,30 @@
package com.yunhai.userbase.model.channel;
import lombok.Data;
import javax.validation.constraints.NotBlank;
/**
* 渠道绑定(手机号证明模式)开放接口入参(/oapi/channel/bindByPhone)。
* 供小程序「一键手机号」使用:手机号归属由渠道验签器(phoneCode → phone)证明,无密码。
*
* @author chenx
* @date 2026/9/25
*/
@Data
public class ChannelBindByPhoneRequest {
/** 渠道码:miniapp */
@NotBlank(message = "渠道不能为空")
private String channel;
@NotBlank(message = "openid 不能为空")
private String openid;
/** 同一开放平台下跨端一致标识;可空 */
private String unionId;
/** 渠道验签器解析出的已验证手机号 */
@NotBlank(message = "手机号不能为空")
private String phone;
}
@@ -0,0 +1,33 @@
package com.yunhai.userbase.model.channel;
import lombok.Data;
import javax.validation.constraints.NotBlank;
/**
* 渠道绑定(凭证模式)开放接口入参(/oapi/channel/bind)。
* openid/unionId 由 SDK 登录端点从一次性 loginTicket 解出后传入,前端永不直传。
*
* @author chenx
* @date 2026/9/25
*/
@Data
public class ChannelBindRequest {
/** 渠道码:wxgzh / miniapp / qywx / dingtalk */
@NotBlank(message = "渠道不能为空")
private String channel;
@NotBlank(message = "openid 不能为空")
private String openid;
/** 同一开放平台下跨端一致标识;可空 */
private String unionId;
/** 登录账号(学生学号 / 教师手机号) */
@NotBlank(message = "账号不能为空")
private String account;
@NotBlank(message = "密码不能为空")
private String password;
}
@@ -0,0 +1,24 @@
package com.yunhai.userbase.model.channel;
import lombok.Data;
import javax.validation.constraints.NotBlank;
/**
* 解绑开放接口入参(/oapi/channel/unbind)。
* account 由 SDK 登录端点从 token payload 解出后传入(服务端定位用户),非客户端自报。
*
* @author chenx
* @date 2026/9/25
*/
@Data
public class ChannelUnbindRequest {
/** 渠道码:wxgzh / miniapp / qywx / dingtalk */
@NotBlank(message = "渠道不能为空")
private String channel;
/** 登录账号(token payload 内 account) */
@NotBlank(message = "账号不能为空")
private String account;
}
@@ -0,0 +1,23 @@
package com.yunhai.userbase.model.customdept;
import lombok.Data;
import javax.validation.constraints.NotEmpty;
import java.util.List;
/**
* @author chenx
* @date 2026/10/21
* @description 部门成员批量添加/移除请求。<br>
* 服务端<b>逐个</b>复核每个 userId:本校 + 教职工(排除学生组 type=8 下的用户)+ 有效账号;
* 任一不合格整批拒绝(不返回「部分成功」,避免前端以为已生效)。
*/
@Data
public class CustomDepartmentMemberRequest {
/** 学校组 id(可空:单校管理员由服务端取本校)。 */
private Integer schoolId;
@NotEmpty(message = "成员列表不能为空")
private List<Integer> userIds;
}
@@ -0,0 +1,20 @@
package com.yunhai.userbase.model.customdept;
import lombok.Data;
/**
* @author chenx
* @date 2026/10/21
* @description 部门成员列表项(管理端弹窗用,字段最小化:不返回手机号/身份证等敏感列)。
*/
@Data
public class CustomDepartmentMemberVo {
private Integer userId;
/** 账号(sys_user.username)。 */
private String account;
/** 姓名(sys_user.real_name)。 */
private String name;
}
@@ -0,0 +1,21 @@
package com.yunhai.userbase.model.customdept;
import lombok.Data;
import javax.validation.constraints.NotNull;
/**
* @author chenx
* @date 2026/10/21
* @description 部门移动请求:只允许移动到<b>本校</b>其他分类;目标分类跨校直接拒绝。
*/
@Data
public class CustomDepartmentMoveRequest {
/** 学校组 id(可空:单校管理员由服务端取本校)。 */
private Integer schoolId;
/** 目标分类 id。 */
@NotNull(message = "目标分类不能为空")
private Integer categoryId;
}
@@ -0,0 +1,25 @@
package com.yunhai.userbase.model.customdept;
import lombok.Data;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Size;
/**
* @author chenx
* @date 2026/10/21
* @description 自定义部门创建/改名请求。新建默认 enabled=1,成员可为空(先建后配)。
*/
@Data
public class CustomDepartmentRequest {
/** 学校组 id(可空:单校管理员由服务端取本校)。 */
private Integer schoolId;
/** 归属分类 id:创建必填;改名不生效(以库内归属为准,防止前端传错分类绕过重名范围)。 */
private Integer categoryId;
@NotBlank(message = "部门名称不能为空")
@Size(max = 64, message = "部门名称不能超过 64 个字符")
private String name;
}
@@ -0,0 +1,24 @@
package com.yunhai.userbase.model.customdept;
import lombok.Data;
import javax.validation.constraints.NotEmpty;
import java.util.List;
/**
* @author chenx
* @date 2026/10/21
* @description 部门排序请求:把某分类下的有效部门按给定 ids 顺序重排(sort_no 从 1 起)。
*/
@Data
public class CustomDepartmentSortRequest {
/** 学校组 id(可空:单校管理员由服务端取本校)。 */
private Integer schoolId;
/** 排序作用范围所在分类 id。 */
private Integer categoryId;
@NotEmpty(message = "部门ID列表不能为空")
private List<Integer> ids;
}
@@ -0,0 +1,31 @@
package com.yunhai.userbase.model.customdept;
import lombok.Data;
/**
* @author chenx
* @date 2026/10/21
* @description 自定义部门列表项(管理端卡片;停用可见、软删默认不出现)。<br>
* {@code enabled} 对外统一 boolean,库内是 1/0 单列,避免与 sys_group.status 的方向混淆。
*/
@Data
public class CustomDepartmentVo {
private Integer id;
private Integer schoolId;
private Integer categoryId;
private String categoryName;
private String name;
private Integer sortNo;
/** 是否启用(true=启用)。 */
private Boolean enabled;
/** 有效成员数。 */
private Long memberCount;
}
@@ -0,0 +1,24 @@
package com.yunhai.userbase.model.customdept;
import lombok.Data;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Size;
/**
* @author chenx
* @date 2026/10/21
* @description 自定义分类创建/改名请求。<br>
* schoolId 只作与服务端身份范围的一致性核对,<b>不作为授权依据</b>(授权见
* {@code AccessScopeService#resolveWritableSchoolId})。
*/
@Data
public class CustomDeptCategoryRequest {
/** 学校组 id(可空:单校管理员由服务端取本校)。 */
private Integer schoolId;
@NotBlank(message = "分类名称不能为空")
@Size(max = 64, message = "分类名称不能超过 64 个字符")
private String name;
}
@@ -0,0 +1,22 @@
package com.yunhai.userbase.model.customdept;
import lombok.Data;
import javax.validation.constraints.NotEmpty;
import java.util.List;
/**
* @author chenx
* @date 2026/10/21
* @description 分类排序请求:把本校有效分类按给定 ids 顺序重排(范式同 SysGroupSortRequest,落库 sort_no 从 1 起)。
*/
@Data
public class CustomDeptCategorySortRequest {
/** 学校组 id(可空:单校管理员由服务端取本校)。 */
private Integer schoolId;
/** 期望顺序的分类 id 列表,必须覆盖本校全部有效分类且不得含别校分类。 */
@NotEmpty(message = "分类ID列表不能为空")
private List<Integer> ids;
}
@@ -0,0 +1,23 @@
package com.yunhai.userbase.model.customdept;
import lombok.Data;
/**
* @author chenx
* @date 2026/10/21
* @description 自定义分类列表项(管理端左侧平铺列表;软删分类不出现在列表里)。
*/
@Data
public class CustomDeptCategoryVo {
private Integer id;
private Integer schoolId;
private String name;
private Integer sortNo;
/** 该分类下<b>有效</b>部门数:前端据此对非空分类禁用「删除」。 */
private Long departmentCount;
}
@@ -0,0 +1,44 @@
package com.yunhai.userbase.model.dept;
import lombok.Data;
/**
* @author chenx
* @date 2026/10/21
* @description 通用部门目录条目(规格书 §4.2 统一响应条目,SYSTEM/CUSTOM 共用同一形状)。<br>
* 引用必须三件套齐全:{@code schoolId + departmentType + departmentId};
* SYSTEM 的 {@code categoryId/categoryName} 恒为 null,层级用 {@code parentId}(照组契约)。
*/
@Data
public class DepartmentEntryVo {
/** 学校组 id。 */
private Integer schoolId;
/** 来源:SYSTEM / CUSTOM(消歧位,禁止靠名称或 ID 单独判断)。 */
private String departmentType;
/** SYSTEM= sys_group.id;CUSTOM= ub_custom_department.id。两域各自独立,可数值相同但语义不同。 */
private Integer departmentId;
private String name;
private Integer categoryId;
private String categoryName;
/** 是否启用(CUSTOM 读 enabled 列,SYSTEM 读 status≠停用)。 */
private Boolean enabled;
/** 是否已软删(仅历史解析口会为 true;SYSTEM 不支持删除,恒 false)。 */
private Boolean deleted;
/** 是否可用于<b>新的</b>选择/绑定:停用或已软删 → false(A10/A12)。 */
private Boolean selectable;
/** SYSTEM=该组直属用户数;CUSTOM=有效成员数。 */
private Long memberCount;
/** 上级部门 id(仅 SYSTEM;withChildren=true 时用于重建层级,CUSTOM 为 null)。 */
private Integer parentId;
}
@@ -0,0 +1,26 @@
package com.yunhai.userbase.model.dept;
import lombok.Data;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
/**
* @author chenx
* @date 2026/10/21
* @description 部门引用({@code POST /oapi/departments/resolve-batch} 的入参项)。<br>
* 业务侧存的就是这三件套;这里<b>不</b>按名称回填,名称只作为解析结果返回。
*/
@Data
public class DepartmentRefRequest {
@NotNull(message = "schoolId 不能为空")
private Integer schoolId;
/** SYSTEM / CUSTOM(大小写不敏感)。 */
@NotBlank(message = "departmentType 不能为空")
private String departmentType;
@NotNull(message = "departmentId 不能为空")
private Integer departmentId;
}
@@ -30,6 +30,16 @@ public class GroupAuthzAppVo {
private Integer sortNo;
/**
* 应用级勾选态:<b>仅当该应用下没有任何模块时</b>才有效。<br>
* 此时授权落在 sys_group_module_perm 的 module_id=0 记录上(整应用授权);
* 有模块的应用恒为 false,勾选态由 modules 树逐节点表达。
*/
private Boolean checked = Boolean.FALSE;
/** 应用级子组继承:仅当该应用下没有模块时有意义,对应 module_id=0 记录的 inherit_flag。 */
private Boolean inheritFlag = Boolean.FALSE;
/** 该应用下的模块权限树;默认空列表,避免 JSON 序列化出现 null。 */
private List<GroupAuthzModuleVo> modules = new ArrayList<>();
}
@@ -32,7 +32,9 @@ public class GroupModuleAuthzItem {
/**
* 模块ID;传 0 表示「该应用下全部模块」。<br>
* 注意:后端不会向 sys_group_module_perm 写入 module_id=0 的伪记录,<br>
* 而是把 0 展开为应用下每个真实模块各写一条权限记录。
* 而是把 0 展开为应用下每个真实模块各写一条权限记录。<br>
* 特殊情形:应用下<b>一个模块都没有</b>时,改写成 -appId 的整应用哨兵记录
* (见 {@code AppLevelModuleId}),保证这类应用也能被授权与回显。
*/
@NotNull(message = "模块ID不能为空(整应用传 0)")
private Integer moduleId;
@@ -38,8 +38,6 @@ public class SysOapiLogListVo {
private String operationDesc;
/** 请求参数JSON(脱敏后) */
private String requestParams;
/** 响应结果(GZIP 解压后原文,脱敏后;随列表带出,详情弹窗直接展示) */
private String responseData;
/** HTTP 状态码 */
private Integer statusCode;
/** 耗时(ms) */
@@ -52,6 +50,8 @@ public class SysOapiLogListVo {
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@JSONField(format = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime createTime;
/** 响应原文字节数(用于前端按大小切换在线预览/下载,response_data 原文不进列表) */
private Integer responseSize;
/**
* 全参构造,顺序需与 {@code SysOapiLog} 的列表投影字段一致。<br>
@@ -61,7 +61,8 @@ public class SysOapiLogListVo {
String requestIp, String requestUri, String requestMethod,
String operationType, String classMethod, String operationDesc,
String requestParams, Integer statusCode, Long costTime,
String userAgent, String errorMsg, LocalDateTime createTime) {
String userAgent, String errorMsg, LocalDateTime createTime,
Integer responseSize) {
this.id = id;
this.logLevel = logLevel;
this.callerAppKey = callerAppKey;
@@ -78,5 +79,6 @@ public class SysOapiLogListVo {
this.userAgent = userAgent;
this.errorMsg = errorMsg;
this.createTime = createTime;
this.responseSize = responseSize;
}
}
@@ -48,6 +48,8 @@ public class SysOperationLogListVo {
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@JSONField(format = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime createTime;
/** 响应原文字节数(用于前端按大小切换在线预览/下载,response_data 原文不进列表) */
private Integer responseSize;
/**
* 全参构造,顺序需与 {@code SysOperationLog} 的列表投影字段一致。<br>
@@ -57,7 +59,7 @@ public class SysOperationLogListVo {
String requestIp, String requestUri, String requestMethod,
String operationType, String classMethod, String requestParams,
Integer statusCode, Long costTime, String userAgent,
String errorMsg, LocalDateTime createTime) {
String errorMsg, LocalDateTime createTime, Integer responseSize) {
this.id = id;
this.logLevel = logLevel;
this.userId = userId;
@@ -73,5 +75,6 @@ public class SysOperationLogListVo {
this.userAgent = userAgent;
this.errorMsg = errorMsg;
this.createTime = createTime;
this.responseSize = responseSize;
}
}
@@ -42,6 +42,10 @@ public class PushRecordEntity {
@Column(name = "payload", columnDefinition = "MEDIUMTEXT")
private String payload;
/** 请求体 UTF-8 字节数(写入时算好,列表按此列切换在线预览/下载,避免查询 payload 大字段)。 */
@Column(name = "payload_size")
private Integer payloadSize;
@Column(name = "sign_t")
private String signT;
@@ -46,6 +46,8 @@ public class PushRecordListVo {
/** 创建时间 */
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime createdAt;
/** 请求体字节数(用于前端按大小切换在线预览/下载,payload 原文不进列表) */
private Integer payloadSize;
/**
* 全参构造,顺序需与 {@code PushRecordEntity} 的列表投影字段一致。<br>
@@ -54,7 +56,7 @@ public class PushRecordListVo {
public PushRecordListVo(Long id, Integer subscriberAppKey, String appName, String interfacePath,
String interfaceLabel, String requestUrl, String signT, Integer httpStatus,
Integer respCode, String respMsg, Integer success, Integer attemptNo,
Integer costMs, String triggerType, LocalDateTime createdAt) {
Integer costMs, String triggerType, LocalDateTime createdAt, Integer payloadSize) {
this.id = id;
this.subscriberAppKey = subscriberAppKey;
this.appName = appName;
@@ -70,5 +72,6 @@ public class PushRecordListVo {
this.costMs = costMs;
this.triggerType = triggerType;
this.createdAt = createdAt;
this.payloadSize = payloadSize;
}
}
@@ -5,7 +5,7 @@ import lombok.Data;
import java.util.List;
/**
* 测试推送请求(测试推送页调用)。
* 手动触发推送请求(应用推送设置的组/用户全量同步调用)。
*
* @author chenx
* @date 2026/8/14
@@ -10,7 +10,7 @@ import java.util.List;
* <ul>
* <li>status:0正常 / 2禁用(由 userbase StatusEnum 映射)</li>
* <li>groups:用户所属组,含 groupId / no / schoolId(schoolId = 组所属学校范围 id)</li>
* <li>roleList:用户身份,含 roleId / schId(schId = 用户主组所属学校范围 id)</li>
* <li>roleList:用户身份,含 roleId / schId(schId = 身份行所属学校,即 sys_user_role_rel.schoolId)</li>
* </ul>
*
* @author chenx
@@ -47,7 +47,7 @@ public class PushUserDTO {
@Data
public static class PushUserRoleRef {
private Integer roleId;
/** 所属学校 id(userbase 无角色-学校关联,统一取用户主组所属学校) */
/** 身份行所属学校 id(sys_user_role_rel.schoolId,学校组 / 区域组 / 根直属各自的实际组 id) */
private Integer schId;
}
}
@@ -0,0 +1,25 @@
package com.yunhai.userbase.model.sysgroup;
import com.yunhai.platform.utils.model.BoaGroup;
/**
* oapi 组树出参扩展(2026-10-20 部门线 P0 最小批):在平台模型 {@link BoaGroup} 之上透出启停状态。<br>
* <ul>
* <li>{@code status}:0=启用 / 1=停用(sys_group.status 原值,语义=停用只影响「选」不影响「看」与「办」);</li>
* <li>反序列化兼容:字段只增不改,消费方(svc-hall / flow-center)经 starter 注入的 Spring ObjectMapper
* 解析,未知字段默认忽略——不升级消费方 SDK 也能照常工作;需要读 status 时按同名 JSON 键取即可;</li>
* <li>不动 BasePlatform 的 BoaGroup(平台模型加字段要发 SDK 版本,超出最小批)。</li>
* </ul>
*/
public class BoaGroupStatusVo extends BoaGroup {
private Integer status;
public Integer getStatus() {
return status;
}
public void setStatus(Integer status) {
this.status = status;
}
}
@@ -0,0 +1,19 @@
package com.yunhai.userbase.model.sysgroup;
import lombok.Data;
import javax.validation.constraints.NotNull;
/**
* @author chenx
* @date 2026/10/21
* @description 设置「包含子组」请求:该校在部门管理页是否连同下级部门一起列出。<br>
* 仅学校节点使用;true=含子组(1)/ false=仅本级(0)。
*/
@Data
public class SysGroupIncludeChildrenRequest {
/** 是否包含子组。 */
@NotNull(message = "includeChildren 不能为空")
private Boolean includeChildren;
}
@@ -16,6 +16,17 @@ public class SysGroupVo {
private String name;
private Integer type;
private Integer sort;
/** 启停状态(2026-10-20 部门线 P0):0=启用 / 1=停用;BeanUtils 同名拷贝,null 视为 0 由前端兜底 */
private Integer status;
/** 包含子组(2026-10-21):1=部门列表含下级部门 / 0=仅本级;BeanUtils 同名拷贝,null 视为 1 由前端兜底 */
private Integer includeChildren;
/** 直属成员数(部门表格「人员」列);口径同 oapi 部门目录:sys_user_group_rel 按 groupId 计数,不排除转出。 */
private Long memberCount;
/**
* 直属成员姓名预览,最多 {@code SysGroupService.MEMBER_PREVIEW_LIMIT} 个。<br>
* 完整名单不走这里(部门可能上百人),前端点「查看全部」调 /api/sysuser/List?groupId=xxx。
*/
private List<String> memberNames = new ArrayList<>();
/** 子组(递归);默认空列表,避免 JSON 序列化出现 null。 */
private List<SysGroupVo> children = new ArrayList<>();
}
@@ -39,7 +39,7 @@ public class ExcelSysUser implements Serializable {
@Excel(name = "用户性质\n(可选,默认普通用户\n组管理员2,\n普通用户0,\n导入不接受别的代码)", orderNum = "8", width = 26)
private Integer nature;
@Excel(name = "用户身份\n(1教师 2教职工 3学生 4家长)", orderNum = "9", width = 24)
@Excel(name = "用户身份\n(1教师 2校领导 3学生 4家长)", orderNum = "9", width = 24)
private Integer identity;
@Excel(name = "性别(可选)", orderNum = "10", width = 12)
@@ -0,0 +1,35 @@
package com.yunhai.userbase.model.sysuser;
import lombok.Data;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Size;
/**
* 本人联系方式修改请求(oapi {@code /oapi/user/self/contact} 专用)。<br>
* 手机号/邮箱至少一项非空(Service 层校验);字段级格式、长度与唯一性校验同在 Service 层
* (手机号口径与 {@code validateProfile} 一致,另做邮箱格式 + 手机号查重)。
*
* @author chenx
*/
@Data
public class SysUserContactRequest {
/**
* 用户ID(必填)。
*/
@NotNull
private Integer userId;
/**
* 手机号(可选;非空时走 1[3-9] 段位格式校验 + 全库唯一查重)。
*/
@Size(max = 20, message = "手机号长度不能超过20字符")
private String phone;
/**
* 邮箱(可选;非空时走邮箱格式校验)。
*/
@Size(max = 100, message = "邮箱长度不能超过100字符")
private String email;
}
@@ -4,7 +4,6 @@ import lombok.Data;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
/**
* @author chenx
@@ -22,7 +21,6 @@ public class SysUserVo {
private String email;
private Integer gender;
private String profilePicture;
private List<Integer> roles;
/** 启用状态:StatusEnum 的 key(0=正常/未禁用,1=禁用) */
private Integer disabled;
/** 用户类型:UserTypeEnum 的 key(0=普通用户,1=超级管理员,2=学校管理员) */
@@ -32,7 +30,9 @@ public class SysUserVo {
/** 组内状态:UserGroupRelStatusEnum 的 key(0=正常,1=转出);仅组上下文列表有值,根节点/未选组为 null */
private Integer groupStatus;
/**
* 主校身份名(如「老师/校领导」):列表页按页批量带出(动态解析主身份,无 N+1)。<br>
* 用户身份名(如「老师、校领导」):列表页按页批量带出(无 N+1)。<br>
* 组上下文列表只取「当前组范围解析出的学校集合」内的身份行(与编辑弹窗「所在组」回显同源);
* 未选组(全平台视角)才展示全部身份;范围内无身份行时留空。<br>
* roles 列已停写(冻结历史值),「用户身份」列展示一律切此字段。
*/
private String identityNames;
@@ -17,21 +17,25 @@ import java.util.List;
* @description
*/
public interface ApplicationInfoRepository extends JpaRepository<ApplicationInfo, Integer>, JpaSpecificationExecutor<ApplicationInfo> {
@Query("SELECT COUNT(a.id) FROM ApplicationInfo a WHERE a.categoryId = :categoryId AND a.appKey > 100000")
/** 读取持久化标识快照;禁止查询前自动 flush 掉待校验的实体改名。 */
@Query("SELECT a.appCode FROM ApplicationInfo a WHERE a.id = :id")
@org.springframework.data.jpa.repository.QueryHints(
@javax.persistence.QueryHint(name = "org.hibernate.flushMode", value = "COMMIT"))
String findPersistedAppCodeById(@Param("id") Integer id);
// 基石应用排除 = AppNature.isBaseApp(appKey<=100000 ∪ app_nature='BASE')的 SQL 形态,常量拼接、语义单点见 AppNature。
// app_nature 过渡期可空:`<> 'BASE'` 遇 NULL 求值为 NULL(非 TRUE)会整行漏掉,必须带 IS NULL 分支。
@Query("SELECT COUNT(a.id) FROM ApplicationInfo a WHERE a.categoryId = :categoryId AND a.appKey > 100000 " +
"AND (a.appNature IS NULL OR a.appNature <> 'BASE')")
Integer countByCategoryId(@Param("categoryId") Integer id);
boolean existsByName(String name);
boolean existsByNameAndIdNot(String name, Integer id);
/**
* 查询当前最大的 appKey 值。<br>
* 用于新增应用时生成从 100000 起自增的整型 appKey。<br>
*
* @return 当前最大 appKey,无记录时返回 null
*/
@Query("SELECT MAX(a.appKey) FROM ApplicationInfo a")
Integer findMaxAppKey();
// findMaxAppKey 已删(2026-10-26 P2-6):凭据 key 一律由 application_key_reservation 的
// insert-only 永久保留行分配(ApplicationInfoService.allocateAppKey),存活表 MAX 不得
// 作为分配依据——已删除应用可能占用更高 key,max(存活)+1 会复用注销凭据。
/**
* 按整型 appKey 查单个应用(验签兜底端点直连读取用)。<br>
@@ -58,23 +62,39 @@ public interface ApplicationInfoRepository extends JpaRepository<ApplicationInf
/**
* 按应用类型查询排序所需的精简视图(仅 id/name/sortNo),不分页。<br>
* 使用构造器表达式做 DTO 投影,避免加载整个实体(含 appSecret、各类 URL 等无关大字段)。<br>
* 排除用户中心自身的基石应用(appKey=100000)。
* 排除基石应用(口径 = AppNature.isBaseApp,SQL 形态见 countByCategoryId 注释)。
*
* @param categoryId 应用类型ID
* @return 该类型下全部应用的排序视图,按 sortNo 升序(未排序排末尾),id 升序兜底
*/
@Query("SELECT new com.yunhai.userbase.model.application.ApplicationInfoSortVo(a.id, a.name, a.sortNo) " +
"FROM ApplicationInfo a WHERE a.categoryId = :categoryId AND a.appKey > 100000 ORDER BY a.sortNo ASC, a.id ASC")
"FROM ApplicationInfo a WHERE a.categoryId = :categoryId AND a.appKey > 100000 " +
"AND (a.appNature IS NULL OR a.appNature <> 'BASE') ORDER BY a.sortNo ASC, a.id ASC")
List<ApplicationInfoSortVo> findSortVoByCategoryId(@Param("categoryId") Integer categoryId);
/**
* 应用授权用:查询全部应用的扁平投影(含 categoryId 供按类型分组),仅取 id/name/pcIcon。<br>
* 按 categoryId、sortNo、id 升序,便于分组后同类型内保持排序。<br>
* 排除用户中心自身的基石应用(appKey=100000)。
* 排除基石应用(口径 = AppNature.isBaseApp,SQL 形态见 countByCategoryId 注释)。
*
* @return 全部应用的授权投影行
*/
@Query("SELECT new com.yunhai.userbase.model.appauthz.AppAuthzAppRow(a.categoryId, a.id, a.name, a.pcIcon) " +
"FROM ApplicationInfo a WHERE a.appKey > 100000 ORDER BY a.categoryId ASC, a.sortNo ASC, a.id ASC")
"FROM ApplicationInfo a WHERE a.appKey > 100000 " +
"AND (a.appNature IS NULL OR a.appNature <> 'BASE') ORDER BY a.categoryId ASC, a.sortNo ASC, a.id ASC")
List<AppAuthzAppRow> findAllAuthzRows();
/**
* 按技术标识查单个应用(app_code = projman app.name,跳转查询键 /oapi/app/by-code 的数据面)。<br>
*
* @param appCode 技术标识(如 notice)
* @return 匹配的应用实体,无则 null
*/
ApplicationInfo findByAppCode(String appCode);
/** 技术标识唯一性校验(新增用)。 */
boolean existsByAppCode(String appCode);
/** 技术标识唯一性校验(编辑排除自身用)。 */
boolean existsByAppCodeAndIdNot(String appCode, Integer id);
}

Some files were not shown because too many files have changed in this diff Show More