package com.yunhai.userbase.controller.oapi; import com.yunhai.platform.appauth.AppAuthFilter; import com.yunhai.platform.apistd.ApiResponse; import com.yunhai.platform.utils.exception.DException; import com.yunhai.platform.utils.model.BoaGroup; import com.yunhai.platform.utils.model.BoaModule; import com.yunhai.platform.utils.model.BoaRoleInfo; 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.model.sysuser.BatchChangeStatusRequest; import com.yunhai.userbase.model.sysuser.SysUserChangeStatusRequest; import com.yunhai.userbase.model.sysuser.SysUserGroupDeleteItem; import com.yunhai.userbase.model.sysuser.SysUserGroupStateRequest; import com.yunhai.userbase.model.sysuser.SysUserMoveRequest; import com.yunhai.userbase.model.sysuser.SysUserPasswordRequest; import com.yunhai.userbase.model.sysuser.SysUserRequest; import com.yunhai.userbase.model.sysuser.SysUserTransferSchoolRequest; import com.yunhai.userbase.service.IApplicationInfoService; import com.yunhai.userbase.service.IGroupAuthzService; import com.yunhai.userbase.service.ISysUserService; import com.yunhai.userbase.service.impl.UserCenterService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; 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 org.springframework.web.multipart.MultipartFile; import javax.servlet.http.HttpServletRequest; import java.util.ArrayList; import java.util.Collections; import java.util.List; /** * 用户中心开放接口:供其他业务服务经 yh-app-auth 签名调用({@code /oapi/user/...})。
*
* 路径复用 {@link Config#OPEN_API_PATH} 统一前缀,与基础平台 {@code DefaultUserCenterClient} * 硬编码的契约严格一致: * {@code GET /oapi/user/byId?id=}、{@code /byLoginName?loginName=}、{@code /byPhone?phone=}、 * {@code /oapi/user/modules?userId=&schId=}。
* 接口本身不做验签——签名由 {@code OpenApiAuthEnforceConfiguration} 在过滤器层强制要求, * 未带 {@code X-App-Key/X-Timestamp/X-Signature} 的请求直接 401。 */ @RestController @RequestMapping(Config.OPEN_API_PATH + "user/") public class UserController { private static final Logger log = LoggerFactory.getLogger(UserController.class); @Autowired private UserCenterService userCenterService; @Autowired private ISysUserService sysUserService; @Autowired private IGroupAuthzService groupAuthzService; @Autowired private IApplicationInfoService applicationInfoService; @GetMapping("byId") public BoaUser byId(@RequestParam("id") Integer id) { return userCenterService.getUserInfoWithDeptById(id); } @GetMapping("byLoginName") public BoaUser byLoginName(@RequestParam("loginName") String loginName) { if (loginName == null || loginName.trim().isEmpty()) { return null; } return userCenterService.getUserInfoWithDeptByAccount(loginName); } @GetMapping("byPhone") public BoaUser byPhone(@RequestParam("phone") String phone) { if (phone == null || phone.trim().isEmpty()) { return null; } return userCenterService.getUserInfoWithDeptByPhone(phone); } /** * 查询用户在调用方应用下、指定学校(上下文)里的已授权模块列表。
*
* 与管理端「权限一览」返回全应用聚合不同,本方法严格按调用方应用维度过滤:
* ① 通过 {@link AppAuthFilter#APP_AUTH_ATTR} 拿到验签时的 appKey(RequireAppSignFilter * 已保证非空);
* ② 查 application_info 映射到具体 appId;
* ③ 调 Service 专用方法:所有 SQL/集合构建均限定在该 appId 范围内(数据库层面过滤)。
*
* 边界:调用方 appKey 查不到对应应用 → 直接返回空列表(安全优先,禁止意外返回其他应用的模块)。
* * @param request 用于取 AppAuthFilter 设置的 appKey * @param userId 用户ID(必填) * @param schId 学校ID/组ID(可选;用于限定「学校上下文」:非空时仅取该学校子树内的真实归属组参与判定, * 为空时取用户全部真实归属组,与权限一览一致) * @return 该应用下用户已授权模块(扁平化;无权限时返回空列表) */ @GetMapping("modules") public List modules(HttpServletRequest request, @RequestParam("userId") Integer userId, @RequestParam(value = "schId", required = false) Integer schId) { if (userId == null) { return Collections.emptyList(); } Integer appId = resolveCallerAppId(request); if (appId == null) { return Collections.emptyList(); } return groupAuthzService.findUserModulesForApp(appId, schId, userId); } /** * 从请求 attribute 中解析调用方 appKey,并映射为 application_info 的主键 id(appKeyToId 本地缓存,TTL 2h)。
* 映射失败返回 null(Controller 层统一返回空列表;异常只打 warn 日志,不给前端 500)。 */ private Integer resolveCallerAppId(HttpServletRequest request) { Object attr = request.getAttribute(AppAuthFilter.APP_AUTH_ATTR); if (attr == null) { log.warn("[oapi:user:modules] 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:user:modules] AppAuth attr is blank, uri={}", request.getRequestURI()); return null; } Integer appKey; try { appKey = Integer.parseInt(appKeyStr.trim()); } catch (NumberFormatException e) { log.warn("[oapi:user:modules] invalid appKey format={}, uri={}", appKeyStr, request.getRequestURI()); return null; } Integer appId = applicationInfoService.findIdByAppKey(appKey); if (appId == null) { log.warn("[oapi:user:modules] appKey={} not found in application_info, uri={}", appKey, request.getRequestURI()); return null; } return appId; } /** * 新增用户:走完整校验(账号唯一/手机号/身份证/密码强度),BCrypt 加密并写入组关联; * 返回创建后的用户(含 id 与组织链)。
* 鉴权由 OpenApiAuthEnforceConfiguration 在过滤器层强制签名;此处不记操作日志(避免明文密码写入 request_params)。 */ @PostMapping("add") public BoaUser add(@RequestBody @Validated(ValidationGroups.Create.class) SysUserRequest request) { Integer id = sysUserService.add(request); return userCenterService.getUserInfoWithDeptById(id); } /** * 编辑用户:密码为空时不修改密码;返回更新后的用户(含 id 与组织链)。 */ @PostMapping("edit/{id}") public BoaUser edit(@PathVariable Integer id, @RequestBody @Validated(ValidationGroups.Update.class) SysUserRequest request) { sysUserService.edit(id, request); return userCenterService.getUserInfoWithDeptById(id); } /** * 删除用户(按组解绑,语义同管理端「从组中删除用户」):仅解除该用户与指定组的关联;
* 仅当用户已无任何组关联时,才级联删除 sys_user 及其角色绑定。
* 这样多校用户只会被退出指定学校,而不会被整体误删。
* 新增 {@code schoolId} 边界:服务端强制 groupId 属于该校子树且不等于 schoolId,避免跨校/整校移除。 */ @PostMapping("delete/{id}/{groupId}/{schoolId}") public void delete(@PathVariable Integer id, @PathVariable Integer groupId, @PathVariable Integer schoolId) { sysUserService.deleteFromGroup(id, groupId, schoolId); } /** * 批量删除用户-组关系:请求体为 {@code SysUserGroupDeleteItem} 数组,每条携带自身 (userId, groupId, schoolId);
* 服务端逐条校验学校边界后解绑,语义与单条 delete 一致。 */ @PostMapping("deleteUsers") public void deleteUsers(@RequestBody List items) { sysUserService.deleteUsers(items); } /** * 批量新增用户:逐条走完整校验(含密码强度,每条需携带 password + confirmPassword); * 返回创建后的用户列表(顺序与入参一致,均含 id 与组织链)。 */ @PostMapping("batchAdd") public List batchAdd(@RequestBody List requests) { List ids = sysUserService.batchAdd(requests); List result = new ArrayList<>(); for (Integer id : ids) { result.add(userCenterService.getUserInfoWithDeptById(id)); } return result; } /** * 移动用户到指定组:仅允许在同一学校(组)范围内移动——用户当前必须已在该学校子树内, * 且目标组也必须在该学校子树内,否则拒绝(禁止跨校移动)。
* 语义同后台「调整组」——覆盖式把用户组归属替换为目标组(目标设为主组)。返回移动后的用户(含新的组织链)。 */ @PostMapping("moveToGroup") public BoaUser moveToGroup(@RequestBody @Validated SysUserMoveRequest request) { sysUserService.moveUserWithinSchool(request.getUserId(), request.getGroupId(), request.getSchoolId()); return userCenterService.getUserInfoWithDeptById(request.getUserId()); } /** * 查询用户的业务角色身份列表(角色 + 主归属学校组上下文)。
* 鉴权由 OpenApiAuthEnforceConfiguration 在过滤器层强制签名。 */ @GetMapping("roles") public List roles(@RequestParam("userId") Integer userId) { return sysUserService.findUserRoles(userId); } /** * 修改用户密码:先校验旧密码,再写入新密码(BCrypt)。
* 鉴权由 OpenApiAuthEnforceConfiguration 在过滤器层强制签名;此处不记操作日志(避免明文密码写入 request_params)。 */ @PostMapping("password") public void password(@RequestBody @Validated SysUserPasswordRequest request) { sysUserService.changePassword(request.getUserId(), request.getOldPassword(), request.getNewPassword()); } /** * 修改用户头像:校验图片(非空/≤20MB/扩展名白名单)→ 压缩 → 存储并写入 profile_picture,
* 返回新头像完整 Web URL。multipart 直传(form 字段 userId + 文件 part file)。
* 与修改密码一致,仅由过滤器层强制应用签名;POST 自动记 OapiLog(文件内容不落库,仅记文件名元数据)。
* 注意必须显式返回 {@link ApiResponse} 信封:若直接返回 String, * ApiResponseAdvice 会跳过包装(String 走 StringHttpMessageConverter 无法套壳), * 响应变成裸文本 URL,调用方(yh-openapi-client)按 JSON 解析会报 deserialize failed。 */ @PostMapping("profilePicture") public ApiResponse profilePicture(@RequestParam("userId") Integer userId, @RequestParam("file") MultipartFile file) { return ApiResponse.success(sysUserService.changeProfilePicture(userId, file)); } /** * 设置用户在指定组上的状态(转出/恢复)。
* state=1 转出、state=0 恢复正常;仅作关系标记,不影响成员查询、组织链与下游推送。 * 仅允许在同一学校(组)范围内操作——groupId 必须属于 schoolId 子树且不等于 schoolId。
* 鉴权由过滤器层强制签名;POST 自动记 OapiLog。 */ @PostMapping("groupState") public void groupState(@RequestBody @Validated SysUserGroupStateRequest request) { sysUserService.changeUserGroupState(request.getUserId(), request.getGroupId(), request.getSchoolId(), request.getState()); } /** * 查询用户在指定组上的状态:0=正常,1=转出。
* 边界与设置接口一致(groupId 必须属于 schoolId 子树且不等于 schoolId); * 用户不在此组时返回业务错误(code=5)。 */ @OapiLog(value = "查询用户组状态", saveResponse = false) @GetMapping("groupState") public Integer groupState(@RequestParam("userId") Integer userId, @RequestParam("groupId") Integer groupId, @RequestParam("schoolId") Integer schoolId) { return sysUserService.getUserGroupState(userId, groupId, schoolId); } /** * 跨校转入(转出后迁移到其他学校),同时支持同校转组(原学校与目标学校相同时,从原组转到另一组)。
* 前置:用户已在原学校(fromSchoolId 子树内)存在至少一条「转出」状态的组关系(先经 groupState 置转出);
* 原组即该转出状态组关系,仅当原学校与原组都与目标一致(fromSchoolId==schoolId 且转出组==groupId)才拒绝。
* 操作成功后:同校转组删除原组关系、新增目标组关系(主归属继承原组,其他组与他校关系保留); * 跨校转入删除原学校范围内全部组关系(其他学校保留),在目标学校建立目标组关系(主归属、状态恢复正常),并推送编辑用户订阅。
* 鉴权由过滤器层强制签名;POST 自动记 OapiLog。 */ @PostMapping("transferToSchool") public BoaUser transferToSchool(@RequestBody @Validated SysUserTransferSchoolRequest request) { sysUserService.transferUserToSchool(request.getUserId(), request.getFromSchoolId(), request.getSchoolId(), request.getGroupId()); return userCenterService.getUserInfoWithDeptById(request.getUserId()); } /** * 设置单个用户状态(启用/禁用账号,语义同管理端 sysuser/Enable、sysuser/Disable 的合并版)。
* disabled=true 禁用、false 启用;变更后自动推送「编辑用户」订阅。
* 鉴权由过滤器层强制签名;POST 自动记 OapiLog。 */ @PostMapping("changeStatus") public void changeStatus(@RequestBody @Validated SysUserChangeStatusRequest request) { sysUserService.changeStatus(request.getUserId(), request.getDisabled()); } /** * 批量设置用户状态(启用/禁用,语义同管理端 sysuser/BatchChangeStatus)。
* 任一 userId 无效则整体拒绝;变更后自动推送「编辑用户」订阅。
* 鉴权由过滤器层强制签名;POST 自动记 OapiLog。 */ @PostMapping("batchChangeStatus") public void batchChangeStatus(@RequestBody @Validated BatchChangeStatusRequest request) { sysUserService.batchChangeStatus(request.getUserIds(), request.getDisabled()); } }