bug修订

This commit is contained in:
chenxing
2026-09-02 08:40:18 +08:00
parent a937984f0b
commit 73e404633d
11 changed files with 290 additions and 37 deletions
@@ -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 访问,无需再改资源映射配置。<br>
* 访问路径:POST {Config.API_PATH}/base/UploadFile(multipart/form-data)。<br>
* 支持 type:image / file / icon / word(html 被 FileUtil 黑名单禁止上传)。<br>
* 返回 {@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<String> 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));
}
/**
@@ -20,6 +20,9 @@ public class UserPermissionUserVo {
/** 是否主归属:1=本职主学校/部门,0=兼职兼任。 */
private Integer isMain;
/** 该行对应的组ID(同一用户多组时每个组出一行,用于按组查看权限)。 */
private Integer groupId;
/** 用户所属部门/组织路径,如 /上海市仙霞高级中学/教师/行政。 */
private String groupPath;
}
@@ -30,8 +30,15 @@ public interface SysUserRoleRelRepository extends JpaRepository<SysUserRoleRel,
/** 用户在某学校下、某状态下的全部角色关联 */
List<SysUserRoleRel> findByUserIdAndSchoolIdAndStatus(Integer userId, Integer schoolId, StatusEnum status);
/** 删除用户在某学校下的全部身份行(按 schoolId 全量覆盖保存用) */
void deleteByUserIdAndSchoolId(Integer userId, Integer schoolId);
/**
* 删除用户在某学校下的全部身份行(按 schoolId 全量覆盖保存用)。<br>
* 必须 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
@@ -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<Integer, SysGroup> allGroupMap = buildAllGroupMap();
// 加载用户与组关联,找到每个用户的主归属组
Set<Integer> userIds = new LinkedHashSet<>();
for (SysUser user : users) {
userIds.add(user.getId());
}
// 批量解析主归属组(isMain=1 取 id 最小,无主兜底 id 最小),一次 IN 查询,消灭全表扫描
Map<Integer, SysUserGroupRel> mainRelMap = mainGroupResolver.resolveByUserIds(new ArrayList<>(userIds));
// 同一用户在多个组时每个组出一行(各自组路径/主兼职标记),
// 不再只解析主归属组——否则多组用户只能看到主组一条数据
Map<Integer, List<SysUserGroupRel>> relsByUser = sysUserGroupRelRepository
.findByUserIdIn(new ArrayList<>(userIds))
.stream()
.collect(Collectors.groupingBy(SysUserGroupRel::getUserId));
List<UserPermissionUserVo> 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<SysUserGroupRel> 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;
}
@@ -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()));
@@ -496,7 +496,7 @@ public class SysUserService implements ISysUserService {
}
/**
* 按页批量带出各用户主校身份名(动态解析主身份,无 N+1)。
* 按页批量带出各用户身份名(动态解析主身份,无 N+1)。
* roles 列已停写(冻结历史值),列表「用户身份」列一律切此新字段展示。
*/
private Page<SysUserVo> attachMainIdentityNames(Page<SysUserVo> page) {
@@ -508,15 +508,32 @@ public class SysUserService implements ISysUserService {
.map(SysUserVo::getId)
.collect(Collectors.toList());
Map<Integer, SysUserRoleRel> mainRelsByUser = resolveMainRoleRelsByUser(userIds);
// 全部正常态身份行按用户分组:列表列展示全部身份名(主身份在前),不只展示主身份
Map<Integer, List<SysUserRoleRel>> 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<SysUserRoleRel> 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;
@@ -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"));
}
}
+2 -2
View File
@@ -16,8 +16,8 @@
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>/app/logs/app.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>/app/logs/app.%d{yyyy-MM-dd}.log</fileNamePattern>
<maxHistory>30</maxHistory>
<fileNamePattern>/app/logs/app.%d{yyyy-MM-dd}.log.zip</fileNamePattern>
<maxHistory>60</maxHistory>
<totalSizeCap>5GB</totalSizeCap>
</rollingPolicy>
<encoder>
@@ -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;
@@ -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):<br>
* ① 开放接口日志关键字搜索:文本走 LIKE,纯数字走 appKey 数值等值——
* 修复前 cb.toString(callerAppKey) 渲染 CAST 表达式,MySQL 方言 SQL 报错;<br>
* ② 推送日志时间搜索:兼容 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));
}
}
@@ -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;
/**
* 身份写入回归(真库集成,事务回滚不留数据):<br>
* ① 新增教师+管理员双身份 → 两行都落库;<br>
* ② 编辑提交原身份集 → 不再因「派生 delete 入队 + saveAll 刷新时 INSERT 先于 DELETE」
* 撞 uk(user_id, role_id, school_id)(修复:bulk 删除立即执行);<br>
* ③ 编辑缩减身份 → 按 schoolId 全量覆盖;<br>
* ④ 权限一览用户搜索 → 同一用户多组每个组出一行。
*/
@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<Integer> 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<SysUserVo> 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<SysGroup> 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<UserPermissionUserVo> rows = groupAuthzService.searchUsers("regress_identity");
List<UserPermissionUserVo> 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<SysGroup> sysUserRepository_findTwoGroups() {
List<SysGroup> all = sysGroupRepository.findAll();
if (all.size() < 2) {
throw new IllegalStateException("库中组数据不足两个,无法覆盖多组场景");
}
return all.subList(0, 2);
}
}