diff --git a/src/main/java/com/yunhai/userbase/controller/api/BaseController.java b/src/main/java/com/yunhai/userbase/controller/api/BaseController.java index ee159c5..3cc1135 100644 --- a/src/main/java/com/yunhai/userbase/controller/api/BaseController.java +++ b/src/main/java/com/yunhai/userbase/controller/api/BaseController.java @@ -3,6 +3,7 @@ package com.yunhai.userbase.controller.api; import com.yunhai.platform.utils.model.BoaUser; import com.yunhai.platform.utils.enums.ClientEnum; import com.yunhai.platform.utils.enums.IEnum; +import com.yunhai.platform.apistd.ApiResponse; import com.yunhai.userbase.config.Config; import com.yunhai.userbase.enums.*; import com.yunhai.userbase.utils.*; @@ -104,19 +105,22 @@ public class BaseController { * 上传成功后可直接用返回的 URL 访问,无需再改资源映射配置。
* 访问路径:POST {Config.API_PATH}/base/UploadFile(multipart/form-data)。
* 支持 type:image / file / icon / word(html 被 FileUtil 黑名单禁止上传)。
+ * 返回 {@link ApiResponse}({code,msg,data},data=文件 URL): + * String 返回类型会走 StringHttpMessageConverter,被 ApiResponseAdvice 跳过包装, + * 前端 Upload 组件拿到裸字符串无法按统一结构取 data。 * * @param file 上传文件(必填,单文件上限 50MB,见 application.yml 的 multipart 配置) * @param type 存储子目录类型,默认 image * @return 文件对外可访问的 Web URL */ @PostMapping("UploadFile") - public String uploadFile(@RequestParam("file") MultipartFile file, - @RequestParam(value = "type", defaultValue = "image") String type) { + public ApiResponse uploadFile(@RequestParam("file") MultipartFile file, + @RequestParam(value = "type", defaultValue = "image") String type) { if (!ALLOWED_UPLOAD_TYPES.contains(type)) { throw new DException("不支持的上传类型: " + type + ",可选: image/file/icon/word"); } // FileUtil.upload 内部已处理空文件校验、危险后缀黑名单、目录创建与访问 URL 拼接 - return FileUtil.upload(file, type); + return ApiResponse.success(FileUtil.upload(file, type)); } /** diff --git a/src/main/java/com/yunhai/userbase/model/groupauthz/UserPermissionUserVo.java b/src/main/java/com/yunhai/userbase/model/groupauthz/UserPermissionUserVo.java index 4ec8fb0..b9a365e 100644 --- a/src/main/java/com/yunhai/userbase/model/groupauthz/UserPermissionUserVo.java +++ b/src/main/java/com/yunhai/userbase/model/groupauthz/UserPermissionUserVo.java @@ -20,6 +20,9 @@ public class UserPermissionUserVo { /** 是否主归属:1=本职主学校/部门,0=兼职兼任。 */ private Integer isMain; + /** 该行对应的组ID(同一用户多组时每个组出一行,用于按组查看权限)。 */ + private Integer groupId; + /** 用户所属部门/组织路径,如 /上海市仙霞高级中学/教师/行政。 */ private String groupPath; } diff --git a/src/main/java/com/yunhai/userbase/repository/SysUserRoleRelRepository.java b/src/main/java/com/yunhai/userbase/repository/SysUserRoleRelRepository.java index ca4bccf..7da8e27 100644 --- a/src/main/java/com/yunhai/userbase/repository/SysUserRoleRelRepository.java +++ b/src/main/java/com/yunhai/userbase/repository/SysUserRoleRelRepository.java @@ -30,8 +30,15 @@ public interface SysUserRoleRelRepository extends JpaRepository findByUserIdAndSchoolIdAndStatus(Integer userId, Integer schoolId, StatusEnum status); - /** 删除用户在某学校下的全部身份行(按 schoolId 全量覆盖保存用) */ - void deleteByUserIdAndSchoolId(Integer userId, Integer schoolId); + /** + * 删除用户在某学校下的全部身份行(按 schoolId 全量覆盖保存用)。
+ * 必须 bulk 立即发 SQL:派生 delete 只把删除动作入队,同一事务内随后 saveAll 的 INSERT + * 在刷新时会先于 DELETE 执行(Hibernate ActionQueue 顺序),与 uk(user_id, role_id, school_id) + * 撞唯一键(编辑提交原身份集即触发 Duplicate entry)。 + */ + @Modifying + @Query("DELETE FROM SysUserRoleRel r WHERE r.userId = :userId AND r.schoolId = :schoolId") + void deleteByUserIdAndSchoolId(@Param("userId") Integer userId, @Param("schoolId") Integer schoolId); /** 删除用户全部身份行(级联删用户时清理,bulk delete 直接发 SQL) */ @Modifying diff --git a/src/main/java/com/yunhai/userbase/service/impl/GroupAuthzService.java b/src/main/java/com/yunhai/userbase/service/impl/GroupAuthzService.java index ada0fd3..56b9792 100644 --- a/src/main/java/com/yunhai/userbase/service/impl/GroupAuthzService.java +++ b/src/main/java/com/yunhai/userbase/service/impl/GroupAuthzService.java @@ -53,6 +53,7 @@ import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Set; +import java.util.stream.Collectors; /** * @author chenx @@ -753,6 +754,7 @@ public class GroupAuthzService implements IGroupAuthzService { vo.setAccount(user.getAccount()); vo.setRealName(user.getRealName()); vo.setIsMain(rel.getIsMain()); + vo.setGroupId(rel.getGroupId()); vo.setGroupPath(groupPath); result.add(vo); } @@ -774,30 +776,43 @@ public class GroupAuthzService implements IGroupAuthzService { return Collections.emptyList(); } - // 加载所有组,用于构建主归属路径 + // 加载所有组,用于构建各组的组织路径 Map allGroupMap = buildAllGroupMap(); - // 加载用户与组关联,找到每个用户的主归属组 Set userIds = new LinkedHashSet<>(); for (SysUser user : users) { userIds.add(user.getId()); } - // 批量解析主归属组(isMain=1 取 id 最小,无主兜底 id 最小),一次 IN 查询,消灭全表扫描 - Map mainRelMap = mainGroupResolver.resolveByUserIds(new ArrayList<>(userIds)); + // 同一用户在多个组时每个组出一行(各自组路径/主兼职标记), + // 不再只解析主归属组——否则多组用户只能看到主组一条数据 + Map> relsByUser = sysUserGroupRelRepository + .findByUserIdIn(new ArrayList<>(userIds)) + .stream() + .collect(Collectors.groupingBy(SysUserGroupRel::getUserId)); List result = new ArrayList<>(); for (SysUser user : users) { - SysUserGroupRel mainRel = mainRelMap.get(user.getId()); - String path = ""; - if (mainRel != null && mainRel.getGroupId() != null) { - path = buildGroupPath(mainRel.getGroupId(), allGroupMap); + List userRels = relsByUser.get(user.getId()); + if (userRels == null || userRels.isEmpty()) { + // 无组关系的用户仍出一行(组路径空),保持可选中查看权限 + UserPermissionUserVo vo = new UserPermissionUserVo(); + vo.setId(user.getId()); + vo.setAccount(user.getAccount()); + vo.setRealName(user.getRealName()); + vo.setGroupPath(""); + result.add(vo); + continue; + } + for (SysUserGroupRel rel : userRels) { + UserPermissionUserVo vo = new UserPermissionUserVo(); + vo.setId(user.getId()); + vo.setAccount(user.getAccount()); + vo.setRealName(user.getRealName()); + vo.setIsMain(rel.getIsMain()); + vo.setGroupId(rel.getGroupId()); + vo.setGroupPath(rel.getGroupId() != null + ? buildGroupPath(rel.getGroupId(), allGroupMap) : ""); + result.add(vo); } - UserPermissionUserVo vo = new UserPermissionUserVo(); - vo.setId(user.getId()); - vo.setAccount(user.getAccount()); - vo.setRealName(user.getRealName()); - vo.setIsMain(mainRel != null ? mainRel.getIsMain() : null); - vo.setGroupPath(path); - result.add(vo); } return result; } diff --git a/src/main/java/com/yunhai/userbase/service/impl/SysOapiLogServiceImpl.java b/src/main/java/com/yunhai/userbase/service/impl/SysOapiLogServiceImpl.java index e6327c7..a70ac55 100644 --- a/src/main/java/com/yunhai/userbase/service/impl/SysOapiLogServiceImpl.java +++ b/src/main/java/com/yunhai/userbase/service/impl/SysOapiLogServiceImpl.java @@ -168,12 +168,18 @@ public class SysOapiLogServiceImpl implements ISysOapiLogService { } } if (StringUtils.isNotBlank(query.getKeyword())) { - String kw = "%" + query.getKeyword().trim() + "%"; - // 单输入框同时匹配:请求路径(requestUri) / 应用名(callerAppName) / appKey(callerAppKey) - predicates.add(cb.or( + String trimmed = query.getKeyword().trim(); + String kw = "%" + trimmed + "%"; + // 单输入框同时匹配:请求路径(requestUri) / 应用名(callerAppName); + // 纯数字再按 appKey 数值等值匹配(cb.toString 会渲染成 CAST 表达式,MySQL 方言下 SQL 报错) + Predicate textMatch = cb.or( cb.like(root.get("requestUri"), kw), - cb.like(root.get("callerAppName"), kw), - cb.like(cb.toString(root.get("callerAppKey")), kw))); + cb.like(root.get("callerAppName"), kw)); + if (trimmed.matches("\\d+")) { + predicates.add(cb.or(textMatch, cb.equal(root.get("callerAppKey"), Integer.valueOf(trimmed)))); + } else { + predicates.add(textMatch); + } } if (query.getStartTime() != null) { predicates.add(cb.greaterThanOrEqualTo(root.get("createTime"), query.getStartTime())); diff --git a/src/main/java/com/yunhai/userbase/service/impl/SysUserService.java b/src/main/java/com/yunhai/userbase/service/impl/SysUserService.java index 5d7178f..67f7a9d 100644 --- a/src/main/java/com/yunhai/userbase/service/impl/SysUserService.java +++ b/src/main/java/com/yunhai/userbase/service/impl/SysUserService.java @@ -496,7 +496,7 @@ public class SysUserService implements ISysUserService { } /** - * 按页批量带出各用户主校身份名(动态解析主身份,无 N+1)。 + * 按页批量带出各用户身份名(动态解析主身份,无 N+1)。 * roles 列已停写(冻结历史值),列表「用户身份」列一律切此新字段展示。 */ private Page attachMainIdentityNames(Page page) { @@ -508,15 +508,32 @@ public class SysUserService implements ISysUserService { .map(SysUserVo::getId) .collect(Collectors.toList()); Map mainRelsByUser = resolveMainRoleRelsByUser(userIds); + // 全部正常态身份行按用户分组:列表列展示全部身份名(主身份在前),不只展示主身份 + Map> roleRelsByUser = sysUserRoleRelRepository + .findByUserIdInAndStatus(userIds, StatusEnum.NORMAL) + .stream() + .collect(Collectors.groupingBy(SysUserRoleRel::getUserId)); for (SysUserVo vo : content) { - SysUserRoleRel mainRel = mainRelsByUser.get(vo.getId()); - if (mainRel == null) { + List rels = roleRelsByUser.get(vo.getId()); + if (rels == null || rels.isEmpty()) { continue; } - SysRoleEnum e = IEnum.getByKey(SysRoleEnum.class, mainRel.getRoleId()); - String name = e != null ? e.getValue() : null; - if (name != null) { - vo.setIdentityNames(name); + SysUserRoleRel mainRel = mainRelsByUser.get(vo.getId()); + String names = rels.stream() + .sorted(Comparator.comparingInt((SysUserRoleRel r) -> + mainRel != null && Objects.equals(r.getId(), mainRel.getId()) ? 0 : 1) + .thenComparingInt(SysUserRoleRel::getSortNo) + .thenComparingInt(SysUserRoleRel::getId)) + .map(SysUserRoleRel::getRoleId) + .distinct() + .map(roleId -> { + SysRoleEnum e = IEnum.getByKey(SysRoleEnum.class, roleId); + return e != null ? e.getValue() : null; + }) + .filter(Objects::nonNull) + .collect(Collectors.joining("、")); + if (!names.isEmpty()) { + vo.setIdentityNames(names); } } return page; diff --git a/src/main/java/com/yunhai/userbase/service/push/PushRecordService.java b/src/main/java/com/yunhai/userbase/service/push/PushRecordService.java index c54ce9e..0c58931 100644 --- a/src/main/java/com/yunhai/userbase/service/push/PushRecordService.java +++ b/src/main/java/com/yunhai/userbase/service/push/PushRecordService.java @@ -27,7 +27,9 @@ import javax.persistence.criteria.CriteriaQuery; import javax.persistence.criteria.Order; import javax.persistence.criteria.Predicate; import javax.persistence.criteria.Root; +import java.time.LocalDate; import java.time.LocalDateTime; +import java.time.LocalTime; import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.List; @@ -164,10 +166,23 @@ public class PushRecordService { } private LocalDateTime parseStart(String s) { - return LocalDateTime.parse(s + "T00:00:00", DateTimeFormatter.ISO_LOCAL_DATE_TIME); + return parseTime(s, LocalTime.MIN); } private LocalDateTime parseEnd(String s) { - return LocalDateTime.parse(s + "T23:59:59", DateTimeFormatter.ISO_LOCAL_DATE_TIME); + return parseTime(s, LocalTime.of(23, 59, 59)); + } + + /** + * 兼容两种入参:yyyy-MM-dd(按日界补 00:00:00 / 23:59:59)与 + * yyyy-MM-dd HH:mm:ss(前端日期选择器带时刻的完整格式,原样解析)。 + * 前者按旧约定拼接解析,后者若仍走拼接会产生 "… HH:mm:ssT00:00:00" 解析异常。 + */ + private LocalDateTime parseTime(String s, LocalTime dayEdge) { + String v = s == null ? "" : s.trim(); + if (v.length() == 10) { + return LocalDate.parse(v, DateTimeFormatter.ISO_LOCAL_DATE).atTime(dayEdge); + } + return LocalDateTime.parse(v, DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")); } } diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml index 20612de..91c0f5f 100644 --- a/src/main/resources/logback-spring.xml +++ b/src/main/resources/logback-spring.xml @@ -16,8 +16,8 @@ /app/logs/app.log - /app/logs/app.%d{yyyy-MM-dd}.log - 30 + /app/logs/app.%d{yyyy-MM-dd}.log.zip + 60 5GB diff --git a/src/test/java/com/yunhai/userbase/UserBaseApplicationTests.java b/src/test/java/com/yunhai/userbase/UserBaseApplicationTests.java index 7683091..62922a2 100644 --- a/src/test/java/com/yunhai/userbase/UserBaseApplicationTests.java +++ b/src/test/java/com/yunhai/userbase/UserBaseApplicationTests.java @@ -1,4 +1,4 @@ -package com.yunhai.userprofile; +package com.yunhai.userbase; import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; diff --git a/src/test/java/com/yunhai/userbase/service/impl/LogSearchRegressionTest.java b/src/test/java/com/yunhai/userbase/service/impl/LogSearchRegressionTest.java new file mode 100644 index 0000000..f9672fc --- /dev/null +++ b/src/test/java/com/yunhai/userbase/service/impl/LogSearchRegressionTest.java @@ -0,0 +1,51 @@ +package com.yunhai.userbase.service.impl; + +import com.yunhai.userbase.model.BasePageQuery; +import com.yunhai.userbase.model.oapilog.SysOapiLogQuery; +import com.yunhai.userbase.model.push.PushRecordQuery; +import com.yunhai.userbase.service.ISysOapiLogService; +import com.yunhai.userbase.service.push.PushRecordService; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.domain.PageRequest; + +/** + * 日志搜索回归(真库执行 SQL,验证谓词语法与时间解析;空表也走完整 SQL):
+ * ① 开放接口日志关键字搜索:文本走 LIKE,纯数字走 appKey 数值等值—— + * 修复前 cb.toString(callerAppKey) 渲染 CAST 表达式,MySQL 方言 SQL 报错;
+ * ② 推送日志时间搜索:兼容 yyyy-MM-dd 与 yyyy-MM-dd HH:mm:ss—— + * 修复前端传带时刻格式时按旧约定拼接解析抛 DateTimeParseException。 + */ +@SpringBootTest +class LogSearchRegressionTest { + + @Autowired + private ISysOapiLogService sysOapiLogService; + @Autowired + private PushRecordService pushRecordService; + + @Test + void oapiLog_keywordSearch_textAndNumeric() { + BasePageQuery page = new BasePageQuery(); + SysOapiLogQuery text = new SysOapiLogQuery(); + text.setKeyword("user"); + sysOapiLogService.pageQuery(text, page); + + SysOapiLogQuery numeric = new SysOapiLogQuery(); + numeric.setKeyword("100001"); + sysOapiLogService.pageQuery(numeric, page); + } + + @Test + void pushLog_timeSearch_bothFormats() { + PushRecordQuery q = new PushRecordQuery(); + q.setStartTime("2026-09-01 00:00:00"); + q.setEndTime("2026-09-01 23:59:59"); + pushRecordService.query(q, PageRequest.of(0, 10)); + + q.setStartTime("2026-09-01"); + q.setEndTime("2026-09-01"); + pushRecordService.query(q, PageRequest.of(0, 10)); + } +} diff --git a/src/test/java/com/yunhai/userbase/service/impl/SysUserIdentityRegressionTest.java b/src/test/java/com/yunhai/userbase/service/impl/SysUserIdentityRegressionTest.java new file mode 100644 index 0000000..6b5c63d --- /dev/null +++ b/src/test/java/com/yunhai/userbase/service/impl/SysUserIdentityRegressionTest.java @@ -0,0 +1,135 @@ +package com.yunhai.userbase.service.impl; + +import com.yunhai.userbase.entity.SysGroup; +import com.yunhai.userbase.entity.SysUserRoleRel; +import com.yunhai.userbase.enums.StatusEnum; +import com.yunhai.userbase.model.groupauthz.UserPermissionUserVo; +import com.yunhai.userbase.model.sysuser.SysUserQuery; +import com.yunhai.userbase.model.sysuser.SysUserRequest; +import com.yunhai.userbase.model.sysuser.SysUserVo; +import com.yunhai.userbase.repository.SysGroupRepository; +import com.yunhai.userbase.repository.SysUserRoleRelRepository; +import com.yunhai.userbase.service.IGroupAuthzService; +import com.yunhai.userbase.service.ISysUserService; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +/** + * 身份写入回归(真库集成,事务回滚不留数据):
+ * ① 新增教师+管理员双身份 → 两行都落库;
+ * ② 编辑提交原身份集 → 不再因「派生 delete 入队 + saveAll 刷新时 INSERT 先于 DELETE」 + * 撞 uk(user_id, role_id, school_id)(修复:bulk 删除立即执行);
+ * ③ 编辑缩减身份 → 按 schoolId 全量覆盖;
+ * ④ 权限一览用户搜索 → 同一用户多组每个组出一行。 + */ +@SpringBootTest +@Transactional +class SysUserIdentityRegressionTest { + + @Autowired + private ISysUserService sysUserService; + @Autowired + private IGroupAuthzService groupAuthzService; + @Autowired + private SysGroupRepository sysGroupRepository; + @Autowired + private SysUserRoleRelRepository sysUserRoleRelRepository; + + private SysUserRequest buildAddRequest(Integer groupId) { + SysUserRequest add = new SysUserRequest(); + add.setAccount("regress_identity"); + add.setPassword("Rk8pb2mW!"); + add.setConfirmPassword("Rk8pb2mW!"); + add.setRealName("回归用户"); + add.setGender(0); + add.setGroupId(groupId); + add.setRoleIds(Arrays.asList(1, 7)); // 教师 + 管理员 + return add; + } + + private List roleIdsOf(Integer userId) { + return sysUserRoleRelRepository.findByUserIdAndStatus(userId, StatusEnum.NORMAL) + .stream().map(SysUserRoleRel::getRoleId).sorted().collect(Collectors.toList()); + } + + @Test + void addThenEdit_identities() { + SysGroup group = sysGroupRepository.findAll().stream().findFirst() + .orElseThrow(() -> new IllegalStateException("库中无组数据")); + + Integer userId = sysUserService.add(buildAddRequest(group.getId())); + assertEquals(Arrays.asList(1, 7), roleIdsOf(userId), "新增教师+管理员双身份应两行都落库"); + + // 列表「用户身份」列应展示全部身份名(不只主身份),多身份按「、」连接 + SysUserQuery query = new SysUserQuery(); + query.setGroupId(group.getId()); + Page page = sysUserService.list(query, PageRequest.of(0, 10)); + SysUserVo mine = page.getContent().stream() + .filter(v -> userId.equals(v.getId())).findFirst().orElse(null); + assertNotNull(mine, "组列表应包含新建用户"); + assertEquals("老师、管理员", mine.getIdentityNames(), "用户身份列应展示全部身份名"); + + Integer schoolId = sysUserRoleRelRepository.findByUserId(userId).stream() + .findFirst().map(SysUserRoleRel::getSchoolId).orElse(null); + assertNotNull(schoolId, "新增后身份行应带 schoolId"); + + // 编辑①:身份不变重复提交(前端编辑总是携带原值)——修复前此处 Duplicate entry 撞 uk + SysUserRequest keep = new SysUserRequest(); + keep.setId(userId); + keep.setAccount("regress_identity"); + keep.setRealName("回归用户改"); + keep.setGender(0); + keep.setSchoolId(schoolId); + keep.setRoleIds(Arrays.asList(1, 7)); + sysUserService.edit(userId, keep); + assertEquals(Arrays.asList(1, 7), roleIdsOf(userId), "编辑提交原身份集应原样保留"); + + // 编辑②:清掉管理员,仅剩教师——按 schoolId 全量覆盖 + SysUserRequest shrink = new SysUserRequest(); + shrink.setId(userId); + shrink.setAccount("regress_identity"); + shrink.setRealName("回归用户改"); + shrink.setGender(0); + shrink.setSchoolId(schoolId); + shrink.setRoleIds(Collections.singletonList(1)); + sysUserService.edit(userId, shrink); + assertEquals(Collections.singletonList(1), roleIdsOf(userId), "编辑缩减身份应按 schoolId 全量覆盖"); + } + + @Test + void searchUsers_multiGroup_shouldReturnRowPerGroup() { + List groups = sysUserRepository_findTwoGroups(); + Integer userId = sysUserService.add(buildAddRequest(groups.get(0).getId())); + // 调整到两个组(第二个组为主组) + sysUserService.changeGroups(Collections.singletonList(userId), + Arrays.asList(groups.get(0).getId(), groups.get(1).getId()), groups.get(1).getId()); + + List rows = groupAuthzService.searchUsers("regress_identity"); + List mine = rows.stream() + .filter(v -> userId.equals(v.getId())).collect(Collectors.toList()); + assertEquals(2, mine.size(), "同一用户在两个组应出两行"); + assertEquals(1, mine.stream().filter(v -> Integer.valueOf(1).equals(v.getIsMain())).count(), + "两行中应恰有一行主归属"); + mine.forEach(v -> assertNotNull(v.getGroupId(), "每行应带 groupId(按组查看权限)")); + } + + private List sysUserRepository_findTwoGroups() { + List all = sysGroupRepository.findAll(); + if (all.size() < 2) { + throw new IllegalStateException("库中组数据不足两个,无法覆盖多组场景"); + } + return all.subList(0, 2); + } +}