feat(release): close handler and microphone gaps

This commit is contained in:
key
2026-07-30 12:01:45 +08:00
parent 8934f13811
commit c16a3e83d7
30 changed files with 830 additions and 49 deletions
@@ -8,6 +8,10 @@ import org.dromara.aihr.direct.AihrDirectDto.AdminFeedbackPage;
import org.dromara.aihr.direct.AihrDirectDto.ChannelItem;
import org.dromara.aihr.direct.AihrDirectDto.FeedbackItem;
import org.dromara.aihr.direct.AihrDirectDto.FeedbackPage;
import org.dromara.aihr.direct.AihrDirectDto.HandlerAssignRequest;
import org.dromara.aihr.direct.AihrDirectDto.HandlerConfigResponse;
import org.dromara.aihr.direct.AihrDirectDto.HandlerRemoveRequest;
import org.dromara.aihr.direct.AihrDirectDto.HandlerUserItem;
import org.dromara.aihr.direct.AihrDirectDto.ReplyRequest;
import org.dromara.aihr.direct.AihrDirectDto.SubmitRequest;
import org.dromara.common.core.domain.R;
@@ -66,4 +70,33 @@ public class AihrDirectController {
public R<AdminFeedbackItem> reply(@PathVariable Long id, @Valid @RequestBody ReplyRequest request) {
return R.ok(directService.reply(id, request));
}
@GetMapping("/admin/handlers")
public R<HandlerConfigResponse> handlers() {
return R.ok(directService.handlers());
}
@GetMapping("/admin/handlers/{channelCode}/candidates")
public R<List<HandlerUserItem>> handlerCandidates(
@PathVariable String channelCode,
@RequestParam(required = false) String keyword
) {
return R.ok(directService.handlerCandidates(channelCode, keyword));
}
@PostMapping("/admin/handlers/{channelCode}")
public R<HandlerConfigResponse> assignHandlers(
@PathVariable String channelCode,
@Valid @RequestBody HandlerAssignRequest request
) {
return R.ok(directService.assignHandlers(channelCode, request));
}
@PostMapping("/admin/handlers/{channelCode}/remove")
public R<HandlerConfigResponse> removeHandler(
@PathVariable String channelCode,
@Valid @RequestBody HandlerRemoveRequest request
) {
return R.ok(directService.removeHandler(channelCode, request));
}
}
@@ -1,6 +1,8 @@
package org.dromara.aihr.direct;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
@@ -82,4 +84,46 @@ public final class AihrDirectDto {
List<AdminFeedbackItem> rows
) {
}
public record HandlerUserItem(
Long userId,
String userName,
String nickName
) {
}
public record HandlerChannelItem(
String channelCode,
String channelName,
String roleKey,
boolean roleAvailable,
List<HandlerUserItem> users
) {
}
public record HandlerConfigResponse(
String tenantId,
int minimumHandlers,
List<HandlerChannelItem> channels
) {
}
public record HandlerAssignRequest(
@NotEmpty(message = "请选择处理人")
@Size(max = 20, message = "一次最多添加 20 名处理人")
List<@NotNull(message = "处理人编号不能为空") Long> userIds,
@NotBlank(message = "当前租户不能为空")
@Size(max = 64, message = "当前租户不能超过 64 个字符")
String expectedTenantId
) {
}
public record HandlerRemoveRequest(
@NotNull(message = "处理人编号不能为空")
Long userId,
@NotBlank(message = "当前租户不能为空")
@Size(max = 64, message = "当前租户不能超过 64 个字符")
String expectedTenantId
) {
}
}
@@ -6,6 +6,11 @@ import org.dromara.aihr.direct.AihrDirectDto.AdminFeedbackPage;
import org.dromara.aihr.direct.AihrDirectDto.ChannelItem;
import org.dromara.aihr.direct.AihrDirectDto.FeedbackItem;
import org.dromara.aihr.direct.AihrDirectDto.FeedbackPage;
import org.dromara.aihr.direct.AihrDirectDto.HandlerAssignRequest;
import org.dromara.aihr.direct.AihrDirectDto.HandlerChannelItem;
import org.dromara.aihr.direct.AihrDirectDto.HandlerConfigResponse;
import org.dromara.aihr.direct.AihrDirectDto.HandlerRemoveRequest;
import org.dromara.aihr.direct.AihrDirectDto.HandlerUserItem;
import org.dromara.aihr.direct.AihrDirectDto.ReplyRequest;
import org.dromara.aihr.direct.AihrDirectDto.SubmitRequest;
import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal;
@@ -44,6 +49,8 @@ public class AihrDirectService {
private static final int DEFAULT_PAGE_SIZE = 20;
private static final int MAX_PAGE_SIZE = 100;
private static final int MINIMUM_HANDLERS = 2;
private static final int MAX_HANDLER_CANDIDATES = 50;
private static final Pattern REQUEST_ID_PATTERN = Pattern.compile("[A-Za-z0-9._:-]+");
private static final List<DirectChannel> CHANNELS = List.of(
new DirectChannel("PRESIDENT", "总裁直达", "direct_president", true),
@@ -206,6 +213,86 @@ public class AihrDirectService {
return adminDetail(tenantId, feedbackId, channelCodes);
}
public HandlerConfigResponse handlers() {
AihrKnowledgePrincipal principal = principalResolver.current();
requireHandlerAdmin(principal);
return handlerConfig(adminTenant(principal));
}
public List<HandlerUserItem> handlerCandidates(String rawChannelCode, String rawKeyword) {
AihrKnowledgePrincipal principal = principalResolver.current();
requireHandlerAdmin(principal);
String tenantId = adminTenant(principal);
DirectRole role = directRole(tenantId, channel(rawChannelCode));
String keyword = clean(rawKeyword);
String like = "%" + keyword + "%";
return jdbcTemplate.query("""
select u.user_id, u.user_name, u.nick_name
from sys_user u
where binary u.tenant_id = binary ?
and u.user_type <> ?
and u.status = '0' and u.del_flag = '0'
and not exists (
select 1
from sys_user_role assigned
where assigned.user_id = u.user_id and assigned.role_id = ?
)
and not exists (
select 1
from sys_user_role elevated
join sys_role elevated_role on elevated_role.role_id = elevated.role_id
where elevated.user_id = u.user_id
and binary elevated_role.tenant_id = binary u.tenant_id
and elevated_role.role_key = ?
and elevated_role.status = '0' and elevated_role.del_flag = '0'
)
and (? = '' or u.user_name like ? or u.nick_name like ?)
order by u.nick_name, u.user_name, u.user_id
limit ?
""", (rs, rowNum) -> new HandlerUserItem(
rs.getLong("user_id"), rs.getString("user_name"), rs.getString("nick_name")
), tenantId, UserType.APP_USER.getUserType(), role.roleId(),
TenantConstants.SUPER_ADMIN_ROLE_KEY, keyword, like, like, MAX_HANDLER_CANDIDATES);
}
@Transactional
public HandlerConfigResponse assignHandlers(String rawChannelCode, HandlerAssignRequest request) {
AihrKnowledgePrincipal principal = principalResolver.current();
requireHandlerAdmin(principal);
String tenantId = adminTenant(principal);
requireExpectedTenant(request.expectedTenantId(), tenantId);
DirectRole role = directRole(tenantId, channel(rawChannelCode));
List<Long> userIds = request.userIds().stream().distinct().toList();
for (Long userId : userIds) {
requireEligibleHandler(tenantId, userId);
jdbcTemplate.update("""
insert into sys_user_role (user_id, role_id)
select ?, ?
where not exists (
select 1 from sys_user_role where user_id = ? and role_id = ?
)
""", userId, role.roleId(), userId, role.roleId());
}
return handlerConfig(tenantId);
}
@Transactional
public HandlerConfigResponse removeHandler(String rawChannelCode, HandlerRemoveRequest request) {
AihrKnowledgePrincipal principal = principalResolver.current();
requireHandlerAdmin(principal);
String tenantId = adminTenant(principal);
requireExpectedTenant(request.expectedTenantId(), tenantId);
DirectRole role = directRole(tenantId, channel(rawChannelCode));
jdbcTemplate.update("""
delete assignment
from sys_user_role assignment
join sys_user u on u.user_id = assignment.user_id
where assignment.role_id = ? and assignment.user_id = ?
and binary u.tenant_id = binary ?
""", role.roleId(), request.userId(), tenantId);
return handlerConfig(tenantId);
}
private FeedbackItem replay(AihrKnowledgePrincipal principal, String requestKey, String requestHash) {
List<Replay> rows = jdbcTemplate.query("""
select id, submit_request_hash
@@ -297,6 +384,92 @@ public class AihrDirectService {
return allowed;
}
private HandlerConfigResponse handlerConfig(String tenantId) {
List<HandlerChannelItem> items = CHANNELS.stream().map(channel -> {
DirectRole role = optionalDirectRole(tenantId, channel);
if (role == null) {
return new HandlerChannelItem(channel.code(), channel.name(), channel.role(), false, List.of());
}
List<HandlerUserItem> users = jdbcTemplate.query("""
select u.user_id, u.user_name, u.nick_name
from sys_user_role assignment
join sys_user u on u.user_id = assignment.user_id
where assignment.role_id = ?
and binary u.tenant_id = binary ?
and u.user_type <> ?
and u.status = '0' and u.del_flag = '0'
and not exists (
select 1
from sys_user_role elevated
join sys_role elevated_role on elevated_role.role_id = elevated.role_id
where elevated.user_id = u.user_id
and binary elevated_role.tenant_id = binary u.tenant_id
and elevated_role.role_key = ?
and elevated_role.status = '0' and elevated_role.del_flag = '0'
)
order by u.nick_name, u.user_name, u.user_id
""", (rs, rowNum) -> new HandlerUserItem(
rs.getLong("user_id"), rs.getString("user_name"), rs.getString("nick_name")
), role.roleId(), tenantId, UserType.APP_USER.getUserType(), TenantConstants.SUPER_ADMIN_ROLE_KEY);
return new HandlerChannelItem(channel.code(), channel.name(), channel.role(), true, users);
}).toList();
return new HandlerConfigResponse(tenantId, MINIMUM_HANDLERS, items);
}
private DirectRole directRole(String tenantId, DirectChannel channel) {
DirectRole role = optionalDirectRole(tenantId, channel);
if (role == null) {
throw new ServiceException(channel.name() + "处理角色不存在或已停用", HttpStatus.CONFLICT);
}
return role;
}
private DirectRole optionalDirectRole(String tenantId, DirectChannel channel) {
List<DirectRole> roles = jdbcTemplate.query("""
select role_id, role_key
from sys_role
where binary tenant_id = binary ? and role_key = ?
and status = '0' and del_flag = '0'
order by role_id
limit 1
""", (rs, rowNum) -> new DirectRole(rs.getLong("role_id"), rs.getString("role_key")),
tenantId, channel.role());
return roles.isEmpty() ? null : roles.get(0);
}
private void requireEligibleHandler(String tenantId, Long userId) {
if (userId == null || userId < 1) {
throw new ServiceException("处理人编号无效", HttpStatus.BAD_REQUEST);
}
Integer count = jdbcTemplate.queryForObject("""
select count(*)
from sys_user u
where binary u.tenant_id = binary ? and u.user_id = ?
and u.user_type <> ?
and u.status = '0' and u.del_flag = '0'
and not exists (
select 1
from sys_user_role elevated
join sys_role elevated_role on elevated_role.role_id = elevated.role_id
where elevated.user_id = u.user_id
and binary elevated_role.tenant_id = binary u.tenant_id
and elevated_role.role_key = ?
and elevated_role.status = '0' and elevated_role.del_flag = '0'
)
""", Integer.class, tenantId, userId, UserType.APP_USER.getUserType(),
TenantConstants.SUPER_ADMIN_ROLE_KEY);
if (count == null || count != 1) {
throw new ServiceException("只能选择当前租户启用的非超级管理员后台用户", HttpStatus.BAD_REQUEST);
}
}
private static void requireHandlerAdmin(AihrKnowledgePrincipal principal) {
if (UserType.APP_USER.getUserType().equals(principal.userType())
|| !principal.roles().contains(TenantConstants.SUPER_ADMIN_ROLE_KEY)) {
throw new ServiceException("仅超级管理员可配置直达处理人", HttpStatus.FORBIDDEN);
}
}
private static Query adminQuery(String tenantId, List<String> channelCodes, String status) {
String placeholders = String.join(",", Collections.nCopies(channelCodes.size(), "?"));
String statusFilter = status == null ? "" : " and status = ?";
@@ -410,6 +583,10 @@ public class AihrDirectService {
return normalized;
}
private static String clean(String value) {
return value == null ? "" : value.trim();
}
private static String adminTenant(AihrKnowledgePrincipal principal) {
String tenantId = TenantHelper.getTenantId();
return tenantId == null || tenantId.isBlank() ? principal.tenantId() : tenantId.trim();
@@ -433,6 +610,9 @@ public class AihrDirectService {
private record DirectChannel(String code, String name, String role, boolean defaultAnonymous) {
}
private record DirectRole(long roleId, String roleKey) {
}
private record Replay(long id, String requestHash) {
}
@@ -2,6 +2,7 @@ package org.dromara.aihr.direct;
import cn.dev33.satoken.annotation.SaCheckLogin;
import org.dromara.aihr.direct.AihrDirectDto.AdminFeedbackItem;
import org.dromara.aihr.direct.AihrDirectDto.HandlerUserItem;
import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal;
import org.dromara.common.core.constant.HttpStatus;
import org.dromara.common.core.constant.TenantConstants;
@@ -62,6 +63,12 @@ class AihrDirectSecurityContractTest {
assertTrue(AihrDirectController.class.isAnnotationPresent(SaCheckLogin.class));
assertTrue(AihrDirectService.class.getMethod("reply", Long.class, AihrDirectDto.ReplyRequest.class)
.isAnnotationPresent(Transactional.class));
assertTrue(AihrDirectService.class.getMethod(
"assignHandlers", String.class, AihrDirectDto.HandlerAssignRequest.class)
.isAnnotationPresent(Transactional.class));
assertTrue(AihrDirectService.class.getMethod(
"removeHandler", String.class, AihrDirectDto.HandlerRemoveRequest.class)
.isAnnotationPresent(Transactional.class));
}
@Test
@@ -73,6 +80,29 @@ class AihrDirectSecurityContractTest {
assertFalse(source.contains("o.ext_party_id = binary u.user_name"));
}
@Test
void handlerConfigurationRejectsNonSuperadminsAndNeverReturnsPhoneNumbers() throws Exception {
Method guard = AihrDirectService.class.getDeclaredMethod(
"requireHandlerAdmin", AihrKnowledgePrincipal.class);
guard.setAccessible(true);
InvocationTargetException handler = assertThrows(InvocationTargetException.class,
() -> guard.invoke(null, principal("sys_user", Set.of("direct_audit"))));
assertEquals(HttpStatus.FORBIDDEN, ((ServiceException) handler.getCause()).getCode());
List<String> fields = List.of(HandlerUserItem.class.getRecordComponents()).stream()
.map(component -> component.getName().toLowerCase())
.toList();
assertFalse(fields.contains("phone"));
assertFalse(fields.contains("phonenumber"));
String source = Files.readString(Path.of(
"src/main/java/org/dromara/aihr/direct/AihrDirectService.java"));
assertTrue(source.contains("u.user_type <> ?"));
assertTrue(source.contains("elevated_role.role_key = ?"));
assertTrue(source.contains("只能选择当前租户启用的非超级管理员后台用户"));
}
private static List<?> allowedChannels(AihrKnowledgePrincipal principal) throws Exception {
Method method = AihrDirectService.class.getDeclaredMethod("allowedChannels", AihrKnowledgePrincipal.class);
method.setAccessible(true);