Files
UserBaseService/src/main/java/com/yunhai/userbase/controller/oapi/UserController.java
T
chenxing 1047d2387f
ci-userbase-service / build-and-push (push) Successful in 2m20s
后端缓存管理
2026-09-07 09:33:10 +08:00

314 lines
16 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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/...})。<br>
* <br>
* 路径复用 {@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=}。<br>
* 接口本身不做验签——签名由 {@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);
}
/**
* 查询用户在<b>调用方应用</b>下、指定学校(上下文)里的已授权模块列表。<br>
* <br>
* 与管理端「权限一览」返回<b>全应用聚合</b>不同,本方法严格按调用方应用维度过滤:<br>
* ① 通过 {@link AppAuthFilter#APP_AUTH_ATTR} 拿到验签时的 appKey(RequireAppSignFilter
* 已保证非空);<br>
* ② 查 application_info 映射到具体 appId;<br>
* ③ 调 Service 专用方法:所有 SQL/集合构建均限定在该 appId 范围内(数据库层面过滤)。<br>
* <br>
* 边界:调用方 appKey 查不到对应应用 → 直接返回空列表(安全优先,禁止意外返回其他应用的模块)。<br>
*
* @param request 用于取 AppAuthFilter 设置的 appKey
* @param userId 用户ID(必填)
* @param schId 学校ID/组ID(可选;用于限定「学校上下文」:非空时仅取该学校子树内的真实归属组参与判定,
* 为空时取用户全部真实归属组,与权限一览一致)
* @return 该应用下用户已授权模块(扁平化;无权限时返回空列表)
*/
@GetMapping("modules")
public List<BoaModule> 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)。<br>
* 映射失败返回 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 与组织链)。<br>
* 鉴权由 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);
}
/**
* 删除用户(按组解绑,语义同管理端「从组中删除用户」):仅解除该用户与指定组的关联;<br>
* 仅当用户已无任何组关联时,才级联删除 sys_user 及其角色绑定。<br>
* 这样多校用户只会被退出指定学校,而不会被整体误删。<br>
* 新增 {@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);<br>
* 服务端逐条校验学校边界后解绑,语义与单条 delete 一致。
*/
@PostMapping("deleteUsers")
public void deleteUsers(@RequestBody List<SysUserGroupDeleteItem> items) {
sysUserService.deleteUsers(items);
}
/**
* 批量新增用户:逐条走完整校验(含密码强度,每条需携带 password + confirmPassword);
* 返回创建后的用户列表(顺序与入参一致,均含 id 与组织链)。
*/
@PostMapping("batchAdd")
public List<BoaUser> batchAdd(@RequestBody List<SysUserRequest> requests) {
List<Integer> ids = sysUserService.batchAdd(requests);
List<BoaUser> result = new ArrayList<>();
for (Integer id : ids) {
result.add(userCenterService.getUserInfoWithDeptById(id));
}
return result;
}
/**
* 移动用户到指定组:仅允许在同一学校(组)范围内移动——用户当前必须已在该学校子树内,
* 且目标组也必须在该学校子树内,否则拒绝(禁止跨校移动)。<br>
* 语义同后台「调整组」——覆盖式把用户组归属替换为目标组(目标设为主组)。返回移动后的用户(含新的组织链)。
*/
@PostMapping("moveToGroup")
public BoaUser moveToGroup(@RequestBody @Validated SysUserMoveRequest request) {
sysUserService.moveUserWithinSchool(request.getUserId(), request.getGroupId(), request.getSchoolId());
return userCenterService.getUserInfoWithDeptById(request.getUserId());
}
/**
* 查询用户的业务角色身份列表(角色 + 主归属学校组上下文)。<br>
* 鉴权由 OpenApiAuthEnforceConfiguration 在过滤器层强制签名。
*/
@GetMapping("roles")
public List<BoaRoleInfo> roles(@RequestParam("userId") Integer userId) {
return sysUserService.findUserRoles(userId);
}
/**
* 修改用户密码:先校验旧密码,再写入新密码(BCrypt)。<br>
* 鉴权由 OpenApiAuthEnforceConfiguration 在过滤器层强制签名;此处不记操作日志(避免明文密码写入 request_params)。
*/
@PostMapping("password")
public void password(@RequestBody @Validated SysUserPasswordRequest request) {
sysUserService.changePassword(request.getUserId(), request.getOldPassword(), request.getNewPassword());
}
/**
* 修改用户头像:校验图片(非空/≤20MB/扩展名白名单)→ 压缩 → 存储并写入 profile_picture,<br>
* 返回新头像完整 Web URL。multipart 直传(form 字段 userId + 文件 part file)。<br>
* 与修改密码一致,仅由过滤器层强制应用签名;POST 自动记 OapiLog(文件内容不落库,仅记文件名元数据)。<br>
* <b>注意必须显式返回 {@link ApiResponse} 信封</b>:若直接返回 String,
* ApiResponseAdvice 会跳过包装(String 走 StringHttpMessageConverter 无法套壳),
* 响应变成裸文本 URL,调用方(yh-openapi-client)按 JSON 解析会报 deserialize failed。
*/
@PostMapping("profilePicture")
public ApiResponse<String> profilePicture(@RequestParam("userId") Integer userId,
@RequestParam("file") MultipartFile file) {
return ApiResponse.success(sysUserService.changeProfilePicture(userId, file));
}
/**
* 设置用户在指定组上的状态(转出/恢复)。<br>
* state=1 转出、state=0 恢复正常;仅作关系标记,不影响成员查询、组织链与下游推送。
* 仅允许在同一学校(组)范围内操作——groupId 必须属于 schoolId 子树且不等于 schoolId。<br>
* 鉴权由过滤器层强制签名;POST 自动记 OapiLog。
*/
@PostMapping("groupState")
public void groupState(@RequestBody @Validated SysUserGroupStateRequest request) {
sysUserService.changeUserGroupState(request.getUserId(), request.getGroupId(),
request.getSchoolId(), request.getState());
}
/**
* 查询用户在指定组上的状态:0=正常,1=转出。<br>
* 边界与设置接口一致(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);
}
/**
* 跨校转入(转出后迁移到其他学校),同时支持同校转组(原学校与目标学校相同时,从原组转到另一组)。<br>
* 前置:用户已在原学校(fromSchoolId 子树内)存在至少一条「转出」状态的组关系(先经 groupState 置转出);<br>
* 原组即该转出状态组关系,仅当原学校与原组都与目标一致(fromSchoolId==schoolId 且转出组==groupId)才拒绝。<br>
* 操作成功后:同校转组删除原组关系、新增目标组关系(主归属继承原组,其他组与他校关系保留);
* 跨校转入删除原学校范围内全部组关系(其他学校保留),在目标学校建立目标组关系(主归属、状态恢复正常),并推送编辑用户订阅。<br>
* 鉴权由过滤器层强制签名;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 的合并版)。<br>
* disabled=true 禁用、false 启用;变更后自动推送「编辑用户」订阅。<br>
* 鉴权由过滤器层强制签名;POST 自动记 OapiLog。
*/
@PostMapping("changeStatus")
public void changeStatus(@RequestBody @Validated SysUserChangeStatusRequest request) {
sysUserService.changeStatus(request.getUserId(), request.getDisabled());
}
/**
* 批量设置用户状态(启用/禁用,语义同管理端 sysuser/BatchChangeStatus)。<br>
* 任一 userId 无效则整体拒绝;变更后自动推送「编辑用户」订阅。<br>
* 鉴权由过滤器层强制签名;POST 自动记 OapiLog。
*/
@PostMapping("batchChangeStatus")
public void batchChangeStatus(@RequestBody @Validated BatchChangeStatusRequest request) {
sysUserService.batchChangeStatus(request.getUserIds(), request.getDisabled());
}
}