feat(aihr): harden M0-M2 mobile training flows

This commit is contained in:
2026-07-23 21:18:13 +08:00
parent 06322c994b
commit 927458b9e0
44 changed files with 1674 additions and 119 deletions
@@ -6,6 +6,8 @@ import cn.dev33.satoken.stp.StpUtil;
import cn.hutool.core.codec.Base64;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.ObjectUtil;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonProperty;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
@@ -22,6 +24,7 @@ import org.dromara.common.core.domain.R;
import org.dromara.common.core.domain.model.LoginBody;
import org.dromara.common.core.domain.model.RegisterBody;
import org.dromara.common.core.domain.model.SocialLoginBody;
import org.dromara.common.core.exception.ServiceException;
import org.dromara.common.core.utils.*;
import org.dromara.common.encrypt.annotation.ApiEncrypt;
import org.dromara.common.json.utils.JsonUtils;
@@ -71,7 +74,6 @@ import java.util.concurrent.TimeUnit;
@RequestMapping("/auth")
public class AuthController {
private static final String MOBILE_CLIENT_ID = "428a8310cd442757ae699df5d894f051";
private static final String MOBILE_GRANT_TYPE = "sms";
private final SocialProperties socialProperties;
@@ -125,25 +127,31 @@ public class AuthController {
* 移动端短信登录,复用 app 客户端和 sms 授权策略。
*/
@PostMapping("/mobile/sms-login")
public R<LoginVo> mobileSmsLogin(@Validated @RequestBody MobileSmsLoginBody body) {
SysClientVo client = clientService.queryByClientId(MOBILE_CLIENT_ID);
public R<MobileSmsLoginResponse> mobileSmsLogin(@Validated @RequestBody MobileSmsLoginBody body) {
SysClientVo client = clientService.queryByClientId(SmsCodeUtils.MOBILE_CLIENT_ID);
if (ObjectUtil.isNull(client) || !StringUtils.contains(client.getGrantType(), MOBILE_GRANT_TYPE)) {
log.info("移动端客户端id: {} 认证类型:{} 异常!.", MOBILE_CLIENT_ID, MOBILE_GRANT_TYPE);
log.info("移动端客户端id: {} 认证类型:{} 异常!.", SmsCodeUtils.MOBILE_CLIENT_ID, MOBILE_GRANT_TYPE);
return R.fail(MessageUtils.message("auth.grant.type.error"));
} else if (!SystemConstants.NORMAL.equals(client.getStatus())) {
return R.fail(MessageUtils.message("auth.grant.type.blocked"));
}
String tenantId = StringUtils.blankToDefault(body.tenantId(), TenantConstants.DEFAULT_TENANT_ID);
// 员工端没有匿名租户选择能力;不信任客户端传入的 tenantId,避免手机号验证码被用于加入其他租户。
String tenantId = mobileTenantId();
loginService.checkTenant(tenantId);
Map<String, String> loginBody = new HashMap<>();
loginBody.put("clientId", MOBILE_CLIENT_ID);
loginBody.put("clientId", SmsCodeUtils.MOBILE_CLIENT_ID);
loginBody.put("grantType", MOBILE_GRANT_TYPE);
loginBody.put("tenantId", tenantId);
loginBody.put("phonenumber", body.phonenumber());
loginBody.put("smsCode", body.smsCode());
return R.ok(IAuthStrategy.login(JsonUtils.toJsonString(loginBody), client, MOBILE_GRANT_TYPE));
LoginVo loginVo = IAuthStrategy.login(JsonUtils.toJsonString(loginBody), client, MOBILE_GRANT_TYPE);
return R.ok(MobileSmsLoginResponse.from(loginVo));
}
static String mobileTenantId() {
return TenantConstants.DEFAULT_TENANT_ID;
}
/**
@@ -272,14 +280,29 @@ public class AuthController {
return R.ok(result);
}
@JsonIgnoreProperties(ignoreUnknown = true)
public record MobileSmsLoginBody(
@NotBlank(message = "{user.phonenumber.not.blank}")
@Pattern(regexp = RegexConstants.MOBILE, message = "{user.mobile.phone.number.not.valid}")
String phonenumber,
@NotBlank(message = "{sms.code.not.blank}")
String smsCode,
String tenantId
String smsCode
) {
}
/**
* 移动端保存会话所需的最小登录响应,避免复用包含后台扩展字段的登录视图对象。
*/
public record MobileSmsLoginResponse(
@JsonProperty("access_token") String accessToken,
@JsonProperty("client_id") String clientId
) {
static MobileSmsLoginResponse from(LoginVo loginVo) {
if (loginVo == null || StringUtils.isBlank(loginVo.getAccessToken()) || StringUtils.isBlank(loginVo.getClientId())) {
throw new ServiceException("移动端登录未返回认证信息");
}
return new MobileSmsLoginResponse(loginVo.getAccessToken(), loginVo.getClientId());
}
}
}
@@ -13,8 +13,10 @@ import lombok.extern.slf4j.Slf4j;
import org.dromara.common.core.constant.Constants;
import org.dromara.common.core.constant.GlobalConstants;
import org.dromara.common.core.constant.RegexConstants;
import org.dromara.common.core.constant.TenantConstants;
import org.dromara.common.core.domain.R;
import org.dromara.common.core.exception.ServiceException;
import org.dromara.common.core.utils.SmsCodeUtils;
import org.dromara.common.core.utils.SpringUtils;
import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.mail.config.properties.MailProperties;
@@ -28,6 +30,9 @@ import org.dromara.sms4j.api.SmsBlend;
import org.dromara.sms4j.api.entity.SmsResponse;
import org.dromara.sms4j.core.factory.SmsFactory;
import org.dromara.web.domain.vo.CaptchaVo;
import org.redisson.api.RBucket;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.env.Environment;
import org.springframework.core.env.Profiles;
@@ -41,6 +46,7 @@ import org.springframework.web.bind.annotation.RestController;
import java.awt.*;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.concurrent.TimeUnit;
/**
* 验证码操作处理
@@ -84,7 +90,6 @@ public class CaptchaController {
public R<Void> smsCode(
@NotBlank(message = "{user.phonenumber.not.blank}")
@Pattern(regexp = RegexConstants.MOBILE, message = "{user.mobile.phone.number.not.valid}") String phonenumber) {
String key = GlobalConstants.CAPTCHA_CODE_KEY + phonenumber;
boolean prodProfile = environment.acceptsProfiles(Profiles.of("prod"));
boolean fixedCodeEnabled = shouldUseFixedSmsCode(
smsDevFixedCode,
@@ -93,7 +98,7 @@ public class CaptchaController {
);
if (fixedCodeEnabled) {
log.warn("aihr.sms.dev-fixed-code 已启用,短信验证码走固定码,未真实发送短信");
RedisUtils.setCacheObject(key, smsDevFixedCode, Duration.ofMinutes(Constants.CAPTCHA_EXPIRATION));
storeSmsCode(RedisUtils.getClient(), TenantConstants.DEFAULT_TENANT_ID, phonenumber, smsDevFixedCode);
return R.ok();
}
if (StringUtils.isBlank(smsLoginTemplateId)) {
@@ -108,10 +113,30 @@ public class CaptchaController {
log.error("验证码短信发送异常 => {}", smsResponse);
return R.fail(String.valueOf(smsResponse.getData()));
}
RedisUtils.setCacheObject(key, code, Duration.ofMinutes(Constants.CAPTCHA_EXPIRATION));
storeSmsCode(RedisUtils.getClient(), TenantConstants.DEFAULT_TENANT_ID, phonenumber, code);
return R.ok();
}
static void storeSmsCode(RedissonClient redisClient, String tenantId, String phonenumber, String code) {
RLock lock = redisClient.getLock(SmsCodeUtils.verificationLockKey(tenantId, phonenumber));
boolean locked = false;
try {
locked = lock.tryLock(2, 5, TimeUnit.SECONDS);
if (!locked) {
throw new ServiceException("验证码服务繁忙,请稍后重试");
}
RBucket<String> bucket = redisClient.getBucket(SmsCodeUtils.cacheKey(tenantId, phonenumber));
bucket.set(code, Duration.ofMinutes(Constants.CAPTCHA_EXPIRATION));
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new ServiceException("验证码服务被中断,请重试");
} finally {
if (locked && lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
static boolean shouldUseFixedSmsCode(String fixedCode, boolean prodProfile, boolean prodFixedCodeEnabled) {
return StringUtils.isNotBlank(fixedCode) && (!prodProfile || prodFixedCodeEnabled);
}
@@ -7,7 +7,6 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.dromara.common.core.constant.Constants;
import org.dromara.common.core.constant.GlobalConstants;
import org.dromara.common.core.constant.SystemConstants;
import org.dromara.common.core.domain.model.LoginUser;
import org.dromara.common.core.domain.model.SmsLoginBody;
@@ -16,6 +15,7 @@ import org.dromara.common.core.enums.UserType;
import org.dromara.common.core.exception.user.CaptchaExpireException;
import org.dromara.common.core.exception.user.UserException;
import org.dromara.common.core.utils.MessageUtils;
import org.dromara.common.core.utils.SmsCodeUtils;
import org.dromara.common.core.utils.StringUtils;
import org.dromara.common.core.utils.ValidatorUtils;
import org.dromara.common.json.utils.JsonUtils;
@@ -29,8 +29,13 @@ import org.dromara.system.mapper.SysUserMapper;
import org.dromara.web.domain.vo.LoginVo;
import org.dromara.web.service.IAuthStrategy;
import org.dromara.web.service.SysLoginService;
import org.redisson.api.RBucket;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.stereotype.Service;
import java.util.concurrent.TimeUnit;
/**
* 短信认证策略
*
@@ -51,9 +56,10 @@ public class SmsAuthStrategy implements IAuthStrategy {
String tenantId = loginBody.getTenantId();
String phonenumber = loginBody.getPhonenumber();
String smsCode = loginBody.getSmsCode();
boolean appClient = isAppClient(client);
LoginUser loginUser = TenantHelper.dynamic(tenantId, () -> {
loginService.checkLogin(LoginType.SMS, tenantId, phonenumber, () -> !validateSmsCode(tenantId, phonenumber, smsCode));
SysUserVo user = loadOrRegisterUserByPhonenumber(tenantId, phonenumber);
SysUserVo user = loadOrRegisterUserByPhonenumber(tenantId, phonenumber, appClient);
// 此处可根据登录用户的数据不同 自行创建 loginUser 属性不够用继承扩展就行了
return loginService.buildLoginUser(user);
});
@@ -80,18 +86,60 @@ public class SmsAuthStrategy implements IAuthStrategy {
* 校验短信验证码
*/
private boolean validateSmsCode(String tenantId, String phonenumber, String smsCode) {
String code = RedisUtils.getCacheObject(GlobalConstants.CAPTCHA_CODE_KEY + phonenumber);
if (StringUtils.isBlank(code)) {
loginService.recordLogininfor(tenantId, phonenumber, Constants.LOGIN_FAIL, MessageUtils.message("user.jcaptcha.expire"));
throw new CaptchaExpireException();
}
return code.equals(smsCode);
return validateSmsCode(RedisUtils.getClient(), tenantId, phonenumber, smsCode);
}
private SysUserVo loadOrRegisterUserByPhonenumber(String tenantId, String phonenumber) {
SysUserVo user = userMapper.selectVoOne(new LambdaQueryWrapper<SysUser>()
boolean validateSmsCode(RedissonClient redisClient, String tenantId, String phonenumber, String smsCode) {
String key = SmsCodeUtils.cacheKey(tenantId, phonenumber);
RLock lock = redisClient.getLock(SmsCodeUtils.verificationLockKey(tenantId, phonenumber));
boolean locked = false;
try {
locked = lock.tryLock(2, 5, TimeUnit.SECONDS);
if (!locked) {
throw new UserException("验证码校验繁忙,请稍后重试");
}
RBucket<String> bucket = redisClient.getBucket(key);
String code = bucket.get();
if (StringUtils.isBlank(code)) {
loginService.recordLogininfor(tenantId, phonenumber, Constants.LOGIN_FAIL, MessageUtils.message("user.jcaptcha.expire"));
throw new CaptchaExpireException();
}
if (!code.equals(smsCode)) {
return false;
}
bucket.delete();
return true;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new UserException("验证码校验被中断,请稍后重试");
} finally {
if (locked && lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
static boolean isAppClient(SysClientVo client) {
return client != null && SmsCodeUtils.MOBILE_CLIENT_ID.equals(client.getClientId());
}
SysUserVo loadOrRegisterUserByPhonenumber(String tenantId, String phonenumber, boolean appClient) {
if (appClient && userMapper.exists(new LambdaQueryWrapper<SysUser>()
.eq(SysUser::getTenantId, tenantId)
.eq(SysUser::getPhonenumber, phonenumber));
.eq(SysUser::getPhonenumber, phonenumber)
.and(wrapper -> wrapper.isNull(SysUser::getUserType)
.or()
.ne(SysUser::getUserType, UserType.APP_USER.getUserType())))) {
log.warn("移动端短信登录拒绝同手机号系统用户:{}", phonenumber);
throw new UserException("移动端账号未开通,请联系管理员");
}
LambdaQueryWrapper<SysUser> query = new LambdaQueryWrapper<SysUser>()
.eq(SysUser::getTenantId, tenantId)
.eq(SysUser::getPhonenumber, phonenumber);
if (appClient) {
query.eq(SysUser::getUserType, UserType.APP_USER.getUserType());
}
SysUserVo user = userMapper.selectVoOne(query);
if (ObjectUtil.isNull(user)) {
SysUser sysUser = new SysUser();
sysUser.setTenantId(tenantId);
@@ -108,6 +156,9 @@ public class SmsAuthStrategy implements IAuthStrategy {
log.info("登录用户:{} 已被停用.", phonenumber);
throw new UserException("user.blocked", phonenumber);
}
if (appClient && (user == null || !UserType.APP_USER.getUserType().equals(user.getUserType()))) {
throw new UserException("移动端账号未开通,请联系管理员");
}
return user;
}
@@ -0,0 +1,82 @@
package org.dromara.web.controller;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.dromara.common.core.constant.Constants;
import org.dromara.common.core.exception.ServiceException;
import org.dromara.common.core.utils.SmsCodeUtils;
import org.dromara.web.domain.vo.LoginVo;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.redisson.api.RBucket;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import java.time.Duration;
import java.util.concurrent.TimeUnit;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@Tag("dev")
class MobileSmsLoginTenantBoundaryTest {
@Test
void mobileLoginIgnoresClientTenantSelection() throws Exception {
AuthController.MobileSmsLoginBody body = new ObjectMapper().readValue("""
{"phonenumber":"13900000000","smsCode":"123456","tenantId":"tenant-b"}
""", AuthController.MobileSmsLoginBody.class);
assertEquals("13900000000", body.phonenumber());
assertEquals("123456", body.smsCode());
assertEquals("000000", AuthController.mobileTenantId());
}
@Test
@SuppressWarnings("unchecked")
void resendingSmsUsesTheSameLockAsVerification() throws Exception {
RedissonClient redisClient = mock(RedissonClient.class);
RLock lock = mock(RLock.class);
RBucket<String> bucket = mock(RBucket.class);
String tenantId = "000000";
String phonenumber = "13900000000";
when(redisClient.getLock(SmsCodeUtils.verificationLockKey(tenantId, phonenumber))).thenReturn(lock);
when(redisClient.<String>getBucket(anyString())).thenReturn(bucket);
when(lock.tryLock(2, 5, TimeUnit.SECONDS)).thenReturn(true);
when(lock.isHeldByCurrentThread()).thenReturn(true);
CaptchaController.storeSmsCode(redisClient, tenantId, phonenumber, "123456");
verify(bucket).set("123456", Duration.ofMinutes(Constants.CAPTCHA_EXPIRATION));
verify(lock).unlock();
}
@Test
void mobileLoginResponseUsesTheH5AuthContractOnly() throws Exception {
LoginVo loginVo = new LoginVo();
loginVo.setAccessToken("test-access-token");
loginVo.setClientId("test-mobile-client");
loginVo.setRefreshToken("must-not-be-returned");
JsonNode json = new ObjectMapper().readTree(new ObjectMapper().writeValueAsString(
AuthController.MobileSmsLoginResponse.from(loginVo)
));
assertEquals("test-access-token", json.path("access_token").asText());
assertEquals("test-mobile-client", json.path("client_id").asText());
assertFalse(json.has("refresh_token"));
}
@Test
void mobileLoginResponseRejectsAnIncompleteStrategyResult() {
LoginVo loginVo = new LoginVo();
loginVo.setClientId("test-mobile-client");
assertThrows(ServiceException.class, () -> AuthController.MobileSmsLoginResponse.from(loginVo));
}
}
@@ -1,25 +1,99 @@
package org.dromara.web.service.impl;
import com.baomidou.mybatisplus.core.MybatisConfiguration;
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
import org.dromara.common.core.enums.UserType;
import org.dromara.common.core.utils.SmsCodeUtils;
import org.apache.ibatis.builder.MapperBuilderAssistant;
import org.dromara.system.domain.SysUser;
import org.dromara.system.domain.vo.SysClientVo;
import org.dromara.system.domain.vo.SysUserVo;
import org.dromara.system.mapper.SysUserMapper;
import org.dromara.web.service.SysLoginService;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.redisson.api.RBucket;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.concurrent.TimeUnit;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.Mockito.*;
@Tag("dev")
class SmsAuthStrategyTenantTest {
@Test
void mobileSmsUserLookupKeepsTenantBoundary() throws Exception {
Path source = Path.of("src/main/java/org/dromara/web/service/impl/SmsAuthStrategy.java");
if (!Files.exists(source)) {
source = Path.of("ruoyi-admin/src/main/java/org/dromara/web/service/impl/SmsAuthStrategy.java");
}
String code = Files.readString(source);
private static final String TEST_MOBILE = "13900000000";
assertTrue(code.contains(".eq(SysUser::getTenantId, tenantId)"));
assertTrue(code.contains(".eq(SysUser::getPhonenumber, phonenumber)"));
@BeforeAll
static void initializeSysUserLambdaMetadata() {
if (TableInfoHelper.getTableInfo(SysUser.class) == null) {
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), ""), SysUser.class);
}
}
@Test
void mobileSmsLoginUsesAppUsersAndRejectsAConflictingSystemUser() {
SysLoginService loginService = mock(SysLoginService.class);
SysUserMapper userMapper = mock(SysUserMapper.class);
SmsAuthStrategy strategy = new SmsAuthStrategy(loginService, userMapper);
when(userMapper.selectVoOne(any())).thenReturn(null);
when(userMapper.exists(any())).thenReturn(true);
assertThrows(RuntimeException.class,
() -> strategy.loadOrRegisterUserByPhonenumber("000000", TEST_MOBILE, true));
verify(userMapper, never()).insert(any(SysUser.class));
SysClientVo appClient = new SysClientVo();
appClient.setClientId(SmsCodeUtils.MOBILE_CLIENT_ID);
assertTrue(SmsAuthStrategy.isAppClient(appClient));
}
@Test
void existingAppUserIsRetainedAndWrongOtpDoesNotConsumeTheCode() throws Exception {
SysLoginService loginService = mock(SysLoginService.class);
SysUserMapper userMapper = mock(SysUserMapper.class);
SmsAuthStrategy strategy = new SmsAuthStrategy(loginService, userMapper);
SysUserVo appUser = new SysUserVo();
appUser.setUserType(UserType.APP_USER.getUserType());
appUser.setStatus("0");
when(userMapper.selectVoOne(any())).thenReturn(appUser);
assertSame(appUser, strategy.loadOrRegisterUserByPhonenumber("000000", TEST_MOBILE, true));
RedissonClient redisClient = mock(RedissonClient.class);
RLock lock = mock(RLock.class);
@SuppressWarnings("unchecked")
RBucket<String> bucket = mock(RBucket.class);
when(redisClient.getLock(SmsCodeUtils.verificationLockKey("000000", TEST_MOBILE))).thenReturn(lock);
when(redisClient.<String>getBucket(anyString())).thenReturn(bucket);
when(lock.tryLock(eq(2L), eq(5L), eq(TimeUnit.SECONDS))).thenReturn(true);
when(lock.isHeldByCurrentThread()).thenReturn(true);
when(bucket.get()).thenReturn("123456");
assertFalse(strategy.validateSmsCode(redisClient, "000000", TEST_MOBILE, "000000"));
verify(bucket, never()).delete();
verify(lock).unlock();
assertTrue(strategy.validateSmsCode(redisClient, "000000", TEST_MOBILE, "123456"));
verify(bucket).delete();
}
@Test
void mobileSmsLoginRejectsAConflictingSystemUserEvenWhenAnAppUserExists() {
SysLoginService loginService = mock(SysLoginService.class);
SysUserMapper userMapper = mock(SysUserMapper.class);
SmsAuthStrategy strategy = new SmsAuthStrategy(loginService, userMapper);
SysUserVo appUser = new SysUserVo();
appUser.setUserType(UserType.APP_USER.getUserType());
when(userMapper.exists(any())).thenReturn(true);
when(userMapper.selectVoOne(any())).thenReturn(appUser);
assertThrows(RuntimeException.class,
() -> strategy.loadOrRegisterUserByPhonenumber("000000", TEST_MOBILE, true));
verify(userMapper, never()).selectVoOne(any());
verify(userMapper, never()).insert(any(SysUser.class));
}
}
@@ -94,6 +94,12 @@
<artifactId>ip2region</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>
@@ -0,0 +1,26 @@
package org.dromara.common.core.utils;
import org.dromara.common.core.constant.GlobalConstants;
import org.dromara.common.core.constant.TenantConstants;
/**
* SMS verification codes are scoped to the authenticated tenant boundary.
*/
public final class SmsCodeUtils {
public static final String MOBILE_CLIENT_ID = "428a8310cd442757ae699df5d894f051";
private static final String VERIFY_LOCK_SUFFIX = ":verify-lock";
private SmsCodeUtils() {
}
public static String cacheKey(String tenantId, String phonenumber) {
String resolvedTenantId = StringUtils.blankToDefault(tenantId, TenantConstants.DEFAULT_TENANT_ID);
String resolvedPhone = StringUtils.blankToDefault(phonenumber, "");
return GlobalConstants.CAPTCHA_CODE_KEY + "sms:" + resolvedTenantId + ":" + resolvedPhone;
}
public static String verificationLockKey(String tenantId, String phonenumber) {
return cacheKey(tenantId, phonenumber) + VERIFY_LOCK_SUFFIX;
}
}
@@ -0,0 +1,22 @@
package org.dromara.common.core.utils;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Tag;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
@Tag("dev")
class SmsCodeUtilsTest {
private static final String TEST_MOBILE = "13900000000";
@Test
void scopesSmsCodeKeysByTenantAndPhone() {
String defaultTenant = SmsCodeUtils.cacheKey("000000", TEST_MOBILE);
String anotherTenant = SmsCodeUtils.cacheKey("tenant-b", TEST_MOBILE);
assertNotEquals(defaultTenant, anotherTenant);
assertEquals(defaultTenant, SmsCodeUtils.cacheKey("", TEST_MOBILE));
}
}
@@ -11,6 +11,8 @@ import org.dromara.aihr.domain.AihrPracticeDto.CalibrationRequest;
import org.dromara.aihr.domain.AihrPracticeDto.CalibrationResponse;
import org.dromara.aihr.domain.AihrPracticeDto.FinishRequest;
import org.dromara.aihr.domain.AihrPracticeDto.FinishResponse;
import org.dromara.aihr.domain.AihrPracticeDto.HelpEventResponse;
import org.dromara.aihr.domain.AihrPracticeDto.HelpRequest;
import org.dromara.aihr.domain.AihrPracticeDto.PrepCardResponse;
import org.dromara.aihr.domain.AihrPracticeDto.RealtimeSdpRequest;
import org.dromara.aihr.domain.AihrPracticeDto.RealtimeSdpResponse;
@@ -90,6 +92,15 @@ public class AihrPracticeController {
return R.ok(practiceSeedService.prepCard(id));
}
@PostMapping("/sessions/{sessionId}/help")
public R<HelpEventResponse> recordHelp(@PathVariable String sessionId, @RequestBody(required = false) HelpRequest request) {
String username = currentAppUsername();
if (username.isBlank()) {
return R.fail("请使用员工端账号发起求助");
}
return R.ok(practiceSeedService.recordHelp(sessionId, request == null ? null : request.roundIndex(), username));
}
@GetMapping("/rubrics")
public R<List<RubricResponse>> rubrics(@RequestParam(required = false) String scenarioId) {
return R.ok(practiceSeedService.rubrics(scenarioId));
@@ -9,6 +9,7 @@ import org.dromara.aihr.domain.AihrSpeechDto.TtsRequest;
import org.dromara.aihr.domain.AihrSpeechDto.TtsResponse;
import org.dromara.aihr.service.AihrMultipartFiles;
import org.dromara.aihr.service.AihrSpeechService;
import org.dromara.common.core.constant.HttpStatus;
import org.dromara.common.core.domain.R;
import org.dromara.common.core.domain.model.LoginUser;
import org.dromara.common.core.enums.UserType;
@@ -29,6 +30,7 @@ import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Base64;
import java.util.List;
import java.util.Optional;
/**
* 对练语音 API:ASR 语音输入 + TTS 客户语音播报。未配置语音模型时返回失败,前端降级为文本。
@@ -115,6 +117,10 @@ public class AihrSpeechController {
@PostMapping("/tts")
public R<TtsResponse> tts(@RequestBody TtsRequest request) {
Optional<String> invalidDialect = AihrSpeechService.invalidDialectMessage(request == null ? null : request.voiceProfile());
if (invalidDialect.isPresent()) {
return R.fail(HttpStatus.BAD_REQUEST, invalidDialect.get());
}
if (!speechService.ttsConfigured()) {
return R.fail(NOT_CONFIGURED_HINT);
}
@@ -107,6 +107,12 @@ public final class AihrPracticeDto {
public record PrepCardResponse(List<String> keyPoints, List<String> redLines, List<String> scripts, String promptVersion) {
}
public record HelpRequest(Integer roundIndex) {
}
public record HelpEventResponse(String sessionId, String scenarioId, Integer roundIndex, String recordedAt) {
}
public record RealtimeSdpRequest(String offerSdp) {
}
@@ -14,7 +14,7 @@ public final class AihrSpeechDto {
public record TtsRequest(String text, String voice, VoiceProfile voiceProfile) {
}
public record VoiceProfile(String role, String voice, Double speed, String emotion) {
public record VoiceProfile(String role, String voice, Double speed, String emotion, String dialect) {
}
public record TtsResponse(String audioUrl, String source, Long ossId, String inlineAudioUrl) {
@@ -7,6 +7,7 @@ import lombok.extern.slf4j.Slf4j;
import org.dromara.aihr.domain.AihrPracticeDto.DialogueResponse;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
@@ -23,6 +24,7 @@ public class AihrPracticeLlmService {
private static final double CUSTOMER_TEMPERATURE = 0.7;
private static final double SCORE_TEMPERATURE = 0.0;
private static final double DAILY_DRILL_TEMPERATURE = 0.0;
private static final double PREP_CARD_TEMPERATURE = 0.0;
private static final int MAX_CUSTOMER_CHARS = 80;
private final AihrModelSeedService modelService;
@@ -57,6 +59,24 @@ public class AihrPracticeLlmService {
public record DailyDrillAssessment(int score, String feedback) {
}
public record PrepCard(List<String> keyPoints, List<String> redLines, List<String> scripts) {
}
/**
* 训练前卡严格解析固定三栏 JSON。模板由调用方从租户提示词配置加载,模型不可用时返回 empty,
* 由场景字段回退,不让训练入口因为模型调用失败不可用。
*/
public Optional<PrepCard> prepCard(String systemPrompt, String userPrompt) {
if (systemPrompt == null || systemPrompt.isBlank() || userPrompt == null || userPrompt.isBlank()) {
return Optional.empty();
}
return modelService.tryChat(
AihrSensitiveText.forModel(systemPrompt),
AihrSensitiveText.forModel(userPrompt),
PREP_CARD_TEMPERATURE)
.flatMap(this::parsePrepCard);
}
/**
* 生成 AI 客户(业主)的下一句回应。nextBeat 为 seed 剧本中下一轮台词,作为剧情推进锚点。
*/
@@ -250,6 +270,40 @@ public class AihrPracticeLlmService {
}
}
private Optional<PrepCard> parsePrepCard(String content) {
try {
JsonNode root = objectMapper.readTree(extractJson(content));
List<String> keyPoints = jsonStrings(root.path("keyPoints"), 3, 120);
List<String> redLines = jsonStrings(root.path("redLines"), 3, 120);
List<String> scripts = jsonStrings(root.path("scripts"), 2, 160);
if (keyPoints.size() != 3 || redLines.size() != 3 || scripts.size() != 2) {
return Optional.empty();
}
return Optional.of(new PrepCard(keyPoints, redLines, scripts));
} catch (Exception e) {
log.warn("practice prep card parse failed, falls back to scenario(处理错误已隐藏)");
return Optional.empty();
}
}
private static List<String> jsonStrings(JsonNode node, int expectedItems, int maxChars) {
if (node == null || !node.isArray() || node.size() != expectedItems) {
return List.of();
}
List<String> values = new ArrayList<>(expectedItems);
for (JsonNode item : node) {
if (!item.isTextual()) {
return List.of();
}
String value = truncateText(item.asText(), maxChars).trim();
if (value.isBlank()) {
return List.of();
}
values.add(value);
}
return values;
}
/** 维度字段缺失时回落到 total,避免 asInt() 把缺失当 0 分落库。 */
private static int dimension(JsonNode root, String field, int fallback) {
JsonNode node = root.path(field);
@@ -17,6 +17,7 @@ import org.dromara.aihr.domain.AihrPracticeDto.DimensionResponse;
import org.dromara.aihr.domain.AihrPracticeDto.FinishRequest;
import org.dromara.aihr.domain.AihrPracticeDto.FinishResponse;
import org.dromara.aihr.domain.AihrPracticeDto.GrowthStageResponse;
import org.dromara.aihr.domain.AihrPracticeDto.HelpEventResponse;
import org.dromara.aihr.domain.AihrPracticeDto.PersonaResponse;
import org.dromara.aihr.domain.AihrPracticeDto.PrepCardResponse;
import org.dromara.aihr.domain.AihrPracticeDto.PracticeAlertResponse;
@@ -90,6 +91,7 @@ import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Supplier;
/**
@@ -109,6 +111,20 @@ public class AihrPracticeSeedService {
"detailed", "内容更细致,拆成更具体的动作、信息点和反馈节点。",
"professional", "表达更专业,突出SOP依据、责任边界、留痕和闭环口径。"
);
private static final String PREP_CARD_SYSTEM_PROMPT = """
你是物业培训教练。只返回 JSON,不要 markdown。字段固定为 keyPoints、redLines、scripts。
keyPoints 和 redLines 必须各 3 条,scripts 必须 2 条;只能依据输入场景和 SOP 信息,不得编造时限、政策或承诺。
""";
private static final String PREP_CARD_USER_TEMPLATE = """
场景:{{scenarioName}}。
显性诉求:{{explicitNeed}}。
SOP引用:{{sopRefs}}。
成功条件:{{successCriteria}}。
易激化点:{{irritants}}。
可强化表达:{{delights}}。
请生成训练前预习卡。
""";
private static final String PREP_CARD_PROMPT_VERSION = "builtin-prep_card-v1";
private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("MM-dd HH:mm");
private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai");
// 阶段一只统计“员工收到业主话术后到提交下一轮”的陪练响应时长;正式首响/工单处理时效仍需业务系统数据。
@@ -422,7 +438,7 @@ public class AihrPracticeSeedService {
public PrepCardResponse prepCard(String scenarioId) {
ensureScenarioEnabledForStart(scenarioId);
ScenarioResponse scenario = scenario(scenarioId);
return new PrepCardResponse(
PrepCardResponse fallback = new PrepCardResponse(
List.of(
firstNonBlank(scenario.explicitNeed(), "先确认业主当前诉求"),
firstNonBlank(scenario.successCriteria(), "给出责任人、处理节点和反馈方式"),
@@ -433,8 +449,33 @@ public class AihrPracticeSeedService {
"我先确认您的诉求和现场情况,再给您明确的责任人和首次反馈时间。",
firstNonBlank(scenario.delights(), "这个问题我会跟进到闭环,过程节点会同步给您。")
),
"builtin-prep_card-v1"
PREP_CARD_PROMPT_VERSION
);
PrepCardPrompt prompt = prepCardPrompt();
if (practiceLlmService == null) {
return fallback;
}
return practiceLlmService.prepCard(prompt.systemPrompt(), renderPrepCardPrompt(prompt.template(), scenario))
.map(card -> new PrepCardResponse(card.keyPoints(), card.redLines(), card.scripts(), prompt.promptVersion()))
.orElseGet(() -> new PrepCardResponse(
fallback.keyPoints(), fallback.redLines(), fallback.scripts(), prompt.promptVersion() + ":fallback"
));
}
/** 记录员工主动点开的训练求助,和知识检索日志分开保存,便于后续复盘和知识补齐。 */
public HelpEventResponse recordHelp(String sessionId, Integer ignoredRequestedRoundIndex, String ownerIdentity) {
ActiveSession session = isBlank(sessionId) ? null : activeSessions.get(sessionId);
requireSessionOwner(session, ownerIdentity, tenantId());
ScenarioSeed scenario = resolveScenario(session.scenarioId(), sessionId);
int roundIndex = normalizeRoundIndex(session.currentRoundIndex().get(), scenario.rounds().size());
LocalDateTime now = LocalDateTime.now();
jdbcTemplate.update("""
INSERT INTO aihr_practice_help_event
(tenant_id, session_id, scenario_id, ext_party_id, round_index, create_time)
VALUES (?, ?, ?, ?, ?, ?)
""",
session.tenantId(), sessionId, scenario.id(), session.extPartyId(), roundIndex, Timestamp.valueOf(now));
return new HelpEventResponse(sessionId, scenario.id(), roundIndex, now.format(TIME_FORMATTER));
}
public List<RubricResponse> rubrics(String scenarioId) {
@@ -540,7 +581,12 @@ public class AihrPracticeSeedService {
List<String> customerLines = new ArrayList<>();
customerLines.add(firstRound.customer());
long promptPresentedAt = System.currentTimeMillis();
activeSessions.put(sessionId, new ActiveSession(scenario.id(), trainee, resolveExtPartyId(request, trainee), request == null ? null : request.assignmentId(), isMobile(request), LocalDateTime.ofInstant(java.time.Instant.ofEpochMilli(promptPresentedAt), java.time.ZoneId.systemDefault()), new ArrayList<>(), new ArrayList<>(), new ArrayList<>(), customerLines, new ArrayList<>(), new ArrayList<>(List.of(promptPresentedAt)), new ArrayList<>()));
activeSessions.put(sessionId, new ActiveSession(
tenantId(), scenario.id(), trainee, resolveExtPartyId(request, trainee), request == null ? null : request.assignmentId(),
isMobile(request), LocalDateTime.ofInstant(java.time.Instant.ofEpochMilli(promptPresentedAt), java.time.ZoneId.systemDefault()),
new AtomicInteger(0), new ArrayList<>(), new ArrayList<>(), new ArrayList<>(), customerLines,
new ArrayList<>(), new ArrayList<>(List.of(promptPresentedAt)), new ArrayList<>()
));
return new StartResponse(
sessionId,
scenario.id(),
@@ -566,14 +612,24 @@ public class AihrPracticeSeedService {
String style = normalizeRegenerateStyle(request == null ? null : request.style());
ActiveSession session = request == null || isBlank(request.sessionId()) ? null : activeSessions.get(request.sessionId());
if (!isBlank(ownerIdentity)) {
requireSessionOwner(session, ownerIdentity);
requireSessionOwner(session, ownerIdentity, tenantId());
}
validateAudioOwnership(request == null ? null : request.traineeAudioOssId(), ownerIdentity, ownerUserId);
ScenarioSeed scenario = resolveScenario(session == null ? null : session.scenarioId(), request == null ? null : request.sessionId());
int roundIndex = normalizeRoundIndex(request == null ? null : request.roundIndex(), scenario.rounds().size());
if (session == null) {
return handleTurn(request, null, scenario, style);
}
synchronized (session) {
return handleTurn(request, session, scenario, style);
}
}
private TurnResponse handleTurn(TurnRequest request, ActiveSession session, ScenarioSeed scenario, String style) {
if (request != null && Boolean.TRUE.equals(request.regenerate())) {
int roundIndex = regenerateRoundIndex(session, request.roundIndex(), scenario.rounds().size());
return regenerateCoachHint(request, session, scenario, roundIndex, style);
}
int roundIndex = authoritativeRoundIndex(session, request == null ? null : request.roundIndex(), scenario.rounds().size());
rememberResponseLatency(session, roundIndex, System.currentTimeMillis());
rememberTraineeReply(request, roundIndex);
RoundSeed currentRound = scenario.rounds().get(roundIndex);
@@ -587,6 +643,9 @@ public class AihrPracticeSeedService {
String coachHint = redFlag ? redFlagCoachHint(resolved.coachHint()) : resolved.coachHint();
rememberTurnEvidence(session, roundIndex, resolved.emotion(), resolved.trust(), redFlag, coachHint);
if (!finished) {
if (!redFlag && session != null) {
session.currentRoundIndex().set(nextRoundIndex);
}
rememberPromptPresentedAt(session, nextRoundIndex, System.currentTimeMillis());
}
return new TurnResponse(
@@ -602,6 +661,35 @@ public class AihrPracticeSeedService {
);
}
private int authoritativeRoundIndex(ActiveSession session, Integer requestedRoundIndex, int roundCount) {
if (session == null) {
return normalizeRoundIndex(requestedRoundIndex, roundCount);
}
int activeRoundIndex = normalizeRoundIndex(session.currentRoundIndex().get(), roundCount);
if (requestedRoundIndex != null && (requestedRoundIndex < 0
|| requestedRoundIndex >= roundCount
|| requestedRoundIndex != activeRoundIndex)) {
throw new ServiceException("训练回合已变化,请查看最新对话后再继续");
}
return activeRoundIndex;
}
private int regenerateRoundIndex(ActiveSession session, Integer requestedRoundIndex, int roundCount) {
if (session == null) {
return normalizeRoundIndex(requestedRoundIndex, roundCount);
}
int roundIndex = requestedRoundIndex == null
? normalizeRoundIndex(session.currentRoundIndex().get(), roundCount)
: requestedRoundIndex;
if (roundIndex < 0 || roundIndex >= roundCount) {
throw new ServiceException("训练回合已变化,请查看最新对话后再继续");
}
if (turnEvidence(session, roundIndex) == null) {
throw new ServiceException("请先完成该回合后再调整教练建议");
}
return roundIndex;
}
private TurnResponse regenerateCoachHint(TurnRequest request, ActiveSession session, ScenarioSeed scenario, int roundIndex, String style) {
if (isBlank(style)) {
throw new ServiceException("请选择有效的调整风格");
@@ -717,7 +805,7 @@ public class AihrPracticeSeedService {
activeSession = sessionId == null ? null : activeSessions.remove(sessionId);
} else {
ActiveSession existing = sessionId == null ? null : activeSessions.get(sessionId);
requireSessionOwner(existing, ownerIdentity);
requireSessionOwner(existing, ownerIdentity, tenantId());
activeSession = activeSessions.remove(sessionId);
}
ScenarioSeed scenario = resolveScenario(activeSession == null ? null : activeSession.scenarioId(), sessionId);
@@ -3343,10 +3431,12 @@ public class AihrPracticeSeedService {
return request.extPartyId().trim();
}
private static void requireSessionOwner(ActiveSession session, String ownerIdentity) {
private static void requireSessionOwner(ActiveSession session, String ownerIdentity, String requestTenantId) {
String owner = ownerIdentity == null ? "" : ownerIdentity.trim();
String sessionOwner = session == null || session.extPartyId() == null ? "" : session.extPartyId().trim();
if (session == null || !owner.equals(sessionOwner)) {
String tenant = requestTenantId == null ? "" : requestTenantId.trim();
String sessionTenant = session == null || session.tenantId() == null ? "" : session.tenantId().trim();
if (session == null || !owner.equals(sessionOwner) || !tenant.equals(sessionTenant)) {
throw new ServiceException("训练会话不存在或无权访问");
}
}
@@ -4039,6 +4129,41 @@ public class AihrPracticeSeedService {
""", this::mapPromptTemplate, tenantId(), id);
}
private PrepCardPrompt prepCardPrompt() {
if (jdbcTemplate == null) {
return new PrepCardPrompt(PREP_CARD_SYSTEM_PROMPT, PREP_CARD_USER_TEMPLATE, PREP_CARD_PROMPT_VERSION);
}
try {
List<PrepCardPrompt> rows = jdbcTemplate.query("""
SELECT system_prompt, template, version
FROM aihr_prompt_template
WHERE tenant_id = ? AND code = 'prep_card' AND output_format = 'json_prep' AND enabled = 1
ORDER BY version DESC, id DESC
LIMIT 1
""", (rs, rowNum) -> new PrepCardPrompt(
firstNonBlank(rs.getString("system_prompt"), PREP_CARD_SYSTEM_PROMPT),
firstNonBlank(rs.getString("template"), PREP_CARD_USER_TEMPLATE),
"prep_card:" + firstNonBlank(rs.getString("version"), "v1")
), tenantId());
if (!rows.isEmpty()) {
return rows.get(0);
}
} catch (DataAccessException ignored) {
// Prompt table is optional in the local seed fallback.
}
return new PrepCardPrompt(PREP_CARD_SYSTEM_PROMPT, PREP_CARD_USER_TEMPLATE, PREP_CARD_PROMPT_VERSION);
}
private String renderPrepCardPrompt(String template, ScenarioResponse scenario) {
return firstNonBlank(template, PREP_CARD_USER_TEMPLATE)
.replace("{{scenarioName}}", firstNonBlank(scenario.name(), ""))
.replace("{{explicitNeed}}", firstNonBlank(scenario.explicitNeed(), ""))
.replace("{{sopRefs}}", firstNonBlank(scenario.sopRefs(), ""))
.replace("{{successCriteria}}", firstNonBlank(scenario.successCriteria(), ""))
.replace("{{irritants}}", firstNonBlank(scenario.irritants(), ""))
.replace("{{delights}}", firstNonBlank(scenario.delights(), ""));
}
private CalibrationResponse latestCalibration(String sessionId) {
List<CalibrationResponse> rows = jdbcTemplate.query("""
SELECT id, session_id, original_score, corrected_score, reviewer, reason, status, create_time
@@ -5484,6 +5609,9 @@ public class AihrPracticeSeedService {
private record TurnEvidence(Integer emotion, Integer trust, Boolean redFlag, String coachHint) {
}
private record PrepCardPrompt(String systemPrompt, String template, String promptVersion) {
}
private record OrgScopeRow(String projectCode, String positionLevel) {
}
@@ -5538,8 +5666,9 @@ public class AihrPracticeSeedService {
}
}
private record ActiveSession(String scenarioId, String trainee, String extPartyId, Long assignmentId, boolean mobile, LocalDateTime startedAt,
List<String> traineeReplies, List<String> traineeAudioUrls, List<Long> traineeAudioOssIds,
private record ActiveSession(String tenantId, String scenarioId, String trainee, String extPartyId, Long assignmentId, boolean mobile,
LocalDateTime startedAt, AtomicInteger currentRoundIndex, List<String> traineeReplies,
List<String> traineeAudioUrls, List<Long> traineeAudioOssIds,
List<String> customerLines, List<TurnEvidence> turnEvidence,
List<Long> promptPresentedAtMillis, List<Long> responseLatenciesMs) {
}
@@ -47,6 +47,11 @@ public class AihrSpeechService {
"intense", "请用情绪强烈、急切、有压力且明显不满的语气说",
"calm", "请用平静、自然的语气说"
);
private static final Map<String, String> DIALECT_PROMPTS = Map.of(
"mandarin", "请使用自然普通话表达",
"cantonese", "请使用自然粤语口语表达",
"sichuanese", "请使用自然四川话口语表达"
);
private final AihrModelSeedService modelService;
private final ObjectMapper objectMapper;
@@ -59,6 +64,16 @@ public class AihrSpeechService {
return modelService.speechModel("tts").isPresent();
}
public static Optional<String> invalidDialectMessage(VoiceProfile voiceProfile) {
String rawDialect = voiceProfile == null || voiceProfile.dialect() == null
? ""
: voiceProfile.dialect().trim();
if (rawDialect.isEmpty() || DIALECT_PROMPTS.containsKey(rawDialect.toLowerCase(Locale.ROOT))) {
return Optional.empty();
}
return Optional.of("dialect 仅支持 mandarin、cantonese、sichuanese");
}
/**
* 语音转写:POST {base}/audio/transcriptions(multipart)。
*/
@@ -132,9 +147,11 @@ public class AihrSpeechService {
}
String emotion = normalizedEmotion(voiceProfile == null ? null : voiceProfile.emotion(), role);
String dialect = normalizedDialect(voiceProfile == null ? null : voiceProfile.dialect());
String sanitizedText = AihrSensitiveText.forModel(text.trim());
String input = voiceProfile != null && expressiveCosyVoice && EMOTION_PROMPTS.containsKey(emotion)
? EMOTION_PROMPTS.get(emotion) + "。<|endofprompt|>" + sanitizedText
String expressivePrompt = expressivePrompt(emotion, dialect);
String input = voiceProfile != null && expressiveCosyVoice && !expressivePrompt.isBlank()
? expressivePrompt + "。<|endofprompt|>" + sanitizedText
: sanitizedText;
ObjectNode body = objectMapper.createObjectNode();
@@ -174,6 +191,26 @@ public class AihrSpeechService {
};
}
private static String normalizedDialect(String dialect) {
String value = dialect == null ? "" : dialect.trim().toLowerCase(Locale.ROOT);
if (value.isBlank() || DIALECT_PROMPTS.containsKey(value)) {
return value;
}
throw new IllegalArgumentException("dialect 仅支持 mandarin、cantonese、sichuanese");
}
private static String expressivePrompt(String emotion, String dialect) {
String emotionPrompt = EMOTION_PROMPTS.getOrDefault(emotion, "");
String dialectPrompt = DIALECT_PROMPTS.getOrDefault(dialect, "");
if (emotionPrompt.isBlank()) {
return dialectPrompt;
}
if (dialectPrompt.isBlank()) {
return emotionPrompt;
}
return emotionPrompt + "," + dialectPrompt;
}
private static double resolveSpeed(Double speed, String emotion) {
if (speed != null && Double.isFinite(speed)) {
return Math.max(0.7, Math.min(1.3, speed));
@@ -43,4 +43,52 @@ class AihrPracticeLlmServiceTest {
assertTrue(service.scoreDailyDrill("题目", "参考答案", "员工回答").isEmpty());
}
@Test
void prepCardParsesOnlyTheFixedThreeColumnStructure() {
AihrModelSeedService modelService = mock(AihrModelSeedService.class);
when(modelService.tryChat(anyString(), anyString(), eq(0.0)))
.thenReturn(Optional.of("""
{"keyPoints":["先安抚","确认责任人","给首次反馈时间"],
"redLines":["不推责","不绝对承诺","不与业主争辩"],
"scripts":["我先确认现场情况","十分钟内给您明确节点"],
"extra":"不应透出"}
"""));
AihrPracticeLlmService service = new AihrPracticeLlmService(modelService, new ObjectMapper());
var card = service.prepCard("只返回 JSON", "投诉处理场景");
assertTrue(card.isPresent());
assertEquals(3, card.get().keyPoints().size());
assertEquals(3, card.get().redLines().size());
assertEquals(2, card.get().scripts().size());
}
@Test
void prepCardRejectsPartialColumnsSoTheCallerCanUseScenarioFallback() {
AihrModelSeedService modelService = mock(AihrModelSeedService.class);
when(modelService.tryChat(anyString(), anyString(), eq(0.0)))
.thenReturn(Optional.of("""
{"keyPoints":["先安抚","确认责任人"],
"redLines":["不推责","不绝对承诺","不争辩"],
"scripts":["我先确认现场情况","十分钟内给您明确节点"]}
"""));
AihrPracticeLlmService service = new AihrPracticeLlmService(modelService, new ObjectMapper());
assertTrue(service.prepCard("只返回 JSON", "投诉处理场景").isEmpty());
}
@Test
void prepCardRejectsExtraItemsInsteadOfSilentlyTruncatingThem() {
AihrModelSeedService modelService = mock(AihrModelSeedService.class);
when(modelService.tryChat(anyString(), anyString(), eq(0.0)))
.thenReturn(Optional.of("""
{"keyPoints":["先安抚","确认责任人","给首次反馈时间","多余项"],
"redLines":["不推责","不绝对承诺","不争辩"],
"scripts":["我先确认现场情况","十分钟内给您明确节点"]}
"""));
AihrPracticeLlmService service = new AihrPracticeLlmService(modelService, new ObjectMapper());
assertTrue(service.prepCard("只返回 JSON", "投诉处理场景").isEmpty());
}
}
@@ -16,9 +16,12 @@ import org.dromara.aihr.domain.AihrPracticeDto.TeamMemberResponse;
import org.dromara.aihr.domain.AihrPracticeDto.TurnRequest;
import org.dromara.common.core.constant.HttpStatus;
import org.dromara.common.core.exception.ServiceException;
import org.dromara.common.tenant.helper.TenantHelper;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentMatchers;
import org.mockito.MockedStatic;
import org.mockito.Mockito;
import org.mockito.invocation.InvocationOnMock;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
@@ -43,6 +46,7 @@ import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -52,6 +56,7 @@ import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.contains;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
@@ -550,6 +555,95 @@ public class AihrPracticeSeedServiceTest {
assertEquals("训练会话不存在或无权访问", missing.getMessage());
}
@Test
public void m2PrepCardUsesTheConfiguredTemplatePathAndHelpIsPersistedForTheSessionOwner() {
JdbcTemplate jdbcTemplate = mock(JdbcTemplate.class);
when(jdbcTemplate.query(contains("SELECT enabled"), any(RowMapper.class), eq("000000"), eq("complaint-water")))
.thenReturn(List.of());
when(jdbcTemplate.query(contains("SELECT scenario_code"), any(RowMapper.class), eq("000000"), eq("complaint-water")))
.thenReturn(List.of());
when(jdbcTemplate.query(contains("FROM aihr_prompt_template"), any(RowMapper.class), eq("000000")))
.thenReturn(List.of());
AihrPracticeLlmService llm = mock(AihrPracticeLlmService.class);
when(llm.prepCard(anyString(), anyString())).thenReturn(Optional.of(new AihrPracticeLlmService.PrepCard(
List.of("先安抚", "确认事实", "给反馈时间"),
List.of("不推责", "不绝对承诺", "不争辩"),
List.of("我先确认情况", "十分钟内回复您")
)));
AihrPracticeSeedService service = new AihrPracticeSeedService(new ObjectMapper(), jdbcTemplate, llm, null);
var prep = service.prepCard("complaint-water");
var session = service.start(new StartRequest("employee-a", "complaint-water", "mobile", null));
var help = service.recordHelp(session.sessionId(), 2, "employee-a");
assertEquals(List.of("先安抚", "确认事实", "给反馈时间"), prep.keyPoints());
assertEquals("builtin-prep_card-v1", prep.promptVersion());
assertEquals(session.sessionId(), help.sessionId());
assertEquals("complaint-water", help.scenarioId());
assertEquals(0, help.roundIndex());
verify(jdbcTemplate).update(
contains("INSERT INTO aihr_practice_help_event"),
eq("000000"), eq(session.sessionId()), eq("complaint-water"), eq("employee-a"), eq(0), any(Timestamp.class)
);
assertThrows(ServiceException.class, () -> service.recordHelp(session.sessionId(), 0, "employee-b"));
}
@Test
public void m2HelpRejectsAValidOwnerWhenTheTenantContextChanged() {
JdbcTemplate jdbcTemplate = mock(JdbcTemplate.class);
when(jdbcTemplate.query(contains("SELECT enabled"), any(RowMapper.class), eq("tenant-a"), eq("complaint-water")))
.thenReturn(List.of());
when(jdbcTemplate.query(contains("SELECT scenario_code"), any(RowMapper.class), eq("tenant-a"), eq("complaint-water")))
.thenReturn(List.of());
AihrPracticeSeedService service = new AihrPracticeSeedService(new ObjectMapper(), jdbcTemplate, null, null);
try (MockedStatic<TenantHelper> tenantHelper = Mockito.mockStatic(TenantHelper.class, Mockito.CALLS_REAL_METHODS)) {
tenantHelper.when(TenantHelper::getTenantId).thenReturn("tenant-a");
var session = service.start(new StartRequest("employee-a", "complaint-water", "mobile", null));
tenantHelper.when(TenantHelper::getTenantId).thenReturn("tenant-b");
ServiceException error = assertThrows(ServiceException.class,
() -> service.recordHelp(session.sessionId(), 0, "employee-a"));
assertEquals("训练会话不存在或无权访问", error.getMessage());
}
verify(jdbcTemplate, never()).update(contains("INSERT INTO aihr_practice_help_event"), ArgumentMatchers.<Object[]>any());
}
@Test
public void m2TurnRejectsSkippedRoundsAndHelpUsesTheServerTrackedRound() {
JdbcTemplate jdbcTemplate = mock(JdbcTemplate.class);
when(jdbcTemplate.query(contains("SELECT enabled"), any(RowMapper.class), eq("000000"), eq("complaint-water")))
.thenReturn(List.of());
when(jdbcTemplate.query(contains("SELECT scenario_code"), any(RowMapper.class), eq("000000"), eq("complaint-water")))
.thenReturn(List.of());
AihrPracticeLlmService llm = mock(AihrPracticeLlmService.class);
when(llm.customerTurn(
anyString(), anyString(), anyString(), ArgumentMatchers.anyList(), anyString(), anyString(),
ArgumentMatchers.anyInt(), ArgumentMatchers.anyInt(), anyString()
)).thenReturn(Optional.empty());
when(llm.customerTurn(
anyString(), anyString(), anyString(), ArgumentMatchers.anyList(), anyString(), anyString(),
ArgumentMatchers.anyInt(), ArgumentMatchers.anyInt(), anyString(), anyString()
)).thenReturn(Optional.empty());
AihrPracticeSeedService service = new AihrPracticeSeedService(new ObjectMapper(), jdbcTemplate, llm, null);
var session = service.start(new StartRequest("employee-a", "complaint-water", "mobile", null));
ServiceException skipped = assertThrows(ServiceException.class,
() -> service.turn(new TurnRequest(session.sessionId(), 2, "我先确认现场情况", null, null, false, null), "employee-a"));
assertEquals("训练回合已变化,请查看最新对话后再继续", skipped.getMessage());
var helpBeforeValidTurn = service.recordHelp(session.sessionId(), 2, "employee-a");
var firstTurn = service.turn(new TurnRequest(session.sessionId(), 0, "我先确认现场情况", null, null, false, null), "employee-a");
var adjustedFirstTurn = service.turn(new TurnRequest(
session.sessionId(), 0, "我先确认现场情况", null, null, true, "serious"
), "employee-a");
var help = service.recordHelp(session.sessionId(), 2, "employee-a");
assertEquals(0, helpBeforeValidTurn.roundIndex());
assertEquals(1, firstTurn.roundIndex());
assertEquals(1, adjustedFirstTurn.roundIndex());
assertEquals(1, help.roundIndex());
}
@Test
public void practiceAudioMustMatchCurrentAppUploaderAndDailyAudioIsReadable() throws Exception {
String service = Files.readString(Path.of("src/main/java/org/dromara/aihr/service/AihrPracticeSeedService.java"));
@@ -8,6 +8,7 @@ import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
@Tag("dev")
@@ -25,21 +26,38 @@ class AihrSpeechServiceTest {
var mentor = AihrSpeechService.buildRequestBody(
mapper, runtime, "先承接情绪,再说明处理节点。", null,
new VoiceProfile("mentor", null, null, null));
new VoiceProfile("mentor", null, null, null, "mandarin"));
var customer = AihrSpeechService.buildRequestBody(
mapper, runtime, "你们到底什么时候处理?", null,
new VoiceProfile("customer", null, 9.0, "intense"));
new VoiceProfile("customer", null, 9.0, "intense", "sichuanese"));
var legacy = AihrSpeechService.buildRequestBody(
mapper, runtime, "普通播报", "alex", null);
assertEquals("FunAudioLLM/CosyVoice2-0.5B:anna", mentor.path("voice").asText());
assertEquals("speech:shifu-warm-v1:cm3hz4wfz02jy106j6z6muix7:ysvbyzypjgmnceokxedr", mentor.path("voice").asText());
assertEquals(0.88, mentor.path("speed").asDouble());
assertTrue(mentor.path("input").asText().contains("师傅语气"));
assertEquals("FunAudioLLM/CosyVoice2-0.5B:bella", customer.path("voice").asText());
assertEquals(1.3, customer.path("speed").asDouble());
assertTrue(customer.path("input").asText().contains("情绪强烈"));
assertTrue(customer.path("input").asText().contains("四川话"));
assertEquals("FunAudioLLM/CosyVoice2-0.5B:alex", legacy.path("voice").asText());
assertFalse(legacy.has("speed"));
assertFalse(legacy.path("input").asText().contains("<|endofprompt|>"));
}
@Test
void rejectsUnknownDialectInsteadOfSilentlyFallingBackToMandarin() {
ObjectMapper mapper = new ObjectMapper();
SpeechModel runtime = new SpeechModel(
"siliconflow",
"FunAudioLLM/CosyVoice2-0.5B",
"https://api.siliconflow.cn/v1",
"test-key"
);
VoiceProfile invalid = new VoiceProfile("customer", null, null, "calm", "minnan");
assertTrue(AihrSpeechService.invalidDialectMessage(invalid).isPresent());
assertThrows(IllegalArgumentException.class, () -> AihrSpeechService.buildRequestBody(
mapper, runtime, "测试", null, invalid));
}
}
@@ -62,6 +62,19 @@ CREATE TABLE IF NOT EXISTS `aihr_practice_session` (
KEY `idx_aihr_practice_review` (`tenant_id`, `mode`, `status`, `finished_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 对练记录';
CREATE TABLE IF NOT EXISTS `aihr_practice_help_event` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
`tenant_id` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT '000000' COMMENT '租户编号',
`session_id` varchar(100) NOT NULL COMMENT '训练会话ID',
`scenario_id` varchar(80) NOT NULL COMMENT '训练场景ID',
`ext_party_id` varchar(100) DEFAULT NULL COMMENT '员工外部主体ID',
`round_index` int NOT NULL DEFAULT 0 COMMENT '求助时所在回合',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
PRIMARY KEY (`id`),
KEY `idx_aihr_practice_help_session` (`tenant_id`, `session_id`, `create_time`),
KEY `idx_aihr_practice_help_party` (`tenant_id`, `ext_party_id`, `create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 训练求助事件';
CREATE TABLE IF NOT EXISTS `aihr_practice_annotation` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
`tenant_id` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT '000000' COMMENT '租户编号',
@@ -0,0 +1,22 @@
-- AIHR M2 训练求助事件迁移(MySQL 8.x)
-- 训练中的“求助 SOP”与知识检索日志分开留痕;可重复执行。
CREATE TABLE IF NOT EXISTS `aihr_practice_help_event` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
`tenant_id` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT '000000' COMMENT '租户编号',
`session_id` varchar(100) NOT NULL COMMENT '训练会话ID',
`scenario_id` varchar(80) NOT NULL COMMENT '训练场景ID',
`ext_party_id` varchar(100) DEFAULT NULL COMMENT '员工外部主体ID',
`round_index` int NOT NULL DEFAULT 0 COMMENT '求助时所在回合',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
PRIMARY KEY (`id`),
KEY `idx_aihr_practice_help_session` (`tenant_id`, `session_id`, `create_time`),
KEY `idx_aihr_practice_help_party` (`tenant_id`, `ext_party_id`, `create_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='AI HR 训练求助事件';
-- 兼容曾由开发环境运行时 DDL 创建的同名表,统一到发布 schema 的排序规则。
ALTER TABLE `aihr_practice_help_event`
CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
ALTER TABLE `aihr_practice_help_event`
MODIFY COLUMN `tenant_id` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT '000000' COMMENT '租户编号';