feat(aihr): harden M0-M2 mobile training flows
This commit is contained in:
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+28
-3
@@ -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);
|
||||
}
|
||||
|
||||
+62
-11
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+82
@@ -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));
|
||||
}
|
||||
}
|
||||
+86
-12
@@ -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>
|
||||
|
||||
+26
@@ -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;
|
||||
}
|
||||
}
|
||||
+22
@@ -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
@@ -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));
|
||||
|
||||
+6
@@ -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);
|
||||
}
|
||||
|
||||
+6
@@ -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) {
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -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) {
|
||||
|
||||
+54
@@ -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);
|
||||
|
||||
+139
-10
@@ -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) {
|
||||
}
|
||||
|
||||
+39
-2
@@ -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));
|
||||
|
||||
+48
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
+94
@@ -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"));
|
||||
|
||||
+21
-3
@@ -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 '租户编号';
|
||||
Reference in New Issue
Block a user